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..248a71f9 --- /dev/null +++ b/backend/db/migrations/0020_academics_split.sql @@ -0,0 +1,98 @@ +-- 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; + +-- 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; +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; +-- 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). + +-- 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..af8a80c6 --- /dev/null +++ b/backend/db/migrations/0021_gradebook.sql @@ -0,0 +1,64 @@ +-- 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() +); +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, + -- 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 + 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..0d715190 --- /dev/null +++ b/backend/db/migrations/0026_ops.sql @@ -0,0 +1,32 @@ +-- 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); + +-- 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();