From 72443e96956e74bba630da8e8c90115e8a15c891 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:05:50 -0400 Subject: [PATCH 1/3] feat(db): schema-foundation migrations 0019-0026 (full target schema) Authors the complete modular target schema as one coherent set: terms+schools+updated_at trigger (0019), academics catalog/offering/enrollment split with data-preserving catalog transform (0020), gradebook re-keyed to enrollment (0021), analytics re-keyed to offering (0022), graph integrity + mastery events (0023), identity profile split (0024), study/sessions integrity (0025), ops cleanup (0026). Text PKs, FKs+ON DELETE, real types, CHECK enums read off code, encryption columns kept TEXT. Validated 0001->0026 end-to-end against Postgres 15 incl. the catalog transform. Source of truth for the per-domain code PRs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../0019_conventions_terms_schools.sql | 46 ++++++ .../db/migrations/0020_academics_split.sql | 90 ++++++++++++ backend/db/migrations/0021_gradebook.sql | 41 ++++++ backend/db/migrations/0022_analytics.sql | 39 +++++ .../db/migrations/0023_graph_integrity.sql | 54 +++++++ backend/db/migrations/0024_identity_split.sql | 67 +++++++++ .../db/migrations/0025_study_integrity.sql | 136 ++++++++++++++++++ backend/db/migrations/0026_ops.sql | 28 ++++ 8 files changed, 501 insertions(+) create mode 100644 backend/db/migrations/0019_conventions_terms_schools.sql create mode 100644 backend/db/migrations/0020_academics_split.sql create mode 100644 backend/db/migrations/0021_gradebook.sql create mode 100644 backend/db/migrations/0022_analytics.sql create mode 100644 backend/db/migrations/0023_graph_integrity.sql create mode 100644 backend/db/migrations/0024_identity_split.sql create mode 100644 backend/db/migrations/0025_study_integrity.sql create mode 100644 backend/db/migrations/0026_ops.sql diff --git a/backend/db/migrations/0019_conventions_terms_schools.sql b/backend/db/migrations/0019_conventions_terms_schools.sql new file mode 100644 index 00000000..ac8cb3b2 --- /dev/null +++ b/backend/db/migrations/0019_conventions_terms_schools.sql @@ -0,0 +1,46 @@ +-- 0019: shared conventions + term/school entities (additive, non-breaking) +-- Part of the DB modular redesign (docs/superpowers/specs/2026-06-23-db-modular-redesign-design.md). +-- Nothing here breaks existing code; later migrations build on the terms entity + trigger. + +-- Reusable updated_at trigger. Every later mutable table attaches this so updated_at +-- can never be forgotten by application code. +CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Schools (optional namespacing for the catalog). Free-text courses.school is retired in 0020. +CREATE TABLE IF NOT EXISTS schools ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Structured, orderable term entity. "Current term" is date-derived (today in [start,end]). +CREATE TABLE IF NOT EXISTS terms ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + term TEXT NOT NULL CHECK (term IN ('Fall','Spring','Summer','Winter')), + year INTEGER NOT NULL, + label TEXT NOT NULL, -- e.g. 'Spring 2026' (matches legacy courses.semester) + start_date DATE NOT NULL, + end_date DATE NOT NULL, + sort_key INTEGER NOT NULL, -- year*10 + term ordinal (Spring=1,Summer=2,Fall=3,Winter=4) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (term, year) +); + +-- Seed canonical terms. The legacy catalog default is 'Spring 2026'; the surrounding terms +-- give date-derived "current" something to resolve to and establish ordering. +-- NOTE: contiguous, non-overlapping ranges so exactly one term contains any given date. +INSERT INTO terms (id, term, year, label, start_date, end_date, sort_key) VALUES + ('fall-2025', 'Fall', 2025, 'Fall 2025', '2025-08-25', '2026-01-04', 20253), + ('spring-2026', 'Spring', 2026, 'Spring 2026', '2026-01-05', '2026-05-17', 20261), + ('summer-2026', 'Summer', 2026, 'Summer 2026', '2026-05-18', '2026-08-23', 20262), + ('fall-2026', 'Fall', 2026, 'Fall 2026', '2026-08-24', '2027-01-03', 20263) +ON CONFLICT (term, year) DO NOTHING; + +-- Before promoting to prod: SELECT DISTINCT semester FROM courses; and add a terms row +-- (matching label) for any value not covered above, or 0020's term mapping will fail loudly. diff --git a/backend/db/migrations/0020_academics_split.sql b/backend/db/migrations/0020_academics_split.sql new file mode 100644 index 00000000..c7622b1f --- /dev/null +++ b/backend/db/migrations/0020_academics_split.sql @@ -0,0 +1,90 @@ +-- 0020: academics split — courses (catalog+offering+free-text term) -> abstract courses +-- + course_offerings (per-term) + terms FK; user_courses -> enrollments. +-- Data-preserving: the existing `courses` rows are the only catalog data and are transformed +-- in place (rename + reshape) rather than dropped. Runs identically on staging (empty) and +-- prod (real catalog). + +-- 1. The existing `courses` table is already offering-shaped (semester/instructor/meeting/ +-- location). Rename it to course_offerings; all inbound FKs follow the rename. +ALTER TABLE courses RENAME TO course_offerings; + +-- 2. Add the offering's structural columns (nullable for now; backfilled below). +ALTER TABLE course_offerings ADD COLUMN IF NOT EXISTS course_id TEXT; +ALTER TABLE course_offerings ADD COLUMN IF NOT EXISTS term_id TEXT; +ALTER TABLE course_offerings ADD COLUMN IF NOT EXISTS section TEXT; +ALTER TABLE course_offerings ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +-- 3. New abstract catalog table. +CREATE TABLE courses ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + school_id TEXT REFERENCES schools(id) ON DELETE RESTRICT, + course_code TEXT NOT NULL, + course_name TEXT NOT NULL, + department TEXT, + credits INTEGER, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + UNIQUE (school_id, course_code) +); + +-- 4. One abstract course per distinct course_code (collapse offerings). Aggregates pick a +-- representative name/dept/credits/description per code. +INSERT INTO courses (id, course_code, course_name, department, credits, description) +SELECT gen_random_uuid()::text, + course_code, + max(course_name), + max(department), + max(credits), + max(description) +FROM course_offerings +GROUP BY course_code; + +-- 5. Point each offering at its abstract course and term. +UPDATE course_offerings o + SET course_id = c.id + FROM courses c + WHERE c.course_code = o.course_code; + +UPDATE course_offerings o + SET term_id = t.id + FROM terms t + WHERE t.label = trim(o.semester); + +-- Fail loudly if any offering's semester string had no matching term (add the terms row in 0019). +ALTER TABLE course_offerings ALTER COLUMN course_id SET NOT NULL; +ALTER TABLE course_offerings ALTER COLUMN term_id SET NOT NULL; + +-- 6. Drop the now-abstract columns + legacy free-text term/school from the offering. +ALTER TABLE course_offerings DROP COLUMN course_name; +ALTER TABLE course_offerings DROP COLUMN department; +ALTER TABLE course_offerings DROP COLUMN credits; +ALTER TABLE course_offerings DROP COLUMN description; +ALTER TABLE course_offerings DROP COLUMN semester; +ALTER TABLE course_offerings DROP COLUMN school; -- free-text school retired; populate `schools` separately if needed + +-- 7. Constrain the offering. +ALTER TABLE course_offerings ADD CONSTRAINT course_offerings_course_id_fkey + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE; +ALTER TABLE course_offerings ADD CONSTRAINT course_offerings_term_id_fkey + FOREIGN KEY (term_id) REFERENCES terms(id) ON DELETE RESTRICT; +-- Plain UNIQUE (NULLs distinct): prevents exact dup sections, but legacy rows with an +-- unspecified (NULL) section — e.g. two sections of one course in a term — remain allowed. +ALTER TABLE course_offerings ADD CONSTRAINT course_offerings_unique + UNIQUE (course_id, term_id, section); + +-- 8. Enrollment: user_courses -> enrollments, course_id -> offering_id. The renamed table +-- keeps its data (none today), its user FK, and its UNIQUE(user_id, *) — all follow renames. +ALTER TABLE user_courses RENAME TO enrollments; +ALTER TABLE enrollments RENAME COLUMN course_id TO offering_id; +-- enrollments.offering_id now references course_offerings(id) (inherited from step 1's rename). +-- enrollments.syllabus_doc_id -> documents FK is re-established in 0025 (documents is recreated there). + +-- Triggers +DROP TRIGGER IF EXISTS trg_courses_updated_at ON courses; +CREATE TRIGGER trg_courses_updated_at BEFORE UPDATE ON courses + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_course_offerings_updated_at ON course_offerings; +CREATE TRIGGER trg_course_offerings_updated_at BEFORE UPDATE ON course_offerings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/backend/db/migrations/0021_gradebook.sql b/backend/db/migrations/0021_gradebook.sql new file mode 100644 index 00000000..10b04f7a --- /dev/null +++ b/backend/db/migrations/0021_gradebook.sql @@ -0,0 +1,41 @@ +-- 0021: gradebook re-keyed to enrollment. No user data, so drop/recreate to target shape. +-- 🔒 = column-encrypted (stays TEXT, decrypted at read via decrypt_numeric/decrypt_if_present). + +DROP TABLE IF EXISTS assignments CASCADE; +DROP TABLE IF EXISTS course_categories CASCADE; + +CREATE TABLE gradebook_categories ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + enrollment_id TEXT NOT NULL REFERENCES enrollments(id) ON DELETE CASCADE, + name TEXT NOT NULL, + weight NUMERIC NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_gradebook_categories_enrollment ON gradebook_categories(enrollment_id); + +CREATE TABLE assignments ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + enrollment_id TEXT REFERENCES enrollments(id) ON DELETE CASCADE, -- nullable: calendar-only items + category_id TEXT REFERENCES gradebook_categories(id) ON DELETE SET NULL, + title TEXT NOT NULL, + due_date DATE, + assignment_type TEXT CHECK (assignment_type IN ('homework','exam','reading','project','quiz','other')), + notes TEXT, -- 🔒 + points_possible TEXT, -- 🔒 (numeric semantics; decrypt_numeric at read) + points_earned TEXT, -- 🔒 + source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual','syllabus')), + google_event_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_assignments_enrollment ON assignments(enrollment_id); +CREATE INDEX idx_assignments_due ON assignments(due_date); + +DROP TRIGGER IF EXISTS trg_gradebook_categories_updated_at ON gradebook_categories; +CREATE TRIGGER trg_gradebook_categories_updated_at BEFORE UPDATE ON gradebook_categories + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_assignments_updated_at ON assignments; +CREATE TRIGGER trg_assignments_updated_at BEFORE UPDATE ON assignments + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/backend/db/migrations/0022_analytics.sql b/backend/db/migrations/0022_analytics.sql new file mode 100644 index 00000000..5cbade20 --- /dev/null +++ b/backend/db/migrations/0022_analytics.sql @@ -0,0 +1,39 @@ +-- 0022: class analytics re-keyed to offering. The last free-text `semester` columns disappear. +-- No user data -> drop/recreate. course_context_service.py upserts these via on_conflict. + +DROP TABLE IF EXISTS course_concept_stats CASCADE; +DROP TABLE IF EXISTS course_summary CASCADE; + +CREATE TABLE offering_concept_stats ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + offering_id TEXT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE, + concept_name TEXT NOT NULL, + student_count INTEGER NOT NULL DEFAULT 0, + avg_mastery_score DOUBLE PRECISION NOT NULL DEFAULT 0.0, + pct_mastered DOUBLE PRECISION NOT NULL DEFAULT 0.0, + pct_struggling DOUBLE PRECISION NOT NULL DEFAULT 0.0, + pct_unexplored DOUBLE PRECISION NOT NULL DEFAULT 0.0, + common_misconceptions TEXT[] NOT NULL DEFAULT '{}', + effective_explanations TEXT[] NOT NULL DEFAULT '{}', + prerequisite_gaps TEXT[] NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (offering_id, concept_name) +); + +CREATE TABLE offering_summary ( + offering_id TEXT PRIMARY KEY REFERENCES course_offerings(id) ON DELETE CASCADE, + student_count INTEGER NOT NULL DEFAULT 0, + avg_class_mastery DOUBLE PRECISION NOT NULL DEFAULT 0.0, + top_struggling_concepts TEXT[] NOT NULL DEFAULT '{}', + top_mastered_concepts TEXT[] NOT NULL DEFAULT '{}', + summary_text TEXT, + summary_hash TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +DROP TRIGGER IF EXISTS trg_offering_concept_stats_updated_at ON offering_concept_stats; +CREATE TRIGGER trg_offering_concept_stats_updated_at BEFORE UPDATE ON offering_concept_stats + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_offering_summary_updated_at ON offering_summary; +CREATE TRIGGER trg_offering_summary_updated_at BEFORE UPDATE ON offering_summary + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/backend/db/migrations/0023_graph_integrity.sql b/backend/db/migrations/0023_graph_integrity.sql new file mode 100644 index 00000000..55c8a235 --- /dev/null +++ b/backend/db/migrations/0023_graph_integrity.sql @@ -0,0 +1,54 @@ +-- 0023: knowledge-graph integrity — FKs, UNIQUE-backed dedup, indexes, mastery-event rows. +-- graph_nodes stay on the ABSTRACT course (mastery is cumulative across terms). +-- No user data -> drop/recreate. CASCADE clears FKs from note_concepts/quiz_* (rebuilt in 0025). + +DROP TABLE IF EXISTS graph_edges CASCADE; +DROP TABLE IF EXISTS graph_nodes CASCADE; + +CREATE TABLE graph_nodes ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + course_id TEXT REFERENCES courses(id) ON DELETE SET NULL, -- abstract course; nullable + concept_name TEXT NOT NULL, + subject TEXT, + mastery_score DOUBLE PRECISION NOT NULL DEFAULT 0.0, + mastery_tier TEXT NOT NULL DEFAULT 'unexplored' + CHECK (mastery_tier IN ('unexplored','struggling','learning','mastered','subject_root')), + times_studied INTEGER NOT NULL DEFAULT 0, + last_studied_at TIMESTAMPTZ, + color TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE NULLS NOT DISTINCT (user_id, course_id, concept_name) -- backs dedup; retires dedup_nodes.py (#181) +); +CREATE INDEX idx_graph_nodes_user ON graph_nodes(user_id); +CREATE INDEX idx_graph_nodes_course ON graph_nodes(course_id); + +-- Append-only mastery events (replaces graph_nodes.mastery_events jsonb; fixes non-atomic RMW #247). +CREATE TABLE node_mastery_events ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + node_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE, + delta DOUBLE PRECISION NOT NULL, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_node_mastery_events_node ON node_mastery_events(node_id, created_at); + +CREATE TABLE graph_edges ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- FK added (#179) + source_node_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE, + target_node_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE, + relationship_type TEXT NOT NULL DEFAULT 'related' + CHECK (relationship_type IN ('related','prerequisite','builds_on','part_of')), + strength DOUBLE PRECISION NOT NULL DEFAULT 0.5, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, source_node_id, target_node_id, relationship_type) -- backs dedup (#195) +); +CREATE INDEX idx_graph_edges_user ON graph_edges(user_id); +CREATE INDEX idx_graph_edges_source ON graph_edges(source_node_id); +CREATE INDEX idx_graph_edges_target ON graph_edges(target_node_id); -- (#160) + +DROP TRIGGER IF EXISTS trg_graph_nodes_updated_at ON graph_nodes; +CREATE TRIGGER trg_graph_nodes_updated_at BEFORE UPDATE ON graph_nodes + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/backend/db/migrations/0024_identity_split.sql b/backend/db/migrations/0024_identity_split.sql new file mode 100644 index 00000000..e823e9ba --- /dev/null +++ b/backend/db/migrations/0024_identity_split.sql @@ -0,0 +1,67 @@ +-- 0024: identity split — public profile moves out of `users` into `user_profiles`, and the +-- duplicated profile columns are removed from `user_settings`. One source of truth per field. +-- `users` is NOT dropped (social/gamification FK it); we slim it in place. No user data today. +-- 🔒 = column-encrypted (stays TEXT). + +CREATE TABLE user_profiles ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + name TEXT, -- 🔒 + first_name TEXT, -- 🔒 + last_name TEXT, -- 🔒 + username TEXT UNIQUE, + avatar_url TEXT, + bio TEXT, -- 🔒 + location TEXT, -- 🔒 + website TEXT, + year TEXT, -- free-text (class standing); no fixed set + majors TEXT[] NOT NULL DEFAULT '{}', + minors TEXT[] NOT NULL DEFAULT '{}', + learning_style TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Slim `users` to identity + auth + activity. (Empty table -> USING casts are never evaluated.) +ALTER TABLE users DROP COLUMN IF EXISTS name; +ALTER TABLE users DROP COLUMN IF EXISTS first_name; +ALTER TABLE users DROP COLUMN IF EXISTS last_name; +ALTER TABLE users DROP COLUMN IF EXISTS username; +ALTER TABLE users DROP COLUMN IF EXISTS avatar_url; +ALTER TABLE users DROP COLUMN IF EXISTS bio; +ALTER TABLE users DROP COLUMN IF EXISTS location; +ALTER TABLE users DROP COLUMN IF EXISTS website; +ALTER TABLE users DROP COLUMN IF EXISTS year; +ALTER TABLE users DROP COLUMN IF EXISTS majors; +ALTER TABLE users DROP COLUMN IF EXISTS minors; +ALTER TABLE users DROP COLUMN IF EXISTS learning_style; + +ALTER TABLE users ALTER COLUMN last_active_date TYPE DATE USING last_active_date::date; +ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); +ALTER TABLE users RENAME COLUMN room_id TO current_room_id; +ALTER TABLE users ADD CONSTRAINT users_current_room_id_fkey + FOREIGN KEY (current_room_id) REFERENCES rooms(id) ON DELETE SET NULL; + +-- Remove profile fields duplicated onto user_settings (now owned by user_profiles). +ALTER TABLE user_settings DROP COLUMN IF EXISTS display_name; +ALTER TABLE user_settings DROP COLUMN IF EXISTS username; +ALTER TABLE user_settings DROP COLUMN IF EXISTS bio; +ALTER TABLE user_settings DROP COLUMN IF EXISTS location; +ALTER TABLE user_settings DROP COLUMN IF EXISTS website; + +-- OAuth token expiry as a real instant; add updated_at. +ALTER TABLE oauth_tokens ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at::timestamptz; +ALTER TABLE oauth_tokens ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +-- Triggers +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; +CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_user_profiles_updated_at ON user_profiles; +CREATE TRIGGER trg_user_profiles_updated_at BEFORE UPDATE ON user_profiles + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_user_settings_updated_at ON user_settings; +CREATE TRIGGER trg_user_settings_updated_at BEFORE UPDATE ON user_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_oauth_tokens_updated_at ON oauth_tokens; +CREATE TRIGGER trg_oauth_tokens_updated_at BEFORE UPDATE ON oauth_tokens + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/backend/db/migrations/0025_study_integrity.sql b/backend/db/migrations/0025_study_integrity.sql new file mode 100644 index 00000000..4ee5b227 --- /dev/null +++ b/backend/db/migrations/0025_study_integrity.sql @@ -0,0 +1,136 @@ +-- 0025: study & sessions — FKs, indexes, real types, enums, offering scoping. No user data -> +-- drop/recreate. Class artifacts reference the OFFERING; concept links reference graph_nodes. +-- 🔒 = column-encrypted (stays TEXT). + +DROP TABLE IF EXISTS messages CASCADE; +DROP TABLE IF EXISTS sessions CASCADE; +DROP TABLE IF EXISTS note_concepts CASCADE; +DROP TABLE IF EXISTS notes CASCADE; +DROP TABLE IF EXISTS quiz_context CASCADE; +DROP TABLE IF EXISTS quiz_attempts CASCADE; +DROP TABLE IF EXISTS study_guides CASCADE; +DROP TABLE IF EXISTS flashcards CASCADE; +DROP TABLE IF EXISTS documents CASCADE; -- also drops enrollments.syllabus_doc_id FK; re-added at end + +CREATE TABLE documents ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + offering_id TEXT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + category TEXT NOT NULL CHECK (category IN + ('syllabus','lecture_notes','slides','reading','assignment','study_guide','other')), + summary TEXT, -- 🔒 + concept_notes TEXT, -- 🔒 + flashcards JSONB, + request_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + processed_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_documents_user ON documents(user_id); +CREATE INDEX idx_documents_offering ON documents(offering_id); + +CREATE TABLE notes ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- FK added (#180) + offering_id TEXT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE, -- FK added (#180) + title TEXT, -- 🔒 + body TEXT, -- 🔒 + tags TEXT[] NOT NULL DEFAULT '{}', + last_summary TEXT, -- 🔒 + last_summary_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_notes_user ON notes(user_id); +CREATE INDEX idx_notes_offering ON notes(offering_id); + +CREATE TABLE note_concepts ( + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + concept_node_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE, -- FK added + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (note_id, concept_node_id) +); + +CREATE TABLE flashcards ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + offering_id TEXT REFERENCES course_offerings(id) ON DELETE SET NULL, + topic TEXT NOT NULL, + front TEXT NOT NULL, + back TEXT NOT NULL, + times_reviewed INTEGER NOT NULL DEFAULT 0, + last_rating INTEGER, + last_reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_flashcards_user ON flashcards(user_id); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + offering_id TEXT REFERENCES course_offerings(id) ON DELETE SET NULL, -- nullable: general tutoring + mode TEXT NOT NULL CHECK (mode IN ('socratic','expository','teachback')), + topic TEXT NOT NULL, + name TEXT, + summary_json TEXT, -- 🔒 + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ +); +CREATE INDEX idx_sessions_user ON sessions(user_id); + +CREATE TABLE messages ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL, -- 'user' / 'assistant' (set in code; left unconstrained) + content TEXT NOT NULL, -- 🔒 + graph_update_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_messages_session ON messages(session_id, created_at); + +CREATE TABLE quiz_attempts ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concept_node_id TEXT REFERENCES graph_nodes(id) ON DELETE SET NULL, + score INTEGER, + total INTEGER, + difficulty TEXT CHECK (difficulty IN ('easy','medium','hard')), + questions_json JSONB, + answers_json JSONB, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_quiz_attempts_user ON quiz_attempts(user_id); +CREATE INDEX idx_quiz_attempts_concept ON quiz_attempts(concept_node_id); + +CREATE TABLE quiz_context ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concept_node_id TEXT NOT NULL REFERENCES graph_nodes(id) ON DELETE CASCADE, + context_json JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE study_guides ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + offering_id TEXT NOT NULL REFERENCES course_offerings(id) ON DELETE CASCADE, + exam_id TEXT NOT NULL, + content JSONB NOT NULL, + generated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_study_guides_user ON study_guides(user_id); + +-- Re-establish the enrollment -> document back-reference (dropped with documents above). +ALTER TABLE enrollments ADD CONSTRAINT enrollments_syllabus_doc_id_fkey + FOREIGN KEY (syllabus_doc_id) REFERENCES documents(id) ON DELETE SET NULL; + +-- Triggers (mutable tables) +DROP TRIGGER IF EXISTS trg_notes_updated_at ON notes; +CREATE TRIGGER trg_notes_updated_at BEFORE UPDATE ON notes + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +DROP TRIGGER IF EXISTS trg_quiz_context_updated_at ON quiz_context; +CREATE TRIGGER trg_quiz_context_updated_at BEFORE UPDATE ON quiz_context + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/backend/db/migrations/0026_ops.sql b/backend/db/migrations/0026_ops.sql new file mode 100644 index 00000000..d61aed07 --- /dev/null +++ b/backend/db/migrations/0026_ops.sql @@ -0,0 +1,28 @@ +-- 0026: ops cleanup — add the missing user FKs and retire the integer-sequence PKs for +-- convention consistency (text/uuid like the rest). No user data -> drop/recreate. + +DROP TABLE IF EXISTS feedback CASCADE; +DROP TABLE IF EXISTS issue_reports CASCADE; + +CREATE TABLE feedback ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + type TEXT NOT NULL, + rating INTEGER NOT NULL, + selected_options JSONB NOT NULL DEFAULT '[]', + comment TEXT, + topic TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_feedback_user ON feedback(user_id); + +CREATE TABLE issue_reports ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + topic TEXT NOT NULL, + description TEXT NOT NULL, + screenshot_urls JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_issue_reports_user ON issue_reports(user_id); From e82646433d11dcb8b87a067a7120c6e7ecac81d5 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Wed, 24 Jun 2026 01:19:05 -0400 Subject: [PATCH 2/3] fix(db): add gen_random_uuid default to course_offerings and enrollments ids The renamed baseline tables inherited a TEXT PRIMARY KEY with no default, unlike every other table in the redesign. Add gen_random_uuid()::text so the app does not have to supply ids on insert for these two tables. --- backend/db/migrations/0020_academics_split.sql | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/db/migrations/0020_academics_split.sql b/backend/db/migrations/0020_academics_split.sql index c7622b1f..248a71f9 100644 --- a/backend/db/migrations/0020_academics_split.sql +++ b/backend/db/migrations/0020_academics_split.sql @@ -8,6 +8,11 @@ -- location). Rename it to course_offerings; all inbound FKs follow the rename. ALTER TABLE courses RENAME TO course_offerings; +-- 1a. The renamed table inherits its baseline `id TEXT PRIMARY KEY` with no default. Give it +-- the same gen_random_uuid()::text default as every other table in this redesign so the app +-- does not have to supply ids on insert. +ALTER TABLE course_offerings ALTER COLUMN id SET DEFAULT gen_random_uuid()::text; + -- 2. Add the offering's structural columns (nullable for now; backfilled below). ALTER TABLE course_offerings ADD COLUMN IF NOT EXISTS course_id TEXT; ALTER TABLE course_offerings ADD COLUMN IF NOT EXISTS term_id TEXT; @@ -78,6 +83,9 @@ ALTER TABLE course_offerings ADD CONSTRAINT course_offerings_unique -- keeps its data (none today), its user FK, and its UNIQUE(user_id, *) — all follow renames. ALTER TABLE user_courses RENAME TO enrollments; ALTER TABLE enrollments RENAME COLUMN course_id TO offering_id; +-- Same as course_offerings: the renamed table inherits its baseline `id TEXT PRIMARY KEY` +-- with no default; give it the redesign's gen_random_uuid()::text default. +ALTER TABLE enrollments ALTER COLUMN id SET DEFAULT gen_random_uuid()::text; -- enrollments.offering_id now references course_offerings(id) (inherited from step 1's rename). -- enrollments.syllabus_doc_id -> documents FK is re-established in 0025 (documents is recreated there). From c26ecf4b0011803c1847ff37a49147dd54b54afd Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:35:50 -0400 Subject: [PATCH 3/3] feat(db): absorb parallel gradebook/newsletter/gradescope schema into redesign Folds the in-flight DB changes from origin/Gradebook and origin/chore/staging-ops-scripts into the redesign so nothing is lost and the schema stays the single source of truth: drop-lowest -> gradebook_categories.drop_lowest; bell-curve policy -> enrollments.curve_*; per-assignment curve stats + gradescope_assignment_id -> assignments; gradescope_credentials + gradescope_course_links (link re-targeted to enrollment_id) -> 0027; newsletter_emails.approved_at -> 0026. Those branches' migration files are now superseded; their CODE rewire is tracked in filed issues. Validated 0001->0027 against Postgres 15. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/db/migrations/0021_gradebook.sql | 23 +++++++++++++ backend/db/migrations/0026_ops.sql | 4 +++ backend/db/migrations/0027_gradescope.sql | 39 +++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 backend/db/migrations/0027_gradescope.sql diff --git a/backend/db/migrations/0021_gradebook.sql b/backend/db/migrations/0021_gradebook.sql index 10b04f7a..af8a80c6 100644 --- a/backend/db/migrations/0021_gradebook.sql +++ b/backend/db/migrations/0021_gradebook.sql @@ -1,15 +1,27 @@ -- 0021: gradebook re-keyed to enrollment. No user data, so drop/recreate to target shape. -- 🔒 = column-encrypted (stays TEXT, decrypted at read via decrypt_numeric/decrypt_if_present). +-- ABSORBS the parallel `origin/Gradebook` work, re-expressed against the enrollment-keyed shape: +-- * drop-lowest policy (was course_categories.drop_lowest) -> gradebook_categories.drop_lowest +-- * bell-curve policy (was user_courses.curve_*) -> enrollments.curve_* +-- * per-assignment curve stats + gradescope id (was assignments.*) -> assignments below +-- Gradescope credential/link tables live in 0027. See issues filed for the code rewire. DROP TABLE IF EXISTS assignments CASCADE; DROP TABLE IF EXISTS course_categories CASCADE; +-- Per-course curve policy lives on the enrollment row (was user_courses.curve_*). +ALTER TABLE enrollments + ADD COLUMN curve_mode TEXT NOT NULL DEFAULT 'raw' CHECK (curve_mode IN ('raw','curved')), + ADD COLUMN curve_avg_target NUMERIC, + ADD COLUMN curve_sd_delta NUMERIC; + CREATE TABLE gradebook_categories ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, enrollment_id TEXT NOT NULL REFERENCES enrollments(id) ON DELETE CASCADE, name TEXT NOT NULL, weight NUMERIC NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, + drop_lowest INTEGER NOT NULL DEFAULT 0 CHECK (drop_lowest >= 0), -- absorbed from 0019_gradebook_drops created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -27,11 +39,22 @@ CREATE TABLE assignments ( points_earned TEXT, -- 🔒 source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual','syllabus')), google_event_id TEXT, + -- absorbed from origin/Gradebook (curve stats are plaintext NUMERIC class stats, not student-identifying) + gradescope_assignment_id TEXT, + curve_class_mean NUMERIC, + curve_class_sd NUMERIC, + curve_avg_target NUMERIC, + curve_sd_delta NUMERIC, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_assignments_enrollment ON assignments(enrollment_id); CREATE INDEX idx_assignments_due ON assignments(due_date); +-- Gradescope idempotency key, re-targeted from course_id to enrollment_id (assignments are +-- now enrollment-scoped). Only enforced when a gradescope id is present. +CREATE UNIQUE INDEX idx_assignments_gradescope_id + ON assignments(enrollment_id, gradescope_assignment_id) + WHERE gradescope_assignment_id IS NOT NULL; DROP TRIGGER IF EXISTS trg_gradebook_categories_updated_at ON gradebook_categories; CREATE TRIGGER trg_gradebook_categories_updated_at BEFORE UPDATE ON gradebook_categories diff --git a/backend/db/migrations/0026_ops.sql b/backend/db/migrations/0026_ops.sql index d61aed07..0d715190 100644 --- a/backend/db/migrations/0026_ops.sql +++ b/backend/db/migrations/0026_ops.sql @@ -26,3 +26,7 @@ CREATE TABLE issue_reports ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_issue_reports_user ON issue_reports(user_id); + +-- Absorbed from 0019_newsletter_approved_at (drift fix; prod already has this column). +-- Admin allowlist endpoints (routes/admin.py) read/write it; NULL = pending approval. +ALTER TABLE newsletter_emails ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; diff --git a/backend/db/migrations/0027_gradescope.sql b/backend/db/migrations/0027_gradescope.sql new file mode 100644 index 00000000..26675a91 --- /dev/null +++ b/backend/db/migrations/0027_gradescope.sql @@ -0,0 +1,39 @@ +-- 0027: Gradescope sync tables, absorbed from origin/Gradebook (0020_gradescope) and +-- re-expressed against the redesign. The assignments.gradescope_assignment_id column + +-- idempotency index live in 0021 (assignments is created there). +-- 🔒 = column-encrypted (stays TEXT). +-- +-- REWIRE NEEDED (see filed issue): the per-course link is re-targeted from courses(id) to +-- enrollments(id), because gradescope sync writes into a specific enrolled class's gradebook +-- (assignments are now enrollment-scoped). Confirm this matches the intended picker UX. + +-- Encrypted Gradescope credentials, one row per user. Unchanged from the original. +CREATE TABLE gradescope_credentials ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + auth_mode TEXT NOT NULL DEFAULT 'password' CHECK (auth_mode IN ('password','cookies')), + email_encrypted TEXT, -- 🔒 + password_encrypted TEXT, -- 🔒 + cookies_encrypted TEXT, -- 🔒 + last_synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT gradescope_credentials_payload_chk CHECK ( + (auth_mode = 'password' AND email_encrypted IS NOT NULL AND password_encrypted IS NOT NULL) + OR (auth_mode = 'cookies' AND cookies_encrypted IS NOT NULL) + ) +); + +-- Per-enrollment link to a Gradescope course id (was per (user, courses.id)). +CREATE TABLE gradescope_course_links ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + enrollment_id TEXT NOT NULL REFERENCES enrollments(id) ON DELETE CASCADE, + gradescope_course_id TEXT NOT NULL, + last_synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (enrollment_id, gradescope_course_id) +); +CREATE INDEX idx_gradescope_links_enrollment ON gradescope_course_links(enrollment_id); + +DROP TRIGGER IF EXISTS trg_gradescope_credentials_updated_at ON gradescope_credentials; +CREATE TRIGGER trg_gradescope_credentials_updated_at BEFORE UPDATE ON gradescope_credentials + FOR EACH ROW EXECUTE FUNCTION set_updated_at();