This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Multi-tenant SaaS LMS built with Next.js 16 (App Router, React 19) and Supabase. Schools operate as independent tenants on subdomains (school-slug.platform.com). The platform uses RLS for data security — database queries go directly from components, not through server actions.
Stack: Next.js 16.1.5 · Supabase (PostgreSQL 15, Auth, Storage) · Shadcn UI (base-mira) · Tailwind CSS v4 · TypeScript strict · Stripe Connect · next-intl (en/es)
npm run dev # Dev server at http://localhost:3000
npm run build # Production build (TypeScript + lint check)
npm run lint # ESLint
npm run typecheck # tsc --noEmit
npm run test:unit # Vitest unit tests
npx vitest run -t "name" # Single unit test
supabase db push # Apply local migrations to cloud
supabase migration new <name> # Create migration file
npm run db:reset # Reset local DB (migrations + seed)
npm run db:types # Regenerate lib/database.types.ts from linked project
npx playwright test # Run all E2E tests
npx playwright test --ui # Interactive test runner
npx playwright test -g "name" # Run single test/web-design-guidelines <path> skill audits UI for accessibility + best practices.
Branches: <type>/<slug>-<issueNumber> (e.g. fix/binance-settings-category-479).
Every request goes through proxy.ts (the single middleware file — not middleware.ts), which:
- Extracts tenant slug from subdomain (
school.lmsplatform.com→"school") - Resolves tenant ID from
tenantstable - Injects
x-tenant-idheader into the response - Checks
tenant_usersmembership; redirects non-members to/join-school - Enforces role-based route guards (
/dashboard/student,/dashboard/teacher,/dashboard/admin)
In development, pass x-tenant-slug header to simulate a subdomain.
import { getCurrentTenantId } from '@/lib/supabase/tenant'
const tenantId = await getCurrentTenantId() // reads x-tenant-id headerDefault tenant ID (single-tenant fallback): 00000000-0000-0000-0000-000000000001
custom_access_token_hook() injects into every JWT: user_role (global), tenant_role (student/teacher/admin in current tenant), tenant_id, is_super_admin.
import { getUserRole } from '@/lib/supabase/get-user-role'
const role = await getUserRole() // tenant_users row is authoritative; falls back to JWT tenant_role/user_role only if no active membershipAfter a tenant switch, always call supabase.auth.refreshSession() to get updated claims.
RLS is enabled on all tenant-scoped tables, but filter by tenant_id explicitly anyway — clarity and performance, not just security:
const supabase = await createClient() // @/lib/supabase/server
const tenantId = await getCurrentTenantId()
const { data } = await supabase.from('courses').select('*').eq('tenant_id', tenantId)Client imports: server components → @/lib/supabase/server · client components → @/lib/supabase/client · admin/bypass-RLS → createAdminClient() from @/lib/supabase/admin.
When using createAdminClient() in server actions, manually validate tenant ownership before writes:
const { data: resource } = await adminClient.from('products').select('tenant_id').eq('product_id', id).single()
if (resource.tenant_id !== tenantId) throw new Error('Access denied')Use direct RLS queries for all reads. Use server actions (in app/actions/: admin/, teacher/, payment-requests.ts, join-school.ts, onboarding.ts) only for multi-step mutations (payment processing, enrollment), service-role operations, or external API calls (Stripe, email).
Two separate Stripe integrations:
| School Billing (Platform) | Student Payments (Connect) | |
|---|---|---|
| Who pays | School admin pays platform | Student pays school |
| Stripe mode | Billing (Checkout + Subscriptions) | Connect (PaymentIntents) |
| Webhook | /api/stripe/platform-webhook |
/api/stripe/webhook |
| Env var | STRIPE_PLATFORM_WEBHOOK_SECRET |
STRIPE_WEBHOOK_SECRET |
| Customer ID | tenants.stripe_customer_id |
profiles.stripe_customer_id |
Student payments have two flows, both produce a transaction and call enroll_user() RPC on success:
- Stripe Connect:
app/api/stripe/create-payment-intent/route.tssetsapplication_fee_amount(platform fee) +transfer_data.destination(school's account); revenue split inrevenue_splits(default 20% platform / 80% school) - Manual/offline: student creates a
payment_requestsrow (app/actions/payment-requests.ts) → admin confirms receipt →enroll_user()RPC
Also supported (products.payment_provider): paypal, lemonsqueezy, solana, solana_subs, binance — see docs/PROVIDER_AGNOSTIC_PAYMENTS_SPIKE.md.
Key invariants:
- Transaction
status:pending,successful,failed,archived,canceled,refunded subscriptions.cancel_at_period_endis the ONLY signal that a cancel is scheduled (since20260726120000, issue #545).cancel_atis nullable and purely informational — the CHECKsubscriptions_cancel_at_requires_scheduleallows it to be non-NULL only while the flag is set, so clear both together (both reactivate actions do). It shipped asNOT NULL DEFAULT now()with no writer setting it, which made the Solana crank cancel every subscription at its first rollover.subscription_statusisactive/canceled/expired/renewed/past_due;renewedandpast_dueboth count as LIVE (parallel-subscription guards, billing UI, plan change), and cancelling must never improve a status- A refund is not all-or-nothing (since #547). A PARTIAL refund keeps
transactions.status = 'successful'and records the slice inrefunded_amount(major units of the row's own currency); only a FULL refund setsrefunded_amount = amount, flipsstatusto'refunded'and revokes the entitlement. Every money sum must useamount - refunded_amount(netOfRefunds()inlib/payments/payouts-owed.ts).NormalizedBillingEvent.amountis always MAJOR units, converted in each provider's own mapper (Lemon Squeezy reports cents, Binance a USD-pegged stablecoin); an absent amount or a currency mismatch falls back to a full refund - Whether the platform takes a fee is
ProviderCapabilities.bearsPlatformFee, neverrevenue_splits.applies_to_providers(retired in #547 — it stored the labelsstripe/manual, not provider slugs, so every PayPal/LS/Binance sale bore 0% on the school's screens whilegetPayoutsOwedapplied the full split). The rate always comes from the transaction's ownschool_percentage_snapshot, so the school-facing and platform-facing figures reconcile.transactionshas nocreated_at— it istransaction_date; querying or merely ordering by the former 42703s the whole request - Access control lives in
entitlements, notenrollments(since migration20260516150000):entitlements(user_id,course_id,tenant_id,source_type,source_id,status,expires_at) is the polymorphic source of truth for product/subscription access.enrollments.product_id/subscription_idand their old CHECK constraint were dropped —enrollmentsis now a learning-progress record only (user_id,course_id,status,tenant_id,enrollment_date) enroll_user()RPC loops through ALL courses for a product (a product can map to multiple courses viaproduct_courses) and writes toentitlements- Subscriptions grant access, not auto-enrollment — students self-enroll via
/dashboard/student/browse(useEnrollment()hook);plan_coursesdefines which courses a plan covers - Transactions have two partial unique indexes (not one):
(user_id, product_id) WHERE plan_id IS NULL AND status IN ('pending','successful')and(user_id, plan_id) WHERE product_id IS NULL AND status IN (...), plustransactions_provider_charge_id_uniquefor Solana idempotency transactionsis server-write-only.authenticatedhas no INSERT grant (#538) and an UPDATE grant on onlystatus,provider_subscription_id,stripe_payment_intent_id(#528) — every insert usescreateAdminClient()or a SECURITY DEFINER function.amountand thesettlement_*columns decide what the buyer owes (settlement_baseis what the on-chain Solana payment is verified against), so they are derived fromproducts/plansserver-side, never taken from the request. A user-scoped insert fails withpermission denied for table transactions— by design, not a bug to re-grant around
All routes live under app/[locale]/ ([locale] is always /en/ or /es/). Public routes (no auth): /auth/*, /, /create-school, /creators, /join-school, /platform-pricing, /pricing, /courses, /verify, /oauth/consent. Role routing after login: /dashboard/student · /dashboard/teacher · /dashboard/admin. /platform/* is guarded separately by checkSuperAdmin() in proxy.ts, independent of tenant role.
116 tables. Key groups: multi-tenancy (tenants, tenant_users, tenant_settings, super_admins) · users (profiles — global, no tenant_id) · content (courses, lessons, exercises, exams, exam_questions) · progress (enrollments — progress only, lesson_completions, exam_submissions) · commerce (products, plans, transactions, subscriptions, payment_requests, entitlements — course-access source of truth) · revenue (revenue_splits, payouts, invoices) · platform billing (platform_plans, platform_subscriptions, platform_payment_requests) · gamification (10 gamification_* tables incl. gamification_profiles, gamification_xp_transactions, gamification_achievements; plus leagues in league_tiers/league_memberships) · certificates · notifications.
profiles and gamification_levels are global (no tenant_id).
Key RPCs:
supabase.rpc('enroll_user', { _user_id, _product_id })
supabase.rpc('handle_new_subscription', { _user_id, _plan_id, _transaction_id, _start_date }) // trigger-invoked; writes to entitlements
supabase.rpc('has_course_access', { _user_id, _course_id }) // access check; _course_id is integer — cast ::int from SQL
supabase.rpc('award_xp', { _user_id, _action_type, _xp_amount, _reference_id, _reference_type }) // overload adds _tenant_id
supabase.rpc('create_exam_submission', { p_student_id, p_exam_id, p_answers })
supabase.rpc('save_exam_feedback', { p_submission_id, p_exam_id, p_student_id, p_answers, p_overall_feedback, p_score, p_question_feedback, p_ai_model, p_processing_time_ms })
supabase.rpc('get_plan_features', { _tenant_id })Limits live in platform_plans.limits (JSONB): free (5 courses/50 students/10% fee) · starter (15/200/5%, $9/mo) · pro (100/1000/2%, $29/mo) · business (unlimited courses/5000 students, 0% fee, $79/mo) · enterprise (unlimited/0%, $199/mo). get_plan_features(_tenant_id) RPC is the single source of truth. Client hook: usePlanFeatures(). Component: <FeatureGate feature="..." />.
mcp-server/ exposes LMS course-management tools/resources/prompts/widgets to AI agents, built on mcp-use (not raw @modelcontextprotocol/sdk). Read the mcp-apps-builder skill before touching it. Runs standalone on port 3000 (cd mcp-server && npm run dev; npm run mcp:build from root). Auth is Supabase OAuth 2.1 — every query runs through a request-scoped, RLS-aware client using the caller's token (no service-role data access); role/tenant come from JWT claims, gated per-role in src/tool-policy.ts. In production, app/api/mcp/[[...path]]/route.ts fronts it at https://<tenant>.<domain>/api/mcp. Tool inventory lives in mcp-server/src/tools/*.ts, widgets in mcp-server/resources/<name>/widget.tsx — read those directly rather than relying on a list here, as they change frequently.
See .env.example for the full annotated list. Minimum to run locally:
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY= # Bypasses RLS — admin ops only
NEXT_PUBLIC_PLATFORM_DOMAIN= # e.g. lvh.me for local dev, lmsplatform.com in prod
NEXT_PUBLIC_OPENAI_API_KEY is exposed to the browser bundle (speech input) — never put a production key there.
E2E tests in tests/playwright/ — 33 spec files (tenant isolation, auth security, payment flows, enrollment, entitlements, gamification, community, i18n, platform panel, plan change, teacher/admin CRUD). Read the directory rather than a list here; it changes often. Highest-priority: tenant-isolation.spec.ts, auth-security.spec.ts, payment-flows.spec.ts, evaluations-security.spec.ts.
The Playwright config has no webServer — start npm run dev yourself first, use lvh.me (never localhost), and keep --workers=1 locally or GoTrue rate-limits the sign-ins. Unit tests: npm run test:unit (Vitest, tests/unit/).
Test accounts (from supabase/seed.sql, seeded by supabase db reset):
student@e2etest.com/password123— student (Default School)owner@e2etest.com/password123— admin (Default School) + super admin (/platform/*)creator@codeacademy.com/password123— admin (Code Academy, subdomaincode-academy.lvh.me:3000)alice@student.com/password123— student (Code Academy)
Pre-commit checklist: npm run build · tenant filter on every query · tested with all relevant roles · loading + error states handled.
product_courses— never.single(): a course can belong to multiple products.lesson_completionsusesuser_id, notstudent_id. Has notenant_id— never filter by it.exercise_completionshas notenant_id— filter byuser_idonly.examshas nopassing_scoreorallow_retake— use 70 as default threshold, assume retakes allowed.profileshas noemail— get emails viacreateAdminClient().auth.admin.getUserById().exam_submissionsorder column issubmission_date, notsubmitted_at.- Transaction status is
'successful', not'succeeded'. - Exam child tables (
exam_questions,exam_answers,exam_question_scores,exam_scores,question_options) have notenant_id— filter by exam_id/submission_id only; adding.eq('tenant_id', …)errors the whole query.exam_questionsalso has nosequence. exercise_completionsusesuser_id, notstudent_id, and stores onlyscore+completed_at— submissions/feedback live inexercise_evaluationsand theexercise_*_submissionstables.subscriptionsusessubscription_status(notstatus),provider_subscription_id(notstripe_subscription_id), andcreated(notcreated_at).plansusesplan_nameandduration_in_days— there is noname, nointerval, nostatus. Soft-deleted viadeleted_at.transactionshas three unique indexes, product-shaped and plan-shaped kept separate plus(payment_provider, provider_charge_id)for webhook idempotency — seedocs/DATABASE_SCHEMA.md.- Creating test users via SQL won't fire
handle_new_user()— manually insertprofiles,user_roles,auth.identities. UseNULLforphone,''for nullable strings. proxy.tsis the only middleware — do not createmiddleware.ts(conflict).createAdminClient()lives in@/lib/supabase/admin, NOT@/lib/supabase/server.- Button component uses
@base-ui/react— noasChildprop. Wrap<Link>around<Button>instead. - Stripe API v2025 types need
anycasts forSubscription/Invoiceobjects. getUserRole()checkstenant_usersfirst (authoritative), resolving the user via thex-user-idheader — no extragetUser()call. It only falls back togetSession()-derived JWT claims (tenant_role/user_role) when there's no active membership row.isSuperAdmin()queries thesuper_adminstable directly — does not trust JWT claims.- API routes get tenant context via
proxy.tstoo —x-tenant-idis set for/api/*routes. enroll_user()RPC loops through ALL courses per product viaproduct_courses(FOR loop).
- Sentry DSN is hardcoded in
sentry.server.config.ts— intentional (public DSN), consider moving to env var for consistency. - Test account passwords (
password123) are for local development only, seeded bysupabase db reset. Never use in a deployed environment.
docs/DATABASE_SCHEMA.md— complete schema with relationshipsdocs/AUTH.md— auth flowsdocs/AI_AGENT_GUIDE.md— detailed patternsdocs/MONETIZATION.md— school billing, feature gating, LATAM payments, revenue dashboarddocs/COMMUNITY_SPACES.md— community feed, comments, reactions, polls, moderation, security
Canonical source: PRODUCT.md (strategy) + DESIGN.md (visual system). Read PRODUCT.md before any UI work — it carries the register (product, with (public)/* and Puck blocks in brand), the four user groups, the anti-references, and the five design principles. The summary below is a pointer, not the authority.
Users span independent creators/solo educators and multi-staff schools, across LATAM and English-speaking markets (en/es). Brand personality: minimal, elegant, focused — content over chrome, no visual noise.
- Aesthetic: clean, spacious, content-first; hierarchy via typography weight/size over color/ornament. References: Duolingo/Khan Academy, Teachable/Thinkific. Anti-references: cluttered enterprise dashboards, generic Bootstrap.
- Theme: light + dark, tenant theming overrides primary/accent via CSS custom properties. Default primary is teal-cyan
oklch(0.52 0.105 223.128)light /oklch(0.45 0.085 224.283)dark — hue ~223, not 293. Nothing may depend on that hue holding; tenants override it. - Typography/icons: Noto Sans (body), Geist Sans/Mono (UI/code); Tabler Icons + Lucide (outline style).
- Motion: subtle, via
motionlib; respectprefers-reduced-motion; convey state changes, not decoration. - Principles: content over chrome · obvious over clever · consistent structure across tenants (brand via color/logo, not layout) · WCAG AA by default · progressive disclosure (sheets/dialogs for detail).
- Stack: Shadcn UI (base-mira,
@base-ui/reactprimitives) · Tailwind v4 with OKLCH tokens ·motion+tw-animate-css·next-themes+TenantCssVars·--radius: 0.625rembase.
When reporting information to me, be extremely concise and sacrifice grammar for sake of concision.