Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/db/migrations/0019_conventions_terms_schools.sql
Original file line numberDiff line numberDiff line change
@@ -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.
98 changes: 98 additions & 0 deletions backend/db/migrations/0020_academics_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
64 changes: 64 additions & 0 deletions backend/db/migrations/0021_gradebook.sql
Original file line numberDiff line numberDiff line change
@@ -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();
39 changes: 39 additions & 0 deletions backend/db/migrations/0022_analytics.sql
Original file line numberDiff line numberDiff line change
@@ -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();
54 changes: 54 additions & 0 deletions backend/db/migrations/0023_graph_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -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();
67 changes: 67 additions & 0 deletions backend/db/migrations/0024_identity_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/db/migrations/0019_conventions_terms_schools.sql
Original file line numberDiff line numberDiff line change
@@ -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.
98 changes: 98 additions & 0 deletions backend/db/migrations/0020_academics_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
64 changes: 64 additions & 0 deletions backend/db/migrations/0021_gradebook.sql
Original file line numberDiff line numberDiff line change
@@ -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();
39 changes: 39 additions & 0 deletions backend/db/migrations/0022_analytics.sql
Original file line numberDiff line numberDiff line change
@@ -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();
54 changes: 54 additions & 0 deletions backend/db/migrations/0023_graph_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -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();
67 changes: 67 additions & 0 deletions backend/db/migrations/0024_identity_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/db/migrations/0019_conventions_terms_schools.sql
Original file line numberDiff line numberDiff line change
@@ -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.
98 changes: 98 additions & 0 deletions backend/db/migrations/0020_academics_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
64 changes: 64 additions & 0 deletions backend/db/migrations/0021_gradebook.sql
Original file line numberDiff line numberDiff line change
@@ -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();
39 changes: 39 additions & 0 deletions backend/db/migrations/0022_analytics.sql
Original file line numberDiff line numberDiff line change
@@ -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();
54 changes: 54 additions & 0 deletions backend/db/migrations/0023_graph_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -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();
67 changes: 67 additions & 0 deletions backend/db/migrations/0024_identity_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/db/migrations/0019_conventions_terms_schools.sql
Original file line numberDiff line numberDiff line change
@@ -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.
98 changes: 98 additions & 0 deletions backend/db/migrations/0020_academics_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
64 changes: 64 additions & 0 deletions backend/db/migrations/0021_gradebook.sql
Original file line numberDiff line numberDiff line change
@@ -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();
39 changes: 39 additions & 0 deletions backend/db/migrations/0022_analytics.sql
Original file line numberDiff line numberDiff line change
@@ -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();
54 changes: 54 additions & 0 deletions backend/db/migrations/0023_graph_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -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();
67 changes: 67 additions & 0 deletions backend/db/migrations/0024_identity_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/db/migrations/0019_conventions_terms_schools.sql
Original file line numberDiff line numberDiff line change
@@ -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.
98 changes: 98 additions & 0 deletions backend/db/migrations/0020_academics_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
64 changes: 64 additions & 0 deletions backend/db/migrations/0021_gradebook.sql
Original file line numberDiff line numberDiff line change
@@ -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();
39 changes: 39 additions & 0 deletions backend/db/migrations/0022_analytics.sql
Original file line numberDiff line numberDiff line change
@@ -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();
54 changes: 54 additions & 0 deletions backend/db/migrations/0023_graph_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -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();
67 changes: 67 additions & 0 deletions backend/db/migrations/0024_identity_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/db/migrations/0019_conventions_terms_schools.sql
Original file line numberDiff line numberDiff line change
@@ -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.
98 changes: 98 additions & 0 deletions backend/db/migrations/0020_academics_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
64 changes: 64 additions & 0 deletions backend/db/migrations/0021_gradebook.sql
Original file line numberDiff line numberDiff line change
@@ -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();
39 changes: 39 additions & 0 deletions backend/db/migrations/0022_analytics.sql
Original file line numberDiff line numberDiff line change
@@ -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();
54 changes: 54 additions & 0 deletions backend/db/migrations/0023_graph_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -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();
67 changes: 67 additions & 0 deletions backend/db/migrations/0024_identity_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/db/migrations/0019_conventions_terms_schools.sql
Original file line numberDiff line numberDiff line change
@@ -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.
98 changes: 98 additions & 0 deletions backend/db/migrations/0020_academics_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
64 changes: 64 additions & 0 deletions backend/db/migrations/0021_gradebook.sql
Original file line numberDiff line numberDiff line change
@@ -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();
39 changes: 39 additions & 0 deletions backend/db/migrations/0022_analytics.sql
Original file line numberDiff line numberDiff line change
@@ -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();
54 changes: 54 additions & 0 deletions backend/db/migrations/0023_graph_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -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();
67 changes: 67 additions & 0 deletions backend/db/migrations/0024_identity_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/db/migrations/0019_conventions_terms_schools.sql
Original file line numberDiff line numberDiff line change
@@ -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.
98 changes: 98 additions & 0 deletions backend/db/migrations/0020_academics_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
64 changes: 64 additions & 0 deletions backend/db/migrations/0021_gradebook.sql
Original file line numberDiff line numberDiff line change
@@ -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();
39 changes: 39 additions & 0 deletions backend/db/migrations/0022_analytics.sql
Original file line numberDiff line numberDiff line change
@@ -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();
54 changes: 54 additions & 0 deletions backend/db/migrations/0023_graph_integrity.sql
Original file line numberDiff line numberDiff line change
@@ -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();
67 changes: 67 additions & 0 deletions backend/db/migrations/0024_identity_split.sql
Original file line numberDiff line numberDiff line change
@@ -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();
Loading
Loading