Data model

Platform

Data model

Additive tables on Postgres 17, defined with Drizzle in packages/db. Better Auth owns the base user table. Two vector columns and one vector table carry the AI features, and row-level security scopes both rows and vectors.

Entity relationships

Only the columns that matter for the AI features and access rules are drawn inside the entities. The full column lists are below.

Entity relationship diagram

holdshashasteacheshasjoinscontainssplit_intohashasreceivessubmitsgetsissuesearnsauthorstracked_bylistscategorizes

USER

USER_ROLES

uuid

user_id

user_role

role

boolean

approved

TRAINEE_PROFILES

TRAINER_PROFILES

uuid

user_id

uuid_array

specialization_ids

vector768

competency_embedding

string

embedding_model

COURSES

uuid

id

string

status

vector768

competency_embedding

string

embedding_model

ENROLLMENTS

LEARNING_RESOURCES

uuid

id

string

resource_type

string

file_path

RESOURCE_CHUNKS

uuid

id

uuid

resource_id

text

chunk_text

vector768

embedding

string

embedding_model

QUESTIONNAIRES

uuid

id

timestamptz

deadline

boolean

ai_generated

string

status

QUESTIONS

QUESTIONNAIRE_ATTEMPTS

COURSE_FEEDBACK

CERTIFICATES

NOTICES

NOTICE_READS

DROPDOWN_GROUPS

DROPDOWN_OPTIONS

user is the Better Auth table. Vector columns are 768 wide.

Table reference

Open a group for its columns. Types are Postgres types; UUID primary keys use gen_random_uuid().

People and roles
TableColumns
user_rolesuser_id UUID FK user, role user_role, approved BOOLEAN default false. PK (user_id, role). Enum user_role = super_admin, admin, trainer, trainee
trainee_profilesuser_id PK, qualifications JSONB, work_experience JSONB, interests TEXT[], skills TEXT[], certificates JSONB, bio, updated_at
trainer_profilesuser_id PK, specialization_ids UUID[] (dropdown_options), bio, competency_embedding VECTOR(768), embedding_model TEXT, updated_at
Courses and resources
TableColumns
coursesid, title, description, subject_id FK dropdown_options, trainer_id FK user, status draft|published|archived, competency_embedding VECTOR(768), embedding_model, created_at
enrollmentsid, course_id, trainee_id, status active|completed|dropped, enrolled_at. UNIQUE (course_id, trainee_id)
learning_resourcesid, course_id, uploaded_by, resource_type lecture|presentation|study_material, title, file_path, created_at
resource_chunksid, resource_id FK ON DELETE CASCADE, chunk_text, embedding VECTOR(768), embedding_model
Assessments
TableColumns
questionnairesid, course_id, created_by, title, deadline TIMESTAMPTZ, ai_generated, status draft|published|closed, created_at
questionsid, questionnaire_id FK ON DELETE CASCADE, prompt, options JSONB [{id, text}], correct_option_id TEXT, order_index
questionnaire_attemptsid, questionnaire_id, trainee_id, started_at, submitted_at, score NUMERIC, answers JSONB {question_id: option_id}. UNIQUE (questionnaire_id, trainee_id)
Feedback, certificates, notices, taxonomy
TableColumns
course_feedbackid, course_id, trainee_id, rating INT CHECK 1..5, comment, created_at
certificatesid, trainee_id, course_id, issued_at, file_path, verification_code TEXT UNIQUE (printed as text and QR)
noticesid, title, body, author_id, target_roles user_role[], created_at, expires_at
notice_readsnotice_id, user_id, read_at. PK (notice_id, user_id)
dropdown_groupsid, group_key UNIQUE. Seed: subject_specialization, skill_tag, course_category, assessment_category
dropdown_optionsid, group_id, label, sort_order, is_active

Row-level security policies

Three tables carry private text or vectors. Trainers see their own profile, resources are visible to their uploader, admins and enrolled trainees, and chunks inherit access from their parent resource.

packages/db/sql/rls.sql
ALTER TABLE trainer_profiles   ENABLE ROW LEVEL SECURITY;
ALTER TABLE learning_resources ENABLE ROW LEVEL SECURITY;
ALTER TABLE resource_chunks    ENABLE ROW LEVEL SECURITY;

CREATE POLICY trainer_own_profile ON trainer_profiles
  USING (user_id = current_setting('app.current_user_id', true)::uuid
      OR current_setting('app.current_role', true) = 'admin');

CREATE POLICY resource_owner_or_enrolled ON learning_resources
  USING (uploaded_by = current_setting('app.current_user_id', true)::uuid
      OR current_setting('app.current_role', true) = 'admin'
      OR course_id IN (SELECT course_id FROM enrollments
                       WHERE trainee_id = current_setting('app.current_user_id', true)::uuid));

-- resource_chunks has no owner column, so it defers to the parent row.
CREATE POLICY chunk_via_parent_resource ON resource_chunks
  USING (current_setting('app.current_role', true) = 'admin'
      OR resource_id IN (SELECT id FROM learning_resources
            WHERE uploaded_by = current_setting('app.current_user_id', true)::uuid
               OR course_id IN (SELECT course_id FROM enrollments
                                WHERE trainee_id = current_setting('app.current_user_id', true)::uuid)));
packages/db/sql/rls_super_admin.sql
-- Workers run as the app role super_admin and must write chunks, and refresh
-- embeddings on trainer_profiles and courses. One extra rule per table, nothing else changes.
CREATE POLICY chunk_super_admin ON resource_chunks
  USING (current_setting('app.current_role', true) = 'super_admin')
  WITH CHECK (current_setting('app.current_role', true) = 'super_admin');
Missing setting

current_setting(name, true) returns NULL when the setting is absent, so a request that skipped injectRLSContext matches no rows instead of erroring. A safe default.

Policies OR together

Several permissive policies on one table are combined with OR. Add rules; do not rewrite existing ones to widen access.

Vectors are data

Closeness of vectors reveals who resembles whom even when the text is hidden. Isolation has to cover the vector columns, which is why the competency query uses a separate role.

Postgres roles

RoleUsed byAttributes
app_userAPI request handlers and ai-service workersLOGIN, no BYPASSRLS, does not own the tables. RLS always applies. Workers pass app.current_role = super_admin.
matchersuggestTrainers onlyLOGIN, BYPASSRLS. Own connection string. Never imported by request handlers.
migratorDrizzle migrationsOwns the tables. Not used at runtime.

Vector indexes

packages/db/sql/vector_index.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Build AFTER content is loaded so ivfflat can train its lists on real rows.
CREATE INDEX resource_chunks_embedding_idx
  ON resource_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- Query time: how many lists to scan. Higher is slower and more accurate.
SET ivfflat.probes = 10;
Only chunks need an index

Trainers and courses number in the tens or hundreds, so an exact scan is fast enough. The chunk table grows with content and is the one that needs ivfflat.

Same operator everywhere

Use cosine distance (the <=> operator) in queries and vector_cosine_ops in the index. Mixing operators makes Postgres ignore the index.

Capacity Connect · Team Syntax Squad · SIH 2026 · PS 26075Code samples are implementation sketches.