DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028) - #279

Merged
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign
Jun 25, 2026
Merged

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028)#279
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

The epic cutover. Restructures the Postgres/Supabase schema from one offering-shaped courses table + a users mega-table into 8 bounded domains of typed tables with foreign keys, CHECK enums, a real terms entity, and one source of truth per fact — and rewires the entire backend onto it. Delivered schema-first (all migrations as one source of truth, then per-domain code slices).

What changed

  • Academics: courses split into abstract courses + course_offerings (per term) + terms; user_coursesenrollments(offering_id). New resolver services/academics.py. API boundary still uses the abstract course_id; the split is resolved server-side.
  • Identity: users slimmed; new user_profiles (1:1) holds name/profile fields (names still 🔒). New services/profiles.py for decrypted display names.
  • Gradebook: re-keyed to enrollment_id (gradebook_categories, enrollment-keyed assignments); per-semester + cumulative GPA, bell-curve + drop-lowest; decrypt_numeric on 🔒 points.
  • Knowledge Graph: stays on the abstract course (cumulative); graph_nodes.mastery_events JSON column → append-only node_mastery_events table; UNIQUE-backed node/edge upserts.
  • Class Analytics: course_concept_stats/course_summaryoffering_concept_stats/offering_summary (offering-keyed; free-text semester gone).
  • Study: documents/notes/sessions/study_guides/flashcards re-keyed to offering_id + soft-delete + CHECK enums.
  • Ops: feedback/issue_reports int PK → text PK + FKs.

How it was built & validated

