fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500) - #283

Merged
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire
Jun 28, 2026
Merged

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500)#283
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What & why

After the DB modular redesign, migration 0021_gradebook.sql did DROP TABLE assignments CASCADE and recreated assignmentskeyed on enrollment_id (no user_id/course_id/courses relationship). routes/calendar.py + services/calendar_service.py still spoke the old schema, so every /api/calendar/* call returned PostgREST 400 → 500, which tanked the staging dashboard (its Promise.all fails on the one bad endpoint). The migration itself had flagged this rewire as deferred ("See issues filed for the code rewire").

Reproduced against the live staging DB for the real user: only /api/calendar/upcoming 500'd; all other dashboard domains were already migrated and healthy.

Approach

Mirror the already-migrated gradebook.py helpers (no fragile nested PostgREST embeds). Assignments are always course-tied and key on enrollment_id; a small resolver in services/academics.py bridges (user, abstract course) → enrollment_id. No schema migration. HTTP request/response shapes are unchanged (frontend untouched).

Design spec: docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md
Plan: docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md

Changes (6 TDD commits)

  • services/academics.pyenrollment_id_for(user, course_id, *, create=False) + user_enrollment_ids(user).
  • Read (get_upcoming/get_all/suggest_study_blocks) — fetch the user's enrollments → assignments WHERE enrollment_id IN (...), decorate with abstract course_id/course_code/course_name; no enrollments → {"assignments": []} (the dashboard unblock).
  • Write (/save, calendar_service.insert_new_assignments, syllabus saves in documents.py) — resolve course_id → enrollment_id (create-if-missing), tag source (manual/syllabus), dedup across the enrollment set, encrypt notes exactly once.
  • Ownership scoping (update/delete/sync/export) — enrollment_id IN (caller's enrollments) on both the pre-check and the write (preserves IDOR guarantee [P0] calendar.export_to_google cross-user IDOR leaks decrypted private notes #123).
  • Migrated the calendar/assignment tests to the new schema.

Verification

  • Full backend suite: 803 passed, 1 skipped, 0 failed.
  • Live staging read-path check: _read_assignments resolves against the enrollment-keyed schema (0 rows, no 400).
  • Final whole-branch review: ready to merge — IDOR scoping, encrypt-once, and spec coverage independently verified; migrated dedup/encryption tests now genuinely exercised (were vacuously green before).

Behavior notes / follow-ups

  • Manual /save now requires a course_id — an empty one is silently skipped (spec Decision 1: assignments are always course-tied). Frontend must always send course_id. Consider a 400 instead of a silent drop as a follow-up.
  • Latent (not production):process_and_save_syllabus (OCR-pipeline helper, only invoked by an opt-in live-DB test, no mounted route) feeds assignments without course_id and would save 0 — file a ticket if it's ever wired to a route.
  • Minor cleanups deferred: _read_assignments selects unused source; sync/export call user_enrollment_ids twice; a couple of unused test helpers.

Deploys to staging when merged to main (Railway redeploys the backend). Independent of the frontend proxy/SESSION_SECRET fixes already applied to staging.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Calendar assignments now load, save, edit, delete, and sync more reliably across enrolled courses.
    • Assignment visibility and updates are now correctly limited to the right course membership, reducing accidental cross-course access.
    • Google Calendar export/sync now handles unsynced items more consistently.
    • Assignment notes are stored and restored securely, and syllabus-imported assignments are tagged consistently.

AndresL230and others added 8 commits June 28, 2026 01:23
The calendar route + calendar_service still query the pre-redesign assignments
table (user_id/course_id/courses!left); 0021 re-keyed assignments on
enrollment_id, so every call 400s -> 500 and tanks the dashboard. Spec rewires
the calendar domain to resolve course -> enrollment (mirroring gradebook.py),
keeping the HTTP shapes stable. Decisions: assignments are always course-tied
(no migration), writes auto-create the enrollment, full-domain scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires sync_to_google and export_to_google onto the enrollment-keyed
schema: select/write-back scoped by enrollment_id membership instead of
the removed user_id column; drops courses!left embed in favour of
_course_meta_cached. Updates test_calendar_export_idor.py and
test_calendar_sibling_write_scoping.py to assert the new enrollment_id
boundary (same IDOR guarantee, new key).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment schema
- routes/documents.py: pass source="syllabus" at both save_assignments_to_db
call sites (_save_orchestrator_syllabus and the legacy call_gemini_json path)
- tests/test_calendar_routes.py: rewire TestSaveAssignments to include course_id
in fixtures and mock enrollment_id_for/user_enrollment_ids; rewire
TestGetUpcoming.test_returns_assignments_from_db to the enrollment-keyed row
shape (enrollment_id, no user_id/course_id/courses columns); add _tbl helper
- tests/test_assignment_notes_encryption.py: supply course_id to test fixtures
and mock academics so insert_new_assignments reaches the encryption boundary
- tests/test_documents_routes.py: update assert_called_once_with to include
source='syllabus' to match the new tagged call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

All calendar/assignment backend routes, the calendar service, and the academics service are updated to use enrollment_id membership instead of user_id/course_id for scoping reads, writes, ownership checks, Google sync, and export. Two new enrollment resolver helpers are added to academics.py. Note encryption moves from route handlers into the service layer. Syllabus save calls are tagged with source="syllabus". Tests are updated or added throughout.

Changes

Calendar Assignments Enrollment-keyed Rewire

Layer / File(s)Summary
Enrollment resolver helpers
backend/services/academics.py, backend/tests/test_academics_enrollment_resolver.py
user_enrollment_ids returns enrollment rows for a user; enrollment_id_for resolves or creates an enrollment for a given course, preferring the current-term offering. Unit tests cover found, create, and not-found cases.
calendar_service insert/dedupe keyed by enrollment_id
backend/services/calendar_service.py, backend/tests/test_calendar_write_enrollment.py, backend/tests/test_assignment_notes_encryption.py
load_existing_assignment_keys now dedupes across the user's enrollment set; insert_new_assignments resolves course_idenrollment_id, skips unresolvable assignments, writes enrollment-keyed rows with encrypted notes and an explicit source. New write and encryption tests verify insert shape, deduplication, and None-notes handling.
Calendar read path: _read_assignments and read endpoints
backend/routes/calendar.py, backend/tests/test_calendar_read_enrollment.py
Adds _course_meta_cached, _owned_enrollment_ids, and _read_assignments helpers; /upcoming, /all, and /suggest-study-blocks all route through _read_assignments for enrollment-scoped, decrypted, course-decorated results.
Calendar write path: /save, update, and delete
backend/routes/calendar.py
/save drops in-route note encryption. PATCH /assignments/{id} and DELETE /assignments/{id} replace user_id ownership checks with enrollment_id IN (...) and remove course_id from the patch whitelist.
Google sync and export enrollment scoping
backend/routes/calendar.py
/sync and /export replace user_id-scoped queries and write-backs with enrollment_id IN (...), remove courses!left joins, and derive course labels via _course_meta_cached.
Syllabus save source="syllabus" tagging
backend/routes/documents.py, backend/tests/test_documents_routes.py
Both orchestrator and legacy syllabus persistence paths pass source="syllabus" to save_assignments_to_db; the route test asserts the keyword argument.
Security and scoping tests
backend/tests/test_calendar_export_idor.py, backend/tests/test_calendar_sibling_write_scoping.py, backend/tests/test_calendar_scoping_enrollment.py, backend/tests/test_calendar_sync_export_enrollment.py
IDOR export regression, sibling write scoping, and new enrollment-scoped update/export tests all assert enrollment_id membership filters on read, write, and delete operations instead of user_id.
Existing route tests updated to enrollment schema
backend/tests/test_calendar_routes.py
Save, upcoming, study-blocks, update, and delete tests are updated with expanded academics mocks, enrollment_id-keyed DB row shapes, course_id in payloads, and a blocked course_id patch assertion.
Design spec and implementation plan
docs/superpowers/specs/..., docs/superpowers/plans/...
New design spec and phased implementation plan documenting the full enrollment-rewire scope, decisions, and verification checklist.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • SaplingLearn/Sapling#53: Also modifies backend/routes/calendar.py to populate course_code/course_name on assignment records, directly overlapping with this PR's course metadata decoration logic.
  • SaplingLearn/Sapling#65: Modifies calendar assignment notes encryption/decryption handling in the same route and service files, overlapping with this PR's move of encryption into insert_new_assignments.
  • SaplingLearn/Sapling#235: Tightens IDOR ownership scoping on the same PATCH/DELETE/sync write filters—this PR supersedes that by shifting the scoping key from user_id to enrollment_id.

Poem

🐇 Hopping through the enrollment rows,
No more user_id wherever code goes!
enrollment_id guards each patch and delete,
Notes are encrypted, the schema's complete.
This bunny rewired it all — how neat! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 8.82% 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 names the calendar assignment rewire and matches the schema migration fix.
Description check✅ PassedIt covers the problem, approach, changes, verification, and follow-ups, though it doesn't match the template headings exactly.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/calendar-assignments-enrollment-rewire

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.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8a38cfeCommit Preview URL

Branch Preview URL
Jun 28 2026, 08:38 PM

@AndresL230
AndresL230 merged commit 8192447 into mainJun 28, 2026
5 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md (1)

57-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add language identifier to fenced code block.

The fenced code block showing enrollment_id_for signature lacks a language label. Add python for syntax highlighting and to satisfy linting.

+```python
enrollment_id_for(user_id, course_id, *, create=False) -> str | None

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`
around lines 57 - 60, The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
backend/tests/test_calendar_routes.py (1)

16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the reusable table mock into tests/conftest.py.

_tbl is now duplicated across backend/tests/test_calendar_routes.py, backend/tests/test_calendar_read_enrollment.py, and backend/tests/test_calendar_scoping_enrollment.py, so any change to the fake table contract has to be kept in sync by hand. As per coding guidelines, "shared fixtures such as mock Supabase and mock Gemini belong in tests/conftest.py."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_routes.py` around lines 16 - 21, The reusable
table mock helper `_tbl` is duplicated across multiple calendar tests, so move
it into `tests/conftest.py` as a shared fixture/helper and update
`test_calendar_routes`, `test_calendar_read_enrollment`, and
`test_calendar_scoping_enrollment` to import/use the common version. Keep the
existing `MagicMock` table contract and preserve the per-verb return-value
behavior so all tests share one source of truth.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/academics.py`:
- Around line 159-181: The enrollment lookup in the helper that uses
user_offering_ids_for_course, current_term, and resolve_offering should not fall
back to an arbitrary historical offering when create=True. Change the selection
logic so it only reuses an existing enrollment if it matches the current term,
and otherwise let the code continue into the creation path; keep the existing
enrollment return path only for the current-term match. This ensures the branch
in backend/services/academics.py provisioned by create=True does not return a
random old enrollment.
In `@backend/tests/test_academics_enrollment_resolver.py`:
- Around line 27-36: The existing test only exercises the single-enrollment
fallback and never hits the current-term preference path. Update
test_existing_enrollment_current_term in the academics enrollment resolver tests
to use multiple course offerings and a non-None current_term so
ac.enrollment_id_for actually has to choose between enrollments. Make the setup
in services.academics.user_offering_ids_for_course and
services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.
In `@backend/tests/test_calendar_sibling_write_scoping.py`:
- Around line 22-77: The tests only verify write filters, but they should also
cover the guarded read path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.
---
Nitpick comments:
In `@backend/tests/test_calendar_routes.py`:
- Around line 16-21: The reusable table mock helper `_tbl` is duplicated across
multiple calendar tests, so move it into `tests/conftest.py` as a shared
fixture/helper and update `test_calendar_routes`,
`test_calendar_read_enrollment`, and `test_calendar_scoping_enrollment` to
import/use the common version. Keep the existing `MagicMock` table contract and
preserve the per-verb return-value behavior so all tests share one source of
truth.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`:
- Around line 57-60: The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a175b3-0b0b-4e62-bcf5-51940fdcfee8

📥 Commits

Reviewing files that changed from the base of the PR and between e6aeb5f and 8a38cfe.

📒 Files selected for processing (16)
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/services/academics.py
  • backend/services/calendar_service.py
  • backend/tests/test_academics_enrollment_resolver.py
  • backend/tests/test_assignment_notes_encryption.py
  • backend/tests/test_calendar_export_idor.py
  • backend/tests/test_calendar_read_enrollment.py
  • backend/tests/test_calendar_routes.py
  • backend/tests/test_calendar_scoping_enrollment.py
  • backend/tests/test_calendar_sibling_write_scoping.py
  • backend/tests/test_calendar_sync_export_enrollment.py
  • backend/tests/test_calendar_write_enrollment.py
  • backend/tests/test_documents_routes.py
  • docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md
  • docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md

Comment on lines +159 to +181
offering_ids = user_offering_ids_for_course(user_id, course_id)
if offering_ids:
chosen = offering_ids[0]
cur = current_term()
cur_id = cur["id"] if cur else None
if cur_id:
for oid in offering_ids:
t = term_for_offering(oid)
if t and t.get("id") == cur_id:
chosen = oid
break
rows = table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
if rows:
return rows[0]["id"]

if not create:
return None

offering_id = resolve_offering(course_id, create=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't reuse an arbitrary historical enrollment when create=True.

If the user already has past enrollments for the course but none in current_term(), this branch falls back to offering_ids[0] and returns that enrollment instead of reaching the create path. Because user_offering_ids_for_course() does not order its rows, new assignment writes can land on a random old enrollment rather than the current-term enrollment this helper is meant to provision.

Suggested fix
- offering_ids = user_offering_ids_for_course(user_id, course_id)- if offering_ids:- chosen = offering_ids[0]- cur = current_term()- cur_id = cur["id"] if cur else None- if cur_id:- for oid in offering_ids:- t = term_for_offering(oid)- if t and t.get("id") == cur_id:- chosen = oid- break- rows = table("enrollments").select(- "id",- filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},- limit=1,- )- if rows:- return rows[0]["id"]+ offering_ids = user_offering_ids_for_course(user_id, course_id)+ if offering_ids:+ cur = current_term()+ cur_id = cur["id"] if cur else None+ chosen = None+ if cur_id:+ for oid in offering_ids:+ t = term_for_offering(oid)+ if t and t.get("id") == cur_id:+ chosen = oid+ break+ elif len(offering_ids) == 1 and not create:+ chosen = offering_ids[0]++ if chosen:+ rows = table("enrollments").select(+ "id",+ filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},+ limit=1,+ )+ if rows:+ return rows[0]["id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
chosen=offering_ids[0]
cur=current_term()
cur_id=cur["id"] ifcurelseNone
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
cur=current_term()
cur_id=cur["id"] ifcurelseNone
chosen=None
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
eliflen(offering_ids) ==1andnotcreate:
chosen=offering_ids[0]
ifchosen:
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/academics.py` around lines 159 - 181, The enrollment lookup
in the helper that uses user_offering_ids_for_course, current_term, and
resolve_offering should not fall back to an arbitrary historical offering when
create=True. Change the selection logic so it only reuses an existing enrollment
if it matches the current term, and otherwise let the code continue into the
creation path; keep the existing enrollment return path only for the
current-term match. This ensures the branch in backend/services/academics.py
provisioned by create=True does not return a random old enrollment.

Comment on lines +27 to +36
def test_existing_enrollment_current_term(self):
# user_offering_ids_for_course -> ["o1"]; term match; enrollment e1
tables = {
"course_offerings": _tbl(select=[{"id": "o1"}]),
"enrollments": _tbl(select=[{"id": "e1"}]),
}
with patch("services.academics.table", side_effect=_dispatch(tables)), \
patch("services.academics.user_offering_ids_for_course", return_value=["o1"]), \
patch("services.academics.current_term", return_value=None):
assert ac.enrollment_id_for("user_andres", "CS101") == "e1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test never reaches the current-term preference branch.

current_term is mocked to None and there is only one offering, so the resolver returns the lone enrollment without evaluating any term match. Please make this a multi-offering case with a real current term so the new "prefer current-term enrollment" logic is actually covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_academics_enrollment_resolver.py` around lines 27 - 36,
The existing test only exercises the single-enrollment fallback and never hits
the current-term preference path. Update test_existing_enrollment_current_term
in the academics enrollment resolver tests to use multiple course offerings and
a non-None current_term so ac.enrollment_id_for actually has to choose between
enrollments. Make the setup in services.academics.user_offering_ids_for_course
and services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.

Comment on lines +22 to +77
def test_update_scopes_write_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}] # owner's row exists
r = client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assert r.status_code == 200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert update_filters.get("id") == f"eq.{AID}"

def test_delete_scopes_delete_by_user_id(self):
with patch("routes.calendar.table") as t:
def test_delete_scopes_delete_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}]
r = client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assert r.status_code == 200
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert delete_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert delete_filters.get("id") == f"eq.{AID}"

def test_sync_scopes_writeback_by_user_id(self):
def test_sync_scopes_writeback_by_enrollment_id(self):
unsynced = [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]

with patch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") as build, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") as t:
patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
service = MagicMock()
service.events.return_value.insert.return_value.execute.return_value = {"id": "evt_1"}
build.return_value = service
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value = None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect = [unsynced, []]
r = client.post("/api/calendar/sync", json={"user_id": OWNER})

assert r.status_code == 200
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert the guarded SELECT is enrollment-scoped too.

These cases only verify the update/delete filters. Because the mocked select always returns the owned row here, the tests still pass if the PATCH/DELETE ownership check regresses to filters={"id": ...} or if SYNC stops filtering the unsynced read by enrollment_id. Please assert the relevant select call kwargs carry the same membership guard.

Suggested assertions
 assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert r.status_code == 200
+ first_select_filters = t.return_value.select.call_args_list[0].kwargs["filters"]+ assert "enrollment_id" in first_select_filters+ assert ENROLLMENT_ID in first_select_filters["enrollment_id"]
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_user_id(self):
withpatch("routes.calendar.table") ast:
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assertdelete_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_user_id(self):
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast:
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
first_select_filters=t.return_value.select.call_args_list[0].kwargs["filters"]
assert"enrollment_id"infirst_select_filters
assertENROLLMENT_IDinfirst_select_filters["enrollment_id"]
update_filters=t.return_value.update.call_args.kwargs["filters"]
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_sibling_write_scoping.py` around lines 22 - 77,
The tests only verify write filters, but they should also cover the guarded read
path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500) - #283

Merged
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire
Jun 28, 2026
Merged

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500)#283
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What & why

After the DB modular redesign, migration 0021_gradebook.sql did DROP TABLE assignments CASCADE and recreated assignmentskeyed on enrollment_id (no user_id/course_id/courses relationship). routes/calendar.py + services/calendar_service.py still spoke the old schema, so every /api/calendar/* call returned PostgREST 400 → 500, which tanked the staging dashboard (its Promise.all fails on the one bad endpoint). The migration itself had flagged this rewire as deferred ("See issues filed for the code rewire").

Reproduced against the live staging DB for the real user: only /api/calendar/upcoming 500'd; all other dashboard domains were already migrated and healthy.

Approach

Mirror the already-migrated gradebook.py helpers (no fragile nested PostgREST embeds). Assignments are always course-tied and key on enrollment_id; a small resolver in services/academics.py bridges (user, abstract course) → enrollment_id. No schema migration. HTTP request/response shapes are unchanged (frontend untouched).

Design spec: docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md
Plan: docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md

Changes (6 TDD commits)

  • services/academics.pyenrollment_id_for(user, course_id, *, create=False) + user_enrollment_ids(user).
  • Read (get_upcoming/get_all/suggest_study_blocks) — fetch the user's enrollments → assignments WHERE enrollment_id IN (...), decorate with abstract course_id/course_code/course_name; no enrollments → {"assignments": []} (the dashboard unblock).
  • Write (/save, calendar_service.insert_new_assignments, syllabus saves in documents.py) — resolve course_id → enrollment_id (create-if-missing), tag source (manual/syllabus), dedup across the enrollment set, encrypt notes exactly once.
  • Ownership scoping (update/delete/sync/export) — enrollment_id IN (caller's enrollments) on both the pre-check and the write (preserves IDOR guarantee [P0] calendar.export_to_google cross-user IDOR leaks decrypted private notes #123).
  • Migrated the calendar/assignment tests to the new schema.

Verification

  • Full backend suite: 803 passed, 1 skipped, 0 failed.
  • Live staging read-path check: _read_assignments resolves against the enrollment-keyed schema (0 rows, no 400).
  • Final whole-branch review: ready to merge — IDOR scoping, encrypt-once, and spec coverage independently verified; migrated dedup/encryption tests now genuinely exercised (were vacuously green before).

Behavior notes / follow-ups

  • Manual /save now requires a course_id — an empty one is silently skipped (spec Decision 1: assignments are always course-tied). Frontend must always send course_id. Consider a 400 instead of a silent drop as a follow-up.
  • Latent (not production):process_and_save_syllabus (OCR-pipeline helper, only invoked by an opt-in live-DB test, no mounted route) feeds assignments without course_id and would save 0 — file a ticket if it's ever wired to a route.
  • Minor cleanups deferred: _read_assignments selects unused source; sync/export call user_enrollment_ids twice; a couple of unused test helpers.

Deploys to staging when merged to main (Railway redeploys the backend). Independent of the frontend proxy/SESSION_SECRET fixes already applied to staging.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Calendar assignments now load, save, edit, delete, and sync more reliably across enrolled courses.
    • Assignment visibility and updates are now correctly limited to the right course membership, reducing accidental cross-course access.
    • Google Calendar export/sync now handles unsynced items more consistently.
    • Assignment notes are stored and restored securely, and syllabus-imported assignments are tagged consistently.

AndresL230and others added 8 commits June 28, 2026 01:23
The calendar route + calendar_service still query the pre-redesign assignments
table (user_id/course_id/courses!left); 0021 re-keyed assignments on
enrollment_id, so every call 400s -> 500 and tanks the dashboard. Spec rewires
the calendar domain to resolve course -> enrollment (mirroring gradebook.py),
keeping the HTTP shapes stable. Decisions: assignments are always course-tied
(no migration), writes auto-create the enrollment, full-domain scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires sync_to_google and export_to_google onto the enrollment-keyed
schema: select/write-back scoped by enrollment_id membership instead of
the removed user_id column; drops courses!left embed in favour of
_course_meta_cached. Updates test_calendar_export_idor.py and
test_calendar_sibling_write_scoping.py to assert the new enrollment_id
boundary (same IDOR guarantee, new key).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment schema
- routes/documents.py: pass source="syllabus" at both save_assignments_to_db
call sites (_save_orchestrator_syllabus and the legacy call_gemini_json path)
- tests/test_calendar_routes.py: rewire TestSaveAssignments to include course_id
in fixtures and mock enrollment_id_for/user_enrollment_ids; rewire
TestGetUpcoming.test_returns_assignments_from_db to the enrollment-keyed row
shape (enrollment_id, no user_id/course_id/courses columns); add _tbl helper
- tests/test_assignment_notes_encryption.py: supply course_id to test fixtures
and mock academics so insert_new_assignments reaches the encryption boundary
- tests/test_documents_routes.py: update assert_called_once_with to include
source='syllabus' to match the new tagged call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

All calendar/assignment backend routes, the calendar service, and the academics service are updated to use enrollment_id membership instead of user_id/course_id for scoping reads, writes, ownership checks, Google sync, and export. Two new enrollment resolver helpers are added to academics.py. Note encryption moves from route handlers into the service layer. Syllabus save calls are tagged with source="syllabus". Tests are updated or added throughout.

Changes

Calendar Assignments Enrollment-keyed Rewire

Layer / File(s)Summary
Enrollment resolver helpers
backend/services/academics.py, backend/tests/test_academics_enrollment_resolver.py
user_enrollment_ids returns enrollment rows for a user; enrollment_id_for resolves or creates an enrollment for a given course, preferring the current-term offering. Unit tests cover found, create, and not-found cases.
calendar_service insert/dedupe keyed by enrollment_id
backend/services/calendar_service.py, backend/tests/test_calendar_write_enrollment.py, backend/tests/test_assignment_notes_encryption.py
load_existing_assignment_keys now dedupes across the user's enrollment set; insert_new_assignments resolves course_idenrollment_id, skips unresolvable assignments, writes enrollment-keyed rows with encrypted notes and an explicit source. New write and encryption tests verify insert shape, deduplication, and None-notes handling.
Calendar read path: _read_assignments and read endpoints
backend/routes/calendar.py, backend/tests/test_calendar_read_enrollment.py
Adds _course_meta_cached, _owned_enrollment_ids, and _read_assignments helpers; /upcoming, /all, and /suggest-study-blocks all route through _read_assignments for enrollment-scoped, decrypted, course-decorated results.
Calendar write path: /save, update, and delete
backend/routes/calendar.py
/save drops in-route note encryption. PATCH /assignments/{id} and DELETE /assignments/{id} replace user_id ownership checks with enrollment_id IN (...) and remove course_id from the patch whitelist.
Google sync and export enrollment scoping
backend/routes/calendar.py
/sync and /export replace user_id-scoped queries and write-backs with enrollment_id IN (...), remove courses!left joins, and derive course labels via _course_meta_cached.
Syllabus save source="syllabus" tagging
backend/routes/documents.py, backend/tests/test_documents_routes.py
Both orchestrator and legacy syllabus persistence paths pass source="syllabus" to save_assignments_to_db; the route test asserts the keyword argument.
Security and scoping tests
backend/tests/test_calendar_export_idor.py, backend/tests/test_calendar_sibling_write_scoping.py, backend/tests/test_calendar_scoping_enrollment.py, backend/tests/test_calendar_sync_export_enrollment.py
IDOR export regression, sibling write scoping, and new enrollment-scoped update/export tests all assert enrollment_id membership filters on read, write, and delete operations instead of user_id.
Existing route tests updated to enrollment schema
backend/tests/test_calendar_routes.py
Save, upcoming, study-blocks, update, and delete tests are updated with expanded academics mocks, enrollment_id-keyed DB row shapes, course_id in payloads, and a blocked course_id patch assertion.
Design spec and implementation plan
docs/superpowers/specs/..., docs/superpowers/plans/...
New design spec and phased implementation plan documenting the full enrollment-rewire scope, decisions, and verification checklist.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • SaplingLearn/Sapling#53: Also modifies backend/routes/calendar.py to populate course_code/course_name on assignment records, directly overlapping with this PR's course metadata decoration logic.
  • SaplingLearn/Sapling#65: Modifies calendar assignment notes encryption/decryption handling in the same route and service files, overlapping with this PR's move of encryption into insert_new_assignments.
  • SaplingLearn/Sapling#235: Tightens IDOR ownership scoping on the same PATCH/DELETE/sync write filters—this PR supersedes that by shifting the scoping key from user_id to enrollment_id.

Poem

🐇 Hopping through the enrollment rows,
No more user_id wherever code goes!
enrollment_id guards each patch and delete,
Notes are encrypted, the schema's complete.
This bunny rewired it all — how neat! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 8.82% 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 names the calendar assignment rewire and matches the schema migration fix.
Description check✅ PassedIt covers the problem, approach, changes, verification, and follow-ups, though it doesn't match the template headings exactly.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/calendar-assignments-enrollment-rewire

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.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8a38cfeCommit Preview URL

Branch Preview URL
Jun 28 2026, 08:38 PM

@AndresL230
AndresL230 merged commit 8192447 into mainJun 28, 2026
5 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md (1)

57-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add language identifier to fenced code block.

The fenced code block showing enrollment_id_for signature lacks a language label. Add python for syntax highlighting and to satisfy linting.

+```python
enrollment_id_for(user_id, course_id, *, create=False) -> str | None

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`
around lines 57 - 60, The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
backend/tests/test_calendar_routes.py (1)

16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the reusable table mock into tests/conftest.py.

_tbl is now duplicated across backend/tests/test_calendar_routes.py, backend/tests/test_calendar_read_enrollment.py, and backend/tests/test_calendar_scoping_enrollment.py, so any change to the fake table contract has to be kept in sync by hand. As per coding guidelines, "shared fixtures such as mock Supabase and mock Gemini belong in tests/conftest.py."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_routes.py` around lines 16 - 21, The reusable
table mock helper `_tbl` is duplicated across multiple calendar tests, so move
it into `tests/conftest.py` as a shared fixture/helper and update
`test_calendar_routes`, `test_calendar_read_enrollment`, and
`test_calendar_scoping_enrollment` to import/use the common version. Keep the
existing `MagicMock` table contract and preserve the per-verb return-value
behavior so all tests share one source of truth.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/academics.py`:
- Around line 159-181: The enrollment lookup in the helper that uses
user_offering_ids_for_course, current_term, and resolve_offering should not fall
back to an arbitrary historical offering when create=True. Change the selection
logic so it only reuses an existing enrollment if it matches the current term,
and otherwise let the code continue into the creation path; keep the existing
enrollment return path only for the current-term match. This ensures the branch
in backend/services/academics.py provisioned by create=True does not return a
random old enrollment.
In `@backend/tests/test_academics_enrollment_resolver.py`:
- Around line 27-36: The existing test only exercises the single-enrollment
fallback and never hits the current-term preference path. Update
test_existing_enrollment_current_term in the academics enrollment resolver tests
to use multiple course offerings and a non-None current_term so
ac.enrollment_id_for actually has to choose between enrollments. Make the setup
in services.academics.user_offering_ids_for_course and
services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.
In `@backend/tests/test_calendar_sibling_write_scoping.py`:
- Around line 22-77: The tests only verify write filters, but they should also
cover the guarded read path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.
---
Nitpick comments:
In `@backend/tests/test_calendar_routes.py`:
- Around line 16-21: The reusable table mock helper `_tbl` is duplicated across
multiple calendar tests, so move it into `tests/conftest.py` as a shared
fixture/helper and update `test_calendar_routes`,
`test_calendar_read_enrollment`, and `test_calendar_scoping_enrollment` to
import/use the common version. Keep the existing `MagicMock` table contract and
preserve the per-verb return-value behavior so all tests share one source of
truth.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`:
- Around line 57-60: The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a175b3-0b0b-4e62-bcf5-51940fdcfee8

📥 Commits

Reviewing files that changed from the base of the PR and between e6aeb5f and 8a38cfe.

📒 Files selected for processing (16)
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/services/academics.py
  • backend/services/calendar_service.py
  • backend/tests/test_academics_enrollment_resolver.py
  • backend/tests/test_assignment_notes_encryption.py
  • backend/tests/test_calendar_export_idor.py
  • backend/tests/test_calendar_read_enrollment.py
  • backend/tests/test_calendar_routes.py
  • backend/tests/test_calendar_scoping_enrollment.py
  • backend/tests/test_calendar_sibling_write_scoping.py
  • backend/tests/test_calendar_sync_export_enrollment.py
  • backend/tests/test_calendar_write_enrollment.py
  • backend/tests/test_documents_routes.py
  • docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md
  • docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md

Comment on lines +159 to +181
offering_ids = user_offering_ids_for_course(user_id, course_id)
if offering_ids:
chosen = offering_ids[0]
cur = current_term()
cur_id = cur["id"] if cur else None
if cur_id:
for oid in offering_ids:
t = term_for_offering(oid)
if t and t.get("id") == cur_id:
chosen = oid
break
rows = table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
if rows:
return rows[0]["id"]

if not create:
return None

offering_id = resolve_offering(course_id, create=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't reuse an arbitrary historical enrollment when create=True.

If the user already has past enrollments for the course but none in current_term(), this branch falls back to offering_ids[0] and returns that enrollment instead of reaching the create path. Because user_offering_ids_for_course() does not order its rows, new assignment writes can land on a random old enrollment rather than the current-term enrollment this helper is meant to provision.

Suggested fix
- offering_ids = user_offering_ids_for_course(user_id, course_id)- if offering_ids:- chosen = offering_ids[0]- cur = current_term()- cur_id = cur["id"] if cur else None- if cur_id:- for oid in offering_ids:- t = term_for_offering(oid)- if t and t.get("id") == cur_id:- chosen = oid- break- rows = table("enrollments").select(- "id",- filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},- limit=1,- )- if rows:- return rows[0]["id"]+ offering_ids = user_offering_ids_for_course(user_id, course_id)+ if offering_ids:+ cur = current_term()+ cur_id = cur["id"] if cur else None+ chosen = None+ if cur_id:+ for oid in offering_ids:+ t = term_for_offering(oid)+ if t and t.get("id") == cur_id:+ chosen = oid+ break+ elif len(offering_ids) == 1 and not create:+ chosen = offering_ids[0]++ if chosen:+ rows = table("enrollments").select(+ "id",+ filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},+ limit=1,+ )+ if rows:+ return rows[0]["id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
chosen=offering_ids[0]
cur=current_term()
cur_id=cur["id"] ifcurelseNone
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
cur=current_term()
cur_id=cur["id"] ifcurelseNone
chosen=None
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
eliflen(offering_ids) ==1andnotcreate:
chosen=offering_ids[0]
ifchosen:
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/academics.py` around lines 159 - 181, The enrollment lookup
in the helper that uses user_offering_ids_for_course, current_term, and
resolve_offering should not fall back to an arbitrary historical offering when
create=True. Change the selection logic so it only reuses an existing enrollment
if it matches the current term, and otherwise let the code continue into the
creation path; keep the existing enrollment return path only for the
current-term match. This ensures the branch in backend/services/academics.py
provisioned by create=True does not return a random old enrollment.

Comment on lines +27 to +36
def test_existing_enrollment_current_term(self):
# user_offering_ids_for_course -> ["o1"]; term match; enrollment e1
tables = {
"course_offerings": _tbl(select=[{"id": "o1"}]),
"enrollments": _tbl(select=[{"id": "e1"}]),
}
with patch("services.academics.table", side_effect=_dispatch(tables)), \
patch("services.academics.user_offering_ids_for_course", return_value=["o1"]), \
patch("services.academics.current_term", return_value=None):
assert ac.enrollment_id_for("user_andres", "CS101") == "e1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test never reaches the current-term preference branch.

current_term is mocked to None and there is only one offering, so the resolver returns the lone enrollment without evaluating any term match. Please make this a multi-offering case with a real current term so the new "prefer current-term enrollment" logic is actually covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_academics_enrollment_resolver.py` around lines 27 - 36,
The existing test only exercises the single-enrollment fallback and never hits
the current-term preference path. Update test_existing_enrollment_current_term
in the academics enrollment resolver tests to use multiple course offerings and
a non-None current_term so ac.enrollment_id_for actually has to choose between
enrollments. Make the setup in services.academics.user_offering_ids_for_course
and services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.

Comment on lines +22 to +77
def test_update_scopes_write_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}] # owner's row exists
r = client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assert r.status_code == 200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert update_filters.get("id") == f"eq.{AID}"

def test_delete_scopes_delete_by_user_id(self):
with patch("routes.calendar.table") as t:
def test_delete_scopes_delete_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}]
r = client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assert r.status_code == 200
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert delete_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert delete_filters.get("id") == f"eq.{AID}"

def test_sync_scopes_writeback_by_user_id(self):
def test_sync_scopes_writeback_by_enrollment_id(self):
unsynced = [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]

with patch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") as build, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") as t:
patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
service = MagicMock()
service.events.return_value.insert.return_value.execute.return_value = {"id": "evt_1"}
build.return_value = service
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value = None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect = [unsynced, []]
r = client.post("/api/calendar/sync", json={"user_id": OWNER})

assert r.status_code == 200
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert the guarded SELECT is enrollment-scoped too.

These cases only verify the update/delete filters. Because the mocked select always returns the owned row here, the tests still pass if the PATCH/DELETE ownership check regresses to filters={"id": ...} or if SYNC stops filtering the unsynced read by enrollment_id. Please assert the relevant select call kwargs carry the same membership guard.

Suggested assertions
 assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert r.status_code == 200
+ first_select_filters = t.return_value.select.call_args_list[0].kwargs["filters"]+ assert "enrollment_id" in first_select_filters+ assert ENROLLMENT_ID in first_select_filters["enrollment_id"]
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_user_id(self):
withpatch("routes.calendar.table") ast:
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assertdelete_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_user_id(self):
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast:
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
first_select_filters=t.return_value.select.call_args_list[0].kwargs["filters"]
assert"enrollment_id"infirst_select_filters
assertENROLLMENT_IDinfirst_select_filters["enrollment_id"]
update_filters=t.return_value.update.call_args.kwargs["filters"]
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_sibling_write_scoping.py` around lines 22 - 77,
The tests only verify write filters, but they should also cover the guarded read
path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500) - #283

Merged
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire
Jun 28, 2026
Merged

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500)#283
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What & why

After the DB modular redesign, migration 0021_gradebook.sql did DROP TABLE assignments CASCADE and recreated assignmentskeyed on enrollment_id (no user_id/course_id/courses relationship). routes/calendar.py + services/calendar_service.py still spoke the old schema, so every /api/calendar/* call returned PostgREST 400 → 500, which tanked the staging dashboard (its Promise.all fails on the one bad endpoint). The migration itself had flagged this rewire as deferred ("See issues filed for the code rewire").

Reproduced against the live staging DB for the real user: only /api/calendar/upcoming 500'd; all other dashboard domains were already migrated and healthy.

Approach

Mirror the already-migrated gradebook.py helpers (no fragile nested PostgREST embeds). Assignments are always course-tied and key on enrollment_id; a small resolver in services/academics.py bridges (user, abstract course) → enrollment_id. No schema migration. HTTP request/response shapes are unchanged (frontend untouched).

Design spec: docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md
Plan: docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md

Changes (6 TDD commits)

  • services/academics.pyenrollment_id_for(user, course_id, *, create=False) + user_enrollment_ids(user).
  • Read (get_upcoming/get_all/suggest_study_blocks) — fetch the user's enrollments → assignments WHERE enrollment_id IN (...), decorate with abstract course_id/course_code/course_name; no enrollments → {"assignments": []} (the dashboard unblock).
  • Write (/save, calendar_service.insert_new_assignments, syllabus saves in documents.py) — resolve course_id → enrollment_id (create-if-missing), tag source (manual/syllabus), dedup across the enrollment set, encrypt notes exactly once.
  • Ownership scoping (update/delete/sync/export) — enrollment_id IN (caller's enrollments) on both the pre-check and the write (preserves IDOR guarantee [P0] calendar.export_to_google cross-user IDOR leaks decrypted private notes #123).
  • Migrated the calendar/assignment tests to the new schema.

Verification

  • Full backend suite: 803 passed, 1 skipped, 0 failed.
  • Live staging read-path check: _read_assignments resolves against the enrollment-keyed schema (0 rows, no 400).
  • Final whole-branch review: ready to merge — IDOR scoping, encrypt-once, and spec coverage independently verified; migrated dedup/encryption tests now genuinely exercised (were vacuously green before).

Behavior notes / follow-ups

  • Manual /save now requires a course_id — an empty one is silently skipped (spec Decision 1: assignments are always course-tied). Frontend must always send course_id. Consider a 400 instead of a silent drop as a follow-up.
  • Latent (not production):process_and_save_syllabus (OCR-pipeline helper, only invoked by an opt-in live-DB test, no mounted route) feeds assignments without course_id and would save 0 — file a ticket if it's ever wired to a route.
  • Minor cleanups deferred: _read_assignments selects unused source; sync/export call user_enrollment_ids twice; a couple of unused test helpers.

Deploys to staging when merged to main (Railway redeploys the backend). Independent of the frontend proxy/SESSION_SECRET fixes already applied to staging.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Calendar assignments now load, save, edit, delete, and sync more reliably across enrolled courses.
    • Assignment visibility and updates are now correctly limited to the right course membership, reducing accidental cross-course access.
    • Google Calendar export/sync now handles unsynced items more consistently.
    • Assignment notes are stored and restored securely, and syllabus-imported assignments are tagged consistently.

AndresL230and others added 8 commits June 28, 2026 01:23
The calendar route + calendar_service still query the pre-redesign assignments
table (user_id/course_id/courses!left); 0021 re-keyed assignments on
enrollment_id, so every call 400s -> 500 and tanks the dashboard. Spec rewires
the calendar domain to resolve course -> enrollment (mirroring gradebook.py),
keeping the HTTP shapes stable. Decisions: assignments are always course-tied
(no migration), writes auto-create the enrollment, full-domain scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires sync_to_google and export_to_google onto the enrollment-keyed
schema: select/write-back scoped by enrollment_id membership instead of
the removed user_id column; drops courses!left embed in favour of
_course_meta_cached. Updates test_calendar_export_idor.py and
test_calendar_sibling_write_scoping.py to assert the new enrollment_id
boundary (same IDOR guarantee, new key).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment schema
- routes/documents.py: pass source="syllabus" at both save_assignments_to_db
call sites (_save_orchestrator_syllabus and the legacy call_gemini_json path)
- tests/test_calendar_routes.py: rewire TestSaveAssignments to include course_id
in fixtures and mock enrollment_id_for/user_enrollment_ids; rewire
TestGetUpcoming.test_returns_assignments_from_db to the enrollment-keyed row
shape (enrollment_id, no user_id/course_id/courses columns); add _tbl helper
- tests/test_assignment_notes_encryption.py: supply course_id to test fixtures
and mock academics so insert_new_assignments reaches the encryption boundary
- tests/test_documents_routes.py: update assert_called_once_with to include
source='syllabus' to match the new tagged call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

All calendar/assignment backend routes, the calendar service, and the academics service are updated to use enrollment_id membership instead of user_id/course_id for scoping reads, writes, ownership checks, Google sync, and export. Two new enrollment resolver helpers are added to academics.py. Note encryption moves from route handlers into the service layer. Syllabus save calls are tagged with source="syllabus". Tests are updated or added throughout.

Changes

Calendar Assignments Enrollment-keyed Rewire

Layer / File(s)Summary
Enrollment resolver helpers
backend/services/academics.py, backend/tests/test_academics_enrollment_resolver.py
user_enrollment_ids returns enrollment rows for a user; enrollment_id_for resolves or creates an enrollment for a given course, preferring the current-term offering. Unit tests cover found, create, and not-found cases.
calendar_service insert/dedupe keyed by enrollment_id
backend/services/calendar_service.py, backend/tests/test_calendar_write_enrollment.py, backend/tests/test_assignment_notes_encryption.py
load_existing_assignment_keys now dedupes across the user's enrollment set; insert_new_assignments resolves course_idenrollment_id, skips unresolvable assignments, writes enrollment-keyed rows with encrypted notes and an explicit source. New write and encryption tests verify insert shape, deduplication, and None-notes handling.
Calendar read path: _read_assignments and read endpoints
backend/routes/calendar.py, backend/tests/test_calendar_read_enrollment.py
Adds _course_meta_cached, _owned_enrollment_ids, and _read_assignments helpers; /upcoming, /all, and /suggest-study-blocks all route through _read_assignments for enrollment-scoped, decrypted, course-decorated results.
Calendar write path: /save, update, and delete
backend/routes/calendar.py
/save drops in-route note encryption. PATCH /assignments/{id} and DELETE /assignments/{id} replace user_id ownership checks with enrollment_id IN (...) and remove course_id from the patch whitelist.
Google sync and export enrollment scoping
backend/routes/calendar.py
/sync and /export replace user_id-scoped queries and write-backs with enrollment_id IN (...), remove courses!left joins, and derive course labels via _course_meta_cached.
Syllabus save source="syllabus" tagging
backend/routes/documents.py, backend/tests/test_documents_routes.py
Both orchestrator and legacy syllabus persistence paths pass source="syllabus" to save_assignments_to_db; the route test asserts the keyword argument.
Security and scoping tests
backend/tests/test_calendar_export_idor.py, backend/tests/test_calendar_sibling_write_scoping.py, backend/tests/test_calendar_scoping_enrollment.py, backend/tests/test_calendar_sync_export_enrollment.py
IDOR export regression, sibling write scoping, and new enrollment-scoped update/export tests all assert enrollment_id membership filters on read, write, and delete operations instead of user_id.
Existing route tests updated to enrollment schema
backend/tests/test_calendar_routes.py
Save, upcoming, study-blocks, update, and delete tests are updated with expanded academics mocks, enrollment_id-keyed DB row shapes, course_id in payloads, and a blocked course_id patch assertion.
Design spec and implementation plan
docs/superpowers/specs/..., docs/superpowers/plans/...
New design spec and phased implementation plan documenting the full enrollment-rewire scope, decisions, and verification checklist.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • SaplingLearn/Sapling#53: Also modifies backend/routes/calendar.py to populate course_code/course_name on assignment records, directly overlapping with this PR's course metadata decoration logic.
  • SaplingLearn/Sapling#65: Modifies calendar assignment notes encryption/decryption handling in the same route and service files, overlapping with this PR's move of encryption into insert_new_assignments.
  • SaplingLearn/Sapling#235: Tightens IDOR ownership scoping on the same PATCH/DELETE/sync write filters—this PR supersedes that by shifting the scoping key from user_id to enrollment_id.

Poem

🐇 Hopping through the enrollment rows,
No more user_id wherever code goes!
enrollment_id guards each patch and delete,
Notes are encrypted, the schema's complete.
This bunny rewired it all — how neat! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 8.82% 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 names the calendar assignment rewire and matches the schema migration fix.
Description check✅ PassedIt covers the problem, approach, changes, verification, and follow-ups, though it doesn't match the template headings exactly.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/calendar-assignments-enrollment-rewire

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.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8a38cfeCommit Preview URL

Branch Preview URL
Jun 28 2026, 08:38 PM

@AndresL230
AndresL230 merged commit 8192447 into mainJun 28, 2026
5 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md (1)

57-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add language identifier to fenced code block.

The fenced code block showing enrollment_id_for signature lacks a language label. Add python for syntax highlighting and to satisfy linting.

+```python
enrollment_id_for(user_id, course_id, *, create=False) -> str | None

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`
around lines 57 - 60, The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
backend/tests/test_calendar_routes.py (1)

16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the reusable table mock into tests/conftest.py.

_tbl is now duplicated across backend/tests/test_calendar_routes.py, backend/tests/test_calendar_read_enrollment.py, and backend/tests/test_calendar_scoping_enrollment.py, so any change to the fake table contract has to be kept in sync by hand. As per coding guidelines, "shared fixtures such as mock Supabase and mock Gemini belong in tests/conftest.py."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_routes.py` around lines 16 - 21, The reusable
table mock helper `_tbl` is duplicated across multiple calendar tests, so move
it into `tests/conftest.py` as a shared fixture/helper and update
`test_calendar_routes`, `test_calendar_read_enrollment`, and
`test_calendar_scoping_enrollment` to import/use the common version. Keep the
existing `MagicMock` table contract and preserve the per-verb return-value
behavior so all tests share one source of truth.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/academics.py`:
- Around line 159-181: The enrollment lookup in the helper that uses
user_offering_ids_for_course, current_term, and resolve_offering should not fall
back to an arbitrary historical offering when create=True. Change the selection
logic so it only reuses an existing enrollment if it matches the current term,
and otherwise let the code continue into the creation path; keep the existing
enrollment return path only for the current-term match. This ensures the branch
in backend/services/academics.py provisioned by create=True does not return a
random old enrollment.
In `@backend/tests/test_academics_enrollment_resolver.py`:
- Around line 27-36: The existing test only exercises the single-enrollment
fallback and never hits the current-term preference path. Update
test_existing_enrollment_current_term in the academics enrollment resolver tests
to use multiple course offerings and a non-None current_term so
ac.enrollment_id_for actually has to choose between enrollments. Make the setup
in services.academics.user_offering_ids_for_course and
services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.
In `@backend/tests/test_calendar_sibling_write_scoping.py`:
- Around line 22-77: The tests only verify write filters, but they should also
cover the guarded read path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.
---
Nitpick comments:
In `@backend/tests/test_calendar_routes.py`:
- Around line 16-21: The reusable table mock helper `_tbl` is duplicated across
multiple calendar tests, so move it into `tests/conftest.py` as a shared
fixture/helper and update `test_calendar_routes`,
`test_calendar_read_enrollment`, and `test_calendar_scoping_enrollment` to
import/use the common version. Keep the existing `MagicMock` table contract and
preserve the per-verb return-value behavior so all tests share one source of
truth.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`:
- Around line 57-60: The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a175b3-0b0b-4e62-bcf5-51940fdcfee8

📥 Commits

Reviewing files that changed from the base of the PR and between e6aeb5f and 8a38cfe.

📒 Files selected for processing (16)
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/services/academics.py
  • backend/services/calendar_service.py
  • backend/tests/test_academics_enrollment_resolver.py
  • backend/tests/test_assignment_notes_encryption.py
  • backend/tests/test_calendar_export_idor.py
  • backend/tests/test_calendar_read_enrollment.py
  • backend/tests/test_calendar_routes.py
  • backend/tests/test_calendar_scoping_enrollment.py
  • backend/tests/test_calendar_sibling_write_scoping.py
  • backend/tests/test_calendar_sync_export_enrollment.py
  • backend/tests/test_calendar_write_enrollment.py
  • backend/tests/test_documents_routes.py
  • docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md
  • docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md

Comment on lines +159 to +181
offering_ids = user_offering_ids_for_course(user_id, course_id)
if offering_ids:
chosen = offering_ids[0]
cur = current_term()
cur_id = cur["id"] if cur else None
if cur_id:
for oid in offering_ids:
t = term_for_offering(oid)
if t and t.get("id") == cur_id:
chosen = oid
break
rows = table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
if rows:
return rows[0]["id"]

if not create:
return None

offering_id = resolve_offering(course_id, create=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't reuse an arbitrary historical enrollment when create=True.

If the user already has past enrollments for the course but none in current_term(), this branch falls back to offering_ids[0] and returns that enrollment instead of reaching the create path. Because user_offering_ids_for_course() does not order its rows, new assignment writes can land on a random old enrollment rather than the current-term enrollment this helper is meant to provision.

Suggested fix
- offering_ids = user_offering_ids_for_course(user_id, course_id)- if offering_ids:- chosen = offering_ids[0]- cur = current_term()- cur_id = cur["id"] if cur else None- if cur_id:- for oid in offering_ids:- t = term_for_offering(oid)- if t and t.get("id") == cur_id:- chosen = oid- break- rows = table("enrollments").select(- "id",- filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},- limit=1,- )- if rows:- return rows[0]["id"]+ offering_ids = user_offering_ids_for_course(user_id, course_id)+ if offering_ids:+ cur = current_term()+ cur_id = cur["id"] if cur else None+ chosen = None+ if cur_id:+ for oid in offering_ids:+ t = term_for_offering(oid)+ if t and t.get("id") == cur_id:+ chosen = oid+ break+ elif len(offering_ids) == 1 and not create:+ chosen = offering_ids[0]++ if chosen:+ rows = table("enrollments").select(+ "id",+ filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},+ limit=1,+ )+ if rows:+ return rows[0]["id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
chosen=offering_ids[0]
cur=current_term()
cur_id=cur["id"] ifcurelseNone
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
cur=current_term()
cur_id=cur["id"] ifcurelseNone
chosen=None
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
eliflen(offering_ids) ==1andnotcreate:
chosen=offering_ids[0]
ifchosen:
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/academics.py` around lines 159 - 181, The enrollment lookup
in the helper that uses user_offering_ids_for_course, current_term, and
resolve_offering should not fall back to an arbitrary historical offering when
create=True. Change the selection logic so it only reuses an existing enrollment
if it matches the current term, and otherwise let the code continue into the
creation path; keep the existing enrollment return path only for the
current-term match. This ensures the branch in backend/services/academics.py
provisioned by create=True does not return a random old enrollment.

Comment on lines +27 to +36
def test_existing_enrollment_current_term(self):
# user_offering_ids_for_course -> ["o1"]; term match; enrollment e1
tables = {
"course_offerings": _tbl(select=[{"id": "o1"}]),
"enrollments": _tbl(select=[{"id": "e1"}]),
}
with patch("services.academics.table", side_effect=_dispatch(tables)), \
patch("services.academics.user_offering_ids_for_course", return_value=["o1"]), \
patch("services.academics.current_term", return_value=None):
assert ac.enrollment_id_for("user_andres", "CS101") == "e1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test never reaches the current-term preference branch.

current_term is mocked to None and there is only one offering, so the resolver returns the lone enrollment without evaluating any term match. Please make this a multi-offering case with a real current term so the new "prefer current-term enrollment" logic is actually covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_academics_enrollment_resolver.py` around lines 27 - 36,
The existing test only exercises the single-enrollment fallback and never hits
the current-term preference path. Update test_existing_enrollment_current_term
in the academics enrollment resolver tests to use multiple course offerings and
a non-None current_term so ac.enrollment_id_for actually has to choose between
enrollments. Make the setup in services.academics.user_offering_ids_for_course
and services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.

Comment on lines +22 to +77
def test_update_scopes_write_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}] # owner's row exists
r = client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assert r.status_code == 200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert update_filters.get("id") == f"eq.{AID}"

def test_delete_scopes_delete_by_user_id(self):
with patch("routes.calendar.table") as t:
def test_delete_scopes_delete_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}]
r = client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assert r.status_code == 200
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert delete_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert delete_filters.get("id") == f"eq.{AID}"

def test_sync_scopes_writeback_by_user_id(self):
def test_sync_scopes_writeback_by_enrollment_id(self):
unsynced = [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]

with patch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") as build, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") as t:
patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
service = MagicMock()
service.events.return_value.insert.return_value.execute.return_value = {"id": "evt_1"}
build.return_value = service
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value = None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect = [unsynced, []]
r = client.post("/api/calendar/sync", json={"user_id": OWNER})

assert r.status_code == 200
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert the guarded SELECT is enrollment-scoped too.

These cases only verify the update/delete filters. Because the mocked select always returns the owned row here, the tests still pass if the PATCH/DELETE ownership check regresses to filters={"id": ...} or if SYNC stops filtering the unsynced read by enrollment_id. Please assert the relevant select call kwargs carry the same membership guard.

Suggested assertions
 assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert r.status_code == 200
+ first_select_filters = t.return_value.select.call_args_list[0].kwargs["filters"]+ assert "enrollment_id" in first_select_filters+ assert ENROLLMENT_ID in first_select_filters["enrollment_id"]
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_user_id(self):
withpatch("routes.calendar.table") ast:
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assertdelete_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_user_id(self):
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast:
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
first_select_filters=t.return_value.select.call_args_list[0].kwargs["filters"]
assert"enrollment_id"infirst_select_filters
assertENROLLMENT_IDinfirst_select_filters["enrollment_id"]
update_filters=t.return_value.update.call_args.kwargs["filters"]
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_sibling_write_scoping.py` around lines 22 - 77,
The tests only verify write filters, but they should also cover the guarded read
path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500) - #283

Merged
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire
Jun 28, 2026
Merged

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500)#283
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What & why

After the DB modular redesign, migration 0021_gradebook.sql did DROP TABLE assignments CASCADE and recreated assignmentskeyed on enrollment_id (no user_id/course_id/courses relationship). routes/calendar.py + services/calendar_service.py still spoke the old schema, so every /api/calendar/* call returned PostgREST 400 → 500, which tanked the staging dashboard (its Promise.all fails on the one bad endpoint). The migration itself had flagged this rewire as deferred ("See issues filed for the code rewire").

Reproduced against the live staging DB for the real user: only /api/calendar/upcoming 500'd; all other dashboard domains were already migrated and healthy.

Approach

Mirror the already-migrated gradebook.py helpers (no fragile nested PostgREST embeds). Assignments are always course-tied and key on enrollment_id; a small resolver in services/academics.py bridges (user, abstract course) → enrollment_id. No schema migration. HTTP request/response shapes are unchanged (frontend untouched).

Design spec: docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md
Plan: docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md

Changes (6 TDD commits)

  • services/academics.pyenrollment_id_for(user, course_id, *, create=False) + user_enrollment_ids(user).
  • Read (get_upcoming/get_all/suggest_study_blocks) — fetch the user's enrollments → assignments WHERE enrollment_id IN (...), decorate with abstract course_id/course_code/course_name; no enrollments → {"assignments": []} (the dashboard unblock).
  • Write (/save, calendar_service.insert_new_assignments, syllabus saves in documents.py) — resolve course_id → enrollment_id (create-if-missing), tag source (manual/syllabus), dedup across the enrollment set, encrypt notes exactly once.
  • Ownership scoping (update/delete/sync/export) — enrollment_id IN (caller's enrollments) on both the pre-check and the write (preserves IDOR guarantee [P0] calendar.export_to_google cross-user IDOR leaks decrypted private notes #123).
  • Migrated the calendar/assignment tests to the new schema.

Verification

  • Full backend suite: 803 passed, 1 skipped, 0 failed.
  • Live staging read-path check: _read_assignments resolves against the enrollment-keyed schema (0 rows, no 400).
  • Final whole-branch review: ready to merge — IDOR scoping, encrypt-once, and spec coverage independently verified; migrated dedup/encryption tests now genuinely exercised (were vacuously green before).

Behavior notes / follow-ups

  • Manual /save now requires a course_id — an empty one is silently skipped (spec Decision 1: assignments are always course-tied). Frontend must always send course_id. Consider a 400 instead of a silent drop as a follow-up.
  • Latent (not production):process_and_save_syllabus (OCR-pipeline helper, only invoked by an opt-in live-DB test, no mounted route) feeds assignments without course_id and would save 0 — file a ticket if it's ever wired to a route.
  • Minor cleanups deferred: _read_assignments selects unused source; sync/export call user_enrollment_ids twice; a couple of unused test helpers.

Deploys to staging when merged to main (Railway redeploys the backend). Independent of the frontend proxy/SESSION_SECRET fixes already applied to staging.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Calendar assignments now load, save, edit, delete, and sync more reliably across enrolled courses.
    • Assignment visibility and updates are now correctly limited to the right course membership, reducing accidental cross-course access.
    • Google Calendar export/sync now handles unsynced items more consistently.
    • Assignment notes are stored and restored securely, and syllabus-imported assignments are tagged consistently.

AndresL230and others added 8 commits June 28, 2026 01:23
The calendar route + calendar_service still query the pre-redesign assignments
table (user_id/course_id/courses!left); 0021 re-keyed assignments on
enrollment_id, so every call 400s -> 500 and tanks the dashboard. Spec rewires
the calendar domain to resolve course -> enrollment (mirroring gradebook.py),
keeping the HTTP shapes stable. Decisions: assignments are always course-tied
(no migration), writes auto-create the enrollment, full-domain scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires sync_to_google and export_to_google onto the enrollment-keyed
schema: select/write-back scoped by enrollment_id membership instead of
the removed user_id column; drops courses!left embed in favour of
_course_meta_cached. Updates test_calendar_export_idor.py and
test_calendar_sibling_write_scoping.py to assert the new enrollment_id
boundary (same IDOR guarantee, new key).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment schema
- routes/documents.py: pass source="syllabus" at both save_assignments_to_db
call sites (_save_orchestrator_syllabus and the legacy call_gemini_json path)
- tests/test_calendar_routes.py: rewire TestSaveAssignments to include course_id
in fixtures and mock enrollment_id_for/user_enrollment_ids; rewire
TestGetUpcoming.test_returns_assignments_from_db to the enrollment-keyed row
shape (enrollment_id, no user_id/course_id/courses columns); add _tbl helper
- tests/test_assignment_notes_encryption.py: supply course_id to test fixtures
and mock academics so insert_new_assignments reaches the encryption boundary
- tests/test_documents_routes.py: update assert_called_once_with to include
source='syllabus' to match the new tagged call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

All calendar/assignment backend routes, the calendar service, and the academics service are updated to use enrollment_id membership instead of user_id/course_id for scoping reads, writes, ownership checks, Google sync, and export. Two new enrollment resolver helpers are added to academics.py. Note encryption moves from route handlers into the service layer. Syllabus save calls are tagged with source="syllabus". Tests are updated or added throughout.

Changes

Calendar Assignments Enrollment-keyed Rewire

Layer / File(s)Summary
Enrollment resolver helpers
backend/services/academics.py, backend/tests/test_academics_enrollment_resolver.py
user_enrollment_ids returns enrollment rows for a user; enrollment_id_for resolves or creates an enrollment for a given course, preferring the current-term offering. Unit tests cover found, create, and not-found cases.
calendar_service insert/dedupe keyed by enrollment_id
backend/services/calendar_service.py, backend/tests/test_calendar_write_enrollment.py, backend/tests/test_assignment_notes_encryption.py
load_existing_assignment_keys now dedupes across the user's enrollment set; insert_new_assignments resolves course_idenrollment_id, skips unresolvable assignments, writes enrollment-keyed rows with encrypted notes and an explicit source. New write and encryption tests verify insert shape, deduplication, and None-notes handling.
Calendar read path: _read_assignments and read endpoints
backend/routes/calendar.py, backend/tests/test_calendar_read_enrollment.py
Adds _course_meta_cached, _owned_enrollment_ids, and _read_assignments helpers; /upcoming, /all, and /suggest-study-blocks all route through _read_assignments for enrollment-scoped, decrypted, course-decorated results.
Calendar write path: /save, update, and delete
backend/routes/calendar.py
/save drops in-route note encryption. PATCH /assignments/{id} and DELETE /assignments/{id} replace user_id ownership checks with enrollment_id IN (...) and remove course_id from the patch whitelist.
Google sync and export enrollment scoping
backend/routes/calendar.py
/sync and /export replace user_id-scoped queries and write-backs with enrollment_id IN (...), remove courses!left joins, and derive course labels via _course_meta_cached.
Syllabus save source="syllabus" tagging
backend/routes/documents.py, backend/tests/test_documents_routes.py
Both orchestrator and legacy syllabus persistence paths pass source="syllabus" to save_assignments_to_db; the route test asserts the keyword argument.
Security and scoping tests
backend/tests/test_calendar_export_idor.py, backend/tests/test_calendar_sibling_write_scoping.py, backend/tests/test_calendar_scoping_enrollment.py, backend/tests/test_calendar_sync_export_enrollment.py
IDOR export regression, sibling write scoping, and new enrollment-scoped update/export tests all assert enrollment_id membership filters on read, write, and delete operations instead of user_id.
Existing route tests updated to enrollment schema
backend/tests/test_calendar_routes.py
Save, upcoming, study-blocks, update, and delete tests are updated with expanded academics mocks, enrollment_id-keyed DB row shapes, course_id in payloads, and a blocked course_id patch assertion.
Design spec and implementation plan
docs/superpowers/specs/..., docs/superpowers/plans/...
New design spec and phased implementation plan documenting the full enrollment-rewire scope, decisions, and verification checklist.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • SaplingLearn/Sapling#53: Also modifies backend/routes/calendar.py to populate course_code/course_name on assignment records, directly overlapping with this PR's course metadata decoration logic.
  • SaplingLearn/Sapling#65: Modifies calendar assignment notes encryption/decryption handling in the same route and service files, overlapping with this PR's move of encryption into insert_new_assignments.
  • SaplingLearn/Sapling#235: Tightens IDOR ownership scoping on the same PATCH/DELETE/sync write filters—this PR supersedes that by shifting the scoping key from user_id to enrollment_id.

Poem

🐇 Hopping through the enrollment rows,
No more user_id wherever code goes!
enrollment_id guards each patch and delete,
Notes are encrypted, the schema's complete.
This bunny rewired it all — how neat! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 8.82% 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 names the calendar assignment rewire and matches the schema migration fix.
Description check✅ PassedIt covers the problem, approach, changes, verification, and follow-ups, though it doesn't match the template headings exactly.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/calendar-assignments-enrollment-rewire

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.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8a38cfeCommit Preview URL

Branch Preview URL
Jun 28 2026, 08:38 PM

@AndresL230
AndresL230 merged commit 8192447 into mainJun 28, 2026
5 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md (1)

57-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add language identifier to fenced code block.

The fenced code block showing enrollment_id_for signature lacks a language label. Add python for syntax highlighting and to satisfy linting.

+```python
enrollment_id_for(user_id, course_id, *, create=False) -> str | None

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`
around lines 57 - 60, The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
backend/tests/test_calendar_routes.py (1)

16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the reusable table mock into tests/conftest.py.

_tbl is now duplicated across backend/tests/test_calendar_routes.py, backend/tests/test_calendar_read_enrollment.py, and backend/tests/test_calendar_scoping_enrollment.py, so any change to the fake table contract has to be kept in sync by hand. As per coding guidelines, "shared fixtures such as mock Supabase and mock Gemini belong in tests/conftest.py."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_routes.py` around lines 16 - 21, The reusable
table mock helper `_tbl` is duplicated across multiple calendar tests, so move
it into `tests/conftest.py` as a shared fixture/helper and update
`test_calendar_routes`, `test_calendar_read_enrollment`, and
`test_calendar_scoping_enrollment` to import/use the common version. Keep the
existing `MagicMock` table contract and preserve the per-verb return-value
behavior so all tests share one source of truth.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/academics.py`:
- Around line 159-181: The enrollment lookup in the helper that uses
user_offering_ids_for_course, current_term, and resolve_offering should not fall
back to an arbitrary historical offering when create=True. Change the selection
logic so it only reuses an existing enrollment if it matches the current term,
and otherwise let the code continue into the creation path; keep the existing
enrollment return path only for the current-term match. This ensures the branch
in backend/services/academics.py provisioned by create=True does not return a
random old enrollment.
In `@backend/tests/test_academics_enrollment_resolver.py`:
- Around line 27-36: The existing test only exercises the single-enrollment
fallback and never hits the current-term preference path. Update
test_existing_enrollment_current_term in the academics enrollment resolver tests
to use multiple course offerings and a non-None current_term so
ac.enrollment_id_for actually has to choose between enrollments. Make the setup
in services.academics.user_offering_ids_for_course and
services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.
In `@backend/tests/test_calendar_sibling_write_scoping.py`:
- Around line 22-77: The tests only verify write filters, but they should also
cover the guarded read path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.
---
Nitpick comments:
In `@backend/tests/test_calendar_routes.py`:
- Around line 16-21: The reusable table mock helper `_tbl` is duplicated across
multiple calendar tests, so move it into `tests/conftest.py` as a shared
fixture/helper and update `test_calendar_routes`,
`test_calendar_read_enrollment`, and `test_calendar_scoping_enrollment` to
import/use the common version. Keep the existing `MagicMock` table contract and
preserve the per-verb return-value behavior so all tests share one source of
truth.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`:
- Around line 57-60: The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a175b3-0b0b-4e62-bcf5-51940fdcfee8

📥 Commits

Reviewing files that changed from the base of the PR and between e6aeb5f and 8a38cfe.

📒 Files selected for processing (16)
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/services/academics.py
  • backend/services/calendar_service.py
  • backend/tests/test_academics_enrollment_resolver.py
  • backend/tests/test_assignment_notes_encryption.py
  • backend/tests/test_calendar_export_idor.py
  • backend/tests/test_calendar_read_enrollment.py
  • backend/tests/test_calendar_routes.py
  • backend/tests/test_calendar_scoping_enrollment.py
  • backend/tests/test_calendar_sibling_write_scoping.py
  • backend/tests/test_calendar_sync_export_enrollment.py
  • backend/tests/test_calendar_write_enrollment.py
  • backend/tests/test_documents_routes.py
  • docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md
  • docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md

Comment on lines +159 to +181
offering_ids = user_offering_ids_for_course(user_id, course_id)
if offering_ids:
chosen = offering_ids[0]
cur = current_term()
cur_id = cur["id"] if cur else None
if cur_id:
for oid in offering_ids:
t = term_for_offering(oid)
if t and t.get("id") == cur_id:
chosen = oid
break
rows = table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
if rows:
return rows[0]["id"]

if not create:
return None

offering_id = resolve_offering(course_id, create=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't reuse an arbitrary historical enrollment when create=True.

If the user already has past enrollments for the course but none in current_term(), this branch falls back to offering_ids[0] and returns that enrollment instead of reaching the create path. Because user_offering_ids_for_course() does not order its rows, new assignment writes can land on a random old enrollment rather than the current-term enrollment this helper is meant to provision.

Suggested fix
- offering_ids = user_offering_ids_for_course(user_id, course_id)- if offering_ids:- chosen = offering_ids[0]- cur = current_term()- cur_id = cur["id"] if cur else None- if cur_id:- for oid in offering_ids:- t = term_for_offering(oid)- if t and t.get("id") == cur_id:- chosen = oid- break- rows = table("enrollments").select(- "id",- filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},- limit=1,- )- if rows:- return rows[0]["id"]+ offering_ids = user_offering_ids_for_course(user_id, course_id)+ if offering_ids:+ cur = current_term()+ cur_id = cur["id"] if cur else None+ chosen = None+ if cur_id:+ for oid in offering_ids:+ t = term_for_offering(oid)+ if t and t.get("id") == cur_id:+ chosen = oid+ break+ elif len(offering_ids) == 1 and not create:+ chosen = offering_ids[0]++ if chosen:+ rows = table("enrollments").select(+ "id",+ filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},+ limit=1,+ )+ if rows:+ return rows[0]["id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
chosen=offering_ids[0]
cur=current_term()
cur_id=cur["id"] ifcurelseNone
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
cur=current_term()
cur_id=cur["id"] ifcurelseNone
chosen=None
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
eliflen(offering_ids) ==1andnotcreate:
chosen=offering_ids[0]
ifchosen:
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/academics.py` around lines 159 - 181, The enrollment lookup
in the helper that uses user_offering_ids_for_course, current_term, and
resolve_offering should not fall back to an arbitrary historical offering when
create=True. Change the selection logic so it only reuses an existing enrollment
if it matches the current term, and otherwise let the code continue into the
creation path; keep the existing enrollment return path only for the
current-term match. This ensures the branch in backend/services/academics.py
provisioned by create=True does not return a random old enrollment.

Comment on lines +27 to +36
def test_existing_enrollment_current_term(self):
# user_offering_ids_for_course -> ["o1"]; term match; enrollment e1
tables = {
"course_offerings": _tbl(select=[{"id": "o1"}]),
"enrollments": _tbl(select=[{"id": "e1"}]),
}
with patch("services.academics.table", side_effect=_dispatch(tables)), \
patch("services.academics.user_offering_ids_for_course", return_value=["o1"]), \
patch("services.academics.current_term", return_value=None):
assert ac.enrollment_id_for("user_andres", "CS101") == "e1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test never reaches the current-term preference branch.

current_term is mocked to None and there is only one offering, so the resolver returns the lone enrollment without evaluating any term match. Please make this a multi-offering case with a real current term so the new "prefer current-term enrollment" logic is actually covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_academics_enrollment_resolver.py` around lines 27 - 36,
The existing test only exercises the single-enrollment fallback and never hits
the current-term preference path. Update test_existing_enrollment_current_term
in the academics enrollment resolver tests to use multiple course offerings and
a non-None current_term so ac.enrollment_id_for actually has to choose between
enrollments. Make the setup in services.academics.user_offering_ids_for_course
and services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.

Comment on lines +22 to +77
def test_update_scopes_write_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}] # owner's row exists
r = client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assert r.status_code == 200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert update_filters.get("id") == f"eq.{AID}"

def test_delete_scopes_delete_by_user_id(self):
with patch("routes.calendar.table") as t:
def test_delete_scopes_delete_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}]
r = client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assert r.status_code == 200
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert delete_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert delete_filters.get("id") == f"eq.{AID}"

def test_sync_scopes_writeback_by_user_id(self):
def test_sync_scopes_writeback_by_enrollment_id(self):
unsynced = [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]

with patch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") as build, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") as t:
patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
service = MagicMock()
service.events.return_value.insert.return_value.execute.return_value = {"id": "evt_1"}
build.return_value = service
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value = None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect = [unsynced, []]
r = client.post("/api/calendar/sync", json={"user_id": OWNER})

assert r.status_code == 200
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert the guarded SELECT is enrollment-scoped too.

These cases only verify the update/delete filters. Because the mocked select always returns the owned row here, the tests still pass if the PATCH/DELETE ownership check regresses to filters={"id": ...} or if SYNC stops filtering the unsynced read by enrollment_id. Please assert the relevant select call kwargs carry the same membership guard.

Suggested assertions
 assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert r.status_code == 200
+ first_select_filters = t.return_value.select.call_args_list[0].kwargs["filters"]+ assert "enrollment_id" in first_select_filters+ assert ENROLLMENT_ID in first_select_filters["enrollment_id"]
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_user_id(self):
withpatch("routes.calendar.table") ast:
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assertdelete_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_user_id(self):
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast:
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
first_select_filters=t.return_value.select.call_args_list[0].kwargs["filters"]
assert"enrollment_id"infirst_select_filters
assertENROLLMENT_IDinfirst_select_filters["enrollment_id"]
update_filters=t.return_value.update.call_args.kwargs["filters"]
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_sibling_write_scoping.py` around lines 22 - 77,
The tests only verify write filters, but they should also cover the guarded read
path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500) - #283

Merged
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire
Jun 28, 2026
Merged

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500)#283
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What & why

After the DB modular redesign, migration 0021_gradebook.sql did DROP TABLE assignments CASCADE and recreated assignmentskeyed on enrollment_id (no user_id/course_id/courses relationship). routes/calendar.py + services/calendar_service.py still spoke the old schema, so every /api/calendar/* call returned PostgREST 400 → 500, which tanked the staging dashboard (its Promise.all fails on the one bad endpoint). The migration itself had flagged this rewire as deferred ("See issues filed for the code rewire").

Reproduced against the live staging DB for the real user: only /api/calendar/upcoming 500'd; all other dashboard domains were already migrated and healthy.

Approach

Mirror the already-migrated gradebook.py helpers (no fragile nested PostgREST embeds). Assignments are always course-tied and key on enrollment_id; a small resolver in services/academics.py bridges (user, abstract course) → enrollment_id. No schema migration. HTTP request/response shapes are unchanged (frontend untouched).

Design spec: docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md
Plan: docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md

Changes (6 TDD commits)

  • services/academics.pyenrollment_id_for(user, course_id, *, create=False) + user_enrollment_ids(user).
  • Read (get_upcoming/get_all/suggest_study_blocks) — fetch the user's enrollments → assignments WHERE enrollment_id IN (...), decorate with abstract course_id/course_code/course_name; no enrollments → {"assignments": []} (the dashboard unblock).
  • Write (/save, calendar_service.insert_new_assignments, syllabus saves in documents.py) — resolve course_id → enrollment_id (create-if-missing), tag source (manual/syllabus), dedup across the enrollment set, encrypt notes exactly once.
  • Ownership scoping (update/delete/sync/export) — enrollment_id IN (caller's enrollments) on both the pre-check and the write (preserves IDOR guarantee [P0] calendar.export_to_google cross-user IDOR leaks decrypted private notes #123).
  • Migrated the calendar/assignment tests to the new schema.

Verification

  • Full backend suite: 803 passed, 1 skipped, 0 failed.
  • Live staging read-path check: _read_assignments resolves against the enrollment-keyed schema (0 rows, no 400).
  • Final whole-branch review: ready to merge — IDOR scoping, encrypt-once, and spec coverage independently verified; migrated dedup/encryption tests now genuinely exercised (were vacuously green before).

Behavior notes / follow-ups

  • Manual /save now requires a course_id — an empty one is silently skipped (spec Decision 1: assignments are always course-tied). Frontend must always send course_id. Consider a 400 instead of a silent drop as a follow-up.
  • Latent (not production):process_and_save_syllabus (OCR-pipeline helper, only invoked by an opt-in live-DB test, no mounted route) feeds assignments without course_id and would save 0 — file a ticket if it's ever wired to a route.
  • Minor cleanups deferred: _read_assignments selects unused source; sync/export call user_enrollment_ids twice; a couple of unused test helpers.

Deploys to staging when merged to main (Railway redeploys the backend). Independent of the frontend proxy/SESSION_SECRET fixes already applied to staging.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Calendar assignments now load, save, edit, delete, and sync more reliably across enrolled courses.
    • Assignment visibility and updates are now correctly limited to the right course membership, reducing accidental cross-course access.
    • Google Calendar export/sync now handles unsynced items more consistently.
    • Assignment notes are stored and restored securely, and syllabus-imported assignments are tagged consistently.

AndresL230and others added 8 commits June 28, 2026 01:23
The calendar route + calendar_service still query the pre-redesign assignments
table (user_id/course_id/courses!left); 0021 re-keyed assignments on
enrollment_id, so every call 400s -> 500 and tanks the dashboard. Spec rewires
the calendar domain to resolve course -> enrollment (mirroring gradebook.py),
keeping the HTTP shapes stable. Decisions: assignments are always course-tied
(no migration), writes auto-create the enrollment, full-domain scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires sync_to_google and export_to_google onto the enrollment-keyed
schema: select/write-back scoped by enrollment_id membership instead of
the removed user_id column; drops courses!left embed in favour of
_course_meta_cached. Updates test_calendar_export_idor.py and
test_calendar_sibling_write_scoping.py to assert the new enrollment_id
boundary (same IDOR guarantee, new key).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment schema
- routes/documents.py: pass source="syllabus" at both save_assignments_to_db
call sites (_save_orchestrator_syllabus and the legacy call_gemini_json path)
- tests/test_calendar_routes.py: rewire TestSaveAssignments to include course_id
in fixtures and mock enrollment_id_for/user_enrollment_ids; rewire
TestGetUpcoming.test_returns_assignments_from_db to the enrollment-keyed row
shape (enrollment_id, no user_id/course_id/courses columns); add _tbl helper
- tests/test_assignment_notes_encryption.py: supply course_id to test fixtures
and mock academics so insert_new_assignments reaches the encryption boundary
- tests/test_documents_routes.py: update assert_called_once_with to include
source='syllabus' to match the new tagged call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

All calendar/assignment backend routes, the calendar service, and the academics service are updated to use enrollment_id membership instead of user_id/course_id for scoping reads, writes, ownership checks, Google sync, and export. Two new enrollment resolver helpers are added to academics.py. Note encryption moves from route handlers into the service layer. Syllabus save calls are tagged with source="syllabus". Tests are updated or added throughout.

Changes

Calendar Assignments Enrollment-keyed Rewire

Layer / File(s)Summary
Enrollment resolver helpers
backend/services/academics.py, backend/tests/test_academics_enrollment_resolver.py
user_enrollment_ids returns enrollment rows for a user; enrollment_id_for resolves or creates an enrollment for a given course, preferring the current-term offering. Unit tests cover found, create, and not-found cases.
calendar_service insert/dedupe keyed by enrollment_id
backend/services/calendar_service.py, backend/tests/test_calendar_write_enrollment.py, backend/tests/test_assignment_notes_encryption.py
load_existing_assignment_keys now dedupes across the user's enrollment set; insert_new_assignments resolves course_idenrollment_id, skips unresolvable assignments, writes enrollment-keyed rows with encrypted notes and an explicit source. New write and encryption tests verify insert shape, deduplication, and None-notes handling.
Calendar read path: _read_assignments and read endpoints
backend/routes/calendar.py, backend/tests/test_calendar_read_enrollment.py
Adds _course_meta_cached, _owned_enrollment_ids, and _read_assignments helpers; /upcoming, /all, and /suggest-study-blocks all route through _read_assignments for enrollment-scoped, decrypted, course-decorated results.
Calendar write path: /save, update, and delete
backend/routes/calendar.py
/save drops in-route note encryption. PATCH /assignments/{id} and DELETE /assignments/{id} replace user_id ownership checks with enrollment_id IN (...) and remove course_id from the patch whitelist.
Google sync and export enrollment scoping
backend/routes/calendar.py
/sync and /export replace user_id-scoped queries and write-backs with enrollment_id IN (...), remove courses!left joins, and derive course labels via _course_meta_cached.
Syllabus save source="syllabus" tagging
backend/routes/documents.py, backend/tests/test_documents_routes.py
Both orchestrator and legacy syllabus persistence paths pass source="syllabus" to save_assignments_to_db; the route test asserts the keyword argument.
Security and scoping tests
backend/tests/test_calendar_export_idor.py, backend/tests/test_calendar_sibling_write_scoping.py, backend/tests/test_calendar_scoping_enrollment.py, backend/tests/test_calendar_sync_export_enrollment.py
IDOR export regression, sibling write scoping, and new enrollment-scoped update/export tests all assert enrollment_id membership filters on read, write, and delete operations instead of user_id.
Existing route tests updated to enrollment schema
backend/tests/test_calendar_routes.py
Save, upcoming, study-blocks, update, and delete tests are updated with expanded academics mocks, enrollment_id-keyed DB row shapes, course_id in payloads, and a blocked course_id patch assertion.
Design spec and implementation plan
docs/superpowers/specs/..., docs/superpowers/plans/...
New design spec and phased implementation plan documenting the full enrollment-rewire scope, decisions, and verification checklist.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • SaplingLearn/Sapling#53: Also modifies backend/routes/calendar.py to populate course_code/course_name on assignment records, directly overlapping with this PR's course metadata decoration logic.
  • SaplingLearn/Sapling#65: Modifies calendar assignment notes encryption/decryption handling in the same route and service files, overlapping with this PR's move of encryption into insert_new_assignments.
  • SaplingLearn/Sapling#235: Tightens IDOR ownership scoping on the same PATCH/DELETE/sync write filters—this PR supersedes that by shifting the scoping key from user_id to enrollment_id.

Poem

🐇 Hopping through the enrollment rows,
No more user_id wherever code goes!
enrollment_id guards each patch and delete,
Notes are encrypted, the schema's complete.
This bunny rewired it all — how neat! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 8.82% 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 names the calendar assignment rewire and matches the schema migration fix.
Description check✅ PassedIt covers the problem, approach, changes, verification, and follow-ups, though it doesn't match the template headings exactly.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/calendar-assignments-enrollment-rewire

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.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8a38cfeCommit Preview URL

Branch Preview URL
Jun 28 2026, 08:38 PM

@AndresL230
AndresL230 merged commit 8192447 into mainJun 28, 2026
5 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md (1)

57-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add language identifier to fenced code block.

The fenced code block showing enrollment_id_for signature lacks a language label. Add python for syntax highlighting and to satisfy linting.

+```python
enrollment_id_for(user_id, course_id, *, create=False) -> str | None

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`
around lines 57 - 60, The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
backend/tests/test_calendar_routes.py (1)

16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the reusable table mock into tests/conftest.py.

_tbl is now duplicated across backend/tests/test_calendar_routes.py, backend/tests/test_calendar_read_enrollment.py, and backend/tests/test_calendar_scoping_enrollment.py, so any change to the fake table contract has to be kept in sync by hand. As per coding guidelines, "shared fixtures such as mock Supabase and mock Gemini belong in tests/conftest.py."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_routes.py` around lines 16 - 21, The reusable
table mock helper `_tbl` is duplicated across multiple calendar tests, so move
it into `tests/conftest.py` as a shared fixture/helper and update
`test_calendar_routes`, `test_calendar_read_enrollment`, and
`test_calendar_scoping_enrollment` to import/use the common version. Keep the
existing `MagicMock` table contract and preserve the per-verb return-value
behavior so all tests share one source of truth.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/academics.py`:
- Around line 159-181: The enrollment lookup in the helper that uses
user_offering_ids_for_course, current_term, and resolve_offering should not fall
back to an arbitrary historical offering when create=True. Change the selection
logic so it only reuses an existing enrollment if it matches the current term,
and otherwise let the code continue into the creation path; keep the existing
enrollment return path only for the current-term match. This ensures the branch
in backend/services/academics.py provisioned by create=True does not return a
random old enrollment.
In `@backend/tests/test_academics_enrollment_resolver.py`:
- Around line 27-36: The existing test only exercises the single-enrollment
fallback and never hits the current-term preference path. Update
test_existing_enrollment_current_term in the academics enrollment resolver tests
to use multiple course offerings and a non-None current_term so
ac.enrollment_id_for actually has to choose between enrollments. Make the setup
in services.academics.user_offering_ids_for_course and
services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.
In `@backend/tests/test_calendar_sibling_write_scoping.py`:
- Around line 22-77: The tests only verify write filters, but they should also
cover the guarded read path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.
---
Nitpick comments:
In `@backend/tests/test_calendar_routes.py`:
- Around line 16-21: The reusable table mock helper `_tbl` is duplicated across
multiple calendar tests, so move it into `tests/conftest.py` as a shared
fixture/helper and update `test_calendar_routes`,
`test_calendar_read_enrollment`, and `test_calendar_scoping_enrollment` to
import/use the common version. Keep the existing `MagicMock` table contract and
preserve the per-verb return-value behavior so all tests share one source of
truth.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`:
- Around line 57-60: The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a175b3-0b0b-4e62-bcf5-51940fdcfee8

📥 Commits

Reviewing files that changed from the base of the PR and between e6aeb5f and 8a38cfe.

📒 Files selected for processing (16)
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/services/academics.py
  • backend/services/calendar_service.py
  • backend/tests/test_academics_enrollment_resolver.py
  • backend/tests/test_assignment_notes_encryption.py
  • backend/tests/test_calendar_export_idor.py
  • backend/tests/test_calendar_read_enrollment.py
  • backend/tests/test_calendar_routes.py
  • backend/tests/test_calendar_scoping_enrollment.py
  • backend/tests/test_calendar_sibling_write_scoping.py
  • backend/tests/test_calendar_sync_export_enrollment.py
  • backend/tests/test_calendar_write_enrollment.py
  • backend/tests/test_documents_routes.py
  • docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md
  • docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md

Comment on lines +159 to +181
offering_ids = user_offering_ids_for_course(user_id, course_id)
if offering_ids:
chosen = offering_ids[0]
cur = current_term()
cur_id = cur["id"] if cur else None
if cur_id:
for oid in offering_ids:
t = term_for_offering(oid)
if t and t.get("id") == cur_id:
chosen = oid
break
rows = table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
if rows:
return rows[0]["id"]

if not create:
return None

offering_id = resolve_offering(course_id, create=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't reuse an arbitrary historical enrollment when create=True.

If the user already has past enrollments for the course but none in current_term(), this branch falls back to offering_ids[0] and returns that enrollment instead of reaching the create path. Because user_offering_ids_for_course() does not order its rows, new assignment writes can land on a random old enrollment rather than the current-term enrollment this helper is meant to provision.

Suggested fix
- offering_ids = user_offering_ids_for_course(user_id, course_id)- if offering_ids:- chosen = offering_ids[0]- cur = current_term()- cur_id = cur["id"] if cur else None- if cur_id:- for oid in offering_ids:- t = term_for_offering(oid)- if t and t.get("id") == cur_id:- chosen = oid- break- rows = table("enrollments").select(- "id",- filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},- limit=1,- )- if rows:- return rows[0]["id"]+ offering_ids = user_offering_ids_for_course(user_id, course_id)+ if offering_ids:+ cur = current_term()+ cur_id = cur["id"] if cur else None+ chosen = None+ if cur_id:+ for oid in offering_ids:+ t = term_for_offering(oid)+ if t and t.get("id") == cur_id:+ chosen = oid+ break+ elif len(offering_ids) == 1 and not create:+ chosen = offering_ids[0]++ if chosen:+ rows = table("enrollments").select(+ "id",+ filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},+ limit=1,+ )+ if rows:+ return rows[0]["id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
chosen=offering_ids[0]
cur=current_term()
cur_id=cur["id"] ifcurelseNone
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
cur=current_term()
cur_id=cur["id"] ifcurelseNone
chosen=None
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
eliflen(offering_ids) ==1andnotcreate:
chosen=offering_ids[0]
ifchosen:
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/academics.py` around lines 159 - 181, The enrollment lookup
in the helper that uses user_offering_ids_for_course, current_term, and
resolve_offering should not fall back to an arbitrary historical offering when
create=True. Change the selection logic so it only reuses an existing enrollment
if it matches the current term, and otherwise let the code continue into the
creation path; keep the existing enrollment return path only for the
current-term match. This ensures the branch in backend/services/academics.py
provisioned by create=True does not return a random old enrollment.

Comment on lines +27 to +36
def test_existing_enrollment_current_term(self):
# user_offering_ids_for_course -> ["o1"]; term match; enrollment e1
tables = {
"course_offerings": _tbl(select=[{"id": "o1"}]),
"enrollments": _tbl(select=[{"id": "e1"}]),
}
with patch("services.academics.table", side_effect=_dispatch(tables)), \
patch("services.academics.user_offering_ids_for_course", return_value=["o1"]), \
patch("services.academics.current_term", return_value=None):
assert ac.enrollment_id_for("user_andres", "CS101") == "e1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test never reaches the current-term preference branch.

current_term is mocked to None and there is only one offering, so the resolver returns the lone enrollment without evaluating any term match. Please make this a multi-offering case with a real current term so the new "prefer current-term enrollment" logic is actually covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_academics_enrollment_resolver.py` around lines 27 - 36,
The existing test only exercises the single-enrollment fallback and never hits
the current-term preference path. Update test_existing_enrollment_current_term
in the academics enrollment resolver tests to use multiple course offerings and
a non-None current_term so ac.enrollment_id_for actually has to choose between
enrollments. Make the setup in services.academics.user_offering_ids_for_course
and services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.

Comment on lines +22 to +77
def test_update_scopes_write_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}] # owner's row exists
r = client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assert r.status_code == 200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert update_filters.get("id") == f"eq.{AID}"

def test_delete_scopes_delete_by_user_id(self):
with patch("routes.calendar.table") as t:
def test_delete_scopes_delete_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}]
r = client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assert r.status_code == 200
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert delete_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert delete_filters.get("id") == f"eq.{AID}"

def test_sync_scopes_writeback_by_user_id(self):
def test_sync_scopes_writeback_by_enrollment_id(self):
unsynced = [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]

with patch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") as build, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") as t:
patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
service = MagicMock()
service.events.return_value.insert.return_value.execute.return_value = {"id": "evt_1"}
build.return_value = service
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value = None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect = [unsynced, []]
r = client.post("/api/calendar/sync", json={"user_id": OWNER})

assert r.status_code == 200
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert the guarded SELECT is enrollment-scoped too.

These cases only verify the update/delete filters. Because the mocked select always returns the owned row here, the tests still pass if the PATCH/DELETE ownership check regresses to filters={"id": ...} or if SYNC stops filtering the unsynced read by enrollment_id. Please assert the relevant select call kwargs carry the same membership guard.

Suggested assertions
 assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert r.status_code == 200
+ first_select_filters = t.return_value.select.call_args_list[0].kwargs["filters"]+ assert "enrollment_id" in first_select_filters+ assert ENROLLMENT_ID in first_select_filters["enrollment_id"]
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_user_id(self):
withpatch("routes.calendar.table") ast:
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assertdelete_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_user_id(self):
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast:
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
first_select_filters=t.return_value.select.call_args_list[0].kwargs["filters"]
assert"enrollment_id"infirst_select_filters
assertENROLLMENT_IDinfirst_select_filters["enrollment_id"]
update_filters=t.return_value.update.call_args.kwargs["filters"]
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_sibling_write_scoping.py` around lines 22 - 77,
The tests only verify write filters, but they should also cover the guarded read
path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500) - #283

Merged
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire
Jun 28, 2026
Merged

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500)#283
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What & why

After the DB modular redesign, migration 0021_gradebook.sql did DROP TABLE assignments CASCADE and recreated assignmentskeyed on enrollment_id (no user_id/course_id/courses relationship). routes/calendar.py + services/calendar_service.py still spoke the old schema, so every /api/calendar/* call returned PostgREST 400 → 500, which tanked the staging dashboard (its Promise.all fails on the one bad endpoint). The migration itself had flagged this rewire as deferred ("See issues filed for the code rewire").

Reproduced against the live staging DB for the real user: only /api/calendar/upcoming 500'd; all other dashboard domains were already migrated and healthy.

Approach

Mirror the already-migrated gradebook.py helpers (no fragile nested PostgREST embeds). Assignments are always course-tied and key on enrollment_id; a small resolver in services/academics.py bridges (user, abstract course) → enrollment_id. No schema migration. HTTP request/response shapes are unchanged (frontend untouched).

Design spec: docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md
Plan: docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md

Changes (6 TDD commits)

  • services/academics.pyenrollment_id_for(user, course_id, *, create=False) + user_enrollment_ids(user).
  • Read (get_upcoming/get_all/suggest_study_blocks) — fetch the user's enrollments → assignments WHERE enrollment_id IN (...), decorate with abstract course_id/course_code/course_name; no enrollments → {"assignments": []} (the dashboard unblock).
  • Write (/save, calendar_service.insert_new_assignments, syllabus saves in documents.py) — resolve course_id → enrollment_id (create-if-missing), tag source (manual/syllabus), dedup across the enrollment set, encrypt notes exactly once.
  • Ownership scoping (update/delete/sync/export) — enrollment_id IN (caller's enrollments) on both the pre-check and the write (preserves IDOR guarantee [P0] calendar.export_to_google cross-user IDOR leaks decrypted private notes #123).
  • Migrated the calendar/assignment tests to the new schema.

Verification

  • Full backend suite: 803 passed, 1 skipped, 0 failed.
  • Live staging read-path check: _read_assignments resolves against the enrollment-keyed schema (0 rows, no 400).
  • Final whole-branch review: ready to merge — IDOR scoping, encrypt-once, and spec coverage independently verified; migrated dedup/encryption tests now genuinely exercised (were vacuously green before).

Behavior notes / follow-ups

  • Manual /save now requires a course_id — an empty one is silently skipped (spec Decision 1: assignments are always course-tied). Frontend must always send course_id. Consider a 400 instead of a silent drop as a follow-up.
  • Latent (not production):process_and_save_syllabus (OCR-pipeline helper, only invoked by an opt-in live-DB test, no mounted route) feeds assignments without course_id and would save 0 — file a ticket if it's ever wired to a route.
  • Minor cleanups deferred: _read_assignments selects unused source; sync/export call user_enrollment_ids twice; a couple of unused test helpers.

Deploys to staging when merged to main (Railway redeploys the backend). Independent of the frontend proxy/SESSION_SECRET fixes already applied to staging.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Calendar assignments now load, save, edit, delete, and sync more reliably across enrolled courses.
    • Assignment visibility and updates are now correctly limited to the right course membership, reducing accidental cross-course access.
    • Google Calendar export/sync now handles unsynced items more consistently.
    • Assignment notes are stored and restored securely, and syllabus-imported assignments are tagged consistently.

AndresL230and others added 8 commits June 28, 2026 01:23
The calendar route + calendar_service still query the pre-redesign assignments
table (user_id/course_id/courses!left); 0021 re-keyed assignments on
enrollment_id, so every call 400s -> 500 and tanks the dashboard. Spec rewires
the calendar domain to resolve course -> enrollment (mirroring gradebook.py),
keeping the HTTP shapes stable. Decisions: assignments are always course-tied
(no migration), writes auto-create the enrollment, full-domain scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires sync_to_google and export_to_google onto the enrollment-keyed
schema: select/write-back scoped by enrollment_id membership instead of
the removed user_id column; drops courses!left embed in favour of
_course_meta_cached. Updates test_calendar_export_idor.py and
test_calendar_sibling_write_scoping.py to assert the new enrollment_id
boundary (same IDOR guarantee, new key).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment schema
- routes/documents.py: pass source="syllabus" at both save_assignments_to_db
call sites (_save_orchestrator_syllabus and the legacy call_gemini_json path)
- tests/test_calendar_routes.py: rewire TestSaveAssignments to include course_id
in fixtures and mock enrollment_id_for/user_enrollment_ids; rewire
TestGetUpcoming.test_returns_assignments_from_db to the enrollment-keyed row
shape (enrollment_id, no user_id/course_id/courses columns); add _tbl helper
- tests/test_assignment_notes_encryption.py: supply course_id to test fixtures
and mock academics so insert_new_assignments reaches the encryption boundary
- tests/test_documents_routes.py: update assert_called_once_with to include
source='syllabus' to match the new tagged call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

All calendar/assignment backend routes, the calendar service, and the academics service are updated to use enrollment_id membership instead of user_id/course_id for scoping reads, writes, ownership checks, Google sync, and export. Two new enrollment resolver helpers are added to academics.py. Note encryption moves from route handlers into the service layer. Syllabus save calls are tagged with source="syllabus". Tests are updated or added throughout.

Changes

Calendar Assignments Enrollment-keyed Rewire

Layer / File(s)Summary
Enrollment resolver helpers
backend/services/academics.py, backend/tests/test_academics_enrollment_resolver.py
user_enrollment_ids returns enrollment rows for a user; enrollment_id_for resolves or creates an enrollment for a given course, preferring the current-term offering. Unit tests cover found, create, and not-found cases.
calendar_service insert/dedupe keyed by enrollment_id
backend/services/calendar_service.py, backend/tests/test_calendar_write_enrollment.py, backend/tests/test_assignment_notes_encryption.py
load_existing_assignment_keys now dedupes across the user's enrollment set; insert_new_assignments resolves course_idenrollment_id, skips unresolvable assignments, writes enrollment-keyed rows with encrypted notes and an explicit source. New write and encryption tests verify insert shape, deduplication, and None-notes handling.
Calendar read path: _read_assignments and read endpoints
backend/routes/calendar.py, backend/tests/test_calendar_read_enrollment.py
Adds _course_meta_cached, _owned_enrollment_ids, and _read_assignments helpers; /upcoming, /all, and /suggest-study-blocks all route through _read_assignments for enrollment-scoped, decrypted, course-decorated results.
Calendar write path: /save, update, and delete
backend/routes/calendar.py
/save drops in-route note encryption. PATCH /assignments/{id} and DELETE /assignments/{id} replace user_id ownership checks with enrollment_id IN (...) and remove course_id from the patch whitelist.
Google sync and export enrollment scoping
backend/routes/calendar.py
/sync and /export replace user_id-scoped queries and write-backs with enrollment_id IN (...), remove courses!left joins, and derive course labels via _course_meta_cached.
Syllabus save source="syllabus" tagging
backend/routes/documents.py, backend/tests/test_documents_routes.py
Both orchestrator and legacy syllabus persistence paths pass source="syllabus" to save_assignments_to_db; the route test asserts the keyword argument.
Security and scoping tests
backend/tests/test_calendar_export_idor.py, backend/tests/test_calendar_sibling_write_scoping.py, backend/tests/test_calendar_scoping_enrollment.py, backend/tests/test_calendar_sync_export_enrollment.py
IDOR export regression, sibling write scoping, and new enrollment-scoped update/export tests all assert enrollment_id membership filters on read, write, and delete operations instead of user_id.
Existing route tests updated to enrollment schema
backend/tests/test_calendar_routes.py
Save, upcoming, study-blocks, update, and delete tests are updated with expanded academics mocks, enrollment_id-keyed DB row shapes, course_id in payloads, and a blocked course_id patch assertion.
Design spec and implementation plan
docs/superpowers/specs/..., docs/superpowers/plans/...
New design spec and phased implementation plan documenting the full enrollment-rewire scope, decisions, and verification checklist.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • SaplingLearn/Sapling#53: Also modifies backend/routes/calendar.py to populate course_code/course_name on assignment records, directly overlapping with this PR's course metadata decoration logic.
  • SaplingLearn/Sapling#65: Modifies calendar assignment notes encryption/decryption handling in the same route and service files, overlapping with this PR's move of encryption into insert_new_assignments.
  • SaplingLearn/Sapling#235: Tightens IDOR ownership scoping on the same PATCH/DELETE/sync write filters—this PR supersedes that by shifting the scoping key from user_id to enrollment_id.

Poem

🐇 Hopping through the enrollment rows,
No more user_id wherever code goes!
enrollment_id guards each patch and delete,
Notes are encrypted, the schema's complete.
This bunny rewired it all — how neat! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 8.82% 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 names the calendar assignment rewire and matches the schema migration fix.
Description check✅ PassedIt covers the problem, approach, changes, verification, and follow-ups, though it doesn't match the template headings exactly.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/calendar-assignments-enrollment-rewire

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.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8a38cfeCommit Preview URL

Branch Preview URL
Jun 28 2026, 08:38 PM

@AndresL230
AndresL230 merged commit 8192447 into mainJun 28, 2026
5 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md (1)

57-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add language identifier to fenced code block.

The fenced code block showing enrollment_id_for signature lacks a language label. Add python for syntax highlighting and to satisfy linting.

+```python
enrollment_id_for(user_id, course_id, *, create=False) -> str | None

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`
around lines 57 - 60, The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
backend/tests/test_calendar_routes.py (1)

16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the reusable table mock into tests/conftest.py.

_tbl is now duplicated across backend/tests/test_calendar_routes.py, backend/tests/test_calendar_read_enrollment.py, and backend/tests/test_calendar_scoping_enrollment.py, so any change to the fake table contract has to be kept in sync by hand. As per coding guidelines, "shared fixtures such as mock Supabase and mock Gemini belong in tests/conftest.py."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_routes.py` around lines 16 - 21, The reusable
table mock helper `_tbl` is duplicated across multiple calendar tests, so move
it into `tests/conftest.py` as a shared fixture/helper and update
`test_calendar_routes`, `test_calendar_read_enrollment`, and
`test_calendar_scoping_enrollment` to import/use the common version. Keep the
existing `MagicMock` table contract and preserve the per-verb return-value
behavior so all tests share one source of truth.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/academics.py`:
- Around line 159-181: The enrollment lookup in the helper that uses
user_offering_ids_for_course, current_term, and resolve_offering should not fall
back to an arbitrary historical offering when create=True. Change the selection
logic so it only reuses an existing enrollment if it matches the current term,
and otherwise let the code continue into the creation path; keep the existing
enrollment return path only for the current-term match. This ensures the branch
in backend/services/academics.py provisioned by create=True does not return a
random old enrollment.
In `@backend/tests/test_academics_enrollment_resolver.py`:
- Around line 27-36: The existing test only exercises the single-enrollment
fallback and never hits the current-term preference path. Update
test_existing_enrollment_current_term in the academics enrollment resolver tests
to use multiple course offerings and a non-None current_term so
ac.enrollment_id_for actually has to choose between enrollments. Make the setup
in services.academics.user_offering_ids_for_course and
services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.
In `@backend/tests/test_calendar_sibling_write_scoping.py`:
- Around line 22-77: The tests only verify write filters, but they should also
cover the guarded read path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.
---
Nitpick comments:
In `@backend/tests/test_calendar_routes.py`:
- Around line 16-21: The reusable table mock helper `_tbl` is duplicated across
multiple calendar tests, so move it into `tests/conftest.py` as a shared
fixture/helper and update `test_calendar_routes`,
`test_calendar_read_enrollment`, and `test_calendar_scoping_enrollment` to
import/use the common version. Keep the existing `MagicMock` table contract and
preserve the per-verb return-value behavior so all tests share one source of
truth.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`:
- Around line 57-60: The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a175b3-0b0b-4e62-bcf5-51940fdcfee8

📥 Commits

Reviewing files that changed from the base of the PR and between e6aeb5f and 8a38cfe.

📒 Files selected for processing (16)
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/services/academics.py
  • backend/services/calendar_service.py
  • backend/tests/test_academics_enrollment_resolver.py
  • backend/tests/test_assignment_notes_encryption.py
  • backend/tests/test_calendar_export_idor.py
  • backend/tests/test_calendar_read_enrollment.py
  • backend/tests/test_calendar_routes.py
  • backend/tests/test_calendar_scoping_enrollment.py
  • backend/tests/test_calendar_sibling_write_scoping.py
  • backend/tests/test_calendar_sync_export_enrollment.py
  • backend/tests/test_calendar_write_enrollment.py
  • backend/tests/test_documents_routes.py
  • docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md
  • docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md

Comment on lines +159 to +181
offering_ids = user_offering_ids_for_course(user_id, course_id)
if offering_ids:
chosen = offering_ids[0]
cur = current_term()
cur_id = cur["id"] if cur else None
if cur_id:
for oid in offering_ids:
t = term_for_offering(oid)
if t and t.get("id") == cur_id:
chosen = oid
break
rows = table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
if rows:
return rows[0]["id"]

if not create:
return None

offering_id = resolve_offering(course_id, create=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't reuse an arbitrary historical enrollment when create=True.

If the user already has past enrollments for the course but none in current_term(), this branch falls back to offering_ids[0] and returns that enrollment instead of reaching the create path. Because user_offering_ids_for_course() does not order its rows, new assignment writes can land on a random old enrollment rather than the current-term enrollment this helper is meant to provision.

Suggested fix
- offering_ids = user_offering_ids_for_course(user_id, course_id)- if offering_ids:- chosen = offering_ids[0]- cur = current_term()- cur_id = cur["id"] if cur else None- if cur_id:- for oid in offering_ids:- t = term_for_offering(oid)- if t and t.get("id") == cur_id:- chosen = oid- break- rows = table("enrollments").select(- "id",- filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},- limit=1,- )- if rows:- return rows[0]["id"]+ offering_ids = user_offering_ids_for_course(user_id, course_id)+ if offering_ids:+ cur = current_term()+ cur_id = cur["id"] if cur else None+ chosen = None+ if cur_id:+ for oid in offering_ids:+ t = term_for_offering(oid)+ if t and t.get("id") == cur_id:+ chosen = oid+ break+ elif len(offering_ids) == 1 and not create:+ chosen = offering_ids[0]++ if chosen:+ rows = table("enrollments").select(+ "id",+ filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},+ limit=1,+ )+ if rows:+ return rows[0]["id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
chosen=offering_ids[0]
cur=current_term()
cur_id=cur["id"] ifcurelseNone
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
cur=current_term()
cur_id=cur["id"] ifcurelseNone
chosen=None
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
eliflen(offering_ids) ==1andnotcreate:
chosen=offering_ids[0]
ifchosen:
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/academics.py` around lines 159 - 181, The enrollment lookup
in the helper that uses user_offering_ids_for_course, current_term, and
resolve_offering should not fall back to an arbitrary historical offering when
create=True. Change the selection logic so it only reuses an existing enrollment
if it matches the current term, and otherwise let the code continue into the
creation path; keep the existing enrollment return path only for the
current-term match. This ensures the branch in backend/services/academics.py
provisioned by create=True does not return a random old enrollment.

Comment on lines +27 to +36
def test_existing_enrollment_current_term(self):
# user_offering_ids_for_course -> ["o1"]; term match; enrollment e1
tables = {
"course_offerings": _tbl(select=[{"id": "o1"}]),
"enrollments": _tbl(select=[{"id": "e1"}]),
}
with patch("services.academics.table", side_effect=_dispatch(tables)), \
patch("services.academics.user_offering_ids_for_course", return_value=["o1"]), \
patch("services.academics.current_term", return_value=None):
assert ac.enrollment_id_for("user_andres", "CS101") == "e1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test never reaches the current-term preference branch.

current_term is mocked to None and there is only one offering, so the resolver returns the lone enrollment without evaluating any term match. Please make this a multi-offering case with a real current term so the new "prefer current-term enrollment" logic is actually covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_academics_enrollment_resolver.py` around lines 27 - 36,
The existing test only exercises the single-enrollment fallback and never hits
the current-term preference path. Update test_existing_enrollment_current_term
in the academics enrollment resolver tests to use multiple course offerings and
a non-None current_term so ac.enrollment_id_for actually has to choose between
enrollments. Make the setup in services.academics.user_offering_ids_for_course
and services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.

Comment on lines +22 to +77
def test_update_scopes_write_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}] # owner's row exists
r = client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assert r.status_code == 200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert update_filters.get("id") == f"eq.{AID}"

def test_delete_scopes_delete_by_user_id(self):
with patch("routes.calendar.table") as t:
def test_delete_scopes_delete_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}]
r = client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assert r.status_code == 200
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert delete_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert delete_filters.get("id") == f"eq.{AID}"

def test_sync_scopes_writeback_by_user_id(self):
def test_sync_scopes_writeback_by_enrollment_id(self):
unsynced = [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]

with patch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") as build, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") as t:
patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
service = MagicMock()
service.events.return_value.insert.return_value.execute.return_value = {"id": "evt_1"}
build.return_value = service
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value = None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect = [unsynced, []]
r = client.post("/api/calendar/sync", json={"user_id": OWNER})

assert r.status_code == 200
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert the guarded SELECT is enrollment-scoped too.

These cases only verify the update/delete filters. Because the mocked select always returns the owned row here, the tests still pass if the PATCH/DELETE ownership check regresses to filters={"id": ...} or if SYNC stops filtering the unsynced read by enrollment_id. Please assert the relevant select call kwargs carry the same membership guard.

Suggested assertions
 assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert r.status_code == 200
+ first_select_filters = t.return_value.select.call_args_list[0].kwargs["filters"]+ assert "enrollment_id" in first_select_filters+ assert ENROLLMENT_ID in first_select_filters["enrollment_id"]
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_user_id(self):
withpatch("routes.calendar.table") ast:
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assertdelete_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_user_id(self):
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast:
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
first_select_filters=t.return_value.select.call_args_list[0].kwargs["filters"]
assert"enrollment_id"infirst_select_filters
assertENROLLMENT_IDinfirst_select_filters["enrollment_id"]
update_filters=t.return_value.update.call_args.kwargs["filters"]
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_sibling_write_scoping.py` around lines 22 - 77,
The tests only verify write filters, but they should also cover the guarded read
path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500) - #283

Merged
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire
Jun 28, 2026
Merged

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500)#283
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What & why

After the DB modular redesign, migration 0021_gradebook.sql did DROP TABLE assignments CASCADE and recreated assignmentskeyed on enrollment_id (no user_id/course_id/courses relationship). routes/calendar.py + services/calendar_service.py still spoke the old schema, so every /api/calendar/* call returned PostgREST 400 → 500, which tanked the staging dashboard (its Promise.all fails on the one bad endpoint). The migration itself had flagged this rewire as deferred ("See issues filed for the code rewire").

Reproduced against the live staging DB for the real user: only /api/calendar/upcoming 500'd; all other dashboard domains were already migrated and healthy.

Approach

Mirror the already-migrated gradebook.py helpers (no fragile nested PostgREST embeds). Assignments are always course-tied and key on enrollment_id; a small resolver in services/academics.py bridges (user, abstract course) → enrollment_id. No schema migration. HTTP request/response shapes are unchanged (frontend untouched).

Design spec: docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md
Plan: docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md

Changes (6 TDD commits)

  • services/academics.pyenrollment_id_for(user, course_id, *, create=False) + user_enrollment_ids(user).
  • Read (get_upcoming/get_all/suggest_study_blocks) — fetch the user's enrollments → assignments WHERE enrollment_id IN (...), decorate with abstract course_id/course_code/course_name; no enrollments → {"assignments": []} (the dashboard unblock).
  • Write (/save, calendar_service.insert_new_assignments, syllabus saves in documents.py) — resolve course_id → enrollment_id (create-if-missing), tag source (manual/syllabus), dedup across the enrollment set, encrypt notes exactly once.
  • Ownership scoping (update/delete/sync/export) — enrollment_id IN (caller's enrollments) on both the pre-check and the write (preserves IDOR guarantee [P0] calendar.export_to_google cross-user IDOR leaks decrypted private notes #123).
  • Migrated the calendar/assignment tests to the new schema.

Verification

  • Full backend suite: 803 passed, 1 skipped, 0 failed.
  • Live staging read-path check: _read_assignments resolves against the enrollment-keyed schema (0 rows, no 400).
  • Final whole-branch review: ready to merge — IDOR scoping, encrypt-once, and spec coverage independently verified; migrated dedup/encryption tests now genuinely exercised (were vacuously green before).

Behavior notes / follow-ups

  • Manual /save now requires a course_id — an empty one is silently skipped (spec Decision 1: assignments are always course-tied). Frontend must always send course_id. Consider a 400 instead of a silent drop as a follow-up.
  • Latent (not production):process_and_save_syllabus (OCR-pipeline helper, only invoked by an opt-in live-DB test, no mounted route) feeds assignments without course_id and would save 0 — file a ticket if it's ever wired to a route.
  • Minor cleanups deferred: _read_assignments selects unused source; sync/export call user_enrollment_ids twice; a couple of unused test helpers.

Deploys to staging when merged to main (Railway redeploys the backend). Independent of the frontend proxy/SESSION_SECRET fixes already applied to staging.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Calendar assignments now load, save, edit, delete, and sync more reliably across enrolled courses.
    • Assignment visibility and updates are now correctly limited to the right course membership, reducing accidental cross-course access.
    • Google Calendar export/sync now handles unsynced items more consistently.
    • Assignment notes are stored and restored securely, and syllabus-imported assignments are tagged consistently.

AndresL230and others added 8 commits June 28, 2026 01:23
The calendar route + calendar_service still query the pre-redesign assignments
table (user_id/course_id/courses!left); 0021 re-keyed assignments on
enrollment_id, so every call 400s -> 500 and tanks the dashboard. Spec rewires
the calendar domain to resolve course -> enrollment (mirroring gradebook.py),
keeping the HTTP shapes stable. Decisions: assignments are always course-tied
(no migration), writes auto-create the enrollment, full-domain scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires sync_to_google and export_to_google onto the enrollment-keyed
schema: select/write-back scoped by enrollment_id membership instead of
the removed user_id column; drops courses!left embed in favour of
_course_meta_cached. Updates test_calendar_export_idor.py and
test_calendar_sibling_write_scoping.py to assert the new enrollment_id
boundary (same IDOR guarantee, new key).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment schema
- routes/documents.py: pass source="syllabus" at both save_assignments_to_db
call sites (_save_orchestrator_syllabus and the legacy call_gemini_json path)
- tests/test_calendar_routes.py: rewire TestSaveAssignments to include course_id
in fixtures and mock enrollment_id_for/user_enrollment_ids; rewire
TestGetUpcoming.test_returns_assignments_from_db to the enrollment-keyed row
shape (enrollment_id, no user_id/course_id/courses columns); add _tbl helper
- tests/test_assignment_notes_encryption.py: supply course_id to test fixtures
and mock academics so insert_new_assignments reaches the encryption boundary
- tests/test_documents_routes.py: update assert_called_once_with to include
source='syllabus' to match the new tagged call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

All calendar/assignment backend routes, the calendar service, and the academics service are updated to use enrollment_id membership instead of user_id/course_id for scoping reads, writes, ownership checks, Google sync, and export. Two new enrollment resolver helpers are added to academics.py. Note encryption moves from route handlers into the service layer. Syllabus save calls are tagged with source="syllabus". Tests are updated or added throughout.

Changes

Calendar Assignments Enrollment-keyed Rewire

Layer / File(s)Summary
Enrollment resolver helpers
backend/services/academics.py, backend/tests/test_academics_enrollment_resolver.py
user_enrollment_ids returns enrollment rows for a user; enrollment_id_for resolves or creates an enrollment for a given course, preferring the current-term offering. Unit tests cover found, create, and not-found cases.
calendar_service insert/dedupe keyed by enrollment_id
backend/services/calendar_service.py, backend/tests/test_calendar_write_enrollment.py, backend/tests/test_assignment_notes_encryption.py
load_existing_assignment_keys now dedupes across the user's enrollment set; insert_new_assignments resolves course_idenrollment_id, skips unresolvable assignments, writes enrollment-keyed rows with encrypted notes and an explicit source. New write and encryption tests verify insert shape, deduplication, and None-notes handling.
Calendar read path: _read_assignments and read endpoints
backend/routes/calendar.py, backend/tests/test_calendar_read_enrollment.py
Adds _course_meta_cached, _owned_enrollment_ids, and _read_assignments helpers; /upcoming, /all, and /suggest-study-blocks all route through _read_assignments for enrollment-scoped, decrypted, course-decorated results.
Calendar write path: /save, update, and delete
backend/routes/calendar.py
/save drops in-route note encryption. PATCH /assignments/{id} and DELETE /assignments/{id} replace user_id ownership checks with enrollment_id IN (...) and remove course_id from the patch whitelist.
Google sync and export enrollment scoping
backend/routes/calendar.py
/sync and /export replace user_id-scoped queries and write-backs with enrollment_id IN (...), remove courses!left joins, and derive course labels via _course_meta_cached.
Syllabus save source="syllabus" tagging
backend/routes/documents.py, backend/tests/test_documents_routes.py
Both orchestrator and legacy syllabus persistence paths pass source="syllabus" to save_assignments_to_db; the route test asserts the keyword argument.
Security and scoping tests
backend/tests/test_calendar_export_idor.py, backend/tests/test_calendar_sibling_write_scoping.py, backend/tests/test_calendar_scoping_enrollment.py, backend/tests/test_calendar_sync_export_enrollment.py
IDOR export regression, sibling write scoping, and new enrollment-scoped update/export tests all assert enrollment_id membership filters on read, write, and delete operations instead of user_id.
Existing route tests updated to enrollment schema
backend/tests/test_calendar_routes.py
Save, upcoming, study-blocks, update, and delete tests are updated with expanded academics mocks, enrollment_id-keyed DB row shapes, course_id in payloads, and a blocked course_id patch assertion.
Design spec and implementation plan
docs/superpowers/specs/..., docs/superpowers/plans/...
New design spec and phased implementation plan documenting the full enrollment-rewire scope, decisions, and verification checklist.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • SaplingLearn/Sapling#53: Also modifies backend/routes/calendar.py to populate course_code/course_name on assignment records, directly overlapping with this PR's course metadata decoration logic.
  • SaplingLearn/Sapling#65: Modifies calendar assignment notes encryption/decryption handling in the same route and service files, overlapping with this PR's move of encryption into insert_new_assignments.
  • SaplingLearn/Sapling#235: Tightens IDOR ownership scoping on the same PATCH/DELETE/sync write filters—this PR supersedes that by shifting the scoping key from user_id to enrollment_id.

Poem

🐇 Hopping through the enrollment rows,
No more user_id wherever code goes!
enrollment_id guards each patch and delete,
Notes are encrypted, the schema's complete.
This bunny rewired it all — how neat! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 8.82% 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 names the calendar assignment rewire and matches the schema migration fix.
Description check✅ PassedIt covers the problem, approach, changes, verification, and follow-ups, though it doesn't match the template headings exactly.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/calendar-assignments-enrollment-rewire

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.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8a38cfeCommit Preview URL

Branch Preview URL
Jun 28 2026, 08:38 PM

@AndresL230
AndresL230 merged commit 8192447 into mainJun 28, 2026
5 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md (1)

57-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add language identifier to fenced code block.

The fenced code block showing enrollment_id_for signature lacks a language label. Add python for syntax highlighting and to satisfy linting.

+```python
enrollment_id_for(user_id, course_id, *, create=False) -> str | None

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`
around lines 57 - 60, The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
backend/tests/test_calendar_routes.py (1)

16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the reusable table mock into tests/conftest.py.

_tbl is now duplicated across backend/tests/test_calendar_routes.py, backend/tests/test_calendar_read_enrollment.py, and backend/tests/test_calendar_scoping_enrollment.py, so any change to the fake table contract has to be kept in sync by hand. As per coding guidelines, "shared fixtures such as mock Supabase and mock Gemini belong in tests/conftest.py."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_routes.py` around lines 16 - 21, The reusable
table mock helper `_tbl` is duplicated across multiple calendar tests, so move
it into `tests/conftest.py` as a shared fixture/helper and update
`test_calendar_routes`, `test_calendar_read_enrollment`, and
`test_calendar_scoping_enrollment` to import/use the common version. Keep the
existing `MagicMock` table contract and preserve the per-verb return-value
behavior so all tests share one source of truth.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/academics.py`:
- Around line 159-181: The enrollment lookup in the helper that uses
user_offering_ids_for_course, current_term, and resolve_offering should not fall
back to an arbitrary historical offering when create=True. Change the selection
logic so it only reuses an existing enrollment if it matches the current term,
and otherwise let the code continue into the creation path; keep the existing
enrollment return path only for the current-term match. This ensures the branch
in backend/services/academics.py provisioned by create=True does not return a
random old enrollment.
In `@backend/tests/test_academics_enrollment_resolver.py`:
- Around line 27-36: The existing test only exercises the single-enrollment
fallback and never hits the current-term preference path. Update
test_existing_enrollment_current_term in the academics enrollment resolver tests
to use multiple course offerings and a non-None current_term so
ac.enrollment_id_for actually has to choose between enrollments. Make the setup
in services.academics.user_offering_ids_for_course and
services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.
In `@backend/tests/test_calendar_sibling_write_scoping.py`:
- Around line 22-77: The tests only verify write filters, but they should also
cover the guarded read path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.
---
Nitpick comments:
In `@backend/tests/test_calendar_routes.py`:
- Around line 16-21: The reusable table mock helper `_tbl` is duplicated across
multiple calendar tests, so move it into `tests/conftest.py` as a shared
fixture/helper and update `test_calendar_routes`,
`test_calendar_read_enrollment`, and `test_calendar_scoping_enrollment` to
import/use the common version. Keep the existing `MagicMock` table contract and
preserve the per-verb return-value behavior so all tests share one source of
truth.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`:
- Around line 57-60: The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a175b3-0b0b-4e62-bcf5-51940fdcfee8

📥 Commits

Reviewing files that changed from the base of the PR and between e6aeb5f and 8a38cfe.

📒 Files selected for processing (16)
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/services/academics.py
  • backend/services/calendar_service.py
  • backend/tests/test_academics_enrollment_resolver.py
  • backend/tests/test_assignment_notes_encryption.py
  • backend/tests/test_calendar_export_idor.py
  • backend/tests/test_calendar_read_enrollment.py
  • backend/tests/test_calendar_routes.py
  • backend/tests/test_calendar_scoping_enrollment.py
  • backend/tests/test_calendar_sibling_write_scoping.py
  • backend/tests/test_calendar_sync_export_enrollment.py
  • backend/tests/test_calendar_write_enrollment.py
  • backend/tests/test_documents_routes.py
  • docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md
  • docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md

Comment on lines +159 to +181
offering_ids = user_offering_ids_for_course(user_id, course_id)
if offering_ids:
chosen = offering_ids[0]
cur = current_term()
cur_id = cur["id"] if cur else None
if cur_id:
for oid in offering_ids:
t = term_for_offering(oid)
if t and t.get("id") == cur_id:
chosen = oid
break
rows = table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
if rows:
return rows[0]["id"]

if not create:
return None

offering_id = resolve_offering(course_id, create=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't reuse an arbitrary historical enrollment when create=True.

If the user already has past enrollments for the course but none in current_term(), this branch falls back to offering_ids[0] and returns that enrollment instead of reaching the create path. Because user_offering_ids_for_course() does not order its rows, new assignment writes can land on a random old enrollment rather than the current-term enrollment this helper is meant to provision.

Suggested fix
- offering_ids = user_offering_ids_for_course(user_id, course_id)- if offering_ids:- chosen = offering_ids[0]- cur = current_term()- cur_id = cur["id"] if cur else None- if cur_id:- for oid in offering_ids:- t = term_for_offering(oid)- if t and t.get("id") == cur_id:- chosen = oid- break- rows = table("enrollments").select(- "id",- filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},- limit=1,- )- if rows:- return rows[0]["id"]+ offering_ids = user_offering_ids_for_course(user_id, course_id)+ if offering_ids:+ cur = current_term()+ cur_id = cur["id"] if cur else None+ chosen = None+ if cur_id:+ for oid in offering_ids:+ t = term_for_offering(oid)+ if t and t.get("id") == cur_id:+ chosen = oid+ break+ elif len(offering_ids) == 1 and not create:+ chosen = offering_ids[0]++ if chosen:+ rows = table("enrollments").select(+ "id",+ filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},+ limit=1,+ )+ if rows:+ return rows[0]["id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
chosen=offering_ids[0]
cur=current_term()
cur_id=cur["id"] ifcurelseNone
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
cur=current_term()
cur_id=cur["id"] ifcurelseNone
chosen=None
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
eliflen(offering_ids) ==1andnotcreate:
chosen=offering_ids[0]
ifchosen:
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/academics.py` around lines 159 - 181, The enrollment lookup
in the helper that uses user_offering_ids_for_course, current_term, and
resolve_offering should not fall back to an arbitrary historical offering when
create=True. Change the selection logic so it only reuses an existing enrollment
if it matches the current term, and otherwise let the code continue into the
creation path; keep the existing enrollment return path only for the
current-term match. This ensures the branch in backend/services/academics.py
provisioned by create=True does not return a random old enrollment.

Comment on lines +27 to +36
def test_existing_enrollment_current_term(self):
# user_offering_ids_for_course -> ["o1"]; term match; enrollment e1
tables = {
"course_offerings": _tbl(select=[{"id": "o1"}]),
"enrollments": _tbl(select=[{"id": "e1"}]),
}
with patch("services.academics.table", side_effect=_dispatch(tables)), \
patch("services.academics.user_offering_ids_for_course", return_value=["o1"]), \
patch("services.academics.current_term", return_value=None):
assert ac.enrollment_id_for("user_andres", "CS101") == "e1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test never reaches the current-term preference branch.

current_term is mocked to None and there is only one offering, so the resolver returns the lone enrollment without evaluating any term match. Please make this a multi-offering case with a real current term so the new "prefer current-term enrollment" logic is actually covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_academics_enrollment_resolver.py` around lines 27 - 36,
The existing test only exercises the single-enrollment fallback and never hits
the current-term preference path. Update test_existing_enrollment_current_term
in the academics enrollment resolver tests to use multiple course offerings and
a non-None current_term so ac.enrollment_id_for actually has to choose between
enrollments. Make the setup in services.academics.user_offering_ids_for_course
and services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.

Comment on lines +22 to +77
def test_update_scopes_write_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}] # owner's row exists
r = client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assert r.status_code == 200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert update_filters.get("id") == f"eq.{AID}"

def test_delete_scopes_delete_by_user_id(self):
with patch("routes.calendar.table") as t:
def test_delete_scopes_delete_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}]
r = client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assert r.status_code == 200
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert delete_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert delete_filters.get("id") == f"eq.{AID}"

def test_sync_scopes_writeback_by_user_id(self):
def test_sync_scopes_writeback_by_enrollment_id(self):
unsynced = [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]

with patch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") as build, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") as t:
patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
service = MagicMock()
service.events.return_value.insert.return_value.execute.return_value = {"id": "evt_1"}
build.return_value = service
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value = None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect = [unsynced, []]
r = client.post("/api/calendar/sync", json={"user_id": OWNER})

assert r.status_code == 200
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert the guarded SELECT is enrollment-scoped too.

These cases only verify the update/delete filters. Because the mocked select always returns the owned row here, the tests still pass if the PATCH/DELETE ownership check regresses to filters={"id": ...} or if SYNC stops filtering the unsynced read by enrollment_id. Please assert the relevant select call kwargs carry the same membership guard.

Suggested assertions
 assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert r.status_code == 200
+ first_select_filters = t.return_value.select.call_args_list[0].kwargs["filters"]+ assert "enrollment_id" in first_select_filters+ assert ENROLLMENT_ID in first_select_filters["enrollment_id"]
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_user_id(self):
withpatch("routes.calendar.table") ast:
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assertdelete_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_user_id(self):
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast:
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
first_select_filters=t.return_value.select.call_args_list[0].kwargs["filters"]
assert"enrollment_id"infirst_select_filters
assertENROLLMENT_IDinfirst_select_filters["enrollment_id"]
update_filters=t.return_value.update.call_args.kwargs["filters"]
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_sibling_write_scoping.py` around lines 22 - 77,
The tests only verify write filters, but they should also cover the guarded read
path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500) - #283

Merged
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire
Jun 28, 2026
Merged

fix(calendar): rewire assignments to the enrollment-keyed schema (dashboard 500)#283
AndresL230 merged 8 commits into
mainfrom
feat/calendar-assignments-enrollment-rewire

Conversation

@AndresL230

@AndresL230AndresL230 commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

What & why

After the DB modular redesign, migration 0021_gradebook.sql did DROP TABLE assignments CASCADE and recreated assignmentskeyed on enrollment_id (no user_id/course_id/courses relationship). routes/calendar.py + services/calendar_service.py still spoke the old schema, so every /api/calendar/* call returned PostgREST 400 → 500, which tanked the staging dashboard (its Promise.all fails on the one bad endpoint). The migration itself had flagged this rewire as deferred ("See issues filed for the code rewire").

Reproduced against the live staging DB for the real user: only /api/calendar/upcoming 500'd; all other dashboard domains were already migrated and healthy.

Approach

Mirror the already-migrated gradebook.py helpers (no fragile nested PostgREST embeds). Assignments are always course-tied and key on enrollment_id; a small resolver in services/academics.py bridges (user, abstract course) → enrollment_id. No schema migration. HTTP request/response shapes are unchanged (frontend untouched).

Design spec: docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md
Plan: docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md

Changes (6 TDD commits)

  • services/academics.pyenrollment_id_for(user, course_id, *, create=False) + user_enrollment_ids(user).
  • Read (get_upcoming/get_all/suggest_study_blocks) — fetch the user's enrollments → assignments WHERE enrollment_id IN (...), decorate with abstract course_id/course_code/course_name; no enrollments → {"assignments": []} (the dashboard unblock).
  • Write (/save, calendar_service.insert_new_assignments, syllabus saves in documents.py) — resolve course_id → enrollment_id (create-if-missing), tag source (manual/syllabus), dedup across the enrollment set, encrypt notes exactly once.
  • Ownership scoping (update/delete/sync/export) — enrollment_id IN (caller's enrollments) on both the pre-check and the write (preserves IDOR guarantee [P0] calendar.export_to_google cross-user IDOR leaks decrypted private notes #123).
  • Migrated the calendar/assignment tests to the new schema.

Verification

  • Full backend suite: 803 passed, 1 skipped, 0 failed.
  • Live staging read-path check: _read_assignments resolves against the enrollment-keyed schema (0 rows, no 400).
  • Final whole-branch review: ready to merge — IDOR scoping, encrypt-once, and spec coverage independently verified; migrated dedup/encryption tests now genuinely exercised (were vacuously green before).

Behavior notes / follow-ups

  • Manual /save now requires a course_id — an empty one is silently skipped (spec Decision 1: assignments are always course-tied). Frontend must always send course_id. Consider a 400 instead of a silent drop as a follow-up.
  • Latent (not production):process_and_save_syllabus (OCR-pipeline helper, only invoked by an opt-in live-DB test, no mounted route) feeds assignments without course_id and would save 0 — file a ticket if it's ever wired to a route.
  • Minor cleanups deferred: _read_assignments selects unused source; sync/export call user_enrollment_ids twice; a couple of unused test helpers.

Deploys to staging when merged to main (Railway redeploys the backend). Independent of the frontend proxy/SESSION_SECRET fixes already applied to staging.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Calendar assignments now load, save, edit, delete, and sync more reliably across enrolled courses.
    • Assignment visibility and updates are now correctly limited to the right course membership, reducing accidental cross-course access.
    • Google Calendar export/sync now handles unsynced items more consistently.
    • Assignment notes are stored and restored securely, and syllabus-imported assignments are tagged consistently.

AndresL230and others added 8 commits June 28, 2026 01:23
The calendar route + calendar_service still query the pre-redesign assignments
table (user_id/course_id/courses!left); 0021 re-keyed assignments on
enrollment_id, so every call 400s -> 500 and tanks the dashboard. Spec rewires
the calendar domain to resolve course -> enrollment (mirroring gradebook.py),
keeping the HTTP shapes stable. Decisions: assignments are always course-tied
(no migration), writes auto-create the enrollment, full-domain scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewires sync_to_google and export_to_google onto the enrollment-keyed
schema: select/write-back scoped by enrollment_id membership instead of
the removed user_id column; drops courses!left embed in favour of
_course_meta_cached. Updates test_calendar_export_idor.py and
test_calendar_sibling_write_scoping.py to assert the new enrollment_id
boundary (same IDOR guarantee, new key).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment schema
- routes/documents.py: pass source="syllabus" at both save_assignments_to_db
call sites (_save_orchestrator_syllabus and the legacy call_gemini_json path)
- tests/test_calendar_routes.py: rewire TestSaveAssignments to include course_id
in fixtures and mock enrollment_id_for/user_enrollment_ids; rewire
TestGetUpcoming.test_returns_assignments_from_db to the enrollment-keyed row
shape (enrollment_id, no user_id/course_id/courses columns); add _tbl helper
- tests/test_assignment_notes_encryption.py: supply course_id to test fixtures
and mock academics so insert_new_assignments reaches the encryption boundary
- tests/test_documents_routes.py: update assert_called_once_with to include
source='syllabus' to match the new tagged call
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

All calendar/assignment backend routes, the calendar service, and the academics service are updated to use enrollment_id membership instead of user_id/course_id for scoping reads, writes, ownership checks, Google sync, and export. Two new enrollment resolver helpers are added to academics.py. Note encryption moves from route handlers into the service layer. Syllabus save calls are tagged with source="syllabus". Tests are updated or added throughout.

Changes

Calendar Assignments Enrollment-keyed Rewire

Layer / File(s)Summary
Enrollment resolver helpers
backend/services/academics.py, backend/tests/test_academics_enrollment_resolver.py
user_enrollment_ids returns enrollment rows for a user; enrollment_id_for resolves or creates an enrollment for a given course, preferring the current-term offering. Unit tests cover found, create, and not-found cases.
calendar_service insert/dedupe keyed by enrollment_id
backend/services/calendar_service.py, backend/tests/test_calendar_write_enrollment.py, backend/tests/test_assignment_notes_encryption.py
load_existing_assignment_keys now dedupes across the user's enrollment set; insert_new_assignments resolves course_idenrollment_id, skips unresolvable assignments, writes enrollment-keyed rows with encrypted notes and an explicit source. New write and encryption tests verify insert shape, deduplication, and None-notes handling.
Calendar read path: _read_assignments and read endpoints
backend/routes/calendar.py, backend/tests/test_calendar_read_enrollment.py
Adds _course_meta_cached, _owned_enrollment_ids, and _read_assignments helpers; /upcoming, /all, and /suggest-study-blocks all route through _read_assignments for enrollment-scoped, decrypted, course-decorated results.
Calendar write path: /save, update, and delete
backend/routes/calendar.py
/save drops in-route note encryption. PATCH /assignments/{id} and DELETE /assignments/{id} replace user_id ownership checks with enrollment_id IN (...) and remove course_id from the patch whitelist.
Google sync and export enrollment scoping
backend/routes/calendar.py
/sync and /export replace user_id-scoped queries and write-backs with enrollment_id IN (...), remove courses!left joins, and derive course labels via _course_meta_cached.
Syllabus save source="syllabus" tagging
backend/routes/documents.py, backend/tests/test_documents_routes.py
Both orchestrator and legacy syllabus persistence paths pass source="syllabus" to save_assignments_to_db; the route test asserts the keyword argument.
Security and scoping tests
backend/tests/test_calendar_export_idor.py, backend/tests/test_calendar_sibling_write_scoping.py, backend/tests/test_calendar_scoping_enrollment.py, backend/tests/test_calendar_sync_export_enrollment.py
IDOR export regression, sibling write scoping, and new enrollment-scoped update/export tests all assert enrollment_id membership filters on read, write, and delete operations instead of user_id.
Existing route tests updated to enrollment schema
backend/tests/test_calendar_routes.py
Save, upcoming, study-blocks, update, and delete tests are updated with expanded academics mocks, enrollment_id-keyed DB row shapes, course_id in payloads, and a blocked course_id patch assertion.
Design spec and implementation plan
docs/superpowers/specs/..., docs/superpowers/plans/...
New design spec and phased implementation plan documenting the full enrollment-rewire scope, decisions, and verification checklist.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • SaplingLearn/Sapling#53: Also modifies backend/routes/calendar.py to populate course_code/course_name on assignment records, directly overlapping with this PR's course metadata decoration logic.
  • SaplingLearn/Sapling#65: Modifies calendar assignment notes encryption/decryption handling in the same route and service files, overlapping with this PR's move of encryption into insert_new_assignments.
  • SaplingLearn/Sapling#235: Tightens IDOR ownership scoping on the same PATCH/DELETE/sync write filters—this PR supersedes that by shifting the scoping key from user_id to enrollment_id.

Poem

🐇 Hopping through the enrollment rows,
No more user_id wherever code goes!
enrollment_id guards each patch and delete,
Notes are encrypted, the schema's complete.
This bunny rewired it all — how neat! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 8.82% 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 names the calendar assignment rewire and matches the schema migration fix.
Description check✅ PassedIt covers the problem, approach, changes, verification, and follow-ups, though it doesn't match the template headings exactly.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/calendar-assignments-enrollment-rewire

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.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8a38cfeCommit Preview URL

Branch Preview URL
Jun 28 2026, 08:38 PM

@AndresL230
AndresL230 merged commit 8192447 into mainJun 28, 2026
5 of 6 checks passed

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md (1)

57-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add language identifier to fenced code block.

The fenced code block showing enrollment_id_for signature lacks a language label. Add python for syntax highlighting and to satisfy linting.

+```python
enrollment_id_for(user_id, course_id, *, create=False) -> str | None

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`
around lines 57 - 60, The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
backend/tests/test_calendar_routes.py (1)

16-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the reusable table mock into tests/conftest.py.

_tbl is now duplicated across backend/tests/test_calendar_routes.py, backend/tests/test_calendar_read_enrollment.py, and backend/tests/test_calendar_scoping_enrollment.py, so any change to the fake table contract has to be kept in sync by hand. As per coding guidelines, "shared fixtures such as mock Supabase and mock Gemini belong in tests/conftest.py."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_routes.py` around lines 16 - 21, The reusable
table mock helper `_tbl` is duplicated across multiple calendar tests, so move
it into `tests/conftest.py` as a shared fixture/helper and update
`test_calendar_routes`, `test_calendar_read_enrollment`, and
`test_calendar_scoping_enrollment` to import/use the common version. Keep the
existing `MagicMock` table contract and preserve the per-verb return-value
behavior so all tests share one source of truth.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/academics.py`:
- Around line 159-181: The enrollment lookup in the helper that uses
user_offering_ids_for_course, current_term, and resolve_offering should not fall
back to an arbitrary historical offering when create=True. Change the selection
logic so it only reuses an existing enrollment if it matches the current term,
and otherwise let the code continue into the creation path; keep the existing
enrollment return path only for the current-term match. This ensures the branch
in backend/services/academics.py provisioned by create=True does not return a
random old enrollment.
In `@backend/tests/test_academics_enrollment_resolver.py`:
- Around line 27-36: The existing test only exercises the single-enrollment
fallback and never hits the current-term preference path. Update
test_existing_enrollment_current_term in the academics enrollment resolver tests
to use multiple course offerings and a non-None current_term so
ac.enrollment_id_for actually has to choose between enrollments. Make the setup
in services.academics.user_offering_ids_for_course and
services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.
In `@backend/tests/test_calendar_sibling_write_scoping.py`:
- Around line 22-77: The tests only verify write filters, but they should also
cover the guarded read path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.
---
Nitpick comments:
In `@backend/tests/test_calendar_routes.py`:
- Around line 16-21: The reusable table mock helper `_tbl` is duplicated across
multiple calendar tests, so move it into `tests/conftest.py` as a shared
fixture/helper and update `test_calendar_routes`,
`test_calendar_read_enrollment`, and `test_calendar_scoping_enrollment` to
import/use the common version. Keep the existing `MagicMock` table contract and
preserve the per-verb return-value behavior so all tests share one source of
truth.
In
`@docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md`:
- Around line 57-60: The fenced code block for the enrollment_id_for signature
is missing a language identifier, which triggers linting. Update the code fence
in the design doc to use python for the signature shown near enrollment_id_for
so it is properly highlighted and passes the docs check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22a175b3-0b0b-4e62-bcf5-51940fdcfee8

📥 Commits

Reviewing files that changed from the base of the PR and between e6aeb5f and 8a38cfe.

📒 Files selected for processing (16)
  • backend/routes/calendar.py
  • backend/routes/documents.py
  • backend/services/academics.py
  • backend/services/calendar_service.py
  • backend/tests/test_academics_enrollment_resolver.py
  • backend/tests/test_assignment_notes_encryption.py
  • backend/tests/test_calendar_export_idor.py
  • backend/tests/test_calendar_read_enrollment.py
  • backend/tests/test_calendar_routes.py
  • backend/tests/test_calendar_scoping_enrollment.py
  • backend/tests/test_calendar_sibling_write_scoping.py
  • backend/tests/test_calendar_sync_export_enrollment.py
  • backend/tests/test_calendar_write_enrollment.py
  • backend/tests/test_documents_routes.py
  • docs/superpowers/plans/2026-06-28-calendar-assignments-enrollment-rewire.md
  • docs/superpowers/specs/2026-06-28-calendar-assignments-enrollment-rewire-design.md

Comment on lines +159 to +181
offering_ids = user_offering_ids_for_course(user_id, course_id)
if offering_ids:
chosen = offering_ids[0]
cur = current_term()
cur_id = cur["id"] if cur else None
if cur_id:
for oid in offering_ids:
t = term_for_offering(oid)
if t and t.get("id") == cur_id:
chosen = oid
break
rows = table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
if rows:
return rows[0]["id"]

if not create:
return None

offering_id = resolve_offering(course_id, create=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't reuse an arbitrary historical enrollment when create=True.

If the user already has past enrollments for the course but none in current_term(), this branch falls back to offering_ids[0] and returns that enrollment instead of reaching the create path. Because user_offering_ids_for_course() does not order its rows, new assignment writes can land on a random old enrollment rather than the current-term enrollment this helper is meant to provision.

Suggested fix
- offering_ids = user_offering_ids_for_course(user_id, course_id)- if offering_ids:- chosen = offering_ids[0]- cur = current_term()- cur_id = cur["id"] if cur else None- if cur_id:- for oid in offering_ids:- t = term_for_offering(oid)- if t and t.get("id") == cur_id:- chosen = oid- break- rows = table("enrollments").select(- "id",- filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},- limit=1,- )- if rows:- return rows[0]["id"]+ offering_ids = user_offering_ids_for_course(user_id, course_id)+ if offering_ids:+ cur = current_term()+ cur_id = cur["id"] if cur else None+ chosen = None+ if cur_id:+ for oid in offering_ids:+ t = term_for_offering(oid)+ if t and t.get("id") == cur_id:+ chosen = oid+ break+ elif len(offering_ids) == 1 and not create:+ chosen = offering_ids[0]++ if chosen:+ rows = table("enrollments").select(+ "id",+ filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},+ limit=1,+ )+ if rows:+ return rows[0]["id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
chosen=offering_ids[0]
cur=current_term()
cur_id=cur["id"] ifcurelseNone
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
offering_ids=user_offering_ids_for_course(user_id, course_id)
ifoffering_ids:
cur=current_term()
cur_id=cur["id"] ifcurelseNone
chosen=None
ifcur_id:
foroidinoffering_ids:
t=term_for_offering(oid)
iftandt.get("id") ==cur_id:
chosen=oid
break
eliflen(offering_ids) ==1andnotcreate:
chosen=offering_ids[0]
ifchosen:
rows=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{chosen}"},
limit=1,
)
ifrows:
returnrows[0]["id"]
ifnotcreate:
returnNone
offering_id=resolve_offering(course_id, create=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/academics.py` around lines 159 - 181, The enrollment lookup
in the helper that uses user_offering_ids_for_course, current_term, and
resolve_offering should not fall back to an arbitrary historical offering when
create=True. Change the selection logic so it only reuses an existing enrollment
if it matches the current term, and otherwise let the code continue into the
creation path; keep the existing enrollment return path only for the
current-term match. This ensures the branch in backend/services/academics.py
provisioned by create=True does not return a random old enrollment.

Comment on lines +27 to +36
def test_existing_enrollment_current_term(self):
# user_offering_ids_for_course -> ["o1"]; term match; enrollment e1
tables = {
"course_offerings": _tbl(select=[{"id": "o1"}]),
"enrollments": _tbl(select=[{"id": "e1"}]),
}
with patch("services.academics.table", side_effect=_dispatch(tables)), \
patch("services.academics.user_offering_ids_for_course", return_value=["o1"]), \
patch("services.academics.current_term", return_value=None):
assert ac.enrollment_id_for("user_andres", "CS101") == "e1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This test never reaches the current-term preference branch.

current_term is mocked to None and there is only one offering, so the resolver returns the lone enrollment without evaluating any term match. Please make this a multi-offering case with a real current term so the new "prefer current-term enrollment" logic is actually covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_academics_enrollment_resolver.py` around lines 27 - 36,
The existing test only exercises the single-enrollment fallback and never hits
the current-term preference path. Update test_existing_enrollment_current_term
in the academics enrollment resolver tests to use multiple course offerings and
a non-None current_term so ac.enrollment_id_for actually has to choose between
enrollments. Make the setup in services.academics.user_offering_ids_for_course
and services.academics.current_term align with the new branch, and assert the
selected enrollment comes from the current term.

Comment on lines +22 to +77
def test_update_scopes_write_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}] # owner's row exists
r = client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assert r.status_code == 200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert update_filters.get("id") == f"eq.{AID}"

def test_delete_scopes_delete_by_user_id(self):
with patch("routes.calendar.table") as t:
def test_delete_scopes_delete_by_enrollment_id(self):
with patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value = [{"id": AID}]
r = client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assert r.status_code == 200
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert delete_filters.get("user_id") == f"eq.{OWNER}"
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert delete_filters.get("id") == f"eq.{AID}"

def test_sync_scopes_writeback_by_user_id(self):
def test_sync_scopes_writeback_by_enrollment_id(self):
unsynced = [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]

with patch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") as build, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") as t:
patch("routes.calendar.table") as t, \
patch("routes.calendar.academics") as ac:
service = MagicMock()
service.events.return_value.insert.return_value.execute.return_value = {"id": "evt_1"}
build.return_value = service
ac.user_enrollment_ids.return_value = [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value = None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect = [unsynced, []]
r = client.post("/api/calendar/sync", json={"user_id": OWNER})

assert r.status_code == 200
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert update_filters.get("user_id") == f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert the guarded SELECT is enrollment-scoped too.

These cases only verify the update/delete filters. Because the mocked select always returns the owned row here, the tests still pass if the PATCH/DELETE ownership check regresses to filters={"id": ...} or if SYNC stops filtering the unsynced read by enrollment_id. Please assert the relevant select call kwargs carry the same membership guard.

Suggested assertions
 assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
assert r.status_code == 200
+ select_filters = t.return_value.select.call_args.kwargs["filters"]+ assert "enrollment_id" in select_filters+ assert ENROLLMENT_ID in select_filters["enrollment_id"]+ assert select_filters.get("id") == f"eq.{AID}"
delete_filters = t.return_value.delete.call_args.kwargs["filters"]
assert "enrollment_id" in delete_filters
assert ENROLLMENT_ID in delete_filters["enrollment_id"]
assert r.status_code == 200
+ first_select_filters = t.return_value.select.call_args_list[0].kwargs["filters"]+ assert "enrollment_id" in first_select_filters+ assert ENROLLMENT_ID in first_select_filters["enrollment_id"]
update_filters = t.return_value.update.call_args.kwargs["filters"]
assert "enrollment_id" in update_filters
assert ENROLLMENT_ID in update_filters["enrollment_id"]
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
# The UPDATE filter must include user_id, not just id.
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_user_id(self):
withpatch("routes.calendar.table") ast:
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assertdelete_filters.get("user_id") ==f"eq.{OWNER}"
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_user_id(self):
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "title": "HW", "due_date": "2026-03-01",
"notes": None, "google_event_id": None, "courses": {},
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast:
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
update_filters=t.return_value.update.call_args.kwargs["filters"]
assertupdate_filters.get("user_id") ==f"eq.{OWNER}"
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
deftest_update_scopes_write_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}] # owner's row exists
r=client.patch(
f"/api/calendar/assignments/{AID}",
json={"user_id": OWNER, "title": "New title"},
)
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
# The UPDATE filter must scope by enrollment_id, not user_id.
update_filters=t.return_value.update.call_args.kwargs["filters"]
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
assertupdate_filters.get("id") ==f"eq.{AID}"
deftest_delete_scopes_delete_by_enrollment_id(self):
withpatch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
t.return_value.select.return_value= [{"id": AID}]
r=client.delete(f"/api/calendar/assignments/{AID}?user_id={OWNER}")
assertr.status_code==200
select_filters=t.return_value.select.call_args.kwargs["filters"]
assert"enrollment_id"inselect_filters
assertENROLLMENT_IDinselect_filters["enrollment_id"]
assertselect_filters.get("id") ==f"eq.{AID}"
delete_filters=t.return_value.delete.call_args.kwargs["filters"]
assert"enrollment_id"indelete_filters
assertENROLLMENT_IDindelete_filters["enrollment_id"]
assertdelete_filters.get("id") ==f"eq.{AID}"
deftest_sync_scopes_writeback_by_enrollment_id(self):
unsynced= [{
"id": AID, "enrollment_id": ENROLLMENT_ID, "title": "HW",
"due_date": "2026-03-01", "notes": None, "google_event_id": None,
}]
withpatch("routes.calendar._require_google_creds", return_value=MagicMock()), \
patch("routes.calendar.build") asbuild, \
patch("routes.calendar.decrypt_if_present", return_value=""), \
patch("routes.calendar.table") ast, \
patch("routes.calendar.academics") asac:
service=MagicMock()
service.events.return_value.insert.return_value.execute.return_value= {"id": "evt_1"}
build.return_value=service
ac.user_enrollment_ids.return_value= [{"id": ENROLLMENT_ID, "offering_id": "o1"}]
# offering_course_id returns None so _course_meta_cached skips the
# courses table select (keeps select side_effect list simple).
ac.offering_course_id.return_value=None
# select returns the unsynced row on the first call, [] thereafter.
t.return_value.select.side_effect= [unsynced, []]
r=client.post("/api/calendar/sync", json={"user_id": OWNER})
assertr.status_code==200
first_select_filters=t.return_value.select.call_args_list[0].kwargs["filters"]
assert"enrollment_id"infirst_select_filters
assertENROLLMENT_IDinfirst_select_filters["enrollment_id"]
update_filters=t.return_value.update.call_args.kwargs["filters"]
# Write-back must scope by enrollment_id (not user_id, which no longer
# exists on the assignments table) — same IDOR guarantee, new key.
assert"enrollment_id"inupdate_filters
assertENROLLMENT_IDinupdate_filters["enrollment_id"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_calendar_sibling_write_scoping.py` around lines 22 - 77,
The tests only verify write filters, but they should also cover the guarded read
path in the calendar routes. Update the assertions in
test_update_scopes_write_by_enrollment_id,
test_delete_scopes_delete_by_enrollment_id, and
test_sync_scopes_writeback_by_enrollment_id to inspect the relevant table.select
call kwargs and confirm the same enrollment_id membership guard is used before
the write/delete. Use the existing routes.calendar.table and
routes.calendar.academics mocks to locate the select/filter setup and assert it
matches the write-scoping behavior.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230