The LMS database is built on PostgreSQL 15 via Supabase. As of the latest migration it has 116 tables in the public schema, organized into logical domains:
- User Management:
profiles(global),user_roles,roles,permissions,role_permissions - Multi-Tenancy:
tenants,tenant_users,tenant_settings,tenant_invitations,super_admins,system_settings - Course Content:
courses,course_categories,lessons,lesson_resources,lesson_checkpoints,lesson_checkpoint_attempts,exercises,exams,exam_questions,question_options,content_versions,lessons_ai_tasks,lessons_ai_task_messages,prompt_templates - Access Control:
entitlements— the source of truth for course access - Progress:
enrollments(progress record only),lesson_completions,lesson_passed,lesson_views,exam_submissions,exam_answers,exam_scores,exam_question_scores,exercise_completions,exercise_evaluations - Commerce:
products,product_courses,product_post_registration_steps,plans,plan_courses,transactions,subscriptions,payment_requests,webhook_events,tenant_payment_wallets - Platform Billing:
platform_plans,platform_subscriptions,platform_payment_requests,access_cutoff_notifications - Revenue:
revenue_splits,payouts,invoices - Gamification: 10
gamification_*tables plus leagues (league_tiers,league_memberships) - Adaptive Practice (FSRS/Elo):
review_cards,practice_attempts,student_topic_ratings,study_goals - Certificates:
certificates,certificate_templates,certificate_shares,certificate_verification_log,issuer_keys - AI Tutoring:
course_ai_tutors,aristotle_sessions,aristotle_messages,exam_ai_configs - Landing Pages:
landing_pages,landing_page_templates - Community:
community_posts,community_comments,community_reactions,community_poll_options,community_poll_votes,community_flags,community_user_mutes - Social / Messaging:
messages,chats,chat_conversations,chat_messages,lesson_comments,comments,comment_reactions,comment_flags,reviews,item_ratings - Support:
tickets,ticket_messages - Notifications:
notifications,user_notifications,notification_templates,notification_preferences,device_push_tokens - API/MCP:
mcp_api_tokens,mcp_audit_log - Media:
exercise_media_submissions,exercise_files,exercise_code_student_submissions - Legacy / unused:
assignments,submissions,grades— present but not wired into current flows
For the exact current list, don't trust this page — ask the database (see Verifying this document).
- Primary keys use
_idsuffix:product_id,course_id,enrollment_id,transaction_id,submission_id,request_id,exam_id,question_id,option_id,score_id. Exceptions:lessons,exercisesandlesson_completionsuse a bareid. - Access lives in
entitlements, notenrollments(since migration20260516150000).enrollmentsis a learning-progress record only; itsproduct_id/subscription_idcolumns and their CHECK constraint were dropped. profilesis GLOBAL — notenant_id. Same forgamification_levels,platform_plans,plan_courses, and ~50 child tables (full list under Tables withouttenant_id). Adding.eq('tenant_id', …)to a table that lacks the column errors the entire query.lesson_completionsusesuser_id(NOTstudent_id), and has notenant_id.exercise_completionsusesuser_id(NOTstudent_id), and has notenant_id.exam_submissionsusesstudent_id, and its order column issubmission_date(NOTsubmitted_at).- Exam child tables have no
tenant_id:exam_questions,exam_answers,exam_scores,exam_question_scores,question_options. Isolation comes from RLS through the parent exam/submission. examshas nopassing_scoreand noallow_retake— use 70 as the default threshold and assume retakes are allowed.exam_questionshas nosequencecolumn — questions come back in insertion order.- Transaction status is
'successful'(NOT'succeeded'). Full enum:pending,successful,failed,archived,canceled,refunded product_coursescan have multiple rows per course — a single course can belong to many products. NEVER use.single()on this table.- Enrollment status uses enum
enrollement_status:'active'|'disabled'(the typo in the enum name is legacy — keep it) subscriptionsstatus column issubscription_status, notstatus; the provider id isprovider_subscription_id, notstripe_subscription_id.certificateshas TWO FKs toprofiles(user_id,revoked_by) — must use FK hint:profiles!certificates_user_id_fkey(...)profileshas NOemailcolumn — get emails viacreateAdminClient().auth.admin.getUserById()courseshas noslug— route bycourse_id.gamification_profileshas nocoinscolumn — balance =floor(total_xp / 10) - total_coins_spent.
GLOBAL table — no tenant_id column. Auto-created on signup by handle_new_user() trigger.
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | References auth.users(id) |
full_name |
TEXT | |
username |
TEXT | |
avatar_url |
TEXT | |
website |
TEXT | |
bio |
TEXT | |
currency_id |
||
stripe_customer_id |
TEXT | Student payments (Connect). Note a legacy stripeCustomerID column also exists |
data_person |
JSONB | |
onboarding_completed |
BOOLEAN | |
deactivated_at |
TIMESTAMPTZ | |
created_at |
TIMESTAMPTZ |
No email column and no updated_at. Emails come from createAdminClient().auth.admin.getUserById().
Assigns global roles to users. Default role student assigned on signup.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
user_id |
UUID FK → profiles | |
role |
VARCHAR(50) | student, teacher, admin |
created_at |
TIMESTAMPTZ |
UNIQUE(user_id, role) — users can have multiple roles.
School/organization records.
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
slug |
VARCHAR(100) UNIQUE | Used in subdomain routing |
name |
VARCHAR(255) | |
domain |
VARCHAR(255) | Custom domain |
logo_url |
TEXT | |
primary_color |
VARCHAR(7) | |
secondary_color |
VARCHAR(7) | |
plan |
VARCHAR(50) | Legacy — see platform_subscriptions |
status |
VARCHAR(50) | active, etc. |
stripe_account_id |
VARCHAR(255) | Stripe Connect (for student payments) |
stripe_customer_id |
VARCHAR(255) | Platform billing (school pays platform) |
billing_email |
VARCHAR(255) | |
billing_period_end |
TIMESTAMPTZ | |
billing_status |
VARCHAR(50) | free, active, past_due, canceled |
access_cutoff_at |
TIMESTAMPTZ | When an unpaid school's content access is suspended |
stripe_charges_enabled / stripe_payouts_enabled / stripe_details_submitted |
BOOLEAN | Stripe Connect onboarding state |
Many-to-many user-tenant relationships. Authoritative source for per-tenant roles.
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
tenant_id |
UUID FK → tenants | |
user_id |
UUID FK → auth.users | |
role |
VARCHAR(50) | student, teacher, admin |
status |
VARCHAR(50) | active, etc. |
joined_at |
TIMESTAMPTZ |
UNIQUE(tenant_id, user_id)
Per-tenant key-value configuration.
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
tenant_id |
UUID FK → tenants | |
setting_key |
VARCHAR(255) | |
setting_value |
JSONB |
UNIQUE(tenant_id, setting_key)
Email invitations to join a school. Checked during /join-school flow to auto-assign the invited role.
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
tenant_id |
UUID FK → tenants | |
email |
TEXT | |
role |
TEXT | student or teacher |
invited_by |
UUID FK → auth.users | |
status |
TEXT | pending, accepted, expired |
created_at |
TIMESTAMPTZ | |
accepted_at |
TIMESTAMPTZ |
UNIQUE(tenant_id, email, status)
Platform-level super admin users.
| Column | Type | Notes |
|---|---|---|
id |
UUID PK | |
user_id |
UUID FK → auth.users, UNIQUE | |
created_at |
TIMESTAMPTZ |
Course catalog. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
course_id |
SERIAL PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
title |
VARCHAR(255) | |
description |
TEXT | |
thumbnail_url |
TEXT | |
author_id |
UUID FK → profiles | |
category_id |
INTEGER FK → course_categories | |
status |
VARCHAR(50) | draft, published, archived |
require_sequential_completion |
BOOLEAN | Default false |
learning_objectives |
TEXT[] | text[], not JSONB |
estimated_duration_minutes |
INTEGER | |
tags |
||
published_at / archived_at / deleted_at |
TIMESTAMPTZ | Soft lifecycle — filter deleted_at IS NULL |
created_at / updated_at |
TIMESTAMPTZ |
No slug column — use course_id for routing. author_id has two FKs (courses_author_id_fkey → auth.users, courses_author_profile_fkey → profiles), so joins to profiles need an explicit FK hint.
Course categorization. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
name |
VARCHAR(100) | |
description |
TEXT |
Course lessons. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
course_id |
INTEGER FK → courses(course_id) | |
title |
VARCHAR(255) | |
content |
TEXT | MDX/Markdown |
video_url |
TEXT | |
sequence |
INTEGER | Order within course |
status |
VARCHAR(50) | draft, published |
publish_at |
TIMESTAMPTZ | Scheduled publishing |
is_preview |
BOOLEAN | Viewable without access (public preview) |
description / summary / image |
||
embed_code |
TEXT | Alternative to video_url |
transcript |
TEXT | Video transcript (YouTube captions) |
ai_task_description / ai_task_instructions |
TEXT | Inline AI task; richer config lives in lessons_ai_tasks |
created_at / updated_at |
TIMESTAMPTZ |
PK is a bare id, not lesson_id — XP triggers joining lessons on l.lesson_id is a known past bug.
UNIQUE(course_id, sequence)
Tracking student progress. Uses user_id (NOT student_id).
| Column | Type | Notes |
|---|---|---|
id |
BIGINT PK | GENERATED ALWAYS AS IDENTITY |
user_id |
UUID FK → profiles | NOT student_id |
lesson_id |
BIGINT FK → lessons | |
completed_at |
TIMESTAMPTZ |
UNIQUE(user_id, lesson_id)
Hierarchical comments on lessons.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
lesson_id |
INTEGER FK → lessons | |
user_id |
UUID FK → profiles | |
parent_id |
INTEGER FK → lesson_comments | For replies |
content |
TEXT | |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
File attachments per lesson. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
id |
BIGINT PK | GENERATED ALWAYS AS IDENTITY |
lesson_id |
BIGINT FK → lessons | |
tenant_id |
UUID FK → tenants | |
file_name |
TEXT | |
file_path |
TEXT | |
file_size |
BIGINT | |
mime_type |
TEXT | |
uploaded_by |
UUID FK → auth.users | |
display_order |
INT | |
created_at |
TIMESTAMPTZ |
Storage bucket: lesson-resources (private, 10MB limit).
Practice exercises within lessons. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
lesson_id |
INTEGER FK → lessons | |
title |
VARCHAR(255) | |
description |
TEXT | |
exercise_type |
exercise_type enum |
See values below |
exercise_config |
JSONB | Type-specific configuration |
instructions |
TEXT | Student-facing prompt |
system_prompt |
TEXT | For AI-evaluated exercises |
difficulty_level |
difficulty_level |
easy, medium, hard |
time_limit |
INTEGER | |
course_id |
INTEGER FK → courses | Denormalized alongside lesson_id |
active_file / visible_files |
Sandpack code-exercise setup | |
template_id / template_variables |
Built from a prompt_templates row |
|
created_by |
UUID | |
status |
VARCHAR(50) | |
created_at / updated_at |
TIMESTAMPTZ |
There are no initial_code, solution_code or test_cases columns — code-exercise setup lives in exercise_config / active_file / visible_files.
exercise_type enum values: quiz, coding_challenge, essay, multiple_choice, true_false, fill_in_the_blank, discussion, audio_evaluation, video_evaluation, real_time_conversation, artifact
Records that a student finished an exercise. Uses user_id, and has NO tenant_id — filtering by tenant_id errors the query.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
exercise_id |
INTEGER FK → exercises | |
user_id |
UUID FK → auth.users | NOT student_id |
completed_by |
UUID | Who marked it complete (self or teacher) |
score |
NUMERIC | |
completed_at |
TIMESTAMPTZ |
The submission content and AI feedback live in exercise_evaluations / exercise_code_student_submissions / exercise_media_submissions, not here.
AI chat for exercise help.
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
exercise_id |
INTEGER FK → exercises | |
student_id |
UUID FK → profiles | |
role |
VARCHAR(50) | user or assistant |
content |
TEXT | |
created_at |
TIMESTAMPTZ |
Audio/video exercise uploads. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
id |
BIGINT PK | GENERATED ALWAYS AS IDENTITY |
exercise_id |
BIGINT FK → exercises | |
user_id |
UUID FK → profiles | |
tenant_id |
UUID | |
media_url |
TEXT | |
media_type |
TEXT | audio or video |
duration_seconds |
INTEGER | |
status |
TEXT | pending, processing, completed, failed |
stt_result |
JSONB | Speech-to-text result |
ai_evaluation |
JSONB | |
score |
NUMERIC(5,2) | |
created_at |
TIMESTAMPTZ |
Course assessments. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
exam_id |
SERIAL PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
course_id |
INTEGER FK → courses(course_id) | |
title |
VARCHAR(255) | |
description |
TEXT | |
duration |
INTEGER | Minutes |
exam_date |
TIMESTAMPTZ | Nullable |
status |
VARCHAR(50) | draft, published |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
No tenant_id, no sequence, no created_at. Filter by exam_id only — isolation comes from RLS on the parent exam.
| Column | Type | Notes |
|---|---|---|
question_id |
SERIAL PK | |
exam_id |
INTEGER FK → exams(exam_id) | |
question_text |
TEXT | |
question_type |
VARCHAR(50) | multiple_choice, true_false, free_text |
points |
INTEGER | Default 1 |
correct_answer |
TEXT | |
ai_grading_criteria |
TEXT | Guidance for AI grading |
grading_rubric |
JSONB | |
expected_keywords |
TEXT[] | |
max_length |
INTEGER | For free-text answers |
Sibling child tables — also without tenant_id: question_options, exam_answers, exam_scores, exam_question_scores.
Options for multiple choice/true-false questions.
| Column | Type | Notes |
|---|---|---|
option_id |
SERIAL PK | |
question_id |
INTEGER FK → exam_questions(question_id) | |
option_text |
TEXT | |
is_correct |
BOOLEAN | |
created_at |
TIMESTAMPTZ |
Student exam submissions. Order column is submission_date (NOT submitted_at). Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
submission_id |
INTEGER PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
exam_id |
INTEGER FK → exams(exam_id) | |
student_id |
UUID FK → profiles | |
submission_date |
TIMESTAMPTZ | NOT submitted_at |
ai_data |
JSONB | AI feedback per question |
score |
NUMERIC | |
feedback |
TEXT | Overall feedback |
evaluated_at |
TIMESTAMPTZ | |
review_status |
VARCHAR | NOT is_reviewed |
requires_attention |
BOOLEAN | Flags low-confidence AI grades for a human |
ai_model_used / ai_processing_time_ms / ai_confidence_score |
Grading telemetry |
Processed by create_exam_submission() and save_exam_feedback() RPCs.
The source of truth for course access. Polymorphic: one row per (user, course, source). Tenant-scoped. Introduced by migration 20260516150000.
| Column | Type | Notes |
|---|---|---|
entitlement_id |
BIGINT PK | GENERATED ALWAYS AS IDENTITY |
user_id |
UUID FK → auth.users | NOT NULL |
course_id |
INTEGER FK → courses(course_id) | NOT NULL |
tenant_id |
UUID FK → tenants | NOT NULL |
source_type |
entitlement_source |
product, subscription, free, admin_grant |
source_id |
INTEGER | The product/plan id — NULL for free and admin_grant |
status |
entitlement_status |
active, revoked, expired (there is no canceled) |
granted_at |
TIMESTAMPTZ | NOT NULL, default now() |
expires_at |
TIMESTAMPTZ | NULL = no expiry |
revoked_at |
TIMESTAMPTZ |
UNIQUE(user_id, course_id, source_type, source_id) NULLS NOT DISTINCT — so a user can hold access to the same course from a product and a subscription simultaneously without collision.
CHECK entitlements_source_id_shape: source_id must be NOT NULL for product/subscription, and NULL for free/admin_grant.
Written by enroll_user() and handle_new_subscription(). Read via has_course_access(_user_id uuid, _course_id integer) — note the second arg is integer, so cast ::int when calling from SQL.
Learning-progress record only — it does not grant access. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
enrollment_id |
INTEGER PK | |
user_id |
UUID FK → auth.users | NOT NULL |
course_id |
INTEGER FK → courses(course_id) | NOT NULL |
tenant_id |
UUID FK → tenants | NOT NULL |
status |
enrollement_status |
active, disabled |
enrollment_date |
TIMESTAMPTZ |
UNIQUE(user_id, course_id)
The old
product_id/subscription_idcolumns and their "one or the other" CHECK constraint were dropped in the entitlements migration. If you find code or docs referencing them, they are out of date. Students on a subscription self-enroll from/dashboard/student/browse(useEnrollment()hook); subscriptions grant access but do not auto-enroll.
Never gate on the presence of an enrollment row (issue #532). Nothing revokes one — refunds revoke entitlements and
tenants.access_cutoff_atis read only insidehas_course_access()— so a row survives every revocation path, and until migration20260725160000any tenant member could insert one for any course in their tenant. The INSERT policy now also requireshas_course_access(); legitimate writers (enroll_user(),self_enroll_subscription_course(),grant_free_entitlement(),issue_certificate_if_eligible()) are allSECURITY DEFINERand unaffected.
Purchasable course bundles. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
product_id |
SERIAL PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
name |
VARCHAR(255) | |
description |
TEXT | |
price |
NUMERIC | |
currency |
currency_type |
usd, eur, mxn, cop, clp, pen, ars, brl |
image |
TEXT | |
payment_provider |
VARCHAR(50) | stripe, manual, paypal, lemonsqueezy, solana, solana_subs, binance, binance_personal |
provider_product_id |
TEXT | NOT stripe_product_id — provider-agnostic |
provider_price_id |
TEXT | NOT stripe_price_id |
status |
VARCHAR(50) | |
created_at |
TIMESTAMPTZ |
Links products to courses. Tenant-scoped. A course can belong to multiple products — NEVER use .single().
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
product_id |
INTEGER FK → products | |
course_id |
INTEGER FK → courses |
UNIQUE(product_id, course_id)
Subscription plans sold by a school to its students. Tenant-scoped. Column names differ from products — there is no name, no interval, no status.
| Column | Type | Notes |
|---|---|---|
plan_id |
SERIAL PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
plan_name |
VARCHAR(100) | NOT name |
description |
TEXT | |
price |
NUMERIC(10,2) | NOT NULL |
duration_in_days |
INTEGER | NOT interval — CHECK > 0 (e.g. 30, 365) |
currency |
currency_type |
usd, eur, mxn, cop, clp, pen, ars, brl |
features |
TEXT | |
thumbnail |
TEXT | |
payment_provider |
VARCHAR(20) | stripe, manual, paypal, lemonsqueezy, solana, solana_subs, binance, binance_personal |
provider_product_id |
TEXT | Provider-agnostic (not stripe_product_id) |
provider_price_id |
TEXT | |
deleted_at |
TIMESTAMPTZ | Soft delete — filter it out |
created_at |
TIMESTAMPTZ |
Which courses a plan covers. No tenant_id — scope through the parent plan.
Student subscriptions to a school's plans. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
subscription_id |
SERIAL PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
user_id |
UUID FK → auth.users | |
plan_id |
INTEGER FK → plans | |
subscription_status |
VARCHAR | NOT status |
transaction_id |
INTEGER FK → transactions | The purchase that created it |
start_date / end_date |
TIMESTAMPTZ | |
current_period_start / current_period_end |
TIMESTAMPTZ | |
trial_start / trial_end |
TIMESTAMPTZ | |
cancel_at |
TIMESTAMPTZ | NULLABLE. When the subscription terminates, or NULL when no cancel is scheduled — informational, never a signal. CHECK subscriptions_cancel_at_requires_schedule: non-NULL only while cancel_at_period_end (#545) |
cancel_at_period_end |
BOOLEAN | NOT NULL DEFAULT false. The single source of truth for "a cancel is scheduled" — read by the Solana crank, the expiry crons and the billing UI. It used to be OR'd with cancel_at <= now(), and since cancel_at defaulted to now() on every INSERT, that canceled every solana_subs subscription at its first rollover (#545) |
canceled_at / ended_at |
TIMESTAMPTZ | |
superseded_by |
INTEGER | Set on plan change — points at the replacement subscription |
payment_provider |
VARCHAR | |
provider_subscription_id |
TEXT | NOT stripe_subscription_id |
provider_metadata |
JSONB | |
created |
TIMESTAMPTZ | NOT created_at |
Payment transactions. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
transaction_id |
INTEGER PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
user_id |
UUID FK → profiles | |
product_id |
INTEGER FK → products | |
plan_id |
INTEGER FK → plans | |
amount |
NUMERIC(10,2) | |
transaction_date |
TIMESTAMPTZ | |
payment_method |
VARCHAR(50) | |
status |
transaction_status |
pending, successful, failed, archived, canceled, refunded |
currency |
currency_type |
usd, eur, mxn, cop, clp, pen, ars, brl |
payment_provider |
VARCHAR | |
provider_charge_id |
TEXT | Idempotency key for webhooks / on-chain confirms |
provider_subscription_id |
TEXT | |
provider_metadata |
JSONB | |
stripe_payment_intent_id |
TEXT | Stripe-specific, kept for history |
school_percentage_snapshot |
NUMERIC | Revenue split frozen at purchase time — database-owned, never caller-supplied |
refunded_amount |
NUMERIC(10,2) | Cumulative amount refunded, in MAJOR units of this row's own currency. 0 = never refunded (#547) |
settlement_currency / settlement_base / settlement_mint / settlement_sol_usd |
Crypto settlement detail (Solana) — server-owned; settlement_base is the figure the on-chain payment is verified against |
Status is 'successful' (NOT 'succeeded').
A refund is not all-or-nothing (#547). A PARTIAL refund leaves status = 'successful' and records the slice in refunded_amount; only a FULL refund sets refunded_amount = amount and flips status to 'refunded'. Anything that sums money must use amount - refunded_amount, or it counts money the platform gave back — netOfRefunds() in lib/payments/payouts-owed.ts is the shared helper, and tests/unit/phantom-column-guard.test.ts fails the build if a new reader forgets. Access is revoked only on a full refund.
There is no created_at on this table — it is transaction_date. Querying the former makes PostgREST reject the whole request with 42703; five shipped screens did this and rendered $0.00 or an empty list rather than an error (#547 §2). Ordering by it is enough to trigger it — the column need not be selected.
Writes are server-only. authenticated holds no INSERT grant (#538) and only a
three-column UPDATE grant — status, provider_subscription_id,
stripe_payment_intent_id (#528). Every insert goes through a service-role client
(the two checkout routes, the webhooks, the reconcilers, payment-requests.ts) or the
SECURITY DEFINER grant_free_subscription(). A user-scoped insert fails with
permission denied for table transactions — that is deliberate: the row's amount and
settlement figures decide what the buyer owes, so they are derived from
products/plans on the server, never accepted from the caller.
Three unique indexes, not one — a purchase is either product-shaped or plan-shaped, never both:
transactions_unique_product UNIQUE (user_id, product_id)
WHERE plan_id IS NULL AND status IN ('pending','successful')
transactions_unique_plan UNIQUE (user_id, plan_id)
WHERE product_id IS NULL AND status IN ('pending','successful')
transactions_provider_charge_id_unique UNIQUE (payment_provider, provider_charge_id)
WHERE provider_charge_id IS NOT NULL AND status = 'successful' -- webhook/Solana idempotencyFailed payments fall outside the predicate, so retries are allowed.
Manual/offline payment requests. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
request_id |
UUID PK | |
tenant_id |
UUID FK → tenants | NOT NULL |
user_id |
UUID FK → profiles | Student requesting |
product_id |
INTEGER FK → products | |
status |
VARCHAR(50) | pending, instructions_sent, payment_received, confirmed |
created_at |
TIMESTAMPTZ |
Platform/school revenue split configuration per tenant.
Payout records to schools.
payout_method is 'stripe_connect' (automated Connect payout) or 'manual' (admin-recorded wire for the platform-settled providers). UNIQUE (tenant_id, period_start, period_end) does not constrain manual rows — both period columns are nullable for that path and Postgres treats NULLs as distinct — so manual payouts carry idempotency_key TEXT, minted once per Mark-as-paid dialog open and enforced by the partial unique index idx_payouts_manual_idempotency (#547). markPayoutPaid treats the resulting 23505 as success. CHECK (amount > 0) forbids a compensating negative row, which is why a duplicate could not be corrected after the fact.
Invoice records.
General messaging system (AI chat, exam prep, etc.).
Course reviews (1-5 rating). UNIQUE(course_id, user_id).
Admin-created notifications. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
id |
BIGSERIAL PK | |
tenant_id |
UUID FK → tenants | |
title |
TEXT | |
content |
TEXT | |
notification_type |
TEXT | announcement, alert, info, success, warning, error, certificate_issued |
priority |
TEXT | low, normal, high, urgent |
target_type |
TEXT | all, role, course, user, custom |
target_roles |
TEXT[] | |
target_course_id |
BIGINT FK → courses | |
target_user_ids |
UUID[] | |
status |
TEXT | draft, scheduled, sent, cancelled |
created_by |
UUID FK → auth.users | |
created_at |
TIMESTAMPTZ |
Per-user notification delivery tracking.
| Column | Type | Notes |
|---|---|---|
id |
BIGSERIAL PK | |
notification_id |
BIGINT FK → notifications | |
user_id |
UUID FK → auth.users | |
in_app_read |
BOOLEAN | |
dismissed |
BOOLEAN | |
created_at |
TIMESTAMPTZ |
Templates for certificate generation. Tenant-scoped (tenant_id added via migration).
Issued certificates. Tenant-scoped. Has TWO FKs to profiles (user_id, revoked_by) — use FK hint when querying.
RPCs check_and_issue_certificate() and calculate_course_completion() are SECURITY DEFINER.
AI task configuration per lesson.
| Column | Type | Notes |
|---|---|---|
id |
BIGINT PK | |
lesson_id |
BIGINT FK → lessons, UNIQUE | |
task_description |
TEXT | Student-facing prompt |
ai_instructions |
TEXT | System prompt for AI |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
AI lesson task chat history.
| Column | Type | Notes |
|---|---|---|
id |
BIGINT PK | |
lesson_id |
BIGINT FK → lessons | |
user_id |
UUID FK → auth.users | |
role |
TEXT | user, assistant, system |
content |
TEXT | |
tool_calls |
JSONB | |
tool_invocations |
JSONB | |
created_at |
TIMESTAMPTZ |
AI exercise prompt templates. Optionally tenant-scoped (can be global with NULL tenant_id).
| Column | Type | Notes |
|---|---|---|
id |
BIGSERIAL PK | |
tenant_id |
UUID FK → tenants | Nullable (global if NULL) |
name |
VARCHAR(255) | |
category |
VARCHAR(100) | lesson_task, exercise, exam_grading |
description |
TEXT | |
task_description_template |
TEXT | With {{variables}} |
system_prompt_template |
TEXT | With {{variables}} |
variables |
JSONB | |
is_system |
BOOLEAN | Built-in templates |
created_by |
UUID FK → auth.users |
Teacher configuration for per-course AI tutor.
| Column | Type | Notes |
|---|---|---|
tutor_id |
UUID PK | |
course_id |
INTEGER FK → courses, UNIQUE | |
tenant_id |
UUID FK → tenants | |
persona |
TEXT | |
teaching_approach |
TEXT | |
boundaries |
TEXT | |
enabled |
BOOLEAN | |
model_config |
JSONB |
Student chat sessions and messages with the course AI tutor. Both tenant-scoped.
Puck visual editor page data. Tenant-scoped.
| Column | Type | Notes |
|---|---|---|
page_id |
UUID PK | |
tenant_id |
UUID FK → tenants | |
title |
TEXT | |
slug |
TEXT | |
is_published |
BOOLEAN | |
puck_data |
JSONB | Puck visual editor data |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
UNIQUE(tenant_id, slug). Old sections/settings columns have been dropped.
Storage bucket: landing-page-assets (public, 5MB limit, images only).
10 tables prefixed gamification_. Tenant-scoped except gamification_levels, which is global.
| Table | Purpose |
|---|---|
gamification_levels |
GLOBAL — level thresholds and titles |
gamification_profiles |
Per-user XP, level, streaks. Created lazily by award_xp() on first award. No coins column — balance = floor(total_xp/10) - total_coins_spent |
gamification_xp_transactions |
XP earn/spend log |
gamification_achievements |
Achievement definitions (bronze → diamond tiers) |
gamification_user_achievements |
Earned achievements per user |
gamification_store_items |
Store items. Column is name (NOT title), is_available (NOT is_active) |
gamification_redemptions |
Item purchases |
gamification_user_rewards |
Active rewards (e.g., double XP) |
gamification_leaderboard_cache |
Cached leaderboard rankings |
gamification_challenge_participants |
Challenge participation |
gamification_daily_capsandgamification_challengeswere documented here previously but do not exist in the database — don't query them.
Weekly leagues live in separate tables: league_tiers (global tier definitions) and league_memberships (per-user, per-week standings). See GAMIFICATION.md.
Personal access tokens for CLI/programmatic MCP access.
| Column | Type | Notes |
|---|---|---|
id |
BIGSERIAL PK | |
user_id |
UUID FK → auth.users | |
token_hash |
TEXT UNIQUE | SHA-256 hash (never stored plaintext) |
name |
TEXT | User-friendly identifier |
last_used_at |
TIMESTAMPTZ | |
expires_at |
TIMESTAMPTZ | NULL = never expires |
is_active |
BOOLEAN | |
created_ip |
INET | |
last_used_ip |
INET | |
created_at |
TIMESTAMPTZ |
Defines the 5-tier pricing (Free, Starter, Pro, Business, Enterprise). Single source of truth for feature gating.
| Column | Type | Notes |
|---|---|---|
plan_id |
UUID PK | |
slug |
VARCHAR(50) UNIQUE | free, starter, pro, business, enterprise |
name |
VARCHAR(100) | |
price_monthly |
NUMERIC(10,2) | 0 for free |
price_yearly |
NUMERIC(10,2) | ~17% discount |
stripe_price_id_monthly |
VARCHAR(255) | |
stripe_price_id_yearly |
VARCHAR(255) | |
features |
JSONB | Feature flags |
limits |
JSONB | {max_courses, max_students} — -1 = unlimited |
transaction_fee_percent |
NUMERIC(5,2) | 10% → 0% |
sort_order |
INTEGER | |
is_active |
BOOLEAN |
Each school's billing subscription. UNIQUE(tenant_id).
| Column | Type | Notes |
|---|---|---|
subscription_id |
UUID PK | |
tenant_id |
UUID FK → tenants | |
plan_id |
UUID FK → platform_plans | |
stripe_subscription_id |
VARCHAR(255) | |
stripe_customer_id |
VARCHAR(255) | |
status |
VARCHAR(50) | active, past_due, canceled, trialing |
payment_method |
VARCHAR(50) | stripe or manual_transfer |
interval |
VARCHAR(20) | monthly or yearly |
Manual bank transfer requests for plan upgrades (LATAM schools).
| Column | Type | Notes |
|---|---|---|
request_id |
UUID PK | |
tenant_id |
UUID FK → tenants | |
plan_id |
UUID FK → platform_plans | |
requested_by |
UUID FK → profiles | |
status |
VARCHAR(50) | pending → instructions_sent → payment_received → confirmed |
interval |
VARCHAR(20) | |
amount |
NUMERIC(10,2) | |
confirmed_by |
UUID FK → profiles |
| Function | Purpose |
|---|---|
handle_new_user() |
Trigger on auth.users INSERT — creates profile + assigns student role |
custom_access_token_hook(event JSONB) |
Injects user_role, tenant_role, tenant_id, is_super_admin into JWT |
| Function | Purpose |
|---|---|
enroll_user(_user_id uuid, _product_id integer) |
Grants entitlements for ALL courses linked to the product (FOR loop over product_courses) |
handle_new_subscription(_user_id uuid, _plan_id integer, _transaction_id integer, _start_date timestamptz) |
Creates the subscription and writes entitlements for the plan's courses. Trigger-invoked — note the 4th arg |
has_course_access(_user_id uuid, _course_id integer) |
Access check against entitlements. Second arg is integer — cast ::int when calling from SQL. Has no staff branch: teachers/admins are not implicitly granted access by this function |
trigger_manage_transactions() |
Trigger on transaction status → 'successful': calls enroll_user() or handle_new_subscription() |
| Function | Purpose |
|---|---|
create_exam_submission(p_student_id uuid, p_exam_id integer, p_answers jsonb) |
Creates exam submission, returns submission_id |
save_exam_feedback(p_submission_id integer, p_exam_id integer, p_student_id uuid, p_answers jsonb, p_overall_feedback text, p_score numeric, p_question_feedback jsonb, p_ai_model varchar, p_processing_time_ms integer) |
Saves AI feedback. All params are p_-prefixed and there are nine of them |
| Function | Purpose |
|---|---|
award_xp(_user_id uuid, _action_type text, _xp_amount integer, _reference_id text, _reference_type text) |
Awards XP, updates streaks, levels up. Creates the gamification profile lazily via UPSERT |
award_xp(…, _tenant_id uuid) |
Overload — same, scoped to an explicit tenant. Trigger functions call this one |
| Function | Purpose |
|---|---|
issue_certificate_if_eligible(p_user_id uuid, p_course_id integer) |
SECURITY DEFINER — the auto-issue path. Template-gated: with no active certificate_templates row for the course, nothing is issued (this is why 100% completion can produce no certificate) |
check_and_issue_certificate(p_user_id uuid, p_course_id integer) |
SECURITY DEFINER — checks completion and issues |
calculate_course_completion(p_user_id uuid, p_course_id integer) |
SECURITY DEFINER — % completion for a course |
| Function | Purpose |
|---|---|
get_plan_features(_tenant_id uuid) |
Returns {plan, plan_name, features, limits, transaction_fee_percent} for feature gating — single source of truth |
get_gamification_features(_tenant_id uuid) |
Gamification feature flags; reads platform_plans.features the same way |
get_tenant_id() |
Resolves the caller's tenant inside RLS policies (JWT claim → anon header → NULL). Fails closed |
get_platform_stats() |
Aggregate platform statistics for the super admin dashboard |
validate_mcp_api_token(token_input text) |
Validates an API token, returns user info |
RLS is enabled on all tenant-scoped tables. Every table with a tenant_id column has RLS policies.
-- SELECT: users in the tenant can read
CREATE POLICY "Users in tenant can view"
ON table_name FOR SELECT
USING (tenant_id IN (
SELECT tu.tenant_id FROM tenant_users tu
WHERE tu.user_id = auth.uid() AND tu.status = 'active'
));
-- ALL: teachers/admins can manage
CREATE POLICY "Teachers and admins can manage"
ON table_name FOR ALL
USING (tenant_id IN (
SELECT tu.tenant_id FROM tenant_users tu
WHERE tu.user_id = auth.uid() AND tu.role IN ('teacher', 'admin') AND tu.status = 'active'
));Key Principles:
- Students can read published content in enrolled courses
- Teachers can manage their own content
- Admins have full access within their tenant
- Users can only update their own profile
createAdminClient()bypasses RLS — always validate tenant ownership manually
import { createClient } from '@/lib/supabase/client'
const supabase = createClient()
const { data, error } = await supabase
.from('courses')
.select('*')
.eq('tenant_id', tenantId)
.eq('status', 'published')import { createClient } from '@/lib/supabase/server'
import { getCurrentTenantId } from '@/lib/supabase/tenant'
const supabase = await createClient()
const tenantId = await getCurrentTenantId()
const { data, error } = await supabase
.from('courses')
.select('*')
.eq('tenant_id', tenantId)
.eq('status', 'published')import { createAdminClient } from '@/lib/supabase/admin'
const supabase = createAdminClient()
// ALWAYS validate tenant ownership manually before writes- Create the migration:
supabase migration new add_table_name - Write the SQL, including a
tenant_id UUID NOT NULL REFERENCES tenants(id)column if the table is tenant-scoped ALTER TABLE … ENABLE ROW LEVEL SECURITYand add policies — in the same migration- Verify from scratch locally:
npm run db:reset - Regenerate types:
npm run db:types→ writeslib/database.types.ts. Commit it with the migration - Apply to cloud:
npm run db:push
Permissive RLS policies OR together — adding a second policy widens access, it never narrows it. To tighten an existing policy you must
DROP POLICYthenCREATE POLICY.
Adding .eq('tenant_id', …) to any of these errors the whole query — a common cause of blank pages. Isolation for the child tables comes from RLS through their parent row.
aristotle_messages, assignments, certificate_shares, certificate_verification_log, chats, comment_flags, comment_reactions, comments, community_poll_options, content_versions, device_push_tokens, exam_ai_configs, exam_answers, exam_question_scores, exam_questions, exam_scores, exam_views, exercise_code_student_submissions, exercise_completions, exercise_files, exercise_messages, gamification_levels, grades, issuer_keys, landing_page_templates, league_tiers, lesson_comments, lesson_completions, lesson_passed, lesson_views, lessons_ai_task_messages, lessons_ai_tasks, mcp_api_tokens, mcp_audit_log, messages, notification_preferences, permissions, plan_courses, platform_plans, profiles, question_options, reviews, role_permissions, roles, submissions, super_admins, system_settings, teacher_preview_sessions, tenants, ticket_messages, tickets, user_notifications, user_roles, user_ui_state, webhook_events
Regenerate that list any time:
SELECT t.table_name FROM information_schema.tables t
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE'
AND NOT EXISTS (SELECT 1 FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = t.table_name
AND c.column_name = 'tenant_id')
ORDER BY 1;This page is hand-maintained and will drift — the schema is 116 tables across 168 migrations. Treat it as orientation, and confirm anything load-bearing against the database itself. The authoritative artifacts, in order:
lib/database.types.ts— generated, always correct if regenerated. Grep it first.- The live database — introspect it directly.
- This document — curated context and pitfalls that the generated types can't express.
# Is the committed type file in sync? (empty diff = yes; the only expected
# difference is the __InternalSupabase header that --linked adds)
npx supabase gen types typescript --local --schema public > /tmp/types.ts
diff /tmp/types.ts lib/database.types.ts
# Regenerate the committed file from the linked cloud project
npm run db:types
# Introspect the local database (no psql binary needed)
docker exec -i supabase_db_lms-front psql -U postgres -d postgres -c "\d courses"
docker exec -i supabase_db_lms-front psql -U postgres -d postgres \
-c "SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'subscriptions' ORDER BY ordinal_position;"
# Every enum and its values
docker exec -i supabase_db_lms-front psql -U postgres -d postgres \
-c "SELECT t.typname, string_agg(e.enumlabel, ', ' ORDER BY e.enumsortorder)
FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid GROUP BY 1 ORDER BY 1;"
# Exact RPC signatures before calling one
docker exec -i supabase_db_lms-front psql -U postgres -d postgres \
-c "SELECT p.proname, pg_get_function_identity_arguments(p.oid) FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public'
AND p.proname = 'award_xp';"If you find this page wrong, fix it in the same PR — that is how it stays useful.