Schema landed as migrations 0019–0028; code rewired in per-domain PRs that accumulated on this epic (#264 schema, #268 academics, #269#274 the 6 domain slices, #275 reconciliation, #276 seed, #277/#278 staging fixes).

  • 790 unit tests pass (mocked) · ruff clean
  • 14/14 service checks pass against live staging seeded data (academics resolver, get_courses incl. multi-term CS101, get_graph, gradebook drop-lowest + decrypt → (92.0,'A-'), user_profiles decrypt, offering analytics, learn/graph-read).
  • 🐛 Two real-DB bugs caught by seeding staging (invisible to mocked tests) and fixed in-epic: 0028 (vestigial course_offerings.course_code NOT NULL, would've broken add_course in prod) and the seed _exists_by id assumption.

For reviewers

  • Developer migration guide: docs/db-modular-redesign-dev-guide.pdf (the id model, rename cheat-sheet, what to change). Design spec + epic plan under docs/superpowers/.
  • Docs trued-up: CLAUDE.md, docs/architecture.md, README.md, ROADMAP.md.

⚠️ Before merging (prod cutover)

  1. Deploy this branch to staging and HTTP-smoke-test the live app (enroll, courses-with-term, gradebook GPA, graph). Service-level E2E is green; the deployed-app pass is the last gate.
  2. Full CI green on the merge.
  3. Prod promotion (prod has no user data, only the catalog): migrate --baseline to record 0001–0018, then migrate to apply 0019+; the 0020 catalog transform is data-driven. Never run dashboard DDL.

Known follow-ups (non-blocking)

Frontend term picker (#260), gradescope code rewire (#265), schools population (school surfaces blank), duplicate subject-root hub for multi-term courses.

Summary by CodeRabbit

  • New Features
    • Added term-aware academics (new /api/semesters) and updated courses/offerings behavior across gradebook, notes, study guides, flashcards, and graph.
    • Added semester-scoped gradebook details, including curve grading and credit-weighted GPA.
    • Added staging DB demo seeding and an opt-in staging E2E runner.
  • Bug Fixes
    • Profile display names now consistently come from the dedicated public-profile data store.
    • Document and note deletion now uses soft delete.
    • Quiz difficulty is validated, and mastery updates now record reliably through the graph update flow.
  • Documentation
    • Updated migration, architecture, and ops/staging guidance for ordered SQL migrations and the new schema conventions.

Resolved issues (auto-close on merge to main)

The DB modular redesign resolves these — semesters DB+backend, the missing indexes/FKs/UNIQUE-dedup, atomic mastery, graph write-integrity, social/students, and the staging catalog+environment:

Closes#100
Closes#128
Closes#137
Closes#138
Closes#158
Closes#160
Closes#161
Closes#176
Closes#177
Closes#178
Closes#179
Closes#180
Closes#181
Closes#195
Closes#247
Closes#258
Closes#259
Closes#266
Closes#267

Partially addressed (NOT closed — follow-ups remain): #142 (frontend term UI #139/#140/#141/#260), #265 (Gradescope code rewire), #126 (assignment-notes encryption + response-boundary leaks).

AndresL230and others added 29 commits June 23, 2026 18:29
Design spec (conventions charter, per-domain target DDL, catalog transform, migration sequencing) and the epic rollout plan (10 PR slices into epic/db-modular-redesign, PR1 detailed, staging->prod promotion runbook). Docs only; seeds the epic branch. Supersedes #137/#138/#142/#259/#260.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(db): modular redesign spec + epic rollout plan
Authors the complete modular target schema as one coherent set: terms+schools+updated_at trigger (0019), academics catalog/offering/enrollment split with data-preserving catalog transform (0020), gradebook re-keyed to enrollment (0021), analytics re-keyed to offering (0022), graph integrity + mastery events (0023), identity profile split (0024), study/sessions integrity (0025), ops cleanup (0026). Text PKs, FKs+ON DELETE, real types, CHECK enums read off code, encryption columns kept TEXT. Validated 0001->0026 end-to-end against Postgres 15 incl. the catalog transform. Source of truth for the per-domain code PRs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts ids
The renamed baseline tables inherited a TEXT PRIMARY KEY with no default,
unlike every other table in the redesign. Add gen_random_uuid()::text so the
app does not have to supply ids on insert for these two tables.
… redesign
Folds the in-flight DB changes from origin/Gradebook and origin/chore/staging-ops-scripts into the redesign so nothing is lost and the schema stays the single source of truth: drop-lowest -> gradebook_categories.drop_lowest; bell-curve policy -> enrollments.curve_*; per-assignment curve stats + gradescope_assignment_id -> assignments; gradescope_credentials + gradescope_course_links (link re-targeted to enrollment_id) -> 0027; newsletter_emails.approved_at -> 0026. Those branches' migration files are now superseded; their CODE rewire is tracked in filed issues. Validated 0001->0027 against Postgres 15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(db): schema-foundation migrations 0019-0026 (target schema)
…rollments (epic slice PR2)
Code-only slice against the already-landed academics split (migrations 0019-0027).
The public API keeps the abstract `course_id`; the term/semester becomes a real
second axis; enrollment resolves to a per-term offering internally.
- services/academics.py (new): term/offering/enrollment resolver — current_term
(date-derived, latest-term fallback), list_terms, resolve_offering (current term,
create-if-missing so new enrollments land in the real current semester),
offering_course_id, user_offering_ids_for_course, term_for_offering.
- graph_service: enrollments→course_offerings→courses/terms join reshaped to the
legacy flat shape; graph stays keyed on the ABSTRACT course_id; add/color/nickname/
delete resolve offerings; get_courses now surfaces `term`.
- course_context_service: offering-scoped analytics (offering_concept_stats/
offering_summary); resolves offering→abstract course for graph_nodes; semester gone.
- graph_read: misconceptions read offering_concept_stats by offering_id.
- onboarding/graph routes: enroll into the current-term offering; GET /api/semesters
(routes/academics.py) from terms; learn/profile course resolution via enrollments.
`gradebook.py` is intentionally left to db/gradebook-code (PR3, with curve+drop-lowest).
Other-slice callers (documents/quiz) degrade gracefully until their slices land.
Tests: +test_academics; updated graph_service/shared_course_context/graph_read_tools/
onboarding/learn suites. Full backend suite 724 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/academics-code: rewire app onto courses/offerings/terms/enrollments (epic slice PR2)
…ice)
Rewire the ops domain's code onto the already-landed 0026_ops schema, which
dropped the SERIAL integer PKs on feedback/issue_reports and recreated them
with TEXT PKs (gen_random_uuid()::text) plus real FKs to users(id) — and
sessions(id) ON DELETE SET NULL for feedback.
- routes/feedback.py: hand-build the text PK with str(uuid.uuid4()) on both the
feedback and issue_reports inserts, per the repo convention (academics.py,
graph_service.py), instead of relying on the dropped SERIAL default. The new
user_id/session_id FKs are already satisfied by the request body / session.
- tests/test_feedback_routes.py: new coverage for the two POST endpoints
(previously untested) — asserts the insert carries a UUID text PK and the
body fields round-trip, using the MagicMock-per-table factory pattern.
- routes/admin.py allowlist approve/revoke already read/write
newsletter_emails.approved_at as 0026 declares it (issue #267) — verified, no
code change needed.
Plan: docs/superpowers/plans/2026-06-24-db-ops-code.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire the identity domain onto the 0024 schema split. Public-profile fields
(name/first_name/last_name/username/avatar_url/bio/location/website/year/
majors/minors/learning_style) now live on a 1:1 user_profiles table; users
keeps only identity + auth + activity. One source of truth per field — nothing
is written to both users and user_profiles, nor duplicated onto user_settings.
- routes/profile.py: _get_user_or_404 reads users (id/email/streak/created_at)
and merges user_profiles; new _get_or_create_profile helper (ensure-row +
decrypt); username uniqueness + writes go to user_profiles; avatar_url
persists to user_profiles; _SETTINGS_COLS drops the moved columns.
- routes/auth.py: get_me reads name/username/avatar_url from user_profiles;
google_callback writes profile fields to user_profiles (insert/upsert) and
keeps only email/auth/activity on users; oauth_tokens.expires_at sent as
None (not "") when absent, since it is TIMESTAMPTZ now.
- routes/onboarding.py: the profile-field write moves to a user_profiles
upsert; onboarding_completed stays on users. Enrollment loop untouched
(academics owns it).
- models: drop display_name from UpdateProfileBody and the moved fields from
SettingsResponse.
- tests: profile/onboarding/decrypt-boundary updated for the split; added an
ensure-row test and a per-table column-contract pin.
Encryption boundary preserved: name/first_name/last_name/bio/location stay
🔒 TEXT (encrypt_if_present at write, decrypt_if_present at read) on
user_profiles; users.email stays 🔒 on users.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed schema
Rewire the gradebook onto the academics-split schema (migration 0021): key
gradebook_categories + assignments on enrollment_id instead of user_id+course_id.
The public API still speaks the abstract course_id plus an optional `semester`
(term label, default = current term); routes resolve (course_id, semester) -> the
user's enrollment via services.academics.
- services/gradebook_service.py: add drop-lowest (per-category, lowest earned/possible
ratio), apply_curve (linear z-score bell curve: avg_target + (raw-mean)*(new_sd/sd),
clamped 0-100), and credit-weighted GPA (gpa_points + weighted_gpa).
- routes/gradebook.py: enrollment resolver (_resolve_enrollment, semester->term via
terms.label); curve folded into current_grade; new PATCH /curve and GET /gpa
(per-semester + cumulative/transcript). points stay encrypted at write, decrypted
at read.
- models: semester + drop_lowest fields, assignment_type enum, SetCurveBody.
- tests: enrollment-keyed route tests (filter-aware fake table patching both
routes.gradebook.table and services.academics.table), service tests for
drop-lowest/curve/GPA incl. a hand-computed credit-weighted GPA fixture
(3.7*3 + 2.7*4)/7 = 3.1285714. Fixed test_response_decrypt_boundary for the new
resolver shape.
Plan: docs/superpowers/plans/2026-06-24-db-gradebook-code.md
Out of scope: gradescope sync (gradescope_assignment_id column preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…courses table
The academics split (0020/0022) already re-keyed class analytics to the
offering (course_concept_stats→offering_concept_stats, course_summary→
offering_summary) and offering-keyed course_context_service.py. The lone
holdout was routes/social.py::get_students, which still read
table("courses").select("user_id,course_name") — a query against the old
offering-shaped courses table that no longer has user_id or per-enrollment rows.
Resolve a user's courses through the enrollment chain instead
(enrollments → course_offerings → courses) via the PostgREST embedded join,
deduping across offerings of the same abstract course. Response shape of
GET /api/social/students is unchanged.
Adds tests/test_social_students.py (5 tests). No new migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents
Rewire services/graph_service.py onto the integrity guarantees from migration
0023_graph_integrity.sql:
- graph_nodes / graph_edges writes now use UNIQUE-backed upserts (on_conflict on
the new (user_id, course_id, concept_name) and
(user_id, source_node_id, target_node_id, relationship_type) constraints),
replacing the non-atomic select-then-insert dedup.
- Mastery changes append a row to the new node_mastery_events table instead of
rewriting the dropped graph_nodes.mastery_events JSONB blob, fixing the
non-atomic read-modify-write (#247). get_graph batch-reads that table to
compute learning_velocity and the trimmed per-node event history (API contract
preserved).
- Delete db/dedup_nodes.py — its (user_id, concept_name) dedup is superseded by
the UNIQUE constraint (#181).
Academics-owned graph_service.py logic (enrollment reshape, course CRUD,
offering resolution, update_course_context call sites) and the abstract-course
graph key are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Study artifacts (documents/notes/sessions/study_guides/flashcards) now key
on offering_id (0025); the knowledge graph + course context stay on the
abstract course_id. Routes resolve the abstract course id from the API
boundary to the current-term offering via services.academics before
reading/writing, and translate offering -> abstract for every graph path.
- notes_service: offering_id column + soft-delete (deleted_at) on read/delete.
- documents: persist offering_id; soft-delete; resolve offering on upload;
study-guide cache + doc reads key on offering; graph/syllabus/course-context
keep the abstract course id.
- study_guide / flashcards: read+write the offering; expose abstract course id
in responses; flashcards.import_commit resolves offering (nullable).
- learn: sessions key on offering_id; doc context reads by offering; wire the
shared-context block to the session's abstract course (resolves the
db/study-code TODO, no more {} degrade).
- quiz: validate difficulty against the 0025 CHECK enum; stop touching the
dropped graph_nodes.mastery_events column — route mastery writes through
services.graph_service.apply_graph_update (sanctioned path), keyed on the
abstract course id. IDOR 404 still fires before any write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/ops-code: feedback/issue_reports PKs + FKs (epic slice PR8)
db/analytics-code: social.py offering re-keying (epic slice PR4)
db/identity-code: user_profiles split (epic slice PR6)
db/graph-code: graph integrity + node_mastery_events (epic slice PR5)
db/study-code: study artifacts → offering_id (epic slice PR7)
db/gradebook-code: semester-aware gradebook + curve + drop-lowest (epic slice PR3)
…0024 identity split
Migration 0024 moved the public profile (incl. the 🔒-encrypted display `name`)
out of `users` into a 1:1 `user_profiles` table, and renamed
`users.room_id` -> `users.current_room_id`. The identity slice updated identity
files but left cross-domain readers/writers of `users.name` untouched; these
break against the real DB (mocked tests didn't catch them).
- services/graph_service.ensure_user_exists: stop INSERTing `name` into `users`
(column no longer exists). Insert only `{id, streak_count}`; do not create a
user_profiles row (onboarding/oauth own it).
- services/profiles.py (new): get_display_name / get_display_names read
user_profiles by user_id and decrypt the name. Tolerate missing rows.
- Repoint name reads onto the helper, preserving each response shape:
main.list_users (also source room_id from current_room_id),
routes/social.py (room detail, room activity, match_partners, school_match,
get_students), routes/quiz.py (quiz-context student name),
routes/learn.py get_user_name, services/users_search.paginate_users.
- services/users_search: CODE-ONLY fix — it reads table("users") directly, not a
DB view, so no 0028 migration is needed; names now come from user_profiles.
- services/flashcard_import_service.dedup_against_existing: filter the flashcards
link column `offering_id` (0025 renamed it from `course_id`); behavior identical.
Tests: new test_profiles_service; assert ensure_user_exists omits `name`; update
roster/social/users_search/flashcard tests for the new sources. Gate green:
2 known env-only test_storage_service failures only; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/identity-reconcile: route users.name readers through user_profiles + flashcard column (epic follow-up)
…ma (#258)
Self-contained, idempotent staging-only seed that lays a small fake demo
dataset on top of migrations 0019-0027 so the live app renders the knowledge
graph, gradebook, and courses-with-term against a real DB. Runs via
`python -m db.seed_staging`; safe to re-run.
- 1 demo school, 3 abstract courses, 4 offerings (CS101 in 2 terms — graph
mastery is cumulative across terms since it's keyed on the abstract course).
- 1 slim user + user_profiles (encrypted name fields), 4 enrollments (incl.
CS101 in both terms), 9 graph_nodes / 4 graph_edges / 6 node_mastery_events.
- Gradebook (4 categories w/ drop_lowest, 6 assignments w/ encrypted points),
plus a document + note on an offering for study endpoints.
- Idempotent via deterministic seed-… ids + upsert-on-UNIQUE / insert-if-absent;
re-runs add nothing. All 🔒 columns go through encrypt_if_present; enum values
read straight off the migration CHECK sets (no guesses). Reuses pre-seeded
terms (read-only) — never touches the real catalog.
- Hermetic tests patch db.seed_staging.table to a recording FakeTable and assert
insertion coverage, FK consistency, enum validity, multi-term, encryption, and
idempotency (2nd run adds no rows). Checklist Step 6 references the new command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/seed-staging: idempotent demo seed for the new schema (#258)
… (0028)
0020 renamed courses→course_offerings and dropped the abstract columns but missed
course_code, which stayed NOT NULL. Existing rows have it populated, but every NEW
offering insert (app resolve_offering/add_course AND seed_staging) omits it → 23502
not-null violation. The abstract course_code lives on `courses` now. Surfaced by
seeding staging; would also break add_course against the real DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): drop vestigial course_code NOT NULL on course_offerings (0028)
user_profiles is keyed on user_id and has no `id` column, so the idempotency
pre-check's `select=id` 400'd against staging. Select a column we're already
filtering on (the natural/PK key) instead — works for every table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): seed _exists_by uses a real column, not hardcoded id
…ular DB redesign + add dev guide
/update-mds across the knowledge docs (per-doc base..HEAD windows): new schema
(courses/course_offerings/terms/enrollments, user_profiles split, gradebook→enrollment,
analytics→offering, node_mastery_events), services/academics.py + services/profiles.py,
the db.migrate / db.seed_staging commands + .env.staging note, and the encryption list
(name fields now on user_profiles). Adds docs/db-modular-redesign-dev-guide.pdf for the team.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86250e2a-c43e-47fe-9830-6329f9404a91

📥 Commits

Reviewing files that changed from the base of the PR and between 77c4855 and afba99f.

📒 Files selected for processing (2)
  • backend/db/e2e_checks/quiz.py
  • backend/db/e2e_staging_http.py
📝 Walkthrough

Walkthrough

Backend guidance, schema migrations, services, routes, tests, and staging E2E checks were updated to match the modular database redesign. The PR shifts academics, identity, analytics, gradebook, graph, study, ops, and related docs/code paths to the new offering- and enrollment-based schema.

Changes

DB modular redesign

Layer / File(s)Summary
Guidance and rollout docs
CLAUDE.md, README.md, ROADMAP.md, docs/architecture.md, docs/staging/setup-checklist.md, docs/superpowers/plans/*, docs/superpowers/specs/*
Repository guidance, architecture notes, rollout instructions, roadmap entries, and redesign plans were updated for the modular schema work and staging E2E rollout.
Academics, identity, graph, and analytics code
backend/db/migrations/0019_*.sql, 0020_*.sql, 0022_*.sql, 0023_*.sql, 0024_*.sql, backend/services/academics.py, backend/routes/academics.py, backend/main.py, backend/routes/auth.py, backend/routes/profile.py, backend/routes/onboarding.py, backend/services/profiles.py, backend/services/course_context_service.py, backend/services/graph_service.py, backend/agents/tools/graph_read.py, backend/routes/social.py, backend/services/users_search.py, backend/tests/test_academics.py, backend/tests/test_graph_service.py, backend/tests/test_graph_read_tools.py, backend/tests/test_shared_course_context.py, backend/tests/test_profile_routes.py, backend/tests/test_profiles_service.py, backend/tests/test_users_roster_auth.py, backend/tests/test_users_search.py, backend/tests/test_onboarding_routes.py, backend/tests/test_social_students.py
New academics helpers/routes, identity/profile reads and writes, graph/context offering lookups, analytics tables, and related test rewrites now use offerings, enrollments, and user_profiles.
Gradebook and graph mastery rules
backend/db/migrations/0021_*.sql, backend/services/gradebook_service.py, backend/routes/gradebook.py, backend/models/__init__.py, backend/routes/quiz.py, backend/tests/test_gradebook_routes.py, backend/tests/test_gradebook_service.py, backend/tests/test_quiz_routes.py, backend/tests/test_response_decrypt_boundary.py
Gradebook routes, math, and models now resolve enrollments by semester and compute drop-lowest, curves, and GPA; graph mastery writes route through event rows.
Study, profile, social, and auth flows
backend/db/migrations/0025_*.sql, backend/services/notes_service.py, backend/services/flashcard_import_service.py, backend/routes/documents.py, backend/routes/flashcards.py, backend/routes/learn.py, backend/routes/notes.py, backend/routes/study_guide.py, backend/tests/test_documents_routes.py, backend/tests/test_flashcard_import_routes.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_learn_routes.py, backend/tests/test_notes_routes.py, backend/tests/test_notes_service.py, backend/tests/test_study_guide_routes.py, backend/tests/test_shared_course_context.py
Documents, notes, flashcards, study guides, and learn/session flows now key off offering_id, use soft deletes, and map back to abstract course ids where needed.
Ops cleanup and staging seed
backend/db/migrations/0026_*.sql, backend/db/migrations/0027_*.sql, backend/db/migrations/0028_*.sql, backend/routes/feedback.py, backend/db/seed_staging.py, backend/tests/test_feedback_routes.py, backend/tests/test_seed_staging.py, backend/db/e2e_checks/*, backend/db/e2e_staging_http.py, backend/tests/conftest.py, backend/tests/test_e2e_staging.py
Feedback and issue-report IDs become explicit UUID text values, Gradescope sync tables are added, the staging seed writes deterministic demo rows, and staging-only HTTP E2E checks were added.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Updates the same course-context service and context refresh flow that this PR extends to offering_id.
  • SaplingLearn/Sapling#65 — Touches the same auth/profile code paths and encrypted profile-field handling moved to user_profiles.
  • SaplingLearn/Sapling#67 — Updates the same document upload and persistence helpers that now key documents by offering_id.

Poem

A rabbit hopped through SQL snow,
Where offerings bloom and old paths go.
With curves and terms my whiskers twitch,
And profile names no longer glitch.
Hooray! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 30.14% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main schema redesign and migration range.
Description check✅ PassedThe description is detailed and covers the main changes and validation, but it doesn't follow the requested template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch epic/db-modular-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This was referenced Jun 25, 2026
@AndresL230
AndresL230 deleted the epic/db-modular-redesign branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028) - #279

Merged
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign
Jun 25, 2026
Merged

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028)#279
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

The epic cutover. Restructures the Postgres/Supabase schema from one offering-shaped courses table + a users mega-table into 8 bounded domains of typed tables with foreign keys, CHECK enums, a real terms entity, and one source of truth per fact — and rewires the entire backend onto it. Delivered schema-first (all migrations as one source of truth, then per-domain code slices).

What changed

  • Academics: courses split into abstract courses + course_offerings (per term) + terms; user_coursesenrollments(offering_id). New resolver services/academics.py. API boundary still uses the abstract course_id; the split is resolved server-side.
  • Identity: users slimmed; new user_profiles (1:1) holds name/profile fields (names still 🔒). New services/profiles.py for decrypted display names.
  • Gradebook: re-keyed to enrollment_id (gradebook_categories, enrollment-keyed assignments); per-semester + cumulative GPA, bell-curve + drop-lowest; decrypt_numeric on 🔒 points.
  • Knowledge Graph: stays on the abstract course (cumulative); graph_nodes.mastery_events JSON column → append-only node_mastery_events table; UNIQUE-backed node/edge upserts.
  • Class Analytics: course_concept_stats/course_summaryoffering_concept_stats/offering_summary (offering-keyed; free-text semester gone).
  • Study: documents/notes/sessions/study_guides/flashcards re-keyed to offering_id + soft-delete + CHECK enums.
  • Ops: feedback/issue_reports int PK → text PK + FKs.

How it was built & validated

Schema landed as migrations 0019–0028; code rewired in per-domain PRs that accumulated on this epic (#264 schema, #268 academics, #269#274 the 6 domain slices, #275 reconciliation, #276 seed, #277/#278 staging fixes).

  • 790 unit tests pass (mocked) · ruff clean
  • 14/14 service checks pass against live staging seeded data (academics resolver, get_courses incl. multi-term CS101, get_graph, gradebook drop-lowest + decrypt → (92.0,'A-'), user_profiles decrypt, offering analytics, learn/graph-read).
  • 🐛 Two real-DB bugs caught by seeding staging (invisible to mocked tests) and fixed in-epic: 0028 (vestigial course_offerings.course_code NOT NULL, would've broken add_course in prod) and the seed _exists_by id assumption.

For reviewers

  • Developer migration guide: docs/db-modular-redesign-dev-guide.pdf (the id model, rename cheat-sheet, what to change). Design spec + epic plan under docs/superpowers/.
  • Docs trued-up: CLAUDE.md, docs/architecture.md, README.md, ROADMAP.md.

⚠️ Before merging (prod cutover)

  1. Deploy this branch to staging and HTTP-smoke-test the live app (enroll, courses-with-term, gradebook GPA, graph). Service-level E2E is green; the deployed-app pass is the last gate.
  2. Full CI green on the merge.
  3. Prod promotion (prod has no user data, only the catalog): migrate --baseline to record 0001–0018, then migrate to apply 0019+; the 0020 catalog transform is data-driven. Never run dashboard DDL.

Known follow-ups (non-blocking)

Frontend term picker (#260), gradescope code rewire (#265), schools population (school surfaces blank), duplicate subject-root hub for multi-term courses.

Summary by CodeRabbit

  • New Features
    • Added term-aware academics (new /api/semesters) and updated courses/offerings behavior across gradebook, notes, study guides, flashcards, and graph.
    • Added semester-scoped gradebook details, including curve grading and credit-weighted GPA.
    • Added staging DB demo seeding and an opt-in staging E2E runner.
  • Bug Fixes
    • Profile display names now consistently come from the dedicated public-profile data store.
    • Document and note deletion now uses soft delete.
    • Quiz difficulty is validated, and mastery updates now record reliably through the graph update flow.
  • Documentation
    • Updated migration, architecture, and ops/staging guidance for ordered SQL migrations and the new schema conventions.

Resolved issues (auto-close on merge to main)

The DB modular redesign resolves these — semesters DB+backend, the missing indexes/FKs/UNIQUE-dedup, atomic mastery, graph write-integrity, social/students, and the staging catalog+environment:

Closes#100
Closes#128
Closes#137
Closes#138
Closes#158
Closes#160
Closes#161
Closes#176
Closes#177
Closes#178
Closes#179
Closes#180
Closes#181
Closes#195
Closes#247
Closes#258
Closes#259
Closes#266
Closes#267

Partially addressed (NOT closed — follow-ups remain): #142 (frontend term UI #139/#140/#141/#260), #265 (Gradescope code rewire), #126 (assignment-notes encryption + response-boundary leaks).

AndresL230and others added 29 commits June 23, 2026 18:29
Design spec (conventions charter, per-domain target DDL, catalog transform, migration sequencing) and the epic rollout plan (10 PR slices into epic/db-modular-redesign, PR1 detailed, staging->prod promotion runbook). Docs only; seeds the epic branch. Supersedes #137/#138/#142/#259/#260.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(db): modular redesign spec + epic rollout plan
Authors the complete modular target schema as one coherent set: terms+schools+updated_at trigger (0019), academics catalog/offering/enrollment split with data-preserving catalog transform (0020), gradebook re-keyed to enrollment (0021), analytics re-keyed to offering (0022), graph integrity + mastery events (0023), identity profile split (0024), study/sessions integrity (0025), ops cleanup (0026). Text PKs, FKs+ON DELETE, real types, CHECK enums read off code, encryption columns kept TEXT. Validated 0001->0026 end-to-end against Postgres 15 incl. the catalog transform. Source of truth for the per-domain code PRs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts ids
The renamed baseline tables inherited a TEXT PRIMARY KEY with no default,
unlike every other table in the redesign. Add gen_random_uuid()::text so the
app does not have to supply ids on insert for these two tables.
… redesign
Folds the in-flight DB changes from origin/Gradebook and origin/chore/staging-ops-scripts into the redesign so nothing is lost and the schema stays the single source of truth: drop-lowest -> gradebook_categories.drop_lowest; bell-curve policy -> enrollments.curve_*; per-assignment curve stats + gradescope_assignment_id -> assignments; gradescope_credentials + gradescope_course_links (link re-targeted to enrollment_id) -> 0027; newsletter_emails.approved_at -> 0026. Those branches' migration files are now superseded; their CODE rewire is tracked in filed issues. Validated 0001->0027 against Postgres 15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(db): schema-foundation migrations 0019-0026 (target schema)
…rollments (epic slice PR2)
Code-only slice against the already-landed academics split (migrations 0019-0027).
The public API keeps the abstract `course_id`; the term/semester becomes a real
second axis; enrollment resolves to a per-term offering internally.
- services/academics.py (new): term/offering/enrollment resolver — current_term
(date-derived, latest-term fallback), list_terms, resolve_offering (current term,
create-if-missing so new enrollments land in the real current semester),
offering_course_id, user_offering_ids_for_course, term_for_offering.
- graph_service: enrollments→course_offerings→courses/terms join reshaped to the
legacy flat shape; graph stays keyed on the ABSTRACT course_id; add/color/nickname/
delete resolve offerings; get_courses now surfaces `term`.
- course_context_service: offering-scoped analytics (offering_concept_stats/
offering_summary); resolves offering→abstract course for graph_nodes; semester gone.
- graph_read: misconceptions read offering_concept_stats by offering_id.
- onboarding/graph routes: enroll into the current-term offering; GET /api/semesters
(routes/academics.py) from terms; learn/profile course resolution via enrollments.
`gradebook.py` is intentionally left to db/gradebook-code (PR3, with curve+drop-lowest).
Other-slice callers (documents/quiz) degrade gracefully until their slices land.
Tests: +test_academics; updated graph_service/shared_course_context/graph_read_tools/
onboarding/learn suites. Full backend suite 724 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/academics-code: rewire app onto courses/offerings/terms/enrollments (epic slice PR2)
…ice)
Rewire the ops domain's code onto the already-landed 0026_ops schema, which
dropped the SERIAL integer PKs on feedback/issue_reports and recreated them
with TEXT PKs (gen_random_uuid()::text) plus real FKs to users(id) — and
sessions(id) ON DELETE SET NULL for feedback.
- routes/feedback.py: hand-build the text PK with str(uuid.uuid4()) on both the
feedback and issue_reports inserts, per the repo convention (academics.py,
graph_service.py), instead of relying on the dropped SERIAL default. The new
user_id/session_id FKs are already satisfied by the request body / session.
- tests/test_feedback_routes.py: new coverage for the two POST endpoints
(previously untested) — asserts the insert carries a UUID text PK and the
body fields round-trip, using the MagicMock-per-table factory pattern.
- routes/admin.py allowlist approve/revoke already read/write
newsletter_emails.approved_at as 0026 declares it (issue #267) — verified, no
code change needed.
Plan: docs/superpowers/plans/2026-06-24-db-ops-code.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire the identity domain onto the 0024 schema split. Public-profile fields
(name/first_name/last_name/username/avatar_url/bio/location/website/year/
majors/minors/learning_style) now live on a 1:1 user_profiles table; users
keeps only identity + auth + activity. One source of truth per field — nothing
is written to both users and user_profiles, nor duplicated onto user_settings.
- routes/profile.py: _get_user_or_404 reads users (id/email/streak/created_at)
and merges user_profiles; new _get_or_create_profile helper (ensure-row +
decrypt); username uniqueness + writes go to user_profiles; avatar_url
persists to user_profiles; _SETTINGS_COLS drops the moved columns.
- routes/auth.py: get_me reads name/username/avatar_url from user_profiles;
google_callback writes profile fields to user_profiles (insert/upsert) and
keeps only email/auth/activity on users; oauth_tokens.expires_at sent as
None (not "") when absent, since it is TIMESTAMPTZ now.
- routes/onboarding.py: the profile-field write moves to a user_profiles
upsert; onboarding_completed stays on users. Enrollment loop untouched
(academics owns it).
- models: drop display_name from UpdateProfileBody and the moved fields from
SettingsResponse.
- tests: profile/onboarding/decrypt-boundary updated for the split; added an
ensure-row test and a per-table column-contract pin.
Encryption boundary preserved: name/first_name/last_name/bio/location stay
🔒 TEXT (encrypt_if_present at write, decrypt_if_present at read) on
user_profiles; users.email stays 🔒 on users.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed schema
Rewire the gradebook onto the academics-split schema (migration 0021): key
gradebook_categories + assignments on enrollment_id instead of user_id+course_id.
The public API still speaks the abstract course_id plus an optional `semester`
(term label, default = current term); routes resolve (course_id, semester) -> the
user's enrollment via services.academics.
- services/gradebook_service.py: add drop-lowest (per-category, lowest earned/possible
ratio), apply_curve (linear z-score bell curve: avg_target + (raw-mean)*(new_sd/sd),
clamped 0-100), and credit-weighted GPA (gpa_points + weighted_gpa).
- routes/gradebook.py: enrollment resolver (_resolve_enrollment, semester->term via
terms.label); curve folded into current_grade; new PATCH /curve and GET /gpa
(per-semester + cumulative/transcript). points stay encrypted at write, decrypted
at read.
- models: semester + drop_lowest fields, assignment_type enum, SetCurveBody.
- tests: enrollment-keyed route tests (filter-aware fake table patching both
routes.gradebook.table and services.academics.table), service tests for
drop-lowest/curve/GPA incl. a hand-computed credit-weighted GPA fixture
(3.7*3 + 2.7*4)/7 = 3.1285714. Fixed test_response_decrypt_boundary for the new
resolver shape.
Plan: docs/superpowers/plans/2026-06-24-db-gradebook-code.md
Out of scope: gradescope sync (gradescope_assignment_id column preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…courses table
The academics split (0020/0022) already re-keyed class analytics to the
offering (course_concept_stats→offering_concept_stats, course_summary→
offering_summary) and offering-keyed course_context_service.py. The lone
holdout was routes/social.py::get_students, which still read
table("courses").select("user_id,course_name") — a query against the old
offering-shaped courses table that no longer has user_id or per-enrollment rows.
Resolve a user's courses through the enrollment chain instead
(enrollments → course_offerings → courses) via the PostgREST embedded join,
deduping across offerings of the same abstract course. Response shape of
GET /api/social/students is unchanged.
Adds tests/test_social_students.py (5 tests). No new migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents
Rewire services/graph_service.py onto the integrity guarantees from migration
0023_graph_integrity.sql:
- graph_nodes / graph_edges writes now use UNIQUE-backed upserts (on_conflict on
the new (user_id, course_id, concept_name) and
(user_id, source_node_id, target_node_id, relationship_type) constraints),
replacing the non-atomic select-then-insert dedup.
- Mastery changes append a row to the new node_mastery_events table instead of
rewriting the dropped graph_nodes.mastery_events JSONB blob, fixing the
non-atomic read-modify-write (#247). get_graph batch-reads that table to
compute learning_velocity and the trimmed per-node event history (API contract
preserved).
- Delete db/dedup_nodes.py — its (user_id, concept_name) dedup is superseded by
the UNIQUE constraint (#181).
Academics-owned graph_service.py logic (enrollment reshape, course CRUD,
offering resolution, update_course_context call sites) and the abstract-course
graph key are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Study artifacts (documents/notes/sessions/study_guides/flashcards) now key
on offering_id (0025); the knowledge graph + course context stay on the
abstract course_id. Routes resolve the abstract course id from the API
boundary to the current-term offering via services.academics before
reading/writing, and translate offering -> abstract for every graph path.
- notes_service: offering_id column + soft-delete (deleted_at) on read/delete.
- documents: persist offering_id; soft-delete; resolve offering on upload;
study-guide cache + doc reads key on offering; graph/syllabus/course-context
keep the abstract course id.
- study_guide / flashcards: read+write the offering; expose abstract course id
in responses; flashcards.import_commit resolves offering (nullable).
- learn: sessions key on offering_id; doc context reads by offering; wire the
shared-context block to the session's abstract course (resolves the
db/study-code TODO, no more {} degrade).
- quiz: validate difficulty against the 0025 CHECK enum; stop touching the
dropped graph_nodes.mastery_events column — route mastery writes through
services.graph_service.apply_graph_update (sanctioned path), keyed on the
abstract course id. IDOR 404 still fires before any write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/ops-code: feedback/issue_reports PKs + FKs (epic slice PR8)
db/analytics-code: social.py offering re-keying (epic slice PR4)
db/identity-code: user_profiles split (epic slice PR6)
db/graph-code: graph integrity + node_mastery_events (epic slice PR5)
db/study-code: study artifacts → offering_id (epic slice PR7)
db/gradebook-code: semester-aware gradebook + curve + drop-lowest (epic slice PR3)
…0024 identity split
Migration 0024 moved the public profile (incl. the 🔒-encrypted display `name`)
out of `users` into a 1:1 `user_profiles` table, and renamed
`users.room_id` -> `users.current_room_id`. The identity slice updated identity
files but left cross-domain readers/writers of `users.name` untouched; these
break against the real DB (mocked tests didn't catch them).
- services/graph_service.ensure_user_exists: stop INSERTing `name` into `users`
(column no longer exists). Insert only `{id, streak_count}`; do not create a
user_profiles row (onboarding/oauth own it).
- services/profiles.py (new): get_display_name / get_display_names read
user_profiles by user_id and decrypt the name. Tolerate missing rows.
- Repoint name reads onto the helper, preserving each response shape:
main.list_users (also source room_id from current_room_id),
routes/social.py (room detail, room activity, match_partners, school_match,
get_students), routes/quiz.py (quiz-context student name),
routes/learn.py get_user_name, services/users_search.paginate_users.
- services/users_search: CODE-ONLY fix — it reads table("users") directly, not a
DB view, so no 0028 migration is needed; names now come from user_profiles.
- services/flashcard_import_service.dedup_against_existing: filter the flashcards
link column `offering_id` (0025 renamed it from `course_id`); behavior identical.
Tests: new test_profiles_service; assert ensure_user_exists omits `name`; update
roster/social/users_search/flashcard tests for the new sources. Gate green:
2 known env-only test_storage_service failures only; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/identity-reconcile: route users.name readers through user_profiles + flashcard column (epic follow-up)
…ma (#258)
Self-contained, idempotent staging-only seed that lays a small fake demo
dataset on top of migrations 0019-0027 so the live app renders the knowledge
graph, gradebook, and courses-with-term against a real DB. Runs via
`python -m db.seed_staging`; safe to re-run.
- 1 demo school, 3 abstract courses, 4 offerings (CS101 in 2 terms — graph
mastery is cumulative across terms since it's keyed on the abstract course).
- 1 slim user + user_profiles (encrypted name fields), 4 enrollments (incl.
CS101 in both terms), 9 graph_nodes / 4 graph_edges / 6 node_mastery_events.
- Gradebook (4 categories w/ drop_lowest, 6 assignments w/ encrypted points),
plus a document + note on an offering for study endpoints.
- Idempotent via deterministic seed-… ids + upsert-on-UNIQUE / insert-if-absent;
re-runs add nothing. All 🔒 columns go through encrypt_if_present; enum values
read straight off the migration CHECK sets (no guesses). Reuses pre-seeded
terms (read-only) — never touches the real catalog.
- Hermetic tests patch db.seed_staging.table to a recording FakeTable and assert
insertion coverage, FK consistency, enum validity, multi-term, encryption, and
idempotency (2nd run adds no rows). Checklist Step 6 references the new command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/seed-staging: idempotent demo seed for the new schema (#258)
… (0028)
0020 renamed courses→course_offerings and dropped the abstract columns but missed
course_code, which stayed NOT NULL. Existing rows have it populated, but every NEW
offering insert (app resolve_offering/add_course AND seed_staging) omits it → 23502
not-null violation. The abstract course_code lives on `courses` now. Surfaced by
seeding staging; would also break add_course against the real DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): drop vestigial course_code NOT NULL on course_offerings (0028)
user_profiles is keyed on user_id and has no `id` column, so the idempotency
pre-check's `select=id` 400'd against staging. Select a column we're already
filtering on (the natural/PK key) instead — works for every table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): seed _exists_by uses a real column, not hardcoded id
…ular DB redesign + add dev guide
/update-mds across the knowledge docs (per-doc base..HEAD windows): new schema
(courses/course_offerings/terms/enrollments, user_profiles split, gradebook→enrollment,
analytics→offering, node_mastery_events), services/academics.py + services/profiles.py,
the db.migrate / db.seed_staging commands + .env.staging note, and the encryption list
(name fields now on user_profiles). Adds docs/db-modular-redesign-dev-guide.pdf for the team.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86250e2a-c43e-47fe-9830-6329f9404a91

📥 Commits

Reviewing files that changed from the base of the PR and between 77c4855 and afba99f.

📒 Files selected for processing (2)
  • backend/db/e2e_checks/quiz.py
  • backend/db/e2e_staging_http.py
📝 Walkthrough

Walkthrough

Backend guidance, schema migrations, services, routes, tests, and staging E2E checks were updated to match the modular database redesign. The PR shifts academics, identity, analytics, gradebook, graph, study, ops, and related docs/code paths to the new offering- and enrollment-based schema.

Changes

DB modular redesign

Layer / File(s)Summary
Guidance and rollout docs
CLAUDE.md, README.md, ROADMAP.md, docs/architecture.md, docs/staging/setup-checklist.md, docs/superpowers/plans/*, docs/superpowers/specs/*
Repository guidance, architecture notes, rollout instructions, roadmap entries, and redesign plans were updated for the modular schema work and staging E2E rollout.
Academics, identity, graph, and analytics code
backend/db/migrations/0019_*.sql, 0020_*.sql, 0022_*.sql, 0023_*.sql, 0024_*.sql, backend/services/academics.py, backend/routes/academics.py, backend/main.py, backend/routes/auth.py, backend/routes/profile.py, backend/routes/onboarding.py, backend/services/profiles.py, backend/services/course_context_service.py, backend/services/graph_service.py, backend/agents/tools/graph_read.py, backend/routes/social.py, backend/services/users_search.py, backend/tests/test_academics.py, backend/tests/test_graph_service.py, backend/tests/test_graph_read_tools.py, backend/tests/test_shared_course_context.py, backend/tests/test_profile_routes.py, backend/tests/test_profiles_service.py, backend/tests/test_users_roster_auth.py, backend/tests/test_users_search.py, backend/tests/test_onboarding_routes.py, backend/tests/test_social_students.py
New academics helpers/routes, identity/profile reads and writes, graph/context offering lookups, analytics tables, and related test rewrites now use offerings, enrollments, and user_profiles.
Gradebook and graph mastery rules
backend/db/migrations/0021_*.sql, backend/services/gradebook_service.py, backend/routes/gradebook.py, backend/models/__init__.py, backend/routes/quiz.py, backend/tests/test_gradebook_routes.py, backend/tests/test_gradebook_service.py, backend/tests/test_quiz_routes.py, backend/tests/test_response_decrypt_boundary.py
Gradebook routes, math, and models now resolve enrollments by semester and compute drop-lowest, curves, and GPA; graph mastery writes route through event rows.
Study, profile, social, and auth flows
backend/db/migrations/0025_*.sql, backend/services/notes_service.py, backend/services/flashcard_import_service.py, backend/routes/documents.py, backend/routes/flashcards.py, backend/routes/learn.py, backend/routes/notes.py, backend/routes/study_guide.py, backend/tests/test_documents_routes.py, backend/tests/test_flashcard_import_routes.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_learn_routes.py, backend/tests/test_notes_routes.py, backend/tests/test_notes_service.py, backend/tests/test_study_guide_routes.py, backend/tests/test_shared_course_context.py
Documents, notes, flashcards, study guides, and learn/session flows now key off offering_id, use soft deletes, and map back to abstract course ids where needed.
Ops cleanup and staging seed
backend/db/migrations/0026_*.sql, backend/db/migrations/0027_*.sql, backend/db/migrations/0028_*.sql, backend/routes/feedback.py, backend/db/seed_staging.py, backend/tests/test_feedback_routes.py, backend/tests/test_seed_staging.py, backend/db/e2e_checks/*, backend/db/e2e_staging_http.py, backend/tests/conftest.py, backend/tests/test_e2e_staging.py
Feedback and issue-report IDs become explicit UUID text values, Gradescope sync tables are added, the staging seed writes deterministic demo rows, and staging-only HTTP E2E checks were added.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Updates the same course-context service and context refresh flow that this PR extends to offering_id.
  • SaplingLearn/Sapling#65 — Touches the same auth/profile code paths and encrypted profile-field handling moved to user_profiles.
  • SaplingLearn/Sapling#67 — Updates the same document upload and persistence helpers that now key documents by offering_id.

Poem

A rabbit hopped through SQL snow,
Where offerings bloom and old paths go.
With curves and terms my whiskers twitch,
And profile names no longer glitch.
Hooray! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 30.14% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main schema redesign and migration range.
Description check✅ PassedThe description is detailed and covers the main changes and validation, but it doesn't follow the requested template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch epic/db-modular-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This was referenced Jun 25, 2026
@AndresL230
AndresL230 deleted the epic/db-modular-redesign branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028) - #279

Merged
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign
Jun 25, 2026
Merged

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028)#279
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

The epic cutover. Restructures the Postgres/Supabase schema from one offering-shaped courses table + a users mega-table into 8 bounded domains of typed tables with foreign keys, CHECK enums, a real terms entity, and one source of truth per fact — and rewires the entire backend onto it. Delivered schema-first (all migrations as one source of truth, then per-domain code slices).

What changed

  • Academics: courses split into abstract courses + course_offerings (per term) + terms; user_coursesenrollments(offering_id). New resolver services/academics.py. API boundary still uses the abstract course_id; the split is resolved server-side.
  • Identity: users slimmed; new user_profiles (1:1) holds name/profile fields (names still 🔒). New services/profiles.py for decrypted display names.
  • Gradebook: re-keyed to enrollment_id (gradebook_categories, enrollment-keyed assignments); per-semester + cumulative GPA, bell-curve + drop-lowest; decrypt_numeric on 🔒 points.
  • Knowledge Graph: stays on the abstract course (cumulative); graph_nodes.mastery_events JSON column → append-only node_mastery_events table; UNIQUE-backed node/edge upserts.
  • Class Analytics: course_concept_stats/course_summaryoffering_concept_stats/offering_summary (offering-keyed; free-text semester gone).
  • Study: documents/notes/sessions/study_guides/flashcards re-keyed to offering_id + soft-delete + CHECK enums.
  • Ops: feedback/issue_reports int PK → text PK + FKs.

How it was built & validated

Schema landed as migrations 0019–0028; code rewired in per-domain PRs that accumulated on this epic (#264 schema, #268 academics, #269#274 the 6 domain slices, #275 reconciliation, #276 seed, #277/#278 staging fixes).

  • 790 unit tests pass (mocked) · ruff clean
  • 14/14 service checks pass against live staging seeded data (academics resolver, get_courses incl. multi-term CS101, get_graph, gradebook drop-lowest + decrypt → (92.0,'A-'), user_profiles decrypt, offering analytics, learn/graph-read).
  • 🐛 Two real-DB bugs caught by seeding staging (invisible to mocked tests) and fixed in-epic: 0028 (vestigial course_offerings.course_code NOT NULL, would've broken add_course in prod) and the seed _exists_by id assumption.

For reviewers

  • Developer migration guide: docs/db-modular-redesign-dev-guide.pdf (the id model, rename cheat-sheet, what to change). Design spec + epic plan under docs/superpowers/.
  • Docs trued-up: CLAUDE.md, docs/architecture.md, README.md, ROADMAP.md.

⚠️ Before merging (prod cutover)

  1. Deploy this branch to staging and HTTP-smoke-test the live app (enroll, courses-with-term, gradebook GPA, graph). Service-level E2E is green; the deployed-app pass is the last gate.
  2. Full CI green on the merge.
  3. Prod promotion (prod has no user data, only the catalog): migrate --baseline to record 0001–0018, then migrate to apply 0019+; the 0020 catalog transform is data-driven. Never run dashboard DDL.

Known follow-ups (non-blocking)

Frontend term picker (#260), gradescope code rewire (#265), schools population (school surfaces blank), duplicate subject-root hub for multi-term courses.

Summary by CodeRabbit

  • New Features
    • Added term-aware academics (new /api/semesters) and updated courses/offerings behavior across gradebook, notes, study guides, flashcards, and graph.
    • Added semester-scoped gradebook details, including curve grading and credit-weighted GPA.
    • Added staging DB demo seeding and an opt-in staging E2E runner.
  • Bug Fixes
    • Profile display names now consistently come from the dedicated public-profile data store.
    • Document and note deletion now uses soft delete.
    • Quiz difficulty is validated, and mastery updates now record reliably through the graph update flow.
  • Documentation
    • Updated migration, architecture, and ops/staging guidance for ordered SQL migrations and the new schema conventions.

Resolved issues (auto-close on merge to main)

The DB modular redesign resolves these — semesters DB+backend, the missing indexes/FKs/UNIQUE-dedup, atomic mastery, graph write-integrity, social/students, and the staging catalog+environment:

Closes#100
Closes#128
Closes#137
Closes#138
Closes#158
Closes#160
Closes#161
Closes#176
Closes#177
Closes#178
Closes#179
Closes#180
Closes#181
Closes#195
Closes#247
Closes#258
Closes#259
Closes#266
Closes#267

Partially addressed (NOT closed — follow-ups remain): #142 (frontend term UI #139/#140/#141/#260), #265 (Gradescope code rewire), #126 (assignment-notes encryption + response-boundary leaks).

AndresL230and others added 29 commits June 23, 2026 18:29
Design spec (conventions charter, per-domain target DDL, catalog transform, migration sequencing) and the epic rollout plan (10 PR slices into epic/db-modular-redesign, PR1 detailed, staging->prod promotion runbook). Docs only; seeds the epic branch. Supersedes #137/#138/#142/#259/#260.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(db): modular redesign spec + epic rollout plan
Authors the complete modular target schema as one coherent set: terms+schools+updated_at trigger (0019), academics catalog/offering/enrollment split with data-preserving catalog transform (0020), gradebook re-keyed to enrollment (0021), analytics re-keyed to offering (0022), graph integrity + mastery events (0023), identity profile split (0024), study/sessions integrity (0025), ops cleanup (0026). Text PKs, FKs+ON DELETE, real types, CHECK enums read off code, encryption columns kept TEXT. Validated 0001->0026 end-to-end against Postgres 15 incl. the catalog transform. Source of truth for the per-domain code PRs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts ids
The renamed baseline tables inherited a TEXT PRIMARY KEY with no default,
unlike every other table in the redesign. Add gen_random_uuid()::text so the
app does not have to supply ids on insert for these two tables.
… redesign
Folds the in-flight DB changes from origin/Gradebook and origin/chore/staging-ops-scripts into the redesign so nothing is lost and the schema stays the single source of truth: drop-lowest -> gradebook_categories.drop_lowest; bell-curve policy -> enrollments.curve_*; per-assignment curve stats + gradescope_assignment_id -> assignments; gradescope_credentials + gradescope_course_links (link re-targeted to enrollment_id) -> 0027; newsletter_emails.approved_at -> 0026. Those branches' migration files are now superseded; their CODE rewire is tracked in filed issues. Validated 0001->0027 against Postgres 15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(db): schema-foundation migrations 0019-0026 (target schema)
…rollments (epic slice PR2)
Code-only slice against the already-landed academics split (migrations 0019-0027).
The public API keeps the abstract `course_id`; the term/semester becomes a real
second axis; enrollment resolves to a per-term offering internally.
- services/academics.py (new): term/offering/enrollment resolver — current_term
(date-derived, latest-term fallback), list_terms, resolve_offering (current term,
create-if-missing so new enrollments land in the real current semester),
offering_course_id, user_offering_ids_for_course, term_for_offering.
- graph_service: enrollments→course_offerings→courses/terms join reshaped to the
legacy flat shape; graph stays keyed on the ABSTRACT course_id; add/color/nickname/
delete resolve offerings; get_courses now surfaces `term`.
- course_context_service: offering-scoped analytics (offering_concept_stats/
offering_summary); resolves offering→abstract course for graph_nodes; semester gone.
- graph_read: misconceptions read offering_concept_stats by offering_id.
- onboarding/graph routes: enroll into the current-term offering; GET /api/semesters
(routes/academics.py) from terms; learn/profile course resolution via enrollments.
`gradebook.py` is intentionally left to db/gradebook-code (PR3, with curve+drop-lowest).
Other-slice callers (documents/quiz) degrade gracefully until their slices land.
Tests: +test_academics; updated graph_service/shared_course_context/graph_read_tools/
onboarding/learn suites. Full backend suite 724 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/academics-code: rewire app onto courses/offerings/terms/enrollments (epic slice PR2)
…ice)
Rewire the ops domain's code onto the already-landed 0026_ops schema, which
dropped the SERIAL integer PKs on feedback/issue_reports and recreated them
with TEXT PKs (gen_random_uuid()::text) plus real FKs to users(id) — and
sessions(id) ON DELETE SET NULL for feedback.
- routes/feedback.py: hand-build the text PK with str(uuid.uuid4()) on both the
feedback and issue_reports inserts, per the repo convention (academics.py,
graph_service.py), instead of relying on the dropped SERIAL default. The new
user_id/session_id FKs are already satisfied by the request body / session.
- tests/test_feedback_routes.py: new coverage for the two POST endpoints
(previously untested) — asserts the insert carries a UUID text PK and the
body fields round-trip, using the MagicMock-per-table factory pattern.
- routes/admin.py allowlist approve/revoke already read/write
newsletter_emails.approved_at as 0026 declares it (issue #267) — verified, no
code change needed.
Plan: docs/superpowers/plans/2026-06-24-db-ops-code.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire the identity domain onto the 0024 schema split. Public-profile fields
(name/first_name/last_name/username/avatar_url/bio/location/website/year/
majors/minors/learning_style) now live on a 1:1 user_profiles table; users
keeps only identity + auth + activity. One source of truth per field — nothing
is written to both users and user_profiles, nor duplicated onto user_settings.
- routes/profile.py: _get_user_or_404 reads users (id/email/streak/created_at)
and merges user_profiles; new _get_or_create_profile helper (ensure-row +
decrypt); username uniqueness + writes go to user_profiles; avatar_url
persists to user_profiles; _SETTINGS_COLS drops the moved columns.
- routes/auth.py: get_me reads name/username/avatar_url from user_profiles;
google_callback writes profile fields to user_profiles (insert/upsert) and
keeps only email/auth/activity on users; oauth_tokens.expires_at sent as
None (not "") when absent, since it is TIMESTAMPTZ now.
- routes/onboarding.py: the profile-field write moves to a user_profiles
upsert; onboarding_completed stays on users. Enrollment loop untouched
(academics owns it).
- models: drop display_name from UpdateProfileBody and the moved fields from
SettingsResponse.
- tests: profile/onboarding/decrypt-boundary updated for the split; added an
ensure-row test and a per-table column-contract pin.
Encryption boundary preserved: name/first_name/last_name/bio/location stay
🔒 TEXT (encrypt_if_present at write, decrypt_if_present at read) on
user_profiles; users.email stays 🔒 on users.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed schema
Rewire the gradebook onto the academics-split schema (migration 0021): key
gradebook_categories + assignments on enrollment_id instead of user_id+course_id.
The public API still speaks the abstract course_id plus an optional `semester`
(term label, default = current term); routes resolve (course_id, semester) -> the
user's enrollment via services.academics.
- services/gradebook_service.py: add drop-lowest (per-category, lowest earned/possible
ratio), apply_curve (linear z-score bell curve: avg_target + (raw-mean)*(new_sd/sd),
clamped 0-100), and credit-weighted GPA (gpa_points + weighted_gpa).
- routes/gradebook.py: enrollment resolver (_resolve_enrollment, semester->term via
terms.label); curve folded into current_grade; new PATCH /curve and GET /gpa
(per-semester + cumulative/transcript). points stay encrypted at write, decrypted
at read.
- models: semester + drop_lowest fields, assignment_type enum, SetCurveBody.
- tests: enrollment-keyed route tests (filter-aware fake table patching both
routes.gradebook.table and services.academics.table), service tests for
drop-lowest/curve/GPA incl. a hand-computed credit-weighted GPA fixture
(3.7*3 + 2.7*4)/7 = 3.1285714. Fixed test_response_decrypt_boundary for the new
resolver shape.
Plan: docs/superpowers/plans/2026-06-24-db-gradebook-code.md
Out of scope: gradescope sync (gradescope_assignment_id column preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…courses table
The academics split (0020/0022) already re-keyed class analytics to the
offering (course_concept_stats→offering_concept_stats, course_summary→
offering_summary) and offering-keyed course_context_service.py. The lone
holdout was routes/social.py::get_students, which still read
table("courses").select("user_id,course_name") — a query against the old
offering-shaped courses table that no longer has user_id or per-enrollment rows.
Resolve a user's courses through the enrollment chain instead
(enrollments → course_offerings → courses) via the PostgREST embedded join,
deduping across offerings of the same abstract course. Response shape of
GET /api/social/students is unchanged.
Adds tests/test_social_students.py (5 tests). No new migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents
Rewire services/graph_service.py onto the integrity guarantees from migration
0023_graph_integrity.sql:
- graph_nodes / graph_edges writes now use UNIQUE-backed upserts (on_conflict on
the new (user_id, course_id, concept_name) and
(user_id, source_node_id, target_node_id, relationship_type) constraints),
replacing the non-atomic select-then-insert dedup.
- Mastery changes append a row to the new node_mastery_events table instead of
rewriting the dropped graph_nodes.mastery_events JSONB blob, fixing the
non-atomic read-modify-write (#247). get_graph batch-reads that table to
compute learning_velocity and the trimmed per-node event history (API contract
preserved).
- Delete db/dedup_nodes.py — its (user_id, concept_name) dedup is superseded by
the UNIQUE constraint (#181).
Academics-owned graph_service.py logic (enrollment reshape, course CRUD,
offering resolution, update_course_context call sites) and the abstract-course
graph key are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Study artifacts (documents/notes/sessions/study_guides/flashcards) now key
on offering_id (0025); the knowledge graph + course context stay on the
abstract course_id. Routes resolve the abstract course id from the API
boundary to the current-term offering via services.academics before
reading/writing, and translate offering -> abstract for every graph path.
- notes_service: offering_id column + soft-delete (deleted_at) on read/delete.
- documents: persist offering_id; soft-delete; resolve offering on upload;
study-guide cache + doc reads key on offering; graph/syllabus/course-context
keep the abstract course id.
- study_guide / flashcards: read+write the offering; expose abstract course id
in responses; flashcards.import_commit resolves offering (nullable).
- learn: sessions key on offering_id; doc context reads by offering; wire the
shared-context block to the session's abstract course (resolves the
db/study-code TODO, no more {} degrade).
- quiz: validate difficulty against the 0025 CHECK enum; stop touching the
dropped graph_nodes.mastery_events column — route mastery writes through
services.graph_service.apply_graph_update (sanctioned path), keyed on the
abstract course id. IDOR 404 still fires before any write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/ops-code: feedback/issue_reports PKs + FKs (epic slice PR8)
db/analytics-code: social.py offering re-keying (epic slice PR4)
db/identity-code: user_profiles split (epic slice PR6)
db/graph-code: graph integrity + node_mastery_events (epic slice PR5)
db/study-code: study artifacts → offering_id (epic slice PR7)
db/gradebook-code: semester-aware gradebook + curve + drop-lowest (epic slice PR3)
…0024 identity split
Migration 0024 moved the public profile (incl. the 🔒-encrypted display `name`)
out of `users` into a 1:1 `user_profiles` table, and renamed
`users.room_id` -> `users.current_room_id`. The identity slice updated identity
files but left cross-domain readers/writers of `users.name` untouched; these
break against the real DB (mocked tests didn't catch them).
- services/graph_service.ensure_user_exists: stop INSERTing `name` into `users`
(column no longer exists). Insert only `{id, streak_count}`; do not create a
user_profiles row (onboarding/oauth own it).
- services/profiles.py (new): get_display_name / get_display_names read
user_profiles by user_id and decrypt the name. Tolerate missing rows.
- Repoint name reads onto the helper, preserving each response shape:
main.list_users (also source room_id from current_room_id),
routes/social.py (room detail, room activity, match_partners, school_match,
get_students), routes/quiz.py (quiz-context student name),
routes/learn.py get_user_name, services/users_search.paginate_users.
- services/users_search: CODE-ONLY fix — it reads table("users") directly, not a
DB view, so no 0028 migration is needed; names now come from user_profiles.
- services/flashcard_import_service.dedup_against_existing: filter the flashcards
link column `offering_id` (0025 renamed it from `course_id`); behavior identical.
Tests: new test_profiles_service; assert ensure_user_exists omits `name`; update
roster/social/users_search/flashcard tests for the new sources. Gate green:
2 known env-only test_storage_service failures only; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/identity-reconcile: route users.name readers through user_profiles + flashcard column (epic follow-up)
…ma (#258)
Self-contained, idempotent staging-only seed that lays a small fake demo
dataset on top of migrations 0019-0027 so the live app renders the knowledge
graph, gradebook, and courses-with-term against a real DB. Runs via
`python -m db.seed_staging`; safe to re-run.
- 1 demo school, 3 abstract courses, 4 offerings (CS101 in 2 terms — graph
mastery is cumulative across terms since it's keyed on the abstract course).
- 1 slim user + user_profiles (encrypted name fields), 4 enrollments (incl.
CS101 in both terms), 9 graph_nodes / 4 graph_edges / 6 node_mastery_events.
- Gradebook (4 categories w/ drop_lowest, 6 assignments w/ encrypted points),
plus a document + note on an offering for study endpoints.
- Idempotent via deterministic seed-… ids + upsert-on-UNIQUE / insert-if-absent;
re-runs add nothing. All 🔒 columns go through encrypt_if_present; enum values
read straight off the migration CHECK sets (no guesses). Reuses pre-seeded
terms (read-only) — never touches the real catalog.
- Hermetic tests patch db.seed_staging.table to a recording FakeTable and assert
insertion coverage, FK consistency, enum validity, multi-term, encryption, and
idempotency (2nd run adds no rows). Checklist Step 6 references the new command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/seed-staging: idempotent demo seed for the new schema (#258)
… (0028)
0020 renamed courses→course_offerings and dropped the abstract columns but missed
course_code, which stayed NOT NULL. Existing rows have it populated, but every NEW
offering insert (app resolve_offering/add_course AND seed_staging) omits it → 23502
not-null violation. The abstract course_code lives on `courses` now. Surfaced by
seeding staging; would also break add_course against the real DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): drop vestigial course_code NOT NULL on course_offerings (0028)
user_profiles is keyed on user_id and has no `id` column, so the idempotency
pre-check's `select=id` 400'd against staging. Select a column we're already
filtering on (the natural/PK key) instead — works for every table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): seed _exists_by uses a real column, not hardcoded id
…ular DB redesign + add dev guide
/update-mds across the knowledge docs (per-doc base..HEAD windows): new schema
(courses/course_offerings/terms/enrollments, user_profiles split, gradebook→enrollment,
analytics→offering, node_mastery_events), services/academics.py + services/profiles.py,
the db.migrate / db.seed_staging commands + .env.staging note, and the encryption list
(name fields now on user_profiles). Adds docs/db-modular-redesign-dev-guide.pdf for the team.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86250e2a-c43e-47fe-9830-6329f9404a91

📥 Commits

Reviewing files that changed from the base of the PR and between 77c4855 and afba99f.

📒 Files selected for processing (2)
  • backend/db/e2e_checks/quiz.py
  • backend/db/e2e_staging_http.py
📝 Walkthrough

Walkthrough

Backend guidance, schema migrations, services, routes, tests, and staging E2E checks were updated to match the modular database redesign. The PR shifts academics, identity, analytics, gradebook, graph, study, ops, and related docs/code paths to the new offering- and enrollment-based schema.

Changes

DB modular redesign

Layer / File(s)Summary
Guidance and rollout docs
CLAUDE.md, README.md, ROADMAP.md, docs/architecture.md, docs/staging/setup-checklist.md, docs/superpowers/plans/*, docs/superpowers/specs/*
Repository guidance, architecture notes, rollout instructions, roadmap entries, and redesign plans were updated for the modular schema work and staging E2E rollout.
Academics, identity, graph, and analytics code
backend/db/migrations/0019_*.sql, 0020_*.sql, 0022_*.sql, 0023_*.sql, 0024_*.sql, backend/services/academics.py, backend/routes/academics.py, backend/main.py, backend/routes/auth.py, backend/routes/profile.py, backend/routes/onboarding.py, backend/services/profiles.py, backend/services/course_context_service.py, backend/services/graph_service.py, backend/agents/tools/graph_read.py, backend/routes/social.py, backend/services/users_search.py, backend/tests/test_academics.py, backend/tests/test_graph_service.py, backend/tests/test_graph_read_tools.py, backend/tests/test_shared_course_context.py, backend/tests/test_profile_routes.py, backend/tests/test_profiles_service.py, backend/tests/test_users_roster_auth.py, backend/tests/test_users_search.py, backend/tests/test_onboarding_routes.py, backend/tests/test_social_students.py
New academics helpers/routes, identity/profile reads and writes, graph/context offering lookups, analytics tables, and related test rewrites now use offerings, enrollments, and user_profiles.
Gradebook and graph mastery rules
backend/db/migrations/0021_*.sql, backend/services/gradebook_service.py, backend/routes/gradebook.py, backend/models/__init__.py, backend/routes/quiz.py, backend/tests/test_gradebook_routes.py, backend/tests/test_gradebook_service.py, backend/tests/test_quiz_routes.py, backend/tests/test_response_decrypt_boundary.py
Gradebook routes, math, and models now resolve enrollments by semester and compute drop-lowest, curves, and GPA; graph mastery writes route through event rows.
Study, profile, social, and auth flows
backend/db/migrations/0025_*.sql, backend/services/notes_service.py, backend/services/flashcard_import_service.py, backend/routes/documents.py, backend/routes/flashcards.py, backend/routes/learn.py, backend/routes/notes.py, backend/routes/study_guide.py, backend/tests/test_documents_routes.py, backend/tests/test_flashcard_import_routes.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_learn_routes.py, backend/tests/test_notes_routes.py, backend/tests/test_notes_service.py, backend/tests/test_study_guide_routes.py, backend/tests/test_shared_course_context.py
Documents, notes, flashcards, study guides, and learn/session flows now key off offering_id, use soft deletes, and map back to abstract course ids where needed.
Ops cleanup and staging seed
backend/db/migrations/0026_*.sql, backend/db/migrations/0027_*.sql, backend/db/migrations/0028_*.sql, backend/routes/feedback.py, backend/db/seed_staging.py, backend/tests/test_feedback_routes.py, backend/tests/test_seed_staging.py, backend/db/e2e_checks/*, backend/db/e2e_staging_http.py, backend/tests/conftest.py, backend/tests/test_e2e_staging.py
Feedback and issue-report IDs become explicit UUID text values, Gradescope sync tables are added, the staging seed writes deterministic demo rows, and staging-only HTTP E2E checks were added.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Updates the same course-context service and context refresh flow that this PR extends to offering_id.
  • SaplingLearn/Sapling#65 — Touches the same auth/profile code paths and encrypted profile-field handling moved to user_profiles.
  • SaplingLearn/Sapling#67 — Updates the same document upload and persistence helpers that now key documents by offering_id.

Poem

A rabbit hopped through SQL snow,
Where offerings bloom and old paths go.
With curves and terms my whiskers twitch,
And profile names no longer glitch.
Hooray! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 30.14% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main schema redesign and migration range.
Description check✅ PassedThe description is detailed and covers the main changes and validation, but it doesn't follow the requested template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch epic/db-modular-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This was referenced Jun 25, 2026
@AndresL230
AndresL230 deleted the epic/db-modular-redesign branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028) - #279

Merged
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign
Jun 25, 2026
Merged

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028)#279
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

The epic cutover. Restructures the Postgres/Supabase schema from one offering-shaped courses table + a users mega-table into 8 bounded domains of typed tables with foreign keys, CHECK enums, a real terms entity, and one source of truth per fact — and rewires the entire backend onto it. Delivered schema-first (all migrations as one source of truth, then per-domain code slices).

What changed

  • Academics: courses split into abstract courses + course_offerings (per term) + terms; user_coursesenrollments(offering_id). New resolver services/academics.py. API boundary still uses the abstract course_id; the split is resolved server-side.
  • Identity: users slimmed; new user_profiles (1:1) holds name/profile fields (names still 🔒). New services/profiles.py for decrypted display names.
  • Gradebook: re-keyed to enrollment_id (gradebook_categories, enrollment-keyed assignments); per-semester + cumulative GPA, bell-curve + drop-lowest; decrypt_numeric on 🔒 points.
  • Knowledge Graph: stays on the abstract course (cumulative); graph_nodes.mastery_events JSON column → append-only node_mastery_events table; UNIQUE-backed node/edge upserts.
  • Class Analytics: course_concept_stats/course_summaryoffering_concept_stats/offering_summary (offering-keyed; free-text semester gone).
  • Study: documents/notes/sessions/study_guides/flashcards re-keyed to offering_id + soft-delete + CHECK enums.
  • Ops: feedback/issue_reports int PK → text PK + FKs.

How it was built & validated

Schema landed as migrations 0019–0028; code rewired in per-domain PRs that accumulated on this epic (#264 schema, #268 academics, #269#274 the 6 domain slices, #275 reconciliation, #276 seed, #277/#278 staging fixes).

  • 790 unit tests pass (mocked) · ruff clean
  • 14/14 service checks pass against live staging seeded data (academics resolver, get_courses incl. multi-term CS101, get_graph, gradebook drop-lowest + decrypt → (92.0,'A-'), user_profiles decrypt, offering analytics, learn/graph-read).
  • 🐛 Two real-DB bugs caught by seeding staging (invisible to mocked tests) and fixed in-epic: 0028 (vestigial course_offerings.course_code NOT NULL, would've broken add_course in prod) and the seed _exists_by id assumption.

For reviewers

  • Developer migration guide: docs/db-modular-redesign-dev-guide.pdf (the id model, rename cheat-sheet, what to change). Design spec + epic plan under docs/superpowers/.
  • Docs trued-up: CLAUDE.md, docs/architecture.md, README.md, ROADMAP.md.

⚠️ Before merging (prod cutover)

  1. Deploy this branch to staging and HTTP-smoke-test the live app (enroll, courses-with-term, gradebook GPA, graph). Service-level E2E is green; the deployed-app pass is the last gate.
  2. Full CI green on the merge.
  3. Prod promotion (prod has no user data, only the catalog): migrate --baseline to record 0001–0018, then migrate to apply 0019+; the 0020 catalog transform is data-driven. Never run dashboard DDL.

Known follow-ups (non-blocking)

Frontend term picker (#260), gradescope code rewire (#265), schools population (school surfaces blank), duplicate subject-root hub for multi-term courses.

Summary by CodeRabbit

  • New Features
    • Added term-aware academics (new /api/semesters) and updated courses/offerings behavior across gradebook, notes, study guides, flashcards, and graph.
    • Added semester-scoped gradebook details, including curve grading and credit-weighted GPA.
    • Added staging DB demo seeding and an opt-in staging E2E runner.
  • Bug Fixes
    • Profile display names now consistently come from the dedicated public-profile data store.
    • Document and note deletion now uses soft delete.
    • Quiz difficulty is validated, and mastery updates now record reliably through the graph update flow.
  • Documentation
    • Updated migration, architecture, and ops/staging guidance for ordered SQL migrations and the new schema conventions.

Resolved issues (auto-close on merge to main)

The DB modular redesign resolves these — semesters DB+backend, the missing indexes/FKs/UNIQUE-dedup, atomic mastery, graph write-integrity, social/students, and the staging catalog+environment:

Closes#100
Closes#128
Closes#137
Closes#138
Closes#158
Closes#160
Closes#161
Closes#176
Closes#177
Closes#178
Closes#179
Closes#180
Closes#181
Closes#195
Closes#247
Closes#258
Closes#259
Closes#266
Closes#267

Partially addressed (NOT closed — follow-ups remain): #142 (frontend term UI #139/#140/#141/#260), #265 (Gradescope code rewire), #126 (assignment-notes encryption + response-boundary leaks).

AndresL230and others added 29 commits June 23, 2026 18:29
Design spec (conventions charter, per-domain target DDL, catalog transform, migration sequencing) and the epic rollout plan (10 PR slices into epic/db-modular-redesign, PR1 detailed, staging->prod promotion runbook). Docs only; seeds the epic branch. Supersedes #137/#138/#142/#259/#260.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(db): modular redesign spec + epic rollout plan
Authors the complete modular target schema as one coherent set: terms+schools+updated_at trigger (0019), academics catalog/offering/enrollment split with data-preserving catalog transform (0020), gradebook re-keyed to enrollment (0021), analytics re-keyed to offering (0022), graph integrity + mastery events (0023), identity profile split (0024), study/sessions integrity (0025), ops cleanup (0026). Text PKs, FKs+ON DELETE, real types, CHECK enums read off code, encryption columns kept TEXT. Validated 0001->0026 end-to-end against Postgres 15 incl. the catalog transform. Source of truth for the per-domain code PRs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts ids
The renamed baseline tables inherited a TEXT PRIMARY KEY with no default,
unlike every other table in the redesign. Add gen_random_uuid()::text so the
app does not have to supply ids on insert for these two tables.
… redesign
Folds the in-flight DB changes from origin/Gradebook and origin/chore/staging-ops-scripts into the redesign so nothing is lost and the schema stays the single source of truth: drop-lowest -> gradebook_categories.drop_lowest; bell-curve policy -> enrollments.curve_*; per-assignment curve stats + gradescope_assignment_id -> assignments; gradescope_credentials + gradescope_course_links (link re-targeted to enrollment_id) -> 0027; newsletter_emails.approved_at -> 0026. Those branches' migration files are now superseded; their CODE rewire is tracked in filed issues. Validated 0001->0027 against Postgres 15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(db): schema-foundation migrations 0019-0026 (target schema)
…rollments (epic slice PR2)
Code-only slice against the already-landed academics split (migrations 0019-0027).
The public API keeps the abstract `course_id`; the term/semester becomes a real
second axis; enrollment resolves to a per-term offering internally.
- services/academics.py (new): term/offering/enrollment resolver — current_term
(date-derived, latest-term fallback), list_terms, resolve_offering (current term,
create-if-missing so new enrollments land in the real current semester),
offering_course_id, user_offering_ids_for_course, term_for_offering.
- graph_service: enrollments→course_offerings→courses/terms join reshaped to the
legacy flat shape; graph stays keyed on the ABSTRACT course_id; add/color/nickname/
delete resolve offerings; get_courses now surfaces `term`.
- course_context_service: offering-scoped analytics (offering_concept_stats/
offering_summary); resolves offering→abstract course for graph_nodes; semester gone.
- graph_read: misconceptions read offering_concept_stats by offering_id.
- onboarding/graph routes: enroll into the current-term offering; GET /api/semesters
(routes/academics.py) from terms; learn/profile course resolution via enrollments.
`gradebook.py` is intentionally left to db/gradebook-code (PR3, with curve+drop-lowest).
Other-slice callers (documents/quiz) degrade gracefully until their slices land.
Tests: +test_academics; updated graph_service/shared_course_context/graph_read_tools/
onboarding/learn suites. Full backend suite 724 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/academics-code: rewire app onto courses/offerings/terms/enrollments (epic slice PR2)
…ice)
Rewire the ops domain's code onto the already-landed 0026_ops schema, which
dropped the SERIAL integer PKs on feedback/issue_reports and recreated them
with TEXT PKs (gen_random_uuid()::text) plus real FKs to users(id) — and
sessions(id) ON DELETE SET NULL for feedback.
- routes/feedback.py: hand-build the text PK with str(uuid.uuid4()) on both the
feedback and issue_reports inserts, per the repo convention (academics.py,
graph_service.py), instead of relying on the dropped SERIAL default. The new
user_id/session_id FKs are already satisfied by the request body / session.
- tests/test_feedback_routes.py: new coverage for the two POST endpoints
(previously untested) — asserts the insert carries a UUID text PK and the
body fields round-trip, using the MagicMock-per-table factory pattern.
- routes/admin.py allowlist approve/revoke already read/write
newsletter_emails.approved_at as 0026 declares it (issue #267) — verified, no
code change needed.
Plan: docs/superpowers/plans/2026-06-24-db-ops-code.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire the identity domain onto the 0024 schema split. Public-profile fields
(name/first_name/last_name/username/avatar_url/bio/location/website/year/
majors/minors/learning_style) now live on a 1:1 user_profiles table; users
keeps only identity + auth + activity. One source of truth per field — nothing
is written to both users and user_profiles, nor duplicated onto user_settings.
- routes/profile.py: _get_user_or_404 reads users (id/email/streak/created_at)
and merges user_profiles; new _get_or_create_profile helper (ensure-row +
decrypt); username uniqueness + writes go to user_profiles; avatar_url
persists to user_profiles; _SETTINGS_COLS drops the moved columns.
- routes/auth.py: get_me reads name/username/avatar_url from user_profiles;
google_callback writes profile fields to user_profiles (insert/upsert) and
keeps only email/auth/activity on users; oauth_tokens.expires_at sent as
None (not "") when absent, since it is TIMESTAMPTZ now.
- routes/onboarding.py: the profile-field write moves to a user_profiles
upsert; onboarding_completed stays on users. Enrollment loop untouched
(academics owns it).
- models: drop display_name from UpdateProfileBody and the moved fields from
SettingsResponse.
- tests: profile/onboarding/decrypt-boundary updated for the split; added an
ensure-row test and a per-table column-contract pin.
Encryption boundary preserved: name/first_name/last_name/bio/location stay
🔒 TEXT (encrypt_if_present at write, decrypt_if_present at read) on
user_profiles; users.email stays 🔒 on users.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed schema
Rewire the gradebook onto the academics-split schema (migration 0021): key
gradebook_categories + assignments on enrollment_id instead of user_id+course_id.
The public API still speaks the abstract course_id plus an optional `semester`
(term label, default = current term); routes resolve (course_id, semester) -> the
user's enrollment via services.academics.
- services/gradebook_service.py: add drop-lowest (per-category, lowest earned/possible
ratio), apply_curve (linear z-score bell curve: avg_target + (raw-mean)*(new_sd/sd),
clamped 0-100), and credit-weighted GPA (gpa_points + weighted_gpa).
- routes/gradebook.py: enrollment resolver (_resolve_enrollment, semester->term via
terms.label); curve folded into current_grade; new PATCH /curve and GET /gpa
(per-semester + cumulative/transcript). points stay encrypted at write, decrypted
at read.
- models: semester + drop_lowest fields, assignment_type enum, SetCurveBody.
- tests: enrollment-keyed route tests (filter-aware fake table patching both
routes.gradebook.table and services.academics.table), service tests for
drop-lowest/curve/GPA incl. a hand-computed credit-weighted GPA fixture
(3.7*3 + 2.7*4)/7 = 3.1285714. Fixed test_response_decrypt_boundary for the new
resolver shape.
Plan: docs/superpowers/plans/2026-06-24-db-gradebook-code.md
Out of scope: gradescope sync (gradescope_assignment_id column preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…courses table
The academics split (0020/0022) already re-keyed class analytics to the
offering (course_concept_stats→offering_concept_stats, course_summary→
offering_summary) and offering-keyed course_context_service.py. The lone
holdout was routes/social.py::get_students, which still read
table("courses").select("user_id,course_name") — a query against the old
offering-shaped courses table that no longer has user_id or per-enrollment rows.
Resolve a user's courses through the enrollment chain instead
(enrollments → course_offerings → courses) via the PostgREST embedded join,
deduping across offerings of the same abstract course. Response shape of
GET /api/social/students is unchanged.
Adds tests/test_social_students.py (5 tests). No new migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents
Rewire services/graph_service.py onto the integrity guarantees from migration
0023_graph_integrity.sql:
- graph_nodes / graph_edges writes now use UNIQUE-backed upserts (on_conflict on
the new (user_id, course_id, concept_name) and
(user_id, source_node_id, target_node_id, relationship_type) constraints),
replacing the non-atomic select-then-insert dedup.
- Mastery changes append a row to the new node_mastery_events table instead of
rewriting the dropped graph_nodes.mastery_events JSONB blob, fixing the
non-atomic read-modify-write (#247). get_graph batch-reads that table to
compute learning_velocity and the trimmed per-node event history (API contract
preserved).
- Delete db/dedup_nodes.py — its (user_id, concept_name) dedup is superseded by
the UNIQUE constraint (#181).
Academics-owned graph_service.py logic (enrollment reshape, course CRUD,
offering resolution, update_course_context call sites) and the abstract-course
graph key are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Study artifacts (documents/notes/sessions/study_guides/flashcards) now key
on offering_id (0025); the knowledge graph + course context stay on the
abstract course_id. Routes resolve the abstract course id from the API
boundary to the current-term offering via services.academics before
reading/writing, and translate offering -> abstract for every graph path.
- notes_service: offering_id column + soft-delete (deleted_at) on read/delete.
- documents: persist offering_id; soft-delete; resolve offering on upload;
study-guide cache + doc reads key on offering; graph/syllabus/course-context
keep the abstract course id.
- study_guide / flashcards: read+write the offering; expose abstract course id
in responses; flashcards.import_commit resolves offering (nullable).
- learn: sessions key on offering_id; doc context reads by offering; wire the
shared-context block to the session's abstract course (resolves the
db/study-code TODO, no more {} degrade).
- quiz: validate difficulty against the 0025 CHECK enum; stop touching the
dropped graph_nodes.mastery_events column — route mastery writes through
services.graph_service.apply_graph_update (sanctioned path), keyed on the
abstract course id. IDOR 404 still fires before any write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/ops-code: feedback/issue_reports PKs + FKs (epic slice PR8)
db/analytics-code: social.py offering re-keying (epic slice PR4)
db/identity-code: user_profiles split (epic slice PR6)
db/graph-code: graph integrity + node_mastery_events (epic slice PR5)
db/study-code: study artifacts → offering_id (epic slice PR7)
db/gradebook-code: semester-aware gradebook + curve + drop-lowest (epic slice PR3)
…0024 identity split
Migration 0024 moved the public profile (incl. the 🔒-encrypted display `name`)
out of `users` into a 1:1 `user_profiles` table, and renamed
`users.room_id` -> `users.current_room_id`. The identity slice updated identity
files but left cross-domain readers/writers of `users.name` untouched; these
break against the real DB (mocked tests didn't catch them).
- services/graph_service.ensure_user_exists: stop INSERTing `name` into `users`
(column no longer exists). Insert only `{id, streak_count}`; do not create a
user_profiles row (onboarding/oauth own it).
- services/profiles.py (new): get_display_name / get_display_names read
user_profiles by user_id and decrypt the name. Tolerate missing rows.
- Repoint name reads onto the helper, preserving each response shape:
main.list_users (also source room_id from current_room_id),
routes/social.py (room detail, room activity, match_partners, school_match,
get_students), routes/quiz.py (quiz-context student name),
routes/learn.py get_user_name, services/users_search.paginate_users.
- services/users_search: CODE-ONLY fix — it reads table("users") directly, not a
DB view, so no 0028 migration is needed; names now come from user_profiles.
- services/flashcard_import_service.dedup_against_existing: filter the flashcards
link column `offering_id` (0025 renamed it from `course_id`); behavior identical.
Tests: new test_profiles_service; assert ensure_user_exists omits `name`; update
roster/social/users_search/flashcard tests for the new sources. Gate green:
2 known env-only test_storage_service failures only; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/identity-reconcile: route users.name readers through user_profiles + flashcard column (epic follow-up)
…ma (#258)
Self-contained, idempotent staging-only seed that lays a small fake demo
dataset on top of migrations 0019-0027 so the live app renders the knowledge
graph, gradebook, and courses-with-term against a real DB. Runs via
`python -m db.seed_staging`; safe to re-run.
- 1 demo school, 3 abstract courses, 4 offerings (CS101 in 2 terms — graph
mastery is cumulative across terms since it's keyed on the abstract course).
- 1 slim user + user_profiles (encrypted name fields), 4 enrollments (incl.
CS101 in both terms), 9 graph_nodes / 4 graph_edges / 6 node_mastery_events.
- Gradebook (4 categories w/ drop_lowest, 6 assignments w/ encrypted points),
plus a document + note on an offering for study endpoints.
- Idempotent via deterministic seed-… ids + upsert-on-UNIQUE / insert-if-absent;
re-runs add nothing. All 🔒 columns go through encrypt_if_present; enum values
read straight off the migration CHECK sets (no guesses). Reuses pre-seeded
terms (read-only) — never touches the real catalog.
- Hermetic tests patch db.seed_staging.table to a recording FakeTable and assert
insertion coverage, FK consistency, enum validity, multi-term, encryption, and
idempotency (2nd run adds no rows). Checklist Step 6 references the new command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/seed-staging: idempotent demo seed for the new schema (#258)
… (0028)
0020 renamed courses→course_offerings and dropped the abstract columns but missed
course_code, which stayed NOT NULL. Existing rows have it populated, but every NEW
offering insert (app resolve_offering/add_course AND seed_staging) omits it → 23502
not-null violation. The abstract course_code lives on `courses` now. Surfaced by
seeding staging; would also break add_course against the real DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): drop vestigial course_code NOT NULL on course_offerings (0028)
user_profiles is keyed on user_id and has no `id` column, so the idempotency
pre-check's `select=id` 400'd against staging. Select a column we're already
filtering on (the natural/PK key) instead — works for every table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): seed _exists_by uses a real column, not hardcoded id
…ular DB redesign + add dev guide
/update-mds across the knowledge docs (per-doc base..HEAD windows): new schema
(courses/course_offerings/terms/enrollments, user_profiles split, gradebook→enrollment,
analytics→offering, node_mastery_events), services/academics.py + services/profiles.py,
the db.migrate / db.seed_staging commands + .env.staging note, and the encryption list
(name fields now on user_profiles). Adds docs/db-modular-redesign-dev-guide.pdf for the team.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86250e2a-c43e-47fe-9830-6329f9404a91

📥 Commits

Reviewing files that changed from the base of the PR and between 77c4855 and afba99f.

📒 Files selected for processing (2)
  • backend/db/e2e_checks/quiz.py
  • backend/db/e2e_staging_http.py
📝 Walkthrough

Walkthrough

Backend guidance, schema migrations, services, routes, tests, and staging E2E checks were updated to match the modular database redesign. The PR shifts academics, identity, analytics, gradebook, graph, study, ops, and related docs/code paths to the new offering- and enrollment-based schema.

Changes

DB modular redesign

Layer / File(s)Summary
Guidance and rollout docs
CLAUDE.md, README.md, ROADMAP.md, docs/architecture.md, docs/staging/setup-checklist.md, docs/superpowers/plans/*, docs/superpowers/specs/*
Repository guidance, architecture notes, rollout instructions, roadmap entries, and redesign plans were updated for the modular schema work and staging E2E rollout.
Academics, identity, graph, and analytics code
backend/db/migrations/0019_*.sql, 0020_*.sql, 0022_*.sql, 0023_*.sql, 0024_*.sql, backend/services/academics.py, backend/routes/academics.py, backend/main.py, backend/routes/auth.py, backend/routes/profile.py, backend/routes/onboarding.py, backend/services/profiles.py, backend/services/course_context_service.py, backend/services/graph_service.py, backend/agents/tools/graph_read.py, backend/routes/social.py, backend/services/users_search.py, backend/tests/test_academics.py, backend/tests/test_graph_service.py, backend/tests/test_graph_read_tools.py, backend/tests/test_shared_course_context.py, backend/tests/test_profile_routes.py, backend/tests/test_profiles_service.py, backend/tests/test_users_roster_auth.py, backend/tests/test_users_search.py, backend/tests/test_onboarding_routes.py, backend/tests/test_social_students.py
New academics helpers/routes, identity/profile reads and writes, graph/context offering lookups, analytics tables, and related test rewrites now use offerings, enrollments, and user_profiles.
Gradebook and graph mastery rules
backend/db/migrations/0021_*.sql, backend/services/gradebook_service.py, backend/routes/gradebook.py, backend/models/__init__.py, backend/routes/quiz.py, backend/tests/test_gradebook_routes.py, backend/tests/test_gradebook_service.py, backend/tests/test_quiz_routes.py, backend/tests/test_response_decrypt_boundary.py
Gradebook routes, math, and models now resolve enrollments by semester and compute drop-lowest, curves, and GPA; graph mastery writes route through event rows.
Study, profile, social, and auth flows
backend/db/migrations/0025_*.sql, backend/services/notes_service.py, backend/services/flashcard_import_service.py, backend/routes/documents.py, backend/routes/flashcards.py, backend/routes/learn.py, backend/routes/notes.py, backend/routes/study_guide.py, backend/tests/test_documents_routes.py, backend/tests/test_flashcard_import_routes.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_learn_routes.py, backend/tests/test_notes_routes.py, backend/tests/test_notes_service.py, backend/tests/test_study_guide_routes.py, backend/tests/test_shared_course_context.py
Documents, notes, flashcards, study guides, and learn/session flows now key off offering_id, use soft deletes, and map back to abstract course ids where needed.
Ops cleanup and staging seed
backend/db/migrations/0026_*.sql, backend/db/migrations/0027_*.sql, backend/db/migrations/0028_*.sql, backend/routes/feedback.py, backend/db/seed_staging.py, backend/tests/test_feedback_routes.py, backend/tests/test_seed_staging.py, backend/db/e2e_checks/*, backend/db/e2e_staging_http.py, backend/tests/conftest.py, backend/tests/test_e2e_staging.py
Feedback and issue-report IDs become explicit UUID text values, Gradescope sync tables are added, the staging seed writes deterministic demo rows, and staging-only HTTP E2E checks were added.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Updates the same course-context service and context refresh flow that this PR extends to offering_id.
  • SaplingLearn/Sapling#65 — Touches the same auth/profile code paths and encrypted profile-field handling moved to user_profiles.
  • SaplingLearn/Sapling#67 — Updates the same document upload and persistence helpers that now key documents by offering_id.

Poem

A rabbit hopped through SQL snow,
Where offerings bloom and old paths go.
With curves and terms my whiskers twitch,
And profile names no longer glitch.
Hooray! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 30.14% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main schema redesign and migration range.
Description check✅ PassedThe description is detailed and covers the main changes and validation, but it doesn't follow the requested template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch epic/db-modular-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This was referenced Jun 25, 2026
@AndresL230
AndresL230 deleted the epic/db-modular-redesign branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028) - #279

Merged
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign
Jun 25, 2026
Merged

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028)#279
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

The epic cutover. Restructures the Postgres/Supabase schema from one offering-shaped courses table + a users mega-table into 8 bounded domains of typed tables with foreign keys, CHECK enums, a real terms entity, and one source of truth per fact — and rewires the entire backend onto it. Delivered schema-first (all migrations as one source of truth, then per-domain code slices).

What changed

  • Academics: courses split into abstract courses + course_offerings (per term) + terms; user_coursesenrollments(offering_id). New resolver services/academics.py. API boundary still uses the abstract course_id; the split is resolved server-side.
  • Identity: users slimmed; new user_profiles (1:1) holds name/profile fields (names still 🔒). New services/profiles.py for decrypted display names.
  • Gradebook: re-keyed to enrollment_id (gradebook_categories, enrollment-keyed assignments); per-semester + cumulative GPA, bell-curve + drop-lowest; decrypt_numeric on 🔒 points.
  • Knowledge Graph: stays on the abstract course (cumulative); graph_nodes.mastery_events JSON column → append-only node_mastery_events table; UNIQUE-backed node/edge upserts.
  • Class Analytics: course_concept_stats/course_summaryoffering_concept_stats/offering_summary (offering-keyed; free-text semester gone).
  • Study: documents/notes/sessions/study_guides/flashcards re-keyed to offering_id + soft-delete + CHECK enums.
  • Ops: feedback/issue_reports int PK → text PK + FKs.

How it was built & validated

Schema landed as migrations 0019–0028; code rewired in per-domain PRs that accumulated on this epic (#264 schema, #268 academics, #269#274 the 6 domain slices, #275 reconciliation, #276 seed, #277/#278 staging fixes).

  • 790 unit tests pass (mocked) · ruff clean
  • 14/14 service checks pass against live staging seeded data (academics resolver, get_courses incl. multi-term CS101, get_graph, gradebook drop-lowest + decrypt → (92.0,'A-'), user_profiles decrypt, offering analytics, learn/graph-read).
  • 🐛 Two real-DB bugs caught by seeding staging (invisible to mocked tests) and fixed in-epic: 0028 (vestigial course_offerings.course_code NOT NULL, would've broken add_course in prod) and the seed _exists_by id assumption.

For reviewers

  • Developer migration guide: docs/db-modular-redesign-dev-guide.pdf (the id model, rename cheat-sheet, what to change). Design spec + epic plan under docs/superpowers/.
  • Docs trued-up: CLAUDE.md, docs/architecture.md, README.md, ROADMAP.md.

⚠️ Before merging (prod cutover)

  1. Deploy this branch to staging and HTTP-smoke-test the live app (enroll, courses-with-term, gradebook GPA, graph). Service-level E2E is green; the deployed-app pass is the last gate.
  2. Full CI green on the merge.
  3. Prod promotion (prod has no user data, only the catalog): migrate --baseline to record 0001–0018, then migrate to apply 0019+; the 0020 catalog transform is data-driven. Never run dashboard DDL.

Known follow-ups (non-blocking)

Frontend term picker (#260), gradescope code rewire (#265), schools population (school surfaces blank), duplicate subject-root hub for multi-term courses.

Summary by CodeRabbit

  • New Features
    • Added term-aware academics (new /api/semesters) and updated courses/offerings behavior across gradebook, notes, study guides, flashcards, and graph.
    • Added semester-scoped gradebook details, including curve grading and credit-weighted GPA.
    • Added staging DB demo seeding and an opt-in staging E2E runner.
  • Bug Fixes
    • Profile display names now consistently come from the dedicated public-profile data store.
    • Document and note deletion now uses soft delete.
    • Quiz difficulty is validated, and mastery updates now record reliably through the graph update flow.
  • Documentation
    • Updated migration, architecture, and ops/staging guidance for ordered SQL migrations and the new schema conventions.

Resolved issues (auto-close on merge to main)

The DB modular redesign resolves these — semesters DB+backend, the missing indexes/FKs/UNIQUE-dedup, atomic mastery, graph write-integrity, social/students, and the staging catalog+environment:

Closes#100
Closes#128
Closes#137
Closes#138
Closes#158
Closes#160
Closes#161
Closes#176
Closes#177
Closes#178
Closes#179
Closes#180
Closes#181
Closes#195
Closes#247
Closes#258
Closes#259
Closes#266
Closes#267

Partially addressed (NOT closed — follow-ups remain): #142 (frontend term UI #139/#140/#141/#260), #265 (Gradescope code rewire), #126 (assignment-notes encryption + response-boundary leaks).

AndresL230and others added 29 commits June 23, 2026 18:29
Design spec (conventions charter, per-domain target DDL, catalog transform, migration sequencing) and the epic rollout plan (10 PR slices into epic/db-modular-redesign, PR1 detailed, staging->prod promotion runbook). Docs only; seeds the epic branch. Supersedes #137/#138/#142/#259/#260.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(db): modular redesign spec + epic rollout plan
Authors the complete modular target schema as one coherent set: terms+schools+updated_at trigger (0019), academics catalog/offering/enrollment split with data-preserving catalog transform (0020), gradebook re-keyed to enrollment (0021), analytics re-keyed to offering (0022), graph integrity + mastery events (0023), identity profile split (0024), study/sessions integrity (0025), ops cleanup (0026). Text PKs, FKs+ON DELETE, real types, CHECK enums read off code, encryption columns kept TEXT. Validated 0001->0026 end-to-end against Postgres 15 incl. the catalog transform. Source of truth for the per-domain code PRs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts ids
The renamed baseline tables inherited a TEXT PRIMARY KEY with no default,
unlike every other table in the redesign. Add gen_random_uuid()::text so the
app does not have to supply ids on insert for these two tables.
… redesign
Folds the in-flight DB changes from origin/Gradebook and origin/chore/staging-ops-scripts into the redesign so nothing is lost and the schema stays the single source of truth: drop-lowest -> gradebook_categories.drop_lowest; bell-curve policy -> enrollments.curve_*; per-assignment curve stats + gradescope_assignment_id -> assignments; gradescope_credentials + gradescope_course_links (link re-targeted to enrollment_id) -> 0027; newsletter_emails.approved_at -> 0026. Those branches' migration files are now superseded; their CODE rewire is tracked in filed issues. Validated 0001->0027 against Postgres 15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(db): schema-foundation migrations 0019-0026 (target schema)
…rollments (epic slice PR2)
Code-only slice against the already-landed academics split (migrations 0019-0027).
The public API keeps the abstract `course_id`; the term/semester becomes a real
second axis; enrollment resolves to a per-term offering internally.
- services/academics.py (new): term/offering/enrollment resolver — current_term
(date-derived, latest-term fallback), list_terms, resolve_offering (current term,
create-if-missing so new enrollments land in the real current semester),
offering_course_id, user_offering_ids_for_course, term_for_offering.
- graph_service: enrollments→course_offerings→courses/terms join reshaped to the
legacy flat shape; graph stays keyed on the ABSTRACT course_id; add/color/nickname/
delete resolve offerings; get_courses now surfaces `term`.
- course_context_service: offering-scoped analytics (offering_concept_stats/
offering_summary); resolves offering→abstract course for graph_nodes; semester gone.
- graph_read: misconceptions read offering_concept_stats by offering_id.
- onboarding/graph routes: enroll into the current-term offering; GET /api/semesters
(routes/academics.py) from terms; learn/profile course resolution via enrollments.
`gradebook.py` is intentionally left to db/gradebook-code (PR3, with curve+drop-lowest).
Other-slice callers (documents/quiz) degrade gracefully until their slices land.
Tests: +test_academics; updated graph_service/shared_course_context/graph_read_tools/
onboarding/learn suites. Full backend suite 724 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/academics-code: rewire app onto courses/offerings/terms/enrollments (epic slice PR2)
…ice)
Rewire the ops domain's code onto the already-landed 0026_ops schema, which
dropped the SERIAL integer PKs on feedback/issue_reports and recreated them
with TEXT PKs (gen_random_uuid()::text) plus real FKs to users(id) — and
sessions(id) ON DELETE SET NULL for feedback.
- routes/feedback.py: hand-build the text PK with str(uuid.uuid4()) on both the
feedback and issue_reports inserts, per the repo convention (academics.py,
graph_service.py), instead of relying on the dropped SERIAL default. The new
user_id/session_id FKs are already satisfied by the request body / session.
- tests/test_feedback_routes.py: new coverage for the two POST endpoints
(previously untested) — asserts the insert carries a UUID text PK and the
body fields round-trip, using the MagicMock-per-table factory pattern.
- routes/admin.py allowlist approve/revoke already read/write
newsletter_emails.approved_at as 0026 declares it (issue #267) — verified, no
code change needed.
Plan: docs/superpowers/plans/2026-06-24-db-ops-code.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire the identity domain onto the 0024 schema split. Public-profile fields
(name/first_name/last_name/username/avatar_url/bio/location/website/year/
majors/minors/learning_style) now live on a 1:1 user_profiles table; users
keeps only identity + auth + activity. One source of truth per field — nothing
is written to both users and user_profiles, nor duplicated onto user_settings.
- routes/profile.py: _get_user_or_404 reads users (id/email/streak/created_at)
and merges user_profiles; new _get_or_create_profile helper (ensure-row +
decrypt); username uniqueness + writes go to user_profiles; avatar_url
persists to user_profiles; _SETTINGS_COLS drops the moved columns.
- routes/auth.py: get_me reads name/username/avatar_url from user_profiles;
google_callback writes profile fields to user_profiles (insert/upsert) and
keeps only email/auth/activity on users; oauth_tokens.expires_at sent as
None (not "") when absent, since it is TIMESTAMPTZ now.
- routes/onboarding.py: the profile-field write moves to a user_profiles
upsert; onboarding_completed stays on users. Enrollment loop untouched
(academics owns it).
- models: drop display_name from UpdateProfileBody and the moved fields from
SettingsResponse.
- tests: profile/onboarding/decrypt-boundary updated for the split; added an
ensure-row test and a per-table column-contract pin.
Encryption boundary preserved: name/first_name/last_name/bio/location stay
🔒 TEXT (encrypt_if_present at write, decrypt_if_present at read) on
user_profiles; users.email stays 🔒 on users.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed schema
Rewire the gradebook onto the academics-split schema (migration 0021): key
gradebook_categories + assignments on enrollment_id instead of user_id+course_id.
The public API still speaks the abstract course_id plus an optional `semester`
(term label, default = current term); routes resolve (course_id, semester) -> the
user's enrollment via services.academics.
- services/gradebook_service.py: add drop-lowest (per-category, lowest earned/possible
ratio), apply_curve (linear z-score bell curve: avg_target + (raw-mean)*(new_sd/sd),
clamped 0-100), and credit-weighted GPA (gpa_points + weighted_gpa).
- routes/gradebook.py: enrollment resolver (_resolve_enrollment, semester->term via
terms.label); curve folded into current_grade; new PATCH /curve and GET /gpa
(per-semester + cumulative/transcript). points stay encrypted at write, decrypted
at read.
- models: semester + drop_lowest fields, assignment_type enum, SetCurveBody.
- tests: enrollment-keyed route tests (filter-aware fake table patching both
routes.gradebook.table and services.academics.table), service tests for
drop-lowest/curve/GPA incl. a hand-computed credit-weighted GPA fixture
(3.7*3 + 2.7*4)/7 = 3.1285714. Fixed test_response_decrypt_boundary for the new
resolver shape.
Plan: docs/superpowers/plans/2026-06-24-db-gradebook-code.md
Out of scope: gradescope sync (gradescope_assignment_id column preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…courses table
The academics split (0020/0022) already re-keyed class analytics to the
offering (course_concept_stats→offering_concept_stats, course_summary→
offering_summary) and offering-keyed course_context_service.py. The lone
holdout was routes/social.py::get_students, which still read
table("courses").select("user_id,course_name") — a query against the old
offering-shaped courses table that no longer has user_id or per-enrollment rows.
Resolve a user's courses through the enrollment chain instead
(enrollments → course_offerings → courses) via the PostgREST embedded join,
deduping across offerings of the same abstract course. Response shape of
GET /api/social/students is unchanged.
Adds tests/test_social_students.py (5 tests). No new migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents
Rewire services/graph_service.py onto the integrity guarantees from migration
0023_graph_integrity.sql:
- graph_nodes / graph_edges writes now use UNIQUE-backed upserts (on_conflict on
the new (user_id, course_id, concept_name) and
(user_id, source_node_id, target_node_id, relationship_type) constraints),
replacing the non-atomic select-then-insert dedup.
- Mastery changes append a row to the new node_mastery_events table instead of
rewriting the dropped graph_nodes.mastery_events JSONB blob, fixing the
non-atomic read-modify-write (#247). get_graph batch-reads that table to
compute learning_velocity and the trimmed per-node event history (API contract
preserved).
- Delete db/dedup_nodes.py — its (user_id, concept_name) dedup is superseded by
the UNIQUE constraint (#181).
Academics-owned graph_service.py logic (enrollment reshape, course CRUD,
offering resolution, update_course_context call sites) and the abstract-course
graph key are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Study artifacts (documents/notes/sessions/study_guides/flashcards) now key
on offering_id (0025); the knowledge graph + course context stay on the
abstract course_id. Routes resolve the abstract course id from the API
boundary to the current-term offering via services.academics before
reading/writing, and translate offering -> abstract for every graph path.
- notes_service: offering_id column + soft-delete (deleted_at) on read/delete.
- documents: persist offering_id; soft-delete; resolve offering on upload;
study-guide cache + doc reads key on offering; graph/syllabus/course-context
keep the abstract course id.
- study_guide / flashcards: read+write the offering; expose abstract course id
in responses; flashcards.import_commit resolves offering (nullable).
- learn: sessions key on offering_id; doc context reads by offering; wire the
shared-context block to the session's abstract course (resolves the
db/study-code TODO, no more {} degrade).
- quiz: validate difficulty against the 0025 CHECK enum; stop touching the
dropped graph_nodes.mastery_events column — route mastery writes through
services.graph_service.apply_graph_update (sanctioned path), keyed on the
abstract course id. IDOR 404 still fires before any write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/ops-code: feedback/issue_reports PKs + FKs (epic slice PR8)
db/analytics-code: social.py offering re-keying (epic slice PR4)
db/identity-code: user_profiles split (epic slice PR6)
db/graph-code: graph integrity + node_mastery_events (epic slice PR5)
db/study-code: study artifacts → offering_id (epic slice PR7)
db/gradebook-code: semester-aware gradebook + curve + drop-lowest (epic slice PR3)
…0024 identity split
Migration 0024 moved the public profile (incl. the 🔒-encrypted display `name`)
out of `users` into a 1:1 `user_profiles` table, and renamed
`users.room_id` -> `users.current_room_id`. The identity slice updated identity
files but left cross-domain readers/writers of `users.name` untouched; these
break against the real DB (mocked tests didn't catch them).
- services/graph_service.ensure_user_exists: stop INSERTing `name` into `users`
(column no longer exists). Insert only `{id, streak_count}`; do not create a
user_profiles row (onboarding/oauth own it).
- services/profiles.py (new): get_display_name / get_display_names read
user_profiles by user_id and decrypt the name. Tolerate missing rows.
- Repoint name reads onto the helper, preserving each response shape:
main.list_users (also source room_id from current_room_id),
routes/social.py (room detail, room activity, match_partners, school_match,
get_students), routes/quiz.py (quiz-context student name),
routes/learn.py get_user_name, services/users_search.paginate_users.
- services/users_search: CODE-ONLY fix — it reads table("users") directly, not a
DB view, so no 0028 migration is needed; names now come from user_profiles.
- services/flashcard_import_service.dedup_against_existing: filter the flashcards
link column `offering_id` (0025 renamed it from `course_id`); behavior identical.
Tests: new test_profiles_service; assert ensure_user_exists omits `name`; update
roster/social/users_search/flashcard tests for the new sources. Gate green:
2 known env-only test_storage_service failures only; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/identity-reconcile: route users.name readers through user_profiles + flashcard column (epic follow-up)
…ma (#258)
Self-contained, idempotent staging-only seed that lays a small fake demo
dataset on top of migrations 0019-0027 so the live app renders the knowledge
graph, gradebook, and courses-with-term against a real DB. Runs via
`python -m db.seed_staging`; safe to re-run.
- 1 demo school, 3 abstract courses, 4 offerings (CS101 in 2 terms — graph
mastery is cumulative across terms since it's keyed on the abstract course).
- 1 slim user + user_profiles (encrypted name fields), 4 enrollments (incl.
CS101 in both terms), 9 graph_nodes / 4 graph_edges / 6 node_mastery_events.
- Gradebook (4 categories w/ drop_lowest, 6 assignments w/ encrypted points),
plus a document + note on an offering for study endpoints.
- Idempotent via deterministic seed-… ids + upsert-on-UNIQUE / insert-if-absent;
re-runs add nothing. All 🔒 columns go through encrypt_if_present; enum values
read straight off the migration CHECK sets (no guesses). Reuses pre-seeded
terms (read-only) — never touches the real catalog.
- Hermetic tests patch db.seed_staging.table to a recording FakeTable and assert
insertion coverage, FK consistency, enum validity, multi-term, encryption, and
idempotency (2nd run adds no rows). Checklist Step 6 references the new command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/seed-staging: idempotent demo seed for the new schema (#258)
… (0028)
0020 renamed courses→course_offerings and dropped the abstract columns but missed
course_code, which stayed NOT NULL. Existing rows have it populated, but every NEW
offering insert (app resolve_offering/add_course AND seed_staging) omits it → 23502
not-null violation. The abstract course_code lives on `courses` now. Surfaced by
seeding staging; would also break add_course against the real DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): drop vestigial course_code NOT NULL on course_offerings (0028)
user_profiles is keyed on user_id and has no `id` column, so the idempotency
pre-check's `select=id` 400'd against staging. Select a column we're already
filtering on (the natural/PK key) instead — works for every table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): seed _exists_by uses a real column, not hardcoded id
…ular DB redesign + add dev guide
/update-mds across the knowledge docs (per-doc base..HEAD windows): new schema
(courses/course_offerings/terms/enrollments, user_profiles split, gradebook→enrollment,
analytics→offering, node_mastery_events), services/academics.py + services/profiles.py,
the db.migrate / db.seed_staging commands + .env.staging note, and the encryption list
(name fields now on user_profiles). Adds docs/db-modular-redesign-dev-guide.pdf for the team.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86250e2a-c43e-47fe-9830-6329f9404a91

📥 Commits

Reviewing files that changed from the base of the PR and between 77c4855 and afba99f.

📒 Files selected for processing (2)
  • backend/db/e2e_checks/quiz.py
  • backend/db/e2e_staging_http.py
📝 Walkthrough

Walkthrough

Backend guidance, schema migrations, services, routes, tests, and staging E2E checks were updated to match the modular database redesign. The PR shifts academics, identity, analytics, gradebook, graph, study, ops, and related docs/code paths to the new offering- and enrollment-based schema.

Changes

DB modular redesign

Layer / File(s)Summary
Guidance and rollout docs
CLAUDE.md, README.md, ROADMAP.md, docs/architecture.md, docs/staging/setup-checklist.md, docs/superpowers/plans/*, docs/superpowers/specs/*
Repository guidance, architecture notes, rollout instructions, roadmap entries, and redesign plans were updated for the modular schema work and staging E2E rollout.
Academics, identity, graph, and analytics code
backend/db/migrations/0019_*.sql, 0020_*.sql, 0022_*.sql, 0023_*.sql, 0024_*.sql, backend/services/academics.py, backend/routes/academics.py, backend/main.py, backend/routes/auth.py, backend/routes/profile.py, backend/routes/onboarding.py, backend/services/profiles.py, backend/services/course_context_service.py, backend/services/graph_service.py, backend/agents/tools/graph_read.py, backend/routes/social.py, backend/services/users_search.py, backend/tests/test_academics.py, backend/tests/test_graph_service.py, backend/tests/test_graph_read_tools.py, backend/tests/test_shared_course_context.py, backend/tests/test_profile_routes.py, backend/tests/test_profiles_service.py, backend/tests/test_users_roster_auth.py, backend/tests/test_users_search.py, backend/tests/test_onboarding_routes.py, backend/tests/test_social_students.py
New academics helpers/routes, identity/profile reads and writes, graph/context offering lookups, analytics tables, and related test rewrites now use offerings, enrollments, and user_profiles.
Gradebook and graph mastery rules
backend/db/migrations/0021_*.sql, backend/services/gradebook_service.py, backend/routes/gradebook.py, backend/models/__init__.py, backend/routes/quiz.py, backend/tests/test_gradebook_routes.py, backend/tests/test_gradebook_service.py, backend/tests/test_quiz_routes.py, backend/tests/test_response_decrypt_boundary.py
Gradebook routes, math, and models now resolve enrollments by semester and compute drop-lowest, curves, and GPA; graph mastery writes route through event rows.
Study, profile, social, and auth flows
backend/db/migrations/0025_*.sql, backend/services/notes_service.py, backend/services/flashcard_import_service.py, backend/routes/documents.py, backend/routes/flashcards.py, backend/routes/learn.py, backend/routes/notes.py, backend/routes/study_guide.py, backend/tests/test_documents_routes.py, backend/tests/test_flashcard_import_routes.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_learn_routes.py, backend/tests/test_notes_routes.py, backend/tests/test_notes_service.py, backend/tests/test_study_guide_routes.py, backend/tests/test_shared_course_context.py
Documents, notes, flashcards, study guides, and learn/session flows now key off offering_id, use soft deletes, and map back to abstract course ids where needed.
Ops cleanup and staging seed
backend/db/migrations/0026_*.sql, backend/db/migrations/0027_*.sql, backend/db/migrations/0028_*.sql, backend/routes/feedback.py, backend/db/seed_staging.py, backend/tests/test_feedback_routes.py, backend/tests/test_seed_staging.py, backend/db/e2e_checks/*, backend/db/e2e_staging_http.py, backend/tests/conftest.py, backend/tests/test_e2e_staging.py
Feedback and issue-report IDs become explicit UUID text values, Gradescope sync tables are added, the staging seed writes deterministic demo rows, and staging-only HTTP E2E checks were added.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Updates the same course-context service and context refresh flow that this PR extends to offering_id.
  • SaplingLearn/Sapling#65 — Touches the same auth/profile code paths and encrypted profile-field handling moved to user_profiles.
  • SaplingLearn/Sapling#67 — Updates the same document upload and persistence helpers that now key documents by offering_id.

Poem

A rabbit hopped through SQL snow,
Where offerings bloom and old paths go.
With curves and terms my whiskers twitch,
And profile names no longer glitch.
Hooray! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 30.14% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main schema redesign and migration range.
Description check✅ PassedThe description is detailed and covers the main changes and validation, but it doesn't follow the requested template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch epic/db-modular-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This was referenced Jun 25, 2026
@AndresL230
AndresL230 deleted the epic/db-modular-redesign branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028) - #279

Merged
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign
Jun 25, 2026
Merged

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028)#279
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

The epic cutover. Restructures the Postgres/Supabase schema from one offering-shaped courses table + a users mega-table into 8 bounded domains of typed tables with foreign keys, CHECK enums, a real terms entity, and one source of truth per fact — and rewires the entire backend onto it. Delivered schema-first (all migrations as one source of truth, then per-domain code slices).

What changed

  • Academics: courses split into abstract courses + course_offerings (per term) + terms; user_coursesenrollments(offering_id). New resolver services/academics.py. API boundary still uses the abstract course_id; the split is resolved server-side.
  • Identity: users slimmed; new user_profiles (1:1) holds name/profile fields (names still 🔒). New services/profiles.py for decrypted display names.
  • Gradebook: re-keyed to enrollment_id (gradebook_categories, enrollment-keyed assignments); per-semester + cumulative GPA, bell-curve + drop-lowest; decrypt_numeric on 🔒 points.
  • Knowledge Graph: stays on the abstract course (cumulative); graph_nodes.mastery_events JSON column → append-only node_mastery_events table; UNIQUE-backed node/edge upserts.
  • Class Analytics: course_concept_stats/course_summaryoffering_concept_stats/offering_summary (offering-keyed; free-text semester gone).
  • Study: documents/notes/sessions/study_guides/flashcards re-keyed to offering_id + soft-delete + CHECK enums.
  • Ops: feedback/issue_reports int PK → text PK + FKs.

How it was built & validated

Schema landed as migrations 0019–0028; code rewired in per-domain PRs that accumulated on this epic (#264 schema, #268 academics, #269#274 the 6 domain slices, #275 reconciliation, #276 seed, #277/#278 staging fixes).

  • 790 unit tests pass (mocked) · ruff clean
  • 14/14 service checks pass against live staging seeded data (academics resolver, get_courses incl. multi-term CS101, get_graph, gradebook drop-lowest + decrypt → (92.0,'A-'), user_profiles decrypt, offering analytics, learn/graph-read).
  • 🐛 Two real-DB bugs caught by seeding staging (invisible to mocked tests) and fixed in-epic: 0028 (vestigial course_offerings.course_code NOT NULL, would've broken add_course in prod) and the seed _exists_by id assumption.

For reviewers

  • Developer migration guide: docs/db-modular-redesign-dev-guide.pdf (the id model, rename cheat-sheet, what to change). Design spec + epic plan under docs/superpowers/.
  • Docs trued-up: CLAUDE.md, docs/architecture.md, README.md, ROADMAP.md.

⚠️ Before merging (prod cutover)

  1. Deploy this branch to staging and HTTP-smoke-test the live app (enroll, courses-with-term, gradebook GPA, graph). Service-level E2E is green; the deployed-app pass is the last gate.
  2. Full CI green on the merge.
  3. Prod promotion (prod has no user data, only the catalog): migrate --baseline to record 0001–0018, then migrate to apply 0019+; the 0020 catalog transform is data-driven. Never run dashboard DDL.

Known follow-ups (non-blocking)

Frontend term picker (#260), gradescope code rewire (#265), schools population (school surfaces blank), duplicate subject-root hub for multi-term courses.

Summary by CodeRabbit

  • New Features
    • Added term-aware academics (new /api/semesters) and updated courses/offerings behavior across gradebook, notes, study guides, flashcards, and graph.
    • Added semester-scoped gradebook details, including curve grading and credit-weighted GPA.
    • Added staging DB demo seeding and an opt-in staging E2E runner.
  • Bug Fixes
    • Profile display names now consistently come from the dedicated public-profile data store.
    • Document and note deletion now uses soft delete.
    • Quiz difficulty is validated, and mastery updates now record reliably through the graph update flow.
  • Documentation
    • Updated migration, architecture, and ops/staging guidance for ordered SQL migrations and the new schema conventions.

Resolved issues (auto-close on merge to main)

The DB modular redesign resolves these — semesters DB+backend, the missing indexes/FKs/UNIQUE-dedup, atomic mastery, graph write-integrity, social/students, and the staging catalog+environment:

Closes#100
Closes#128
Closes#137
Closes#138
Closes#158
Closes#160
Closes#161
Closes#176
Closes#177
Closes#178
Closes#179
Closes#180
Closes#181
Closes#195
Closes#247
Closes#258
Closes#259
Closes#266
Closes#267

Partially addressed (NOT closed — follow-ups remain): #142 (frontend term UI #139/#140/#141/#260), #265 (Gradescope code rewire), #126 (assignment-notes encryption + response-boundary leaks).

AndresL230and others added 29 commits June 23, 2026 18:29
Design spec (conventions charter, per-domain target DDL, catalog transform, migration sequencing) and the epic rollout plan (10 PR slices into epic/db-modular-redesign, PR1 detailed, staging->prod promotion runbook). Docs only; seeds the epic branch. Supersedes #137/#138/#142/#259/#260.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(db): modular redesign spec + epic rollout plan
Authors the complete modular target schema as one coherent set: terms+schools+updated_at trigger (0019), academics catalog/offering/enrollment split with data-preserving catalog transform (0020), gradebook re-keyed to enrollment (0021), analytics re-keyed to offering (0022), graph integrity + mastery events (0023), identity profile split (0024), study/sessions integrity (0025), ops cleanup (0026). Text PKs, FKs+ON DELETE, real types, CHECK enums read off code, encryption columns kept TEXT. Validated 0001->0026 end-to-end against Postgres 15 incl. the catalog transform. Source of truth for the per-domain code PRs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts ids
The renamed baseline tables inherited a TEXT PRIMARY KEY with no default,
unlike every other table in the redesign. Add gen_random_uuid()::text so the
app does not have to supply ids on insert for these two tables.
… redesign
Folds the in-flight DB changes from origin/Gradebook and origin/chore/staging-ops-scripts into the redesign so nothing is lost and the schema stays the single source of truth: drop-lowest -> gradebook_categories.drop_lowest; bell-curve policy -> enrollments.curve_*; per-assignment curve stats + gradescope_assignment_id -> assignments; gradescope_credentials + gradescope_course_links (link re-targeted to enrollment_id) -> 0027; newsletter_emails.approved_at -> 0026. Those branches' migration files are now superseded; their CODE rewire is tracked in filed issues. Validated 0001->0027 against Postgres 15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(db): schema-foundation migrations 0019-0026 (target schema)
…rollments (epic slice PR2)
Code-only slice against the already-landed academics split (migrations 0019-0027).
The public API keeps the abstract `course_id`; the term/semester becomes a real
second axis; enrollment resolves to a per-term offering internally.
- services/academics.py (new): term/offering/enrollment resolver — current_term
(date-derived, latest-term fallback), list_terms, resolve_offering (current term,
create-if-missing so new enrollments land in the real current semester),
offering_course_id, user_offering_ids_for_course, term_for_offering.
- graph_service: enrollments→course_offerings→courses/terms join reshaped to the
legacy flat shape; graph stays keyed on the ABSTRACT course_id; add/color/nickname/
delete resolve offerings; get_courses now surfaces `term`.
- course_context_service: offering-scoped analytics (offering_concept_stats/
offering_summary); resolves offering→abstract course for graph_nodes; semester gone.
- graph_read: misconceptions read offering_concept_stats by offering_id.
- onboarding/graph routes: enroll into the current-term offering; GET /api/semesters
(routes/academics.py) from terms; learn/profile course resolution via enrollments.
`gradebook.py` is intentionally left to db/gradebook-code (PR3, with curve+drop-lowest).
Other-slice callers (documents/quiz) degrade gracefully until their slices land.
Tests: +test_academics; updated graph_service/shared_course_context/graph_read_tools/
onboarding/learn suites. Full backend suite 724 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/academics-code: rewire app onto courses/offerings/terms/enrollments (epic slice PR2)
…ice)
Rewire the ops domain's code onto the already-landed 0026_ops schema, which
dropped the SERIAL integer PKs on feedback/issue_reports and recreated them
with TEXT PKs (gen_random_uuid()::text) plus real FKs to users(id) — and
sessions(id) ON DELETE SET NULL for feedback.
- routes/feedback.py: hand-build the text PK with str(uuid.uuid4()) on both the
feedback and issue_reports inserts, per the repo convention (academics.py,
graph_service.py), instead of relying on the dropped SERIAL default. The new
user_id/session_id FKs are already satisfied by the request body / session.
- tests/test_feedback_routes.py: new coverage for the two POST endpoints
(previously untested) — asserts the insert carries a UUID text PK and the
body fields round-trip, using the MagicMock-per-table factory pattern.
- routes/admin.py allowlist approve/revoke already read/write
newsletter_emails.approved_at as 0026 declares it (issue #267) — verified, no
code change needed.
Plan: docs/superpowers/plans/2026-06-24-db-ops-code.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire the identity domain onto the 0024 schema split. Public-profile fields
(name/first_name/last_name/username/avatar_url/bio/location/website/year/
majors/minors/learning_style) now live on a 1:1 user_profiles table; users
keeps only identity + auth + activity. One source of truth per field — nothing
is written to both users and user_profiles, nor duplicated onto user_settings.
- routes/profile.py: _get_user_or_404 reads users (id/email/streak/created_at)
and merges user_profiles; new _get_or_create_profile helper (ensure-row +
decrypt); username uniqueness + writes go to user_profiles; avatar_url
persists to user_profiles; _SETTINGS_COLS drops the moved columns.
- routes/auth.py: get_me reads name/username/avatar_url from user_profiles;
google_callback writes profile fields to user_profiles (insert/upsert) and
keeps only email/auth/activity on users; oauth_tokens.expires_at sent as
None (not "") when absent, since it is TIMESTAMPTZ now.
- routes/onboarding.py: the profile-field write moves to a user_profiles
upsert; onboarding_completed stays on users. Enrollment loop untouched
(academics owns it).
- models: drop display_name from UpdateProfileBody and the moved fields from
SettingsResponse.
- tests: profile/onboarding/decrypt-boundary updated for the split; added an
ensure-row test and a per-table column-contract pin.
Encryption boundary preserved: name/first_name/last_name/bio/location stay
🔒 TEXT (encrypt_if_present at write, decrypt_if_present at read) on
user_profiles; users.email stays 🔒 on users.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed schema
Rewire the gradebook onto the academics-split schema (migration 0021): key
gradebook_categories + assignments on enrollment_id instead of user_id+course_id.
The public API still speaks the abstract course_id plus an optional `semester`
(term label, default = current term); routes resolve (course_id, semester) -> the
user's enrollment via services.academics.
- services/gradebook_service.py: add drop-lowest (per-category, lowest earned/possible
ratio), apply_curve (linear z-score bell curve: avg_target + (raw-mean)*(new_sd/sd),
clamped 0-100), and credit-weighted GPA (gpa_points + weighted_gpa).
- routes/gradebook.py: enrollment resolver (_resolve_enrollment, semester->term via
terms.label); curve folded into current_grade; new PATCH /curve and GET /gpa
(per-semester + cumulative/transcript). points stay encrypted at write, decrypted
at read.
- models: semester + drop_lowest fields, assignment_type enum, SetCurveBody.
- tests: enrollment-keyed route tests (filter-aware fake table patching both
routes.gradebook.table and services.academics.table), service tests for
drop-lowest/curve/GPA incl. a hand-computed credit-weighted GPA fixture
(3.7*3 + 2.7*4)/7 = 3.1285714. Fixed test_response_decrypt_boundary for the new
resolver shape.
Plan: docs/superpowers/plans/2026-06-24-db-gradebook-code.md
Out of scope: gradescope sync (gradescope_assignment_id column preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…courses table
The academics split (0020/0022) already re-keyed class analytics to the
offering (course_concept_stats→offering_concept_stats, course_summary→
offering_summary) and offering-keyed course_context_service.py. The lone
holdout was routes/social.py::get_students, which still read
table("courses").select("user_id,course_name") — a query against the old
offering-shaped courses table that no longer has user_id or per-enrollment rows.
Resolve a user's courses through the enrollment chain instead
(enrollments → course_offerings → courses) via the PostgREST embedded join,
deduping across offerings of the same abstract course. Response shape of
GET /api/social/students is unchanged.
Adds tests/test_social_students.py (5 tests). No new migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents
Rewire services/graph_service.py onto the integrity guarantees from migration
0023_graph_integrity.sql:
- graph_nodes / graph_edges writes now use UNIQUE-backed upserts (on_conflict on
the new (user_id, course_id, concept_name) and
(user_id, source_node_id, target_node_id, relationship_type) constraints),
replacing the non-atomic select-then-insert dedup.
- Mastery changes append a row to the new node_mastery_events table instead of
rewriting the dropped graph_nodes.mastery_events JSONB blob, fixing the
non-atomic read-modify-write (#247). get_graph batch-reads that table to
compute learning_velocity and the trimmed per-node event history (API contract
preserved).
- Delete db/dedup_nodes.py — its (user_id, concept_name) dedup is superseded by
the UNIQUE constraint (#181).
Academics-owned graph_service.py logic (enrollment reshape, course CRUD,
offering resolution, update_course_context call sites) and the abstract-course
graph key are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Study artifacts (documents/notes/sessions/study_guides/flashcards) now key
on offering_id (0025); the knowledge graph + course context stay on the
abstract course_id. Routes resolve the abstract course id from the API
boundary to the current-term offering via services.academics before
reading/writing, and translate offering -> abstract for every graph path.
- notes_service: offering_id column + soft-delete (deleted_at) on read/delete.
- documents: persist offering_id; soft-delete; resolve offering on upload;
study-guide cache + doc reads key on offering; graph/syllabus/course-context
keep the abstract course id.
- study_guide / flashcards: read+write the offering; expose abstract course id
in responses; flashcards.import_commit resolves offering (nullable).
- learn: sessions key on offering_id; doc context reads by offering; wire the
shared-context block to the session's abstract course (resolves the
db/study-code TODO, no more {} degrade).
- quiz: validate difficulty against the 0025 CHECK enum; stop touching the
dropped graph_nodes.mastery_events column — route mastery writes through
services.graph_service.apply_graph_update (sanctioned path), keyed on the
abstract course id. IDOR 404 still fires before any write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/ops-code: feedback/issue_reports PKs + FKs (epic slice PR8)
db/analytics-code: social.py offering re-keying (epic slice PR4)
db/identity-code: user_profiles split (epic slice PR6)
db/graph-code: graph integrity + node_mastery_events (epic slice PR5)
db/study-code: study artifacts → offering_id (epic slice PR7)
db/gradebook-code: semester-aware gradebook + curve + drop-lowest (epic slice PR3)
…0024 identity split
Migration 0024 moved the public profile (incl. the 🔒-encrypted display `name`)
out of `users` into a 1:1 `user_profiles` table, and renamed
`users.room_id` -> `users.current_room_id`. The identity slice updated identity
files but left cross-domain readers/writers of `users.name` untouched; these
break against the real DB (mocked tests didn't catch them).
- services/graph_service.ensure_user_exists: stop INSERTing `name` into `users`
(column no longer exists). Insert only `{id, streak_count}`; do not create a
user_profiles row (onboarding/oauth own it).
- services/profiles.py (new): get_display_name / get_display_names read
user_profiles by user_id and decrypt the name. Tolerate missing rows.
- Repoint name reads onto the helper, preserving each response shape:
main.list_users (also source room_id from current_room_id),
routes/social.py (room detail, room activity, match_partners, school_match,
get_students), routes/quiz.py (quiz-context student name),
routes/learn.py get_user_name, services/users_search.paginate_users.
- services/users_search: CODE-ONLY fix — it reads table("users") directly, not a
DB view, so no 0028 migration is needed; names now come from user_profiles.
- services/flashcard_import_service.dedup_against_existing: filter the flashcards
link column `offering_id` (0025 renamed it from `course_id`); behavior identical.
Tests: new test_profiles_service; assert ensure_user_exists omits `name`; update
roster/social/users_search/flashcard tests for the new sources. Gate green:
2 known env-only test_storage_service failures only; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/identity-reconcile: route users.name readers through user_profiles + flashcard column (epic follow-up)
…ma (#258)
Self-contained, idempotent staging-only seed that lays a small fake demo
dataset on top of migrations 0019-0027 so the live app renders the knowledge
graph, gradebook, and courses-with-term against a real DB. Runs via
`python -m db.seed_staging`; safe to re-run.
- 1 demo school, 3 abstract courses, 4 offerings (CS101 in 2 terms — graph
mastery is cumulative across terms since it's keyed on the abstract course).
- 1 slim user + user_profiles (encrypted name fields), 4 enrollments (incl.
CS101 in both terms), 9 graph_nodes / 4 graph_edges / 6 node_mastery_events.
- Gradebook (4 categories w/ drop_lowest, 6 assignments w/ encrypted points),
plus a document + note on an offering for study endpoints.
- Idempotent via deterministic seed-… ids + upsert-on-UNIQUE / insert-if-absent;
re-runs add nothing. All 🔒 columns go through encrypt_if_present; enum values
read straight off the migration CHECK sets (no guesses). Reuses pre-seeded
terms (read-only) — never touches the real catalog.
- Hermetic tests patch db.seed_staging.table to a recording FakeTable and assert
insertion coverage, FK consistency, enum validity, multi-term, encryption, and
idempotency (2nd run adds no rows). Checklist Step 6 references the new command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/seed-staging: idempotent demo seed for the new schema (#258)
… (0028)
0020 renamed courses→course_offerings and dropped the abstract columns but missed
course_code, which stayed NOT NULL. Existing rows have it populated, but every NEW
offering insert (app resolve_offering/add_course AND seed_staging) omits it → 23502
not-null violation. The abstract course_code lives on `courses` now. Surfaced by
seeding staging; would also break add_course against the real DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): drop vestigial course_code NOT NULL on course_offerings (0028)
user_profiles is keyed on user_id and has no `id` column, so the idempotency
pre-check's `select=id` 400'd against staging. Select a column we're already
filtering on (the natural/PK key) instead — works for every table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): seed _exists_by uses a real column, not hardcoded id
…ular DB redesign + add dev guide
/update-mds across the knowledge docs (per-doc base..HEAD windows): new schema
(courses/course_offerings/terms/enrollments, user_profiles split, gradebook→enrollment,
analytics→offering, node_mastery_events), services/academics.py + services/profiles.py,
the db.migrate / db.seed_staging commands + .env.staging note, and the encryption list
(name fields now on user_profiles). Adds docs/db-modular-redesign-dev-guide.pdf for the team.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86250e2a-c43e-47fe-9830-6329f9404a91

📥 Commits

Reviewing files that changed from the base of the PR and between 77c4855 and afba99f.

📒 Files selected for processing (2)
  • backend/db/e2e_checks/quiz.py
  • backend/db/e2e_staging_http.py
📝 Walkthrough

Walkthrough

Backend guidance, schema migrations, services, routes, tests, and staging E2E checks were updated to match the modular database redesign. The PR shifts academics, identity, analytics, gradebook, graph, study, ops, and related docs/code paths to the new offering- and enrollment-based schema.

Changes

DB modular redesign

Layer / File(s)Summary
Guidance and rollout docs
CLAUDE.md, README.md, ROADMAP.md, docs/architecture.md, docs/staging/setup-checklist.md, docs/superpowers/plans/*, docs/superpowers/specs/*
Repository guidance, architecture notes, rollout instructions, roadmap entries, and redesign plans were updated for the modular schema work and staging E2E rollout.
Academics, identity, graph, and analytics code
backend/db/migrations/0019_*.sql, 0020_*.sql, 0022_*.sql, 0023_*.sql, 0024_*.sql, backend/services/academics.py, backend/routes/academics.py, backend/main.py, backend/routes/auth.py, backend/routes/profile.py, backend/routes/onboarding.py, backend/services/profiles.py, backend/services/course_context_service.py, backend/services/graph_service.py, backend/agents/tools/graph_read.py, backend/routes/social.py, backend/services/users_search.py, backend/tests/test_academics.py, backend/tests/test_graph_service.py, backend/tests/test_graph_read_tools.py, backend/tests/test_shared_course_context.py, backend/tests/test_profile_routes.py, backend/tests/test_profiles_service.py, backend/tests/test_users_roster_auth.py, backend/tests/test_users_search.py, backend/tests/test_onboarding_routes.py, backend/tests/test_social_students.py
New academics helpers/routes, identity/profile reads and writes, graph/context offering lookups, analytics tables, and related test rewrites now use offerings, enrollments, and user_profiles.
Gradebook and graph mastery rules
backend/db/migrations/0021_*.sql, backend/services/gradebook_service.py, backend/routes/gradebook.py, backend/models/__init__.py, backend/routes/quiz.py, backend/tests/test_gradebook_routes.py, backend/tests/test_gradebook_service.py, backend/tests/test_quiz_routes.py, backend/tests/test_response_decrypt_boundary.py
Gradebook routes, math, and models now resolve enrollments by semester and compute drop-lowest, curves, and GPA; graph mastery writes route through event rows.
Study, profile, social, and auth flows
backend/db/migrations/0025_*.sql, backend/services/notes_service.py, backend/services/flashcard_import_service.py, backend/routes/documents.py, backend/routes/flashcards.py, backend/routes/learn.py, backend/routes/notes.py, backend/routes/study_guide.py, backend/tests/test_documents_routes.py, backend/tests/test_flashcard_import_routes.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_learn_routes.py, backend/tests/test_notes_routes.py, backend/tests/test_notes_service.py, backend/tests/test_study_guide_routes.py, backend/tests/test_shared_course_context.py
Documents, notes, flashcards, study guides, and learn/session flows now key off offering_id, use soft deletes, and map back to abstract course ids where needed.
Ops cleanup and staging seed
backend/db/migrations/0026_*.sql, backend/db/migrations/0027_*.sql, backend/db/migrations/0028_*.sql, backend/routes/feedback.py, backend/db/seed_staging.py, backend/tests/test_feedback_routes.py, backend/tests/test_seed_staging.py, backend/db/e2e_checks/*, backend/db/e2e_staging_http.py, backend/tests/conftest.py, backend/tests/test_e2e_staging.py
Feedback and issue-report IDs become explicit UUID text values, Gradescope sync tables are added, the staging seed writes deterministic demo rows, and staging-only HTTP E2E checks were added.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Updates the same course-context service and context refresh flow that this PR extends to offering_id.
  • SaplingLearn/Sapling#65 — Touches the same auth/profile code paths and encrypted profile-field handling moved to user_profiles.
  • SaplingLearn/Sapling#67 — Updates the same document upload and persistence helpers that now key documents by offering_id.

Poem

A rabbit hopped through SQL snow,
Where offerings bloom and old paths go.
With curves and terms my whiskers twitch,
And profile names no longer glitch.
Hooray! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 30.14% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main schema redesign and migration range.
Description check✅ PassedThe description is detailed and covers the main changes and validation, but it doesn't follow the requested template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch epic/db-modular-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This was referenced Jun 25, 2026
@AndresL230
AndresL230 deleted the epic/db-modular-redesign branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028) - #279

Merged
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign
Jun 25, 2026
Merged

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028)#279
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

The epic cutover. Restructures the Postgres/Supabase schema from one offering-shaped courses table + a users mega-table into 8 bounded domains of typed tables with foreign keys, CHECK enums, a real terms entity, and one source of truth per fact — and rewires the entire backend onto it. Delivered schema-first (all migrations as one source of truth, then per-domain code slices).

What changed

  • Academics: courses split into abstract courses + course_offerings (per term) + terms; user_coursesenrollments(offering_id). New resolver services/academics.py. API boundary still uses the abstract course_id; the split is resolved server-side.
  • Identity: users slimmed; new user_profiles (1:1) holds name/profile fields (names still 🔒). New services/profiles.py for decrypted display names.
  • Gradebook: re-keyed to enrollment_id (gradebook_categories, enrollment-keyed assignments); per-semester + cumulative GPA, bell-curve + drop-lowest; decrypt_numeric on 🔒 points.
  • Knowledge Graph: stays on the abstract course (cumulative); graph_nodes.mastery_events JSON column → append-only node_mastery_events table; UNIQUE-backed node/edge upserts.
  • Class Analytics: course_concept_stats/course_summaryoffering_concept_stats/offering_summary (offering-keyed; free-text semester gone).
  • Study: documents/notes/sessions/study_guides/flashcards re-keyed to offering_id + soft-delete + CHECK enums.
  • Ops: feedback/issue_reports int PK → text PK + FKs.

How it was built & validated

Schema landed as migrations 0019–0028; code rewired in per-domain PRs that accumulated on this epic (#264 schema, #268 academics, #269#274 the 6 domain slices, #275 reconciliation, #276 seed, #277/#278 staging fixes).

  • 790 unit tests pass (mocked) · ruff clean
  • 14/14 service checks pass against live staging seeded data (academics resolver, get_courses incl. multi-term CS101, get_graph, gradebook drop-lowest + decrypt → (92.0,'A-'), user_profiles decrypt, offering analytics, learn/graph-read).
  • 🐛 Two real-DB bugs caught by seeding staging (invisible to mocked tests) and fixed in-epic: 0028 (vestigial course_offerings.course_code NOT NULL, would've broken add_course in prod) and the seed _exists_by id assumption.

For reviewers

  • Developer migration guide: docs/db-modular-redesign-dev-guide.pdf (the id model, rename cheat-sheet, what to change). Design spec + epic plan under docs/superpowers/.
  • Docs trued-up: CLAUDE.md, docs/architecture.md, README.md, ROADMAP.md.

⚠️ Before merging (prod cutover)

  1. Deploy this branch to staging and HTTP-smoke-test the live app (enroll, courses-with-term, gradebook GPA, graph). Service-level E2E is green; the deployed-app pass is the last gate.
  2. Full CI green on the merge.
  3. Prod promotion (prod has no user data, only the catalog): migrate --baseline to record 0001–0018, then migrate to apply 0019+; the 0020 catalog transform is data-driven. Never run dashboard DDL.

Known follow-ups (non-blocking)

Frontend term picker (#260), gradescope code rewire (#265), schools population (school surfaces blank), duplicate subject-root hub for multi-term courses.

Summary by CodeRabbit

  • New Features
    • Added term-aware academics (new /api/semesters) and updated courses/offerings behavior across gradebook, notes, study guides, flashcards, and graph.
    • Added semester-scoped gradebook details, including curve grading and credit-weighted GPA.
    • Added staging DB demo seeding and an opt-in staging E2E runner.
  • Bug Fixes
    • Profile display names now consistently come from the dedicated public-profile data store.
    • Document and note deletion now uses soft delete.
    • Quiz difficulty is validated, and mastery updates now record reliably through the graph update flow.
  • Documentation
    • Updated migration, architecture, and ops/staging guidance for ordered SQL migrations and the new schema conventions.

Resolved issues (auto-close on merge to main)

The DB modular redesign resolves these — semesters DB+backend, the missing indexes/FKs/UNIQUE-dedup, atomic mastery, graph write-integrity, social/students, and the staging catalog+environment:

Closes#100
Closes#128
Closes#137
Closes#138
Closes#158
Closes#160
Closes#161
Closes#176
Closes#177
Closes#178
Closes#179
Closes#180
Closes#181
Closes#195
Closes#247
Closes#258
Closes#259
Closes#266
Closes#267

Partially addressed (NOT closed — follow-ups remain): #142 (frontend term UI #139/#140/#141/#260), #265 (Gradescope code rewire), #126 (assignment-notes encryption + response-boundary leaks).

AndresL230and others added 29 commits June 23, 2026 18:29
Design spec (conventions charter, per-domain target DDL, catalog transform, migration sequencing) and the epic rollout plan (10 PR slices into epic/db-modular-redesign, PR1 detailed, staging->prod promotion runbook). Docs only; seeds the epic branch. Supersedes #137/#138/#142/#259/#260.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(db): modular redesign spec + epic rollout plan
Authors the complete modular target schema as one coherent set: terms+schools+updated_at trigger (0019), academics catalog/offering/enrollment split with data-preserving catalog transform (0020), gradebook re-keyed to enrollment (0021), analytics re-keyed to offering (0022), graph integrity + mastery events (0023), identity profile split (0024), study/sessions integrity (0025), ops cleanup (0026). Text PKs, FKs+ON DELETE, real types, CHECK enums read off code, encryption columns kept TEXT. Validated 0001->0026 end-to-end against Postgres 15 incl. the catalog transform. Source of truth for the per-domain code PRs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts ids
The renamed baseline tables inherited a TEXT PRIMARY KEY with no default,
unlike every other table in the redesign. Add gen_random_uuid()::text so the
app does not have to supply ids on insert for these two tables.
… redesign
Folds the in-flight DB changes from origin/Gradebook and origin/chore/staging-ops-scripts into the redesign so nothing is lost and the schema stays the single source of truth: drop-lowest -> gradebook_categories.drop_lowest; bell-curve policy -> enrollments.curve_*; per-assignment curve stats + gradescope_assignment_id -> assignments; gradescope_credentials + gradescope_course_links (link re-targeted to enrollment_id) -> 0027; newsletter_emails.approved_at -> 0026. Those branches' migration files are now superseded; their CODE rewire is tracked in filed issues. Validated 0001->0027 against Postgres 15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(db): schema-foundation migrations 0019-0026 (target schema)
…rollments (epic slice PR2)
Code-only slice against the already-landed academics split (migrations 0019-0027).
The public API keeps the abstract `course_id`; the term/semester becomes a real
second axis; enrollment resolves to a per-term offering internally.
- services/academics.py (new): term/offering/enrollment resolver — current_term
(date-derived, latest-term fallback), list_terms, resolve_offering (current term,
create-if-missing so new enrollments land in the real current semester),
offering_course_id, user_offering_ids_for_course, term_for_offering.
- graph_service: enrollments→course_offerings→courses/terms join reshaped to the
legacy flat shape; graph stays keyed on the ABSTRACT course_id; add/color/nickname/
delete resolve offerings; get_courses now surfaces `term`.
- course_context_service: offering-scoped analytics (offering_concept_stats/
offering_summary); resolves offering→abstract course for graph_nodes; semester gone.
- graph_read: misconceptions read offering_concept_stats by offering_id.
- onboarding/graph routes: enroll into the current-term offering; GET /api/semesters
(routes/academics.py) from terms; learn/profile course resolution via enrollments.
`gradebook.py` is intentionally left to db/gradebook-code (PR3, with curve+drop-lowest).
Other-slice callers (documents/quiz) degrade gracefully until their slices land.
Tests: +test_academics; updated graph_service/shared_course_context/graph_read_tools/
onboarding/learn suites. Full backend suite 724 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/academics-code: rewire app onto courses/offerings/terms/enrollments (epic slice PR2)
…ice)
Rewire the ops domain's code onto the already-landed 0026_ops schema, which
dropped the SERIAL integer PKs on feedback/issue_reports and recreated them
with TEXT PKs (gen_random_uuid()::text) plus real FKs to users(id) — and
sessions(id) ON DELETE SET NULL for feedback.
- routes/feedback.py: hand-build the text PK with str(uuid.uuid4()) on both the
feedback and issue_reports inserts, per the repo convention (academics.py,
graph_service.py), instead of relying on the dropped SERIAL default. The new
user_id/session_id FKs are already satisfied by the request body / session.
- tests/test_feedback_routes.py: new coverage for the two POST endpoints
(previously untested) — asserts the insert carries a UUID text PK and the
body fields round-trip, using the MagicMock-per-table factory pattern.
- routes/admin.py allowlist approve/revoke already read/write
newsletter_emails.approved_at as 0026 declares it (issue #267) — verified, no
code change needed.
Plan: docs/superpowers/plans/2026-06-24-db-ops-code.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire the identity domain onto the 0024 schema split. Public-profile fields
(name/first_name/last_name/username/avatar_url/bio/location/website/year/
majors/minors/learning_style) now live on a 1:1 user_profiles table; users
keeps only identity + auth + activity. One source of truth per field — nothing
is written to both users and user_profiles, nor duplicated onto user_settings.
- routes/profile.py: _get_user_or_404 reads users (id/email/streak/created_at)
and merges user_profiles; new _get_or_create_profile helper (ensure-row +
decrypt); username uniqueness + writes go to user_profiles; avatar_url
persists to user_profiles; _SETTINGS_COLS drops the moved columns.
- routes/auth.py: get_me reads name/username/avatar_url from user_profiles;
google_callback writes profile fields to user_profiles (insert/upsert) and
keeps only email/auth/activity on users; oauth_tokens.expires_at sent as
None (not "") when absent, since it is TIMESTAMPTZ now.
- routes/onboarding.py: the profile-field write moves to a user_profiles
upsert; onboarding_completed stays on users. Enrollment loop untouched
(academics owns it).
- models: drop display_name from UpdateProfileBody and the moved fields from
SettingsResponse.
- tests: profile/onboarding/decrypt-boundary updated for the split; added an
ensure-row test and a per-table column-contract pin.
Encryption boundary preserved: name/first_name/last_name/bio/location stay
🔒 TEXT (encrypt_if_present at write, decrypt_if_present at read) on
user_profiles; users.email stays 🔒 on users.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed schema
Rewire the gradebook onto the academics-split schema (migration 0021): key
gradebook_categories + assignments on enrollment_id instead of user_id+course_id.
The public API still speaks the abstract course_id plus an optional `semester`
(term label, default = current term); routes resolve (course_id, semester) -> the
user's enrollment via services.academics.
- services/gradebook_service.py: add drop-lowest (per-category, lowest earned/possible
ratio), apply_curve (linear z-score bell curve: avg_target + (raw-mean)*(new_sd/sd),
clamped 0-100), and credit-weighted GPA (gpa_points + weighted_gpa).
- routes/gradebook.py: enrollment resolver (_resolve_enrollment, semester->term via
terms.label); curve folded into current_grade; new PATCH /curve and GET /gpa
(per-semester + cumulative/transcript). points stay encrypted at write, decrypted
at read.
- models: semester + drop_lowest fields, assignment_type enum, SetCurveBody.
- tests: enrollment-keyed route tests (filter-aware fake table patching both
routes.gradebook.table and services.academics.table), service tests for
drop-lowest/curve/GPA incl. a hand-computed credit-weighted GPA fixture
(3.7*3 + 2.7*4)/7 = 3.1285714. Fixed test_response_decrypt_boundary for the new
resolver shape.
Plan: docs/superpowers/plans/2026-06-24-db-gradebook-code.md
Out of scope: gradescope sync (gradescope_assignment_id column preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…courses table
The academics split (0020/0022) already re-keyed class analytics to the
offering (course_concept_stats→offering_concept_stats, course_summary→
offering_summary) and offering-keyed course_context_service.py. The lone
holdout was routes/social.py::get_students, which still read
table("courses").select("user_id,course_name") — a query against the old
offering-shaped courses table that no longer has user_id or per-enrollment rows.
Resolve a user's courses through the enrollment chain instead
(enrollments → course_offerings → courses) via the PostgREST embedded join,
deduping across offerings of the same abstract course. Response shape of
GET /api/social/students is unchanged.
Adds tests/test_social_students.py (5 tests). No new migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents
Rewire services/graph_service.py onto the integrity guarantees from migration
0023_graph_integrity.sql:
- graph_nodes / graph_edges writes now use UNIQUE-backed upserts (on_conflict on
the new (user_id, course_id, concept_name) and
(user_id, source_node_id, target_node_id, relationship_type) constraints),
replacing the non-atomic select-then-insert dedup.
- Mastery changes append a row to the new node_mastery_events table instead of
rewriting the dropped graph_nodes.mastery_events JSONB blob, fixing the
non-atomic read-modify-write (#247). get_graph batch-reads that table to
compute learning_velocity and the trimmed per-node event history (API contract
preserved).
- Delete db/dedup_nodes.py — its (user_id, concept_name) dedup is superseded by
the UNIQUE constraint (#181).
Academics-owned graph_service.py logic (enrollment reshape, course CRUD,
offering resolution, update_course_context call sites) and the abstract-course
graph key are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Study artifacts (documents/notes/sessions/study_guides/flashcards) now key
on offering_id (0025); the knowledge graph + course context stay on the
abstract course_id. Routes resolve the abstract course id from the API
boundary to the current-term offering via services.academics before
reading/writing, and translate offering -> abstract for every graph path.
- notes_service: offering_id column + soft-delete (deleted_at) on read/delete.
- documents: persist offering_id; soft-delete; resolve offering on upload;
study-guide cache + doc reads key on offering; graph/syllabus/course-context
keep the abstract course id.
- study_guide / flashcards: read+write the offering; expose abstract course id
in responses; flashcards.import_commit resolves offering (nullable).
- learn: sessions key on offering_id; doc context reads by offering; wire the
shared-context block to the session's abstract course (resolves the
db/study-code TODO, no more {} degrade).
- quiz: validate difficulty against the 0025 CHECK enum; stop touching the
dropped graph_nodes.mastery_events column — route mastery writes through
services.graph_service.apply_graph_update (sanctioned path), keyed on the
abstract course id. IDOR 404 still fires before any write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/ops-code: feedback/issue_reports PKs + FKs (epic slice PR8)
db/analytics-code: social.py offering re-keying (epic slice PR4)
db/identity-code: user_profiles split (epic slice PR6)
db/graph-code: graph integrity + node_mastery_events (epic slice PR5)
db/study-code: study artifacts → offering_id (epic slice PR7)
db/gradebook-code: semester-aware gradebook + curve + drop-lowest (epic slice PR3)
…0024 identity split
Migration 0024 moved the public profile (incl. the 🔒-encrypted display `name`)
out of `users` into a 1:1 `user_profiles` table, and renamed
`users.room_id` -> `users.current_room_id`. The identity slice updated identity
files but left cross-domain readers/writers of `users.name` untouched; these
break against the real DB (mocked tests didn't catch them).
- services/graph_service.ensure_user_exists: stop INSERTing `name` into `users`
(column no longer exists). Insert only `{id, streak_count}`; do not create a
user_profiles row (onboarding/oauth own it).
- services/profiles.py (new): get_display_name / get_display_names read
user_profiles by user_id and decrypt the name. Tolerate missing rows.
- Repoint name reads onto the helper, preserving each response shape:
main.list_users (also source room_id from current_room_id),
routes/social.py (room detail, room activity, match_partners, school_match,
get_students), routes/quiz.py (quiz-context student name),
routes/learn.py get_user_name, services/users_search.paginate_users.
- services/users_search: CODE-ONLY fix — it reads table("users") directly, not a
DB view, so no 0028 migration is needed; names now come from user_profiles.
- services/flashcard_import_service.dedup_against_existing: filter the flashcards
link column `offering_id` (0025 renamed it from `course_id`); behavior identical.
Tests: new test_profiles_service; assert ensure_user_exists omits `name`; update
roster/social/users_search/flashcard tests for the new sources. Gate green:
2 known env-only test_storage_service failures only; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/identity-reconcile: route users.name readers through user_profiles + flashcard column (epic follow-up)
…ma (#258)
Self-contained, idempotent staging-only seed that lays a small fake demo
dataset on top of migrations 0019-0027 so the live app renders the knowledge
graph, gradebook, and courses-with-term against a real DB. Runs via
`python -m db.seed_staging`; safe to re-run.
- 1 demo school, 3 abstract courses, 4 offerings (CS101 in 2 terms — graph
mastery is cumulative across terms since it's keyed on the abstract course).
- 1 slim user + user_profiles (encrypted name fields), 4 enrollments (incl.
CS101 in both terms), 9 graph_nodes / 4 graph_edges / 6 node_mastery_events.
- Gradebook (4 categories w/ drop_lowest, 6 assignments w/ encrypted points),
plus a document + note on an offering for study endpoints.
- Idempotent via deterministic seed-… ids + upsert-on-UNIQUE / insert-if-absent;
re-runs add nothing. All 🔒 columns go through encrypt_if_present; enum values
read straight off the migration CHECK sets (no guesses). Reuses pre-seeded
terms (read-only) — never touches the real catalog.
- Hermetic tests patch db.seed_staging.table to a recording FakeTable and assert
insertion coverage, FK consistency, enum validity, multi-term, encryption, and
idempotency (2nd run adds no rows). Checklist Step 6 references the new command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/seed-staging: idempotent demo seed for the new schema (#258)
… (0028)
0020 renamed courses→course_offerings and dropped the abstract columns but missed
course_code, which stayed NOT NULL. Existing rows have it populated, but every NEW
offering insert (app resolve_offering/add_course AND seed_staging) omits it → 23502
not-null violation. The abstract course_code lives on `courses` now. Surfaced by
seeding staging; would also break add_course against the real DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): drop vestigial course_code NOT NULL on course_offerings (0028)
user_profiles is keyed on user_id and has no `id` column, so the idempotency
pre-check's `select=id` 400'd against staging. Select a column we're already
filtering on (the natural/PK key) instead — works for every table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): seed _exists_by uses a real column, not hardcoded id
…ular DB redesign + add dev guide
/update-mds across the knowledge docs (per-doc base..HEAD windows): new schema
(courses/course_offerings/terms/enrollments, user_profiles split, gradebook→enrollment,
analytics→offering, node_mastery_events), services/academics.py + services/profiles.py,
the db.migrate / db.seed_staging commands + .env.staging note, and the encryption list
(name fields now on user_profiles). Adds docs/db-modular-redesign-dev-guide.pdf for the team.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86250e2a-c43e-47fe-9830-6329f9404a91

📥 Commits

Reviewing files that changed from the base of the PR and between 77c4855 and afba99f.

📒 Files selected for processing (2)
  • backend/db/e2e_checks/quiz.py
  • backend/db/e2e_staging_http.py
📝 Walkthrough

Walkthrough

Backend guidance, schema migrations, services, routes, tests, and staging E2E checks were updated to match the modular database redesign. The PR shifts academics, identity, analytics, gradebook, graph, study, ops, and related docs/code paths to the new offering- and enrollment-based schema.

Changes

DB modular redesign

Layer / File(s)Summary
Guidance and rollout docs
CLAUDE.md, README.md, ROADMAP.md, docs/architecture.md, docs/staging/setup-checklist.md, docs/superpowers/plans/*, docs/superpowers/specs/*
Repository guidance, architecture notes, rollout instructions, roadmap entries, and redesign plans were updated for the modular schema work and staging E2E rollout.
Academics, identity, graph, and analytics code
backend/db/migrations/0019_*.sql, 0020_*.sql, 0022_*.sql, 0023_*.sql, 0024_*.sql, backend/services/academics.py, backend/routes/academics.py, backend/main.py, backend/routes/auth.py, backend/routes/profile.py, backend/routes/onboarding.py, backend/services/profiles.py, backend/services/course_context_service.py, backend/services/graph_service.py, backend/agents/tools/graph_read.py, backend/routes/social.py, backend/services/users_search.py, backend/tests/test_academics.py, backend/tests/test_graph_service.py, backend/tests/test_graph_read_tools.py, backend/tests/test_shared_course_context.py, backend/tests/test_profile_routes.py, backend/tests/test_profiles_service.py, backend/tests/test_users_roster_auth.py, backend/tests/test_users_search.py, backend/tests/test_onboarding_routes.py, backend/tests/test_social_students.py
New academics helpers/routes, identity/profile reads and writes, graph/context offering lookups, analytics tables, and related test rewrites now use offerings, enrollments, and user_profiles.
Gradebook and graph mastery rules
backend/db/migrations/0021_*.sql, backend/services/gradebook_service.py, backend/routes/gradebook.py, backend/models/__init__.py, backend/routes/quiz.py, backend/tests/test_gradebook_routes.py, backend/tests/test_gradebook_service.py, backend/tests/test_quiz_routes.py, backend/tests/test_response_decrypt_boundary.py
Gradebook routes, math, and models now resolve enrollments by semester and compute drop-lowest, curves, and GPA; graph mastery writes route through event rows.
Study, profile, social, and auth flows
backend/db/migrations/0025_*.sql, backend/services/notes_service.py, backend/services/flashcard_import_service.py, backend/routes/documents.py, backend/routes/flashcards.py, backend/routes/learn.py, backend/routes/notes.py, backend/routes/study_guide.py, backend/tests/test_documents_routes.py, backend/tests/test_flashcard_import_routes.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_learn_routes.py, backend/tests/test_notes_routes.py, backend/tests/test_notes_service.py, backend/tests/test_study_guide_routes.py, backend/tests/test_shared_course_context.py
Documents, notes, flashcards, study guides, and learn/session flows now key off offering_id, use soft deletes, and map back to abstract course ids where needed.
Ops cleanup and staging seed
backend/db/migrations/0026_*.sql, backend/db/migrations/0027_*.sql, backend/db/migrations/0028_*.sql, backend/routes/feedback.py, backend/db/seed_staging.py, backend/tests/test_feedback_routes.py, backend/tests/test_seed_staging.py, backend/db/e2e_checks/*, backend/db/e2e_staging_http.py, backend/tests/conftest.py, backend/tests/test_e2e_staging.py
Feedback and issue-report IDs become explicit UUID text values, Gradescope sync tables are added, the staging seed writes deterministic demo rows, and staging-only HTTP E2E checks were added.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Updates the same course-context service and context refresh flow that this PR extends to offering_id.
  • SaplingLearn/Sapling#65 — Touches the same auth/profile code paths and encrypted profile-field handling moved to user_profiles.
  • SaplingLearn/Sapling#67 — Updates the same document upload and persistence helpers that now key documents by offering_id.

Poem

A rabbit hopped through SQL snow,
Where offerings bloom and old paths go.
With curves and terms my whiskers twitch,
And profile names no longer glitch.
Hooray! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 30.14% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main schema redesign and migration range.
Description check✅ PassedThe description is detailed and covers the main changes and validation, but it doesn't follow the requested template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch epic/db-modular-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This was referenced Jun 25, 2026
@AndresL230
AndresL230 deleted the epic/db-modular-redesign branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028) - #279

Merged
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign
Jun 25, 2026
Merged

DB Modular Redesign: monolith → 8 bounded domains (migrations 0019–0028)#279
AndresL230 merged 34 commits into
mainfrom
epic/db-modular-redesign

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

The epic cutover. Restructures the Postgres/Supabase schema from one offering-shaped courses table + a users mega-table into 8 bounded domains of typed tables with foreign keys, CHECK enums, a real terms entity, and one source of truth per fact — and rewires the entire backend onto it. Delivered schema-first (all migrations as one source of truth, then per-domain code slices).

What changed

  • Academics: courses split into abstract courses + course_offerings (per term) + terms; user_coursesenrollments(offering_id). New resolver services/academics.py. API boundary still uses the abstract course_id; the split is resolved server-side.
  • Identity: users slimmed; new user_profiles (1:1) holds name/profile fields (names still 🔒). New services/profiles.py for decrypted display names.
  • Gradebook: re-keyed to enrollment_id (gradebook_categories, enrollment-keyed assignments); per-semester + cumulative GPA, bell-curve + drop-lowest; decrypt_numeric on 🔒 points.
  • Knowledge Graph: stays on the abstract course (cumulative); graph_nodes.mastery_events JSON column → append-only node_mastery_events table; UNIQUE-backed node/edge upserts.
  • Class Analytics: course_concept_stats/course_summaryoffering_concept_stats/offering_summary (offering-keyed; free-text semester gone).
  • Study: documents/notes/sessions/study_guides/flashcards re-keyed to offering_id + soft-delete + CHECK enums.
  • Ops: feedback/issue_reports int PK → text PK + FKs.

How it was built & validated

Schema landed as migrations 0019–0028; code rewired in per-domain PRs that accumulated on this epic (#264 schema, #268 academics, #269#274 the 6 domain slices, #275 reconciliation, #276 seed, #277/#278 staging fixes).

  • 790 unit tests pass (mocked) · ruff clean
  • 14/14 service checks pass against live staging seeded data (academics resolver, get_courses incl. multi-term CS101, get_graph, gradebook drop-lowest + decrypt → (92.0,'A-'), user_profiles decrypt, offering analytics, learn/graph-read).
  • 🐛 Two real-DB bugs caught by seeding staging (invisible to mocked tests) and fixed in-epic: 0028 (vestigial course_offerings.course_code NOT NULL, would've broken add_course in prod) and the seed _exists_by id assumption.

For reviewers

  • Developer migration guide: docs/db-modular-redesign-dev-guide.pdf (the id model, rename cheat-sheet, what to change). Design spec + epic plan under docs/superpowers/.
  • Docs trued-up: CLAUDE.md, docs/architecture.md, README.md, ROADMAP.md.

⚠️ Before merging (prod cutover)

  1. Deploy this branch to staging and HTTP-smoke-test the live app (enroll, courses-with-term, gradebook GPA, graph). Service-level E2E is green; the deployed-app pass is the last gate.
  2. Full CI green on the merge.
  3. Prod promotion (prod has no user data, only the catalog): migrate --baseline to record 0001–0018, then migrate to apply 0019+; the 0020 catalog transform is data-driven. Never run dashboard DDL.

Known follow-ups (non-blocking)

Frontend term picker (#260), gradescope code rewire (#265), schools population (school surfaces blank), duplicate subject-root hub for multi-term courses.

Summary by CodeRabbit

  • New Features
    • Added term-aware academics (new /api/semesters) and updated courses/offerings behavior across gradebook, notes, study guides, flashcards, and graph.
    • Added semester-scoped gradebook details, including curve grading and credit-weighted GPA.
    • Added staging DB demo seeding and an opt-in staging E2E runner.
  • Bug Fixes
    • Profile display names now consistently come from the dedicated public-profile data store.
    • Document and note deletion now uses soft delete.
    • Quiz difficulty is validated, and mastery updates now record reliably through the graph update flow.
  • Documentation
    • Updated migration, architecture, and ops/staging guidance for ordered SQL migrations and the new schema conventions.

Resolved issues (auto-close on merge to main)

The DB modular redesign resolves these — semesters DB+backend, the missing indexes/FKs/UNIQUE-dedup, atomic mastery, graph write-integrity, social/students, and the staging catalog+environment:

Closes#100
Closes#128
Closes#137
Closes#138
Closes#158
Closes#160
Closes#161
Closes#176
Closes#177
Closes#178
Closes#179
Closes#180
Closes#181
Closes#195
Closes#247
Closes#258
Closes#259
Closes#266
Closes#267

Partially addressed (NOT closed — follow-ups remain): #142 (frontend term UI #139/#140/#141/#260), #265 (Gradescope code rewire), #126 (assignment-notes encryption + response-boundary leaks).

AndresL230and others added 29 commits June 23, 2026 18:29
Design spec (conventions charter, per-domain target DDL, catalog transform, migration sequencing) and the epic rollout plan (10 PR slices into epic/db-modular-redesign, PR1 detailed, staging->prod promotion runbook). Docs only; seeds the epic branch. Supersedes #137/#138/#142/#259/#260.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(db): modular redesign spec + epic rollout plan
Authors the complete modular target schema as one coherent set: terms+schools+updated_at trigger (0019), academics catalog/offering/enrollment split with data-preserving catalog transform (0020), gradebook re-keyed to enrollment (0021), analytics re-keyed to offering (0022), graph integrity + mastery events (0023), identity profile split (0024), study/sessions integrity (0025), ops cleanup (0026). Text PKs, FKs+ON DELETE, real types, CHECK enums read off code, encryption columns kept TEXT. Validated 0001->0026 end-to-end against Postgres 15 incl. the catalog transform. Source of truth for the per-domain code PRs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts ids
The renamed baseline tables inherited a TEXT PRIMARY KEY with no default,
unlike every other table in the redesign. Add gen_random_uuid()::text so the
app does not have to supply ids on insert for these two tables.
… redesign
Folds the in-flight DB changes from origin/Gradebook and origin/chore/staging-ops-scripts into the redesign so nothing is lost and the schema stays the single source of truth: drop-lowest -> gradebook_categories.drop_lowest; bell-curve policy -> enrollments.curve_*; per-assignment curve stats + gradescope_assignment_id -> assignments; gradescope_credentials + gradescope_course_links (link re-targeted to enrollment_id) -> 0027; newsletter_emails.approved_at -> 0026. Those branches' migration files are now superseded; their CODE rewire is tracked in filed issues. Validated 0001->0027 against Postgres 15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(db): schema-foundation migrations 0019-0026 (target schema)
…rollments (epic slice PR2)
Code-only slice against the already-landed academics split (migrations 0019-0027).
The public API keeps the abstract `course_id`; the term/semester becomes a real
second axis; enrollment resolves to a per-term offering internally.
- services/academics.py (new): term/offering/enrollment resolver — current_term
(date-derived, latest-term fallback), list_terms, resolve_offering (current term,
create-if-missing so new enrollments land in the real current semester),
offering_course_id, user_offering_ids_for_course, term_for_offering.
- graph_service: enrollments→course_offerings→courses/terms join reshaped to the
legacy flat shape; graph stays keyed on the ABSTRACT course_id; add/color/nickname/
delete resolve offerings; get_courses now surfaces `term`.
- course_context_service: offering-scoped analytics (offering_concept_stats/
offering_summary); resolves offering→abstract course for graph_nodes; semester gone.
- graph_read: misconceptions read offering_concept_stats by offering_id.
- onboarding/graph routes: enroll into the current-term offering; GET /api/semesters
(routes/academics.py) from terms; learn/profile course resolution via enrollments.
`gradebook.py` is intentionally left to db/gradebook-code (PR3, with curve+drop-lowest).
Other-slice callers (documents/quiz) degrade gracefully until their slices land.
Tests: +test_academics; updated graph_service/shared_course_context/graph_read_tools/
onboarding/learn suites. Full backend suite 724 passed; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/academics-code: rewire app onto courses/offerings/terms/enrollments (epic slice PR2)
…ice)
Rewire the ops domain's code onto the already-landed 0026_ops schema, which
dropped the SERIAL integer PKs on feedback/issue_reports and recreated them
with TEXT PKs (gen_random_uuid()::text) plus real FKs to users(id) — and
sessions(id) ON DELETE SET NULL for feedback.
- routes/feedback.py: hand-build the text PK with str(uuid.uuid4()) on both the
feedback and issue_reports inserts, per the repo convention (academics.py,
graph_service.py), instead of relying on the dropped SERIAL default. The new
user_id/session_id FKs are already satisfied by the request body / session.
- tests/test_feedback_routes.py: new coverage for the two POST endpoints
(previously untested) — asserts the insert carries a UUID text PK and the
body fields round-trip, using the MagicMock-per-table factory pattern.
- routes/admin.py allowlist approve/revoke already read/write
newsletter_emails.approved_at as 0026 declares it (issue #267) — verified, no
code change needed.
Plan: docs/superpowers/plans/2026-06-24-db-ops-code.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewire the identity domain onto the 0024 schema split. Public-profile fields
(name/first_name/last_name/username/avatar_url/bio/location/website/year/
majors/minors/learning_style) now live on a 1:1 user_profiles table; users
keeps only identity + auth + activity. One source of truth per field — nothing
is written to both users and user_profiles, nor duplicated onto user_settings.
- routes/profile.py: _get_user_or_404 reads users (id/email/streak/created_at)
and merges user_profiles; new _get_or_create_profile helper (ensure-row +
decrypt); username uniqueness + writes go to user_profiles; avatar_url
persists to user_profiles; _SETTINGS_COLS drops the moved columns.
- routes/auth.py: get_me reads name/username/avatar_url from user_profiles;
google_callback writes profile fields to user_profiles (insert/upsert) and
keeps only email/auth/activity on users; oauth_tokens.expires_at sent as
None (not "") when absent, since it is TIMESTAMPTZ now.
- routes/onboarding.py: the profile-field write moves to a user_profiles
upsert; onboarding_completed stays on users. Enrollment loop untouched
(academics owns it).
- models: drop display_name from UpdateProfileBody and the moved fields from
SettingsResponse.
- tests: profile/onboarding/decrypt-boundary updated for the split; added an
ensure-row test and a per-table column-contract pin.
Encryption boundary preserved: name/first_name/last_name/bio/location stay
🔒 TEXT (encrypt_if_present at write, decrypt_if_present at read) on
user_profiles; users.email stays 🔒 on users.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed schema
Rewire the gradebook onto the academics-split schema (migration 0021): key
gradebook_categories + assignments on enrollment_id instead of user_id+course_id.
The public API still speaks the abstract course_id plus an optional `semester`
(term label, default = current term); routes resolve (course_id, semester) -> the
user's enrollment via services.academics.
- services/gradebook_service.py: add drop-lowest (per-category, lowest earned/possible
ratio), apply_curve (linear z-score bell curve: avg_target + (raw-mean)*(new_sd/sd),
clamped 0-100), and credit-weighted GPA (gpa_points + weighted_gpa).
- routes/gradebook.py: enrollment resolver (_resolve_enrollment, semester->term via
terms.label); curve folded into current_grade; new PATCH /curve and GET /gpa
(per-semester + cumulative/transcript). points stay encrypted at write, decrypted
at read.
- models: semester + drop_lowest fields, assignment_type enum, SetCurveBody.
- tests: enrollment-keyed route tests (filter-aware fake table patching both
routes.gradebook.table and services.academics.table), service tests for
drop-lowest/curve/GPA incl. a hand-computed credit-weighted GPA fixture
(3.7*3 + 2.7*4)/7 = 3.1285714. Fixed test_response_decrypt_boundary for the new
resolver shape.
Plan: docs/superpowers/plans/2026-06-24-db-gradebook-code.md
Out of scope: gradescope sync (gradescope_assignment_id column preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…courses table
The academics split (0020/0022) already re-keyed class analytics to the
offering (course_concept_stats→offering_concept_stats, course_summary→
offering_summary) and offering-keyed course_context_service.py. The lone
holdout was routes/social.py::get_students, which still read
table("courses").select("user_id,course_name") — a query against the old
offering-shaped courses table that no longer has user_id or per-enrollment rows.
Resolve a user's courses through the enrollment chain instead
(enrollments → course_offerings → courses) via the PostgREST embedded join,
deduping across offerings of the same abstract course. Response shape of
GET /api/social/students is unchanged.
Adds tests/test_social_students.py (5 tests). No new migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents
Rewire services/graph_service.py onto the integrity guarantees from migration
0023_graph_integrity.sql:
- graph_nodes / graph_edges writes now use UNIQUE-backed upserts (on_conflict on
the new (user_id, course_id, concept_name) and
(user_id, source_node_id, target_node_id, relationship_type) constraints),
replacing the non-atomic select-then-insert dedup.
- Mastery changes append a row to the new node_mastery_events table instead of
rewriting the dropped graph_nodes.mastery_events JSONB blob, fixing the
non-atomic read-modify-write (#247). get_graph batch-reads that table to
compute learning_velocity and the trimmed per-node event history (API contract
preserved).
- Delete db/dedup_nodes.py — its (user_id, concept_name) dedup is superseded by
the UNIQUE constraint (#181).
Academics-owned graph_service.py logic (enrollment reshape, course CRUD,
offering resolution, update_course_context call sites) and the abstract-course
graph key are left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Study artifacts (documents/notes/sessions/study_guides/flashcards) now key
on offering_id (0025); the knowledge graph + course context stay on the
abstract course_id. Routes resolve the abstract course id from the API
boundary to the current-term offering via services.academics before
reading/writing, and translate offering -> abstract for every graph path.
- notes_service: offering_id column + soft-delete (deleted_at) on read/delete.
- documents: persist offering_id; soft-delete; resolve offering on upload;
study-guide cache + doc reads key on offering; graph/syllabus/course-context
keep the abstract course id.
- study_guide / flashcards: read+write the offering; expose abstract course id
in responses; flashcards.import_commit resolves offering (nullable).
- learn: sessions key on offering_id; doc context reads by offering; wire the
shared-context block to the session's abstract course (resolves the
db/study-code TODO, no more {} degrade).
- quiz: validate difficulty against the 0025 CHECK enum; stop touching the
dropped graph_nodes.mastery_events column — route mastery writes through
services.graph_service.apply_graph_update (sanctioned path), keyed on the
abstract course id. IDOR 404 still fires before any write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/ops-code: feedback/issue_reports PKs + FKs (epic slice PR8)
db/analytics-code: social.py offering re-keying (epic slice PR4)
db/identity-code: user_profiles split (epic slice PR6)
db/graph-code: graph integrity + node_mastery_events (epic slice PR5)
db/study-code: study artifacts → offering_id (epic slice PR7)
db/gradebook-code: semester-aware gradebook + curve + drop-lowest (epic slice PR3)
…0024 identity split
Migration 0024 moved the public profile (incl. the 🔒-encrypted display `name`)
out of `users` into a 1:1 `user_profiles` table, and renamed
`users.room_id` -> `users.current_room_id`. The identity slice updated identity
files but left cross-domain readers/writers of `users.name` untouched; these
break against the real DB (mocked tests didn't catch them).
- services/graph_service.ensure_user_exists: stop INSERTing `name` into `users`
(column no longer exists). Insert only `{id, streak_count}`; do not create a
user_profiles row (onboarding/oauth own it).
- services/profiles.py (new): get_display_name / get_display_names read
user_profiles by user_id and decrypt the name. Tolerate missing rows.
- Repoint name reads onto the helper, preserving each response shape:
main.list_users (also source room_id from current_room_id),
routes/social.py (room detail, room activity, match_partners, school_match,
get_students), routes/quiz.py (quiz-context student name),
routes/learn.py get_user_name, services/users_search.paginate_users.
- services/users_search: CODE-ONLY fix — it reads table("users") directly, not a
DB view, so no 0028 migration is needed; names now come from user_profiles.
- services/flashcard_import_service.dedup_against_existing: filter the flashcards
link column `offering_id` (0025 renamed it from `course_id`); behavior identical.
Tests: new test_profiles_service; assert ensure_user_exists omits `name`; update
roster/social/users_search/flashcard tests for the new sources. Gate green:
2 known env-only test_storage_service failures only; ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/identity-reconcile: route users.name readers through user_profiles + flashcard column (epic follow-up)
…ma (#258)
Self-contained, idempotent staging-only seed that lays a small fake demo
dataset on top of migrations 0019-0027 so the live app renders the knowledge
graph, gradebook, and courses-with-term against a real DB. Runs via
`python -m db.seed_staging`; safe to re-run.
- 1 demo school, 3 abstract courses, 4 offerings (CS101 in 2 terms — graph
mastery is cumulative across terms since it's keyed on the abstract course).
- 1 slim user + user_profiles (encrypted name fields), 4 enrollments (incl.
CS101 in both terms), 9 graph_nodes / 4 graph_edges / 6 node_mastery_events.
- Gradebook (4 categories w/ drop_lowest, 6 assignments w/ encrypted points),
plus a document + note on an offering for study endpoints.
- Idempotent via deterministic seed-… ids + upsert-on-UNIQUE / insert-if-absent;
re-runs add nothing. All 🔒 columns go through encrypt_if_present; enum values
read straight off the migration CHECK sets (no guesses). Reuses pre-seeded
terms (read-only) — never touches the real catalog.
- Hermetic tests patch db.seed_staging.table to a recording FakeTable and assert
insertion coverage, FK consistency, enum validity, multi-term, encryption, and
idempotency (2nd run adds no rows). Checklist Step 6 references the new command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db/seed-staging: idempotent demo seed for the new schema (#258)
… (0028)
0020 renamed courses→course_offerings and dropped the abstract columns but missed
course_code, which stayed NOT NULL. Existing rows have it populated, but every NEW
offering insert (app resolve_offering/add_course AND seed_staging) omits it → 23502
not-null violation. The abstract course_code lives on `courses` now. Surfaced by
seeding staging; would also break add_course against the real DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): drop vestigial course_code NOT NULL on course_offerings (0028)
user_profiles is keyed on user_id and has no `id` column, so the idempotency
pre-check's `select=id` 400'd against staging. Select a column we're already
filtering on (the natural/PK key) instead — works for every table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): seed _exists_by uses a real column, not hardcoded id
…ular DB redesign + add dev guide
/update-mds across the knowledge docs (per-doc base..HEAD windows): new schema
(courses/course_offerings/terms/enrollments, user_profiles split, gradebook→enrollment,
analytics→offering, node_mastery_events), services/academics.py + services/profiles.py,
the db.migrate / db.seed_staging commands + .env.staging note, and the encryption list
(name fields now on user_profiles). Adds docs/db-modular-redesign-dev-guide.pdf for the team.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@AndresL230, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 1 second. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86250e2a-c43e-47fe-9830-6329f9404a91

📥 Commits

Reviewing files that changed from the base of the PR and between 77c4855 and afba99f.

📒 Files selected for processing (2)
  • backend/db/e2e_checks/quiz.py
  • backend/db/e2e_staging_http.py
📝 Walkthrough

Walkthrough

Backend guidance, schema migrations, services, routes, tests, and staging E2E checks were updated to match the modular database redesign. The PR shifts academics, identity, analytics, gradebook, graph, study, ops, and related docs/code paths to the new offering- and enrollment-based schema.

Changes

DB modular redesign

Layer / File(s)Summary
Guidance and rollout docs
CLAUDE.md, README.md, ROADMAP.md, docs/architecture.md, docs/staging/setup-checklist.md, docs/superpowers/plans/*, docs/superpowers/specs/*
Repository guidance, architecture notes, rollout instructions, roadmap entries, and redesign plans were updated for the modular schema work and staging E2E rollout.
Academics, identity, graph, and analytics code
backend/db/migrations/0019_*.sql, 0020_*.sql, 0022_*.sql, 0023_*.sql, 0024_*.sql, backend/services/academics.py, backend/routes/academics.py, backend/main.py, backend/routes/auth.py, backend/routes/profile.py, backend/routes/onboarding.py, backend/services/profiles.py, backend/services/course_context_service.py, backend/services/graph_service.py, backend/agents/tools/graph_read.py, backend/routes/social.py, backend/services/users_search.py, backend/tests/test_academics.py, backend/tests/test_graph_service.py, backend/tests/test_graph_read_tools.py, backend/tests/test_shared_course_context.py, backend/tests/test_profile_routes.py, backend/tests/test_profiles_service.py, backend/tests/test_users_roster_auth.py, backend/tests/test_users_search.py, backend/tests/test_onboarding_routes.py, backend/tests/test_social_students.py
New academics helpers/routes, identity/profile reads and writes, graph/context offering lookups, analytics tables, and related test rewrites now use offerings, enrollments, and user_profiles.
Gradebook and graph mastery rules
backend/db/migrations/0021_*.sql, backend/services/gradebook_service.py, backend/routes/gradebook.py, backend/models/__init__.py, backend/routes/quiz.py, backend/tests/test_gradebook_routes.py, backend/tests/test_gradebook_service.py, backend/tests/test_quiz_routes.py, backend/tests/test_response_decrypt_boundary.py
Gradebook routes, math, and models now resolve enrollments by semester and compute drop-lowest, curves, and GPA; graph mastery writes route through event rows.
Study, profile, social, and auth flows
backend/db/migrations/0025_*.sql, backend/services/notes_service.py, backend/services/flashcard_import_service.py, backend/routes/documents.py, backend/routes/flashcards.py, backend/routes/learn.py, backend/routes/notes.py, backend/routes/study_guide.py, backend/tests/test_documents_routes.py, backend/tests/test_flashcard_import_routes.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_learn_routes.py, backend/tests/test_notes_routes.py, backend/tests/test_notes_service.py, backend/tests/test_study_guide_routes.py, backend/tests/test_shared_course_context.py
Documents, notes, flashcards, study guides, and learn/session flows now key off offering_id, use soft deletes, and map back to abstract course ids where needed.
Ops cleanup and staging seed
backend/db/migrations/0026_*.sql, backend/db/migrations/0027_*.sql, backend/db/migrations/0028_*.sql, backend/routes/feedback.py, backend/db/seed_staging.py, backend/tests/test_feedback_routes.py, backend/tests/test_seed_staging.py, backend/db/e2e_checks/*, backend/db/e2e_staging_http.py, backend/tests/conftest.py, backend/tests/test_e2e_staging.py
Feedback and issue-report IDs become explicit UUID text values, Gradescope sync tables are added, the staging seed writes deterministic demo rows, and staging-only HTTP E2E checks were added.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Updates the same course-context service and context refresh flow that this PR extends to offering_id.
  • SaplingLearn/Sapling#65 — Touches the same auth/profile code paths and encrypted profile-field handling moved to user_profiles.
  • SaplingLearn/Sapling#67 — Updates the same document upload and persistence helpers that now key documents by offering_id.

Poem

A rabbit hopped through SQL snow,
Where offerings bloom and old paths go.
With curves and terms my whiskers twitch,
And profile names no longer glitch.
Hooray! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 30.14% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main schema redesign and migration range.
Description check✅ PassedThe description is detailed and covers the main changes and validation, but it doesn't follow the requested template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch epic/db-modular-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This was referenced Jun 25, 2026
@AndresL230
AndresL230 deleted the epic/db-modular-redesign branch June 27, 2026 04:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment