Merged
215 changes: 115 additions & 100 deletions backend/routes/calendar.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
)
from db.connection import table
from models import SaveAssignmentsBody, StudyBlockBody, ExportBody, SyncBody
from services import academics
from services.auth_guard import require_self, get_session_user_id
from services.calendar_service import extract_assignments_from_file, insert_new_assignments
from services.encryption import encrypt, encrypt_if_present, decrypt, decrypt_if_present
Expand DownExpand Up@@ -87,6 +88,62 @@ def _require_google_creds(user_id: str) -> "Credentials":
return _get_refreshed_credentials(token_rows[0])


def _course_meta_cached(offering_id, cache):
if not offering_id:
return {}
if offering_id not in cache:
course_id = academics.offering_course_id(offering_id)
course = {}
if course_id:
rows = table("courses").select(
"id,course_code,course_name",
filters={"id": f"eq.{course_id}"}, limit=1,
)
course = rows[0] if rows else {}
cache[offering_id] = {
"course_id": course_id,
"course_code": course.get("course_code"),
"course_name": course.get("course_name"),
}
return cache[offering_id]


def _owned_enrollment_ids(user_id) -> set:
return {e["id"] for e in academics.user_enrollment_ids(user_id)}


def _read_assignments(user_id, *, due_gte=None, limit=None):
enrollments = academics.user_enrollment_ids(user_id)
if not enrollments:
return []
offering_by_enrollment = {e["id"]: e.get("offering_id") for e in enrollments}
ids = ",".join(offering_by_enrollment.keys())
filters = {"enrollment_id": f"in.({ids})"}
if due_gte:
filters["due_date"] = f"gte.{due_gte}"
rows = table("assignments").select(
"id,enrollment_id,title,due_date,assignment_type,notes,google_event_id,source",
filters=filters, order="due_date.asc", limit=limit,
)
cache = {}
out = []
for r in rows:
meta = _course_meta_cached(offering_by_enrollment.get(r.get("enrollment_id")), cache)
out.append({
"id": r["id"],
"user_id": user_id,
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": meta.get("course_id"),
"course_code": meta.get("course_code") or "",
"course_name": meta.get("course_name") or "",
})
return out


# ── Syllabus extraction ───────────────────────────────────────────────────────

@router.post("/extract")
Expand DownExpand Up@@ -125,67 +182,26 @@ def save_assignments(body: SaveAssignmentsBody, request: FastAPIRequest):
"course_id": a.course_id,
"due_date": a.due_date,
"assignment_type": a.assignment_type,
"notes": encrypt_if_present(a.notes),
"notes": a.notes, # raw; insert_new_assignments encrypts
}
for a in body.assignments
]
saved = insert_new_assignments(body.user_id, payload)
saved = insert_new_assignments(body.user_id, payload, source="manual")
return {"saved_count": saved}


@router.get("/upcoming/{user_id}")
def get_upcoming(user_id: str, request: FastAPIRequest):
require_self(user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
limit=20,
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id, due_gte=today, limit=20)}


@router.get("/all/{user_id}")
def get_all_assignments(user_id: str, request: FastAPIRequest):
"""Return all assignments for a user (past and future) for the calendar view."""
require_self(user_id, request)
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}"},
order="due_date.asc",
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id)}


@router.patch("/assignments/{assignment_id}")
Expand All@@ -195,40 +211,42 @@ def update_assignment(assignment_id: str, body: dict, request: FastAPIRequest):
raise HTTPException(status_code=400, detail="user_id is required")
require_self(user_id, request)

owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")

# Whitelist non-sensitive fields. `notes` is excluded; edit notes via /save flow.
ALLOWED = {"title", "course_id", "due_date", "assignment_type"}
ALLOWED = {"title", "due_date", "assignment_type"} # course_id no longer settable here
patch = {k: v for k, v in body.items() if k in ALLOWED}
if not patch:
return {"updated": False}

if "course_id" in patch and patch["course_id"] == "":
patch["course_id"] = None

# Scope the write by user_id too (defense in depth): the scoped SELECT above
# already 404s a non-owned id, but don't rely on that guard alone (#123).
table("assignments").update(
patch, filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
patch, filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"updated": True}


@router.delete("/assignments/{assignment_id}")
def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str = Query(...)):
require_self(user_id, request)
owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")
# Scope the delete by user_id too (defense in depth), not just the guard above.
table("assignments").delete(
filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"deleted": True}

Expand All@@ -237,16 +255,11 @@ def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str
def suggest_study_blocks(body: StudyBlockBody, request: FastAPIRequest):
require_self(body.user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
assignments = table("assignments").select(
"id,title,due_date,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{body.user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
)
assignments = _read_assignments(body.user_id, due_gte=today)
blocks = []
for a in assignments:
course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
cc = course.get("course_code") or ""
cn = course.get("course_name") or ""
cc = a.get("course_code") or ""
cn = a.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
blocks.append({
"topic": f"{course_label}{a['title']}" if course_label else a["title"],
Expand DownExpand Up@@ -330,43 +343,39 @@ def sync_to_google(body: SyncBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
if not owned:
return {"synced_count": 0}
in_clause = f"in.({','.join(owned)})"
unsynced = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "is.null",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "is.null"},
)
# Also catch empty-string google_event_id
unsynced += table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "eq.",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "eq."},
)

enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
cache = {}
synced = 0
for a in unsynced:
if not a.get("due_date"):
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")

meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
"description": decrypt_if_present(a.get("notes")) or "",
"start": {"date": a["due_date"]},
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth), matching export.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{a['id']}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{a['id']}", "enrollment_id": in_clause},
)
synced += 1

Expand All@@ -381,18 +390,24 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
in_clause = f"in.({','.join(owned)})" if owned else "in.()"
cache = {}

exported = 0
skipped = 0
for aid in body.assignment_ids:
# #123: scope by user_id, not just id. Without this an authenticated
# caller could pass another user's assignment UUIDs to read+decrypt
# their private notes, push them into the caller's calendar, and stamp
# google_event_id onto the victim's row. Every sibling endpoint
# (update/delete/sync) already scopes by user_id; a non-owned id now
# returns no row and is skipped.
# #123: scope by enrollment_id membership, not just id. Without this an
# authenticated caller could pass another user's assignment UUIDs to
# read+decrypt their private notes, push them into the caller's calendar,
# and stamp google_event_id onto the victim's row. Scoping to the caller's
# own enrollment ids means a non-owned id returns no row and is skipped.
if not owned:
continue
rows = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
if not rows:
continue
Expand All@@ -402,10 +417,10 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
skipped += 1
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")
meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")

event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
Expand All@@ -414,11 +429,11 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth): never stamp
# google_event_id onto a row the caller doesn't own.
# Scope the write-back by enrollment_id membership (defense in depth):
# never stamp google_event_id onto a row the caller doesn't own.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
exported += 1

Expand Down
4 changes: 2 additions & 2 deletions backend/routes/documents.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,7 +472,7 @@ def _save_orchestrator_syllabus(*, user_id: str, course_id: str, filename: str,
})
if legacy:
try:
save_assignments_to_db(user_id, legacy)
save_assignments_to_db(user_id, legacy, source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand DownExpand Up@@ -985,7 +985,7 @@ async def _legacy_upload_pipeline(
try:
for a in ai["assignments"]:
a["course_id"] = course_id
save_assignments_to_db(user_id, ai["assignments"])
save_assignments_to_db(user_id, ai["assignments"], source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
215 changes: 115 additions & 100 deletions backend/routes/calendar.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
)
from db.connection import table
from models import SaveAssignmentsBody, StudyBlockBody, ExportBody, SyncBody
from services import academics
from services.auth_guard import require_self, get_session_user_id
from services.calendar_service import extract_assignments_from_file, insert_new_assignments
from services.encryption import encrypt, encrypt_if_present, decrypt, decrypt_if_present
Expand DownExpand Up@@ -87,6 +88,62 @@ def _require_google_creds(user_id: str) -> "Credentials":
return _get_refreshed_credentials(token_rows[0])


def _course_meta_cached(offering_id, cache):
if not offering_id:
return {}
if offering_id not in cache:
course_id = academics.offering_course_id(offering_id)
course = {}
if course_id:
rows = table("courses").select(
"id,course_code,course_name",
filters={"id": f"eq.{course_id}"}, limit=1,
)
course = rows[0] if rows else {}
cache[offering_id] = {
"course_id": course_id,
"course_code": course.get("course_code"),
"course_name": course.get("course_name"),
}
return cache[offering_id]


def _owned_enrollment_ids(user_id) -> set:
return {e["id"] for e in academics.user_enrollment_ids(user_id)}


def _read_assignments(user_id, *, due_gte=None, limit=None):
enrollments = academics.user_enrollment_ids(user_id)
if not enrollments:
return []
offering_by_enrollment = {e["id"]: e.get("offering_id") for e in enrollments}
ids = ",".join(offering_by_enrollment.keys())
filters = {"enrollment_id": f"in.({ids})"}
if due_gte:
filters["due_date"] = f"gte.{due_gte}"
rows = table("assignments").select(
"id,enrollment_id,title,due_date,assignment_type,notes,google_event_id,source",
filters=filters, order="due_date.asc", limit=limit,
)
cache = {}
out = []
for r in rows:
meta = _course_meta_cached(offering_by_enrollment.get(r.get("enrollment_id")), cache)
out.append({
"id": r["id"],
"user_id": user_id,
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": meta.get("course_id"),
"course_code": meta.get("course_code") or "",
"course_name": meta.get("course_name") or "",
})
return out


# ── Syllabus extraction ───────────────────────────────────────────────────────

@router.post("/extract")
Expand DownExpand Up@@ -125,67 +182,26 @@ def save_assignments(body: SaveAssignmentsBody, request: FastAPIRequest):
"course_id": a.course_id,
"due_date": a.due_date,
"assignment_type": a.assignment_type,
"notes": encrypt_if_present(a.notes),
"notes": a.notes, # raw; insert_new_assignments encrypts
}
for a in body.assignments
]
saved = insert_new_assignments(body.user_id, payload)
saved = insert_new_assignments(body.user_id, payload, source="manual")
return {"saved_count": saved}


@router.get("/upcoming/{user_id}")
def get_upcoming(user_id: str, request: FastAPIRequest):
require_self(user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
limit=20,
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id, due_gte=today, limit=20)}


@router.get("/all/{user_id}")
def get_all_assignments(user_id: str, request: FastAPIRequest):
"""Return all assignments for a user (past and future) for the calendar view."""
require_self(user_id, request)
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}"},
order="due_date.asc",
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id)}


@router.patch("/assignments/{assignment_id}")
Expand All@@ -195,40 +211,42 @@ def update_assignment(assignment_id: str, body: dict, request: FastAPIRequest):
raise HTTPException(status_code=400, detail="user_id is required")
require_self(user_id, request)

owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")

# Whitelist non-sensitive fields. `notes` is excluded; edit notes via /save flow.
ALLOWED = {"title", "course_id", "due_date", "assignment_type"}
ALLOWED = {"title", "due_date", "assignment_type"} # course_id no longer settable here
patch = {k: v for k, v in body.items() if k in ALLOWED}
if not patch:
return {"updated": False}

if "course_id" in patch and patch["course_id"] == "":
patch["course_id"] = None

# Scope the write by user_id too (defense in depth): the scoped SELECT above
# already 404s a non-owned id, but don't rely on that guard alone (#123).
table("assignments").update(
patch, filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
patch, filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"updated": True}


@router.delete("/assignments/{assignment_id}")
def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str = Query(...)):
require_self(user_id, request)
owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")
# Scope the delete by user_id too (defense in depth), not just the guard above.
table("assignments").delete(
filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"deleted": True}

Expand All@@ -237,16 +255,11 @@ def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str
def suggest_study_blocks(body: StudyBlockBody, request: FastAPIRequest):
require_self(body.user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
assignments = table("assignments").select(
"id,title,due_date,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{body.user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
)
assignments = _read_assignments(body.user_id, due_gte=today)
blocks = []
for a in assignments:
course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
cc = course.get("course_code") or ""
cn = course.get("course_name") or ""
cc = a.get("course_code") or ""
cn = a.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
blocks.append({
"topic": f"{course_label}{a['title']}" if course_label else a["title"],
Expand DownExpand Up@@ -330,43 +343,39 @@ def sync_to_google(body: SyncBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
if not owned:
return {"synced_count": 0}
in_clause = f"in.({','.join(owned)})"
unsynced = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "is.null",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "is.null"},
)
# Also catch empty-string google_event_id
unsynced += table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "eq.",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "eq."},
)

enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
cache = {}
synced = 0
for a in unsynced:
if not a.get("due_date"):
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")

meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
"description": decrypt_if_present(a.get("notes")) or "",
"start": {"date": a["due_date"]},
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth), matching export.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{a['id']}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{a['id']}", "enrollment_id": in_clause},
)
synced += 1

Expand All@@ -381,18 +390,24 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
in_clause = f"in.({','.join(owned)})" if owned else "in.()"
cache = {}

exported = 0
skipped = 0
for aid in body.assignment_ids:
# #123: scope by user_id, not just id. Without this an authenticated
# caller could pass another user's assignment UUIDs to read+decrypt
# their private notes, push them into the caller's calendar, and stamp
# google_event_id onto the victim's row. Every sibling endpoint
# (update/delete/sync) already scopes by user_id; a non-owned id now
# returns no row and is skipped.
# #123: scope by enrollment_id membership, not just id. Without this an
# authenticated caller could pass another user's assignment UUIDs to
# read+decrypt their private notes, push them into the caller's calendar,
# and stamp google_event_id onto the victim's row. Scoping to the caller's
# own enrollment ids means a non-owned id returns no row and is skipped.
if not owned:
continue
rows = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
if not rows:
continue
Expand All@@ -402,10 +417,10 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
skipped += 1
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")
meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")

event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
Expand All@@ -414,11 +429,11 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth): never stamp
# google_event_id onto a row the caller doesn't own.
# Scope the write-back by enrollment_id membership (defense in depth):
# never stamp google_event_id onto a row the caller doesn't own.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
exported += 1

Expand Down
4 changes: 2 additions & 2 deletions backend/routes/documents.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,7 +472,7 @@ def _save_orchestrator_syllabus(*, user_id: str, course_id: str, filename: str,
})
if legacy:
try:
save_assignments_to_db(user_id, legacy)
save_assignments_to_db(user_id, legacy, source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand DownExpand Up@@ -985,7 +985,7 @@ async def _legacy_upload_pipeline(
try:
for a in ai["assignments"]:
a["course_id"] = course_id
save_assignments_to_db(user_id, ai["assignments"])
save_assignments_to_db(user_id, ai["assignments"], source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
215 changes: 115 additions & 100 deletions backend/routes/calendar.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
)
from db.connection import table
from models import SaveAssignmentsBody, StudyBlockBody, ExportBody, SyncBody
from services import academics
from services.auth_guard import require_self, get_session_user_id
from services.calendar_service import extract_assignments_from_file, insert_new_assignments
from services.encryption import encrypt, encrypt_if_present, decrypt, decrypt_if_present
Expand DownExpand Up@@ -87,6 +88,62 @@ def _require_google_creds(user_id: str) -> "Credentials":
return _get_refreshed_credentials(token_rows[0])


def _course_meta_cached(offering_id, cache):
if not offering_id:
return {}
if offering_id not in cache:
course_id = academics.offering_course_id(offering_id)
course = {}
if course_id:
rows = table("courses").select(
"id,course_code,course_name",
filters={"id": f"eq.{course_id}"}, limit=1,
)
course = rows[0] if rows else {}
cache[offering_id] = {
"course_id": course_id,
"course_code": course.get("course_code"),
"course_name": course.get("course_name"),
}
return cache[offering_id]


def _owned_enrollment_ids(user_id) -> set:
return {e["id"] for e in academics.user_enrollment_ids(user_id)}


def _read_assignments(user_id, *, due_gte=None, limit=None):
enrollments = academics.user_enrollment_ids(user_id)
if not enrollments:
return []
offering_by_enrollment = {e["id"]: e.get("offering_id") for e in enrollments}
ids = ",".join(offering_by_enrollment.keys())
filters = {"enrollment_id": f"in.({ids})"}
if due_gte:
filters["due_date"] = f"gte.{due_gte}"
rows = table("assignments").select(
"id,enrollment_id,title,due_date,assignment_type,notes,google_event_id,source",
filters=filters, order="due_date.asc", limit=limit,
)
cache = {}
out = []
for r in rows:
meta = _course_meta_cached(offering_by_enrollment.get(r.get("enrollment_id")), cache)
out.append({
"id": r["id"],
"user_id": user_id,
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": meta.get("course_id"),
"course_code": meta.get("course_code") or "",
"course_name": meta.get("course_name") or "",
})
return out


# ── Syllabus extraction ───────────────────────────────────────────────────────

@router.post("/extract")
Expand DownExpand Up@@ -125,67 +182,26 @@ def save_assignments(body: SaveAssignmentsBody, request: FastAPIRequest):
"course_id": a.course_id,
"due_date": a.due_date,
"assignment_type": a.assignment_type,
"notes": encrypt_if_present(a.notes),
"notes": a.notes, # raw; insert_new_assignments encrypts
}
for a in body.assignments
]
saved = insert_new_assignments(body.user_id, payload)
saved = insert_new_assignments(body.user_id, payload, source="manual")
return {"saved_count": saved}


@router.get("/upcoming/{user_id}")
def get_upcoming(user_id: str, request: FastAPIRequest):
require_self(user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
limit=20,
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id, due_gte=today, limit=20)}


@router.get("/all/{user_id}")
def get_all_assignments(user_id: str, request: FastAPIRequest):
"""Return all assignments for a user (past and future) for the calendar view."""
require_self(user_id, request)
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}"},
order="due_date.asc",
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id)}


@router.patch("/assignments/{assignment_id}")
Expand All@@ -195,40 +211,42 @@ def update_assignment(assignment_id: str, body: dict, request: FastAPIRequest):
raise HTTPException(status_code=400, detail="user_id is required")
require_self(user_id, request)

owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")

# Whitelist non-sensitive fields. `notes` is excluded; edit notes via /save flow.
ALLOWED = {"title", "course_id", "due_date", "assignment_type"}
ALLOWED = {"title", "due_date", "assignment_type"} # course_id no longer settable here
patch = {k: v for k, v in body.items() if k in ALLOWED}
if not patch:
return {"updated": False}

if "course_id" in patch and patch["course_id"] == "":
patch["course_id"] = None

# Scope the write by user_id too (defense in depth): the scoped SELECT above
# already 404s a non-owned id, but don't rely on that guard alone (#123).
table("assignments").update(
patch, filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
patch, filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"updated": True}


@router.delete("/assignments/{assignment_id}")
def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str = Query(...)):
require_self(user_id, request)
owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")
# Scope the delete by user_id too (defense in depth), not just the guard above.
table("assignments").delete(
filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"deleted": True}

Expand All@@ -237,16 +255,11 @@ def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str
def suggest_study_blocks(body: StudyBlockBody, request: FastAPIRequest):
require_self(body.user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
assignments = table("assignments").select(
"id,title,due_date,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{body.user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
)
assignments = _read_assignments(body.user_id, due_gte=today)
blocks = []
for a in assignments:
course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
cc = course.get("course_code") or ""
cn = course.get("course_name") or ""
cc = a.get("course_code") or ""
cn = a.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
blocks.append({
"topic": f"{course_label}{a['title']}" if course_label else a["title"],
Expand DownExpand Up@@ -330,43 +343,39 @@ def sync_to_google(body: SyncBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
if not owned:
return {"synced_count": 0}
in_clause = f"in.({','.join(owned)})"
unsynced = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "is.null",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "is.null"},
)
# Also catch empty-string google_event_id
unsynced += table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "eq.",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "eq."},
)

enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
cache = {}
synced = 0
for a in unsynced:
if not a.get("due_date"):
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")

meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
"description": decrypt_if_present(a.get("notes")) or "",
"start": {"date": a["due_date"]},
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth), matching export.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{a['id']}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{a['id']}", "enrollment_id": in_clause},
)
synced += 1

Expand All@@ -381,18 +390,24 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
in_clause = f"in.({','.join(owned)})" if owned else "in.()"
cache = {}

exported = 0
skipped = 0
for aid in body.assignment_ids:
# #123: scope by user_id, not just id. Without this an authenticated
# caller could pass another user's assignment UUIDs to read+decrypt
# their private notes, push them into the caller's calendar, and stamp
# google_event_id onto the victim's row. Every sibling endpoint
# (update/delete/sync) already scopes by user_id; a non-owned id now
# returns no row and is skipped.
# #123: scope by enrollment_id membership, not just id. Without this an
# authenticated caller could pass another user's assignment UUIDs to
# read+decrypt their private notes, push them into the caller's calendar,
# and stamp google_event_id onto the victim's row. Scoping to the caller's
# own enrollment ids means a non-owned id returns no row and is skipped.
if not owned:
continue
rows = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
if not rows:
continue
Expand All@@ -402,10 +417,10 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
skipped += 1
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")
meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")

event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
Expand All@@ -414,11 +429,11 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth): never stamp
# google_event_id onto a row the caller doesn't own.
# Scope the write-back by enrollment_id membership (defense in depth):
# never stamp google_event_id onto a row the caller doesn't own.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
exported += 1

Expand Down
4 changes: 2 additions & 2 deletions backend/routes/documents.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,7 +472,7 @@ def _save_orchestrator_syllabus(*, user_id: str, course_id: str, filename: str,
})
if legacy:
try:
save_assignments_to_db(user_id, legacy)
save_assignments_to_db(user_id, legacy, source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand DownExpand Up@@ -985,7 +985,7 @@ async def _legacy_upload_pipeline(
try:
for a in ai["assignments"]:
a["course_id"] = course_id
save_assignments_to_db(user_id, ai["assignments"])
save_assignments_to_db(user_id, ai["assignments"], source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
215 changes: 115 additions & 100 deletions backend/routes/calendar.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
)
from db.connection import table
from models import SaveAssignmentsBody, StudyBlockBody, ExportBody, SyncBody
from services import academics
from services.auth_guard import require_self, get_session_user_id
from services.calendar_service import extract_assignments_from_file, insert_new_assignments
from services.encryption import encrypt, encrypt_if_present, decrypt, decrypt_if_present
Expand DownExpand Up@@ -87,6 +88,62 @@ def _require_google_creds(user_id: str) -> "Credentials":
return _get_refreshed_credentials(token_rows[0])


def _course_meta_cached(offering_id, cache):
if not offering_id:
return {}
if offering_id not in cache:
course_id = academics.offering_course_id(offering_id)
course = {}
if course_id:
rows = table("courses").select(
"id,course_code,course_name",
filters={"id": f"eq.{course_id}"}, limit=1,
)
course = rows[0] if rows else {}
cache[offering_id] = {
"course_id": course_id,
"course_code": course.get("course_code"),
"course_name": course.get("course_name"),
}
return cache[offering_id]


def _owned_enrollment_ids(user_id) -> set:
return {e["id"] for e in academics.user_enrollment_ids(user_id)}


def _read_assignments(user_id, *, due_gte=None, limit=None):
enrollments = academics.user_enrollment_ids(user_id)
if not enrollments:
return []
offering_by_enrollment = {e["id"]: e.get("offering_id") for e in enrollments}
ids = ",".join(offering_by_enrollment.keys())
filters = {"enrollment_id": f"in.({ids})"}
if due_gte:
filters["due_date"] = f"gte.{due_gte}"
rows = table("assignments").select(
"id,enrollment_id,title,due_date,assignment_type,notes,google_event_id,source",
filters=filters, order="due_date.asc", limit=limit,
)
cache = {}
out = []
for r in rows:
meta = _course_meta_cached(offering_by_enrollment.get(r.get("enrollment_id")), cache)
out.append({
"id": r["id"],
"user_id": user_id,
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": meta.get("course_id"),
"course_code": meta.get("course_code") or "",
"course_name": meta.get("course_name") or "",
})
return out


# ── Syllabus extraction ───────────────────────────────────────────────────────

@router.post("/extract")
Expand DownExpand Up@@ -125,67 +182,26 @@ def save_assignments(body: SaveAssignmentsBody, request: FastAPIRequest):
"course_id": a.course_id,
"due_date": a.due_date,
"assignment_type": a.assignment_type,
"notes": encrypt_if_present(a.notes),
"notes": a.notes, # raw; insert_new_assignments encrypts
}
for a in body.assignments
]
saved = insert_new_assignments(body.user_id, payload)
saved = insert_new_assignments(body.user_id, payload, source="manual")
return {"saved_count": saved}


@router.get("/upcoming/{user_id}")
def get_upcoming(user_id: str, request: FastAPIRequest):
require_self(user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
limit=20,
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id, due_gte=today, limit=20)}


@router.get("/all/{user_id}")
def get_all_assignments(user_id: str, request: FastAPIRequest):
"""Return all assignments for a user (past and future) for the calendar view."""
require_self(user_id, request)
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}"},
order="due_date.asc",
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id)}


@router.patch("/assignments/{assignment_id}")
Expand All@@ -195,40 +211,42 @@ def update_assignment(assignment_id: str, body: dict, request: FastAPIRequest):
raise HTTPException(status_code=400, detail="user_id is required")
require_self(user_id, request)

owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")

# Whitelist non-sensitive fields. `notes` is excluded; edit notes via /save flow.
ALLOWED = {"title", "course_id", "due_date", "assignment_type"}
ALLOWED = {"title", "due_date", "assignment_type"} # course_id no longer settable here
patch = {k: v for k, v in body.items() if k in ALLOWED}
if not patch:
return {"updated": False}

if "course_id" in patch and patch["course_id"] == "":
patch["course_id"] = None

# Scope the write by user_id too (defense in depth): the scoped SELECT above
# already 404s a non-owned id, but don't rely on that guard alone (#123).
table("assignments").update(
patch, filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
patch, filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"updated": True}


@router.delete("/assignments/{assignment_id}")
def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str = Query(...)):
require_self(user_id, request)
owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")
# Scope the delete by user_id too (defense in depth), not just the guard above.
table("assignments").delete(
filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"deleted": True}

Expand All@@ -237,16 +255,11 @@ def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str
def suggest_study_blocks(body: StudyBlockBody, request: FastAPIRequest):
require_self(body.user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
assignments = table("assignments").select(
"id,title,due_date,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{body.user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
)
assignments = _read_assignments(body.user_id, due_gte=today)
blocks = []
for a in assignments:
course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
cc = course.get("course_code") or ""
cn = course.get("course_name") or ""
cc = a.get("course_code") or ""
cn = a.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
blocks.append({
"topic": f"{course_label}{a['title']}" if course_label else a["title"],
Expand DownExpand Up@@ -330,43 +343,39 @@ def sync_to_google(body: SyncBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
if not owned:
return {"synced_count": 0}
in_clause = f"in.({','.join(owned)})"
unsynced = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "is.null",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "is.null"},
)
# Also catch empty-string google_event_id
unsynced += table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "eq.",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "eq."},
)

enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
cache = {}
synced = 0
for a in unsynced:
if not a.get("due_date"):
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")

meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
"description": decrypt_if_present(a.get("notes")) or "",
"start": {"date": a["due_date"]},
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth), matching export.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{a['id']}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{a['id']}", "enrollment_id": in_clause},
)
synced += 1

Expand All@@ -381,18 +390,24 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
in_clause = f"in.({','.join(owned)})" if owned else "in.()"
cache = {}

exported = 0
skipped = 0
for aid in body.assignment_ids:
# #123: scope by user_id, not just id. Without this an authenticated
# caller could pass another user's assignment UUIDs to read+decrypt
# their private notes, push them into the caller's calendar, and stamp
# google_event_id onto the victim's row. Every sibling endpoint
# (update/delete/sync) already scopes by user_id; a non-owned id now
# returns no row and is skipped.
# #123: scope by enrollment_id membership, not just id. Without this an
# authenticated caller could pass another user's assignment UUIDs to
# read+decrypt their private notes, push them into the caller's calendar,
# and stamp google_event_id onto the victim's row. Scoping to the caller's
# own enrollment ids means a non-owned id returns no row and is skipped.
if not owned:
continue
rows = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
if not rows:
continue
Expand All@@ -402,10 +417,10 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
skipped += 1
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")
meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")

event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
Expand All@@ -414,11 +429,11 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth): never stamp
# google_event_id onto a row the caller doesn't own.
# Scope the write-back by enrollment_id membership (defense in depth):
# never stamp google_event_id onto a row the caller doesn't own.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
exported += 1

Expand Down
4 changes: 2 additions & 2 deletions backend/routes/documents.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,7 +472,7 @@ def _save_orchestrator_syllabus(*, user_id: str, course_id: str, filename: str,
})
if legacy:
try:
save_assignments_to_db(user_id, legacy)
save_assignments_to_db(user_id, legacy, source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand DownExpand Up@@ -985,7 +985,7 @@ async def _legacy_upload_pipeline(
try:
for a in ai["assignments"]:
a["course_id"] = course_id
save_assignments_to_db(user_id, ai["assignments"])
save_assignments_to_db(user_id, ai["assignments"], source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
215 changes: 115 additions & 100 deletions backend/routes/calendar.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
)
from db.connection import table
from models import SaveAssignmentsBody, StudyBlockBody, ExportBody, SyncBody
from services import academics
from services.auth_guard import require_self, get_session_user_id
from services.calendar_service import extract_assignments_from_file, insert_new_assignments
from services.encryption import encrypt, encrypt_if_present, decrypt, decrypt_if_present
Expand DownExpand Up@@ -87,6 +88,62 @@ def _require_google_creds(user_id: str) -> "Credentials":
return _get_refreshed_credentials(token_rows[0])


def _course_meta_cached(offering_id, cache):
if not offering_id:
return {}
if offering_id not in cache:
course_id = academics.offering_course_id(offering_id)
course = {}
if course_id:
rows = table("courses").select(
"id,course_code,course_name",
filters={"id": f"eq.{course_id}"}, limit=1,
)
course = rows[0] if rows else {}
cache[offering_id] = {
"course_id": course_id,
"course_code": course.get("course_code"),
"course_name": course.get("course_name"),
}
return cache[offering_id]


def _owned_enrollment_ids(user_id) -> set:
return {e["id"] for e in academics.user_enrollment_ids(user_id)}


def _read_assignments(user_id, *, due_gte=None, limit=None):
enrollments = academics.user_enrollment_ids(user_id)
if not enrollments:
return []
offering_by_enrollment = {e["id"]: e.get("offering_id") for e in enrollments}
ids = ",".join(offering_by_enrollment.keys())
filters = {"enrollment_id": f"in.({ids})"}
if due_gte:
filters["due_date"] = f"gte.{due_gte}"
rows = table("assignments").select(
"id,enrollment_id,title,due_date,assignment_type,notes,google_event_id,source",
filters=filters, order="due_date.asc", limit=limit,
)
cache = {}
out = []
for r in rows:
meta = _course_meta_cached(offering_by_enrollment.get(r.get("enrollment_id")), cache)
out.append({
"id": r["id"],
"user_id": user_id,
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": meta.get("course_id"),
"course_code": meta.get("course_code") or "",
"course_name": meta.get("course_name") or "",
})
return out


# ── Syllabus extraction ───────────────────────────────────────────────────────

@router.post("/extract")
Expand DownExpand Up@@ -125,67 +182,26 @@ def save_assignments(body: SaveAssignmentsBody, request: FastAPIRequest):
"course_id": a.course_id,
"due_date": a.due_date,
"assignment_type": a.assignment_type,
"notes": encrypt_if_present(a.notes),
"notes": a.notes, # raw; insert_new_assignments encrypts
}
for a in body.assignments
]
saved = insert_new_assignments(body.user_id, payload)
saved = insert_new_assignments(body.user_id, payload, source="manual")
return {"saved_count": saved}


@router.get("/upcoming/{user_id}")
def get_upcoming(user_id: str, request: FastAPIRequest):
require_self(user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
limit=20,
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id, due_gte=today, limit=20)}


@router.get("/all/{user_id}")
def get_all_assignments(user_id: str, request: FastAPIRequest):
"""Return all assignments for a user (past and future) for the calendar view."""
require_self(user_id, request)
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}"},
order="due_date.asc",
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id)}


@router.patch("/assignments/{assignment_id}")
Expand All@@ -195,40 +211,42 @@ def update_assignment(assignment_id: str, body: dict, request: FastAPIRequest):
raise HTTPException(status_code=400, detail="user_id is required")
require_self(user_id, request)

owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")

# Whitelist non-sensitive fields. `notes` is excluded; edit notes via /save flow.
ALLOWED = {"title", "course_id", "due_date", "assignment_type"}
ALLOWED = {"title", "due_date", "assignment_type"} # course_id no longer settable here
patch = {k: v for k, v in body.items() if k in ALLOWED}
if not patch:
return {"updated": False}

if "course_id" in patch and patch["course_id"] == "":
patch["course_id"] = None

# Scope the write by user_id too (defense in depth): the scoped SELECT above
# already 404s a non-owned id, but don't rely on that guard alone (#123).
table("assignments").update(
patch, filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
patch, filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"updated": True}


@router.delete("/assignments/{assignment_id}")
def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str = Query(...)):
require_self(user_id, request)
owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")
# Scope the delete by user_id too (defense in depth), not just the guard above.
table("assignments").delete(
filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"deleted": True}

Expand All@@ -237,16 +255,11 @@ def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str
def suggest_study_blocks(body: StudyBlockBody, request: FastAPIRequest):
require_self(body.user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
assignments = table("assignments").select(
"id,title,due_date,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{body.user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
)
assignments = _read_assignments(body.user_id, due_gte=today)
blocks = []
for a in assignments:
course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
cc = course.get("course_code") or ""
cn = course.get("course_name") or ""
cc = a.get("course_code") or ""
cn = a.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
blocks.append({
"topic": f"{course_label}{a['title']}" if course_label else a["title"],
Expand DownExpand Up@@ -330,43 +343,39 @@ def sync_to_google(body: SyncBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
if not owned:
return {"synced_count": 0}
in_clause = f"in.({','.join(owned)})"
unsynced = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "is.null",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "is.null"},
)
# Also catch empty-string google_event_id
unsynced += table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "eq.",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "eq."},
)

enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
cache = {}
synced = 0
for a in unsynced:
if not a.get("due_date"):
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")

meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
"description": decrypt_if_present(a.get("notes")) or "",
"start": {"date": a["due_date"]},
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth), matching export.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{a['id']}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{a['id']}", "enrollment_id": in_clause},
)
synced += 1

Expand All@@ -381,18 +390,24 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
in_clause = f"in.({','.join(owned)})" if owned else "in.()"
cache = {}

exported = 0
skipped = 0
for aid in body.assignment_ids:
# #123: scope by user_id, not just id. Without this an authenticated
# caller could pass another user's assignment UUIDs to read+decrypt
# their private notes, push them into the caller's calendar, and stamp
# google_event_id onto the victim's row. Every sibling endpoint
# (update/delete/sync) already scopes by user_id; a non-owned id now
# returns no row and is skipped.
# #123: scope by enrollment_id membership, not just id. Without this an
# authenticated caller could pass another user's assignment UUIDs to
# read+decrypt their private notes, push them into the caller's calendar,
# and stamp google_event_id onto the victim's row. Scoping to the caller's
# own enrollment ids means a non-owned id returns no row and is skipped.
if not owned:
continue
rows = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
if not rows:
continue
Expand All@@ -402,10 +417,10 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
skipped += 1
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")
meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")

event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
Expand All@@ -414,11 +429,11 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth): never stamp
# google_event_id onto a row the caller doesn't own.
# Scope the write-back by enrollment_id membership (defense in depth):
# never stamp google_event_id onto a row the caller doesn't own.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
exported += 1

Expand Down
4 changes: 2 additions & 2 deletions backend/routes/documents.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,7 +472,7 @@ def _save_orchestrator_syllabus(*, user_id: str, course_id: str, filename: str,
})
if legacy:
try:
save_assignments_to_db(user_id, legacy)
save_assignments_to_db(user_id, legacy, source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand DownExpand Up@@ -985,7 +985,7 @@ async def _legacy_upload_pipeline(
try:
for a in ai["assignments"]:
a["course_id"] = course_id
save_assignments_to_db(user_id, ai["assignments"])
save_assignments_to_db(user_id, ai["assignments"], source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
215 changes: 115 additions & 100 deletions backend/routes/calendar.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
)
from db.connection import table
from models import SaveAssignmentsBody, StudyBlockBody, ExportBody, SyncBody
from services import academics
from services.auth_guard import require_self, get_session_user_id
from services.calendar_service import extract_assignments_from_file, insert_new_assignments
from services.encryption import encrypt, encrypt_if_present, decrypt, decrypt_if_present
Expand DownExpand Up@@ -87,6 +88,62 @@ def _require_google_creds(user_id: str) -> "Credentials":
return _get_refreshed_credentials(token_rows[0])


def _course_meta_cached(offering_id, cache):
if not offering_id:
return {}
if offering_id not in cache:
course_id = academics.offering_course_id(offering_id)
course = {}
if course_id:
rows = table("courses").select(
"id,course_code,course_name",
filters={"id": f"eq.{course_id}"}, limit=1,
)
course = rows[0] if rows else {}
cache[offering_id] = {
"course_id": course_id,
"course_code": course.get("course_code"),
"course_name": course.get("course_name"),
}
return cache[offering_id]


def _owned_enrollment_ids(user_id) -> set:
return {e["id"] for e in academics.user_enrollment_ids(user_id)}


def _read_assignments(user_id, *, due_gte=None, limit=None):
enrollments = academics.user_enrollment_ids(user_id)
if not enrollments:
return []
offering_by_enrollment = {e["id"]: e.get("offering_id") for e in enrollments}
ids = ",".join(offering_by_enrollment.keys())
filters = {"enrollment_id": f"in.({ids})"}
if due_gte:
filters["due_date"] = f"gte.{due_gte}"
rows = table("assignments").select(
"id,enrollment_id,title,due_date,assignment_type,notes,google_event_id,source",
filters=filters, order="due_date.asc", limit=limit,
)
cache = {}
out = []
for r in rows:
meta = _course_meta_cached(offering_by_enrollment.get(r.get("enrollment_id")), cache)
out.append({
"id": r["id"],
"user_id": user_id,
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": meta.get("course_id"),
"course_code": meta.get("course_code") or "",
"course_name": meta.get("course_name") or "",
})
return out


# ── Syllabus extraction ───────────────────────────────────────────────────────

@router.post("/extract")
Expand DownExpand Up@@ -125,67 +182,26 @@ def save_assignments(body: SaveAssignmentsBody, request: FastAPIRequest):
"course_id": a.course_id,
"due_date": a.due_date,
"assignment_type": a.assignment_type,
"notes": encrypt_if_present(a.notes),
"notes": a.notes, # raw; insert_new_assignments encrypts
}
for a in body.assignments
]
saved = insert_new_assignments(body.user_id, payload)
saved = insert_new_assignments(body.user_id, payload, source="manual")
return {"saved_count": saved}


@router.get("/upcoming/{user_id}")
def get_upcoming(user_id: str, request: FastAPIRequest):
require_self(user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
limit=20,
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id, due_gte=today, limit=20)}


@router.get("/all/{user_id}")
def get_all_assignments(user_id: str, request: FastAPIRequest):
"""Return all assignments for a user (past and future) for the calendar view."""
require_self(user_id, request)
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}"},
order="due_date.asc",
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id)}


@router.patch("/assignments/{assignment_id}")
Expand All@@ -195,40 +211,42 @@ def update_assignment(assignment_id: str, body: dict, request: FastAPIRequest):
raise HTTPException(status_code=400, detail="user_id is required")
require_self(user_id, request)

owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")

# Whitelist non-sensitive fields. `notes` is excluded; edit notes via /save flow.
ALLOWED = {"title", "course_id", "due_date", "assignment_type"}
ALLOWED = {"title", "due_date", "assignment_type"} # course_id no longer settable here
patch = {k: v for k, v in body.items() if k in ALLOWED}
if not patch:
return {"updated": False}

if "course_id" in patch and patch["course_id"] == "":
patch["course_id"] = None

# Scope the write by user_id too (defense in depth): the scoped SELECT above
# already 404s a non-owned id, but don't rely on that guard alone (#123).
table("assignments").update(
patch, filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
patch, filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"updated": True}


@router.delete("/assignments/{assignment_id}")
def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str = Query(...)):
require_self(user_id, request)
owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")
# Scope the delete by user_id too (defense in depth), not just the guard above.
table("assignments").delete(
filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"deleted": True}

Expand All@@ -237,16 +255,11 @@ def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str
def suggest_study_blocks(body: StudyBlockBody, request: FastAPIRequest):
require_self(body.user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
assignments = table("assignments").select(
"id,title,due_date,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{body.user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
)
assignments = _read_assignments(body.user_id, due_gte=today)
blocks = []
for a in assignments:
course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
cc = course.get("course_code") or ""
cn = course.get("course_name") or ""
cc = a.get("course_code") or ""
cn = a.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
blocks.append({
"topic": f"{course_label}{a['title']}" if course_label else a["title"],
Expand DownExpand Up@@ -330,43 +343,39 @@ def sync_to_google(body: SyncBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
if not owned:
return {"synced_count": 0}
in_clause = f"in.({','.join(owned)})"
unsynced = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "is.null",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "is.null"},
)
# Also catch empty-string google_event_id
unsynced += table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "eq.",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "eq."},
)

enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
cache = {}
synced = 0
for a in unsynced:
if not a.get("due_date"):
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")

meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
"description": decrypt_if_present(a.get("notes")) or "",
"start": {"date": a["due_date"]},
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth), matching export.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{a['id']}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{a['id']}", "enrollment_id": in_clause},
)
synced += 1

Expand All@@ -381,18 +390,24 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
in_clause = f"in.({','.join(owned)})" if owned else "in.()"
cache = {}

exported = 0
skipped = 0
for aid in body.assignment_ids:
# #123: scope by user_id, not just id. Without this an authenticated
# caller could pass another user's assignment UUIDs to read+decrypt
# their private notes, push them into the caller's calendar, and stamp
# google_event_id onto the victim's row. Every sibling endpoint
# (update/delete/sync) already scopes by user_id; a non-owned id now
# returns no row and is skipped.
# #123: scope by enrollment_id membership, not just id. Without this an
# authenticated caller could pass another user's assignment UUIDs to
# read+decrypt their private notes, push them into the caller's calendar,
# and stamp google_event_id onto the victim's row. Scoping to the caller's
# own enrollment ids means a non-owned id returns no row and is skipped.
if not owned:
continue
rows = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
if not rows:
continue
Expand All@@ -402,10 +417,10 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
skipped += 1
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")
meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")

event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
Expand All@@ -414,11 +429,11 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth): never stamp
# google_event_id onto a row the caller doesn't own.
# Scope the write-back by enrollment_id membership (defense in depth):
# never stamp google_event_id onto a row the caller doesn't own.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
exported += 1

Expand Down
4 changes: 2 additions & 2 deletions backend/routes/documents.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,7 +472,7 @@ def _save_orchestrator_syllabus(*, user_id: str, course_id: str, filename: str,
})
if legacy:
try:
save_assignments_to_db(user_id, legacy)
save_assignments_to_db(user_id, legacy, source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand DownExpand Up@@ -985,7 +985,7 @@ async def _legacy_upload_pipeline(
try:
for a in ai["assignments"]:
a["course_id"] = course_id
save_assignments_to_db(user_id, ai["assignments"])
save_assignments_to_db(user_id, ai["assignments"], source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
215 changes: 115 additions & 100 deletions backend/routes/calendar.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
)
from db.connection import table
from models import SaveAssignmentsBody, StudyBlockBody, ExportBody, SyncBody
from services import academics
from services.auth_guard import require_self, get_session_user_id
from services.calendar_service import extract_assignments_from_file, insert_new_assignments
from services.encryption import encrypt, encrypt_if_present, decrypt, decrypt_if_present
Expand DownExpand Up@@ -87,6 +88,62 @@ def _require_google_creds(user_id: str) -> "Credentials":
return _get_refreshed_credentials(token_rows[0])


def _course_meta_cached(offering_id, cache):
if not offering_id:
return {}
if offering_id not in cache:
course_id = academics.offering_course_id(offering_id)
course = {}
if course_id:
rows = table("courses").select(
"id,course_code,course_name",
filters={"id": f"eq.{course_id}"}, limit=1,
)
course = rows[0] if rows else {}
cache[offering_id] = {
"course_id": course_id,
"course_code": course.get("course_code"),
"course_name": course.get("course_name"),
}
return cache[offering_id]


def _owned_enrollment_ids(user_id) -> set:
return {e["id"] for e in academics.user_enrollment_ids(user_id)}


def _read_assignments(user_id, *, due_gte=None, limit=None):
enrollments = academics.user_enrollment_ids(user_id)
if not enrollments:
return []
offering_by_enrollment = {e["id"]: e.get("offering_id") for e in enrollments}
ids = ",".join(offering_by_enrollment.keys())
filters = {"enrollment_id": f"in.({ids})"}
if due_gte:
filters["due_date"] = f"gte.{due_gte}"
rows = table("assignments").select(
"id,enrollment_id,title,due_date,assignment_type,notes,google_event_id,source",
filters=filters, order="due_date.asc", limit=limit,
)
cache = {}
out = []
for r in rows:
meta = _course_meta_cached(offering_by_enrollment.get(r.get("enrollment_id")), cache)
out.append({
"id": r["id"],
"user_id": user_id,
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": meta.get("course_id"),
"course_code": meta.get("course_code") or "",
"course_name": meta.get("course_name") or "",
})
return out


# ── Syllabus extraction ───────────────────────────────────────────────────────

@router.post("/extract")
Expand DownExpand Up@@ -125,67 +182,26 @@ def save_assignments(body: SaveAssignmentsBody, request: FastAPIRequest):
"course_id": a.course_id,
"due_date": a.due_date,
"assignment_type": a.assignment_type,
"notes": encrypt_if_present(a.notes),
"notes": a.notes, # raw; insert_new_assignments encrypts
}
for a in body.assignments
]
saved = insert_new_assignments(body.user_id, payload)
saved = insert_new_assignments(body.user_id, payload, source="manual")
return {"saved_count": saved}


@router.get("/upcoming/{user_id}")
def get_upcoming(user_id: str, request: FastAPIRequest):
require_self(user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
limit=20,
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id, due_gte=today, limit=20)}


@router.get("/all/{user_id}")
def get_all_assignments(user_id: str, request: FastAPIRequest):
"""Return all assignments for a user (past and future) for the calendar view."""
require_self(user_id, request)
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}"},
order="due_date.asc",
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id)}


@router.patch("/assignments/{assignment_id}")
Expand All@@ -195,40 +211,42 @@ def update_assignment(assignment_id: str, body: dict, request: FastAPIRequest):
raise HTTPException(status_code=400, detail="user_id is required")
require_self(user_id, request)

owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")

# Whitelist non-sensitive fields. `notes` is excluded; edit notes via /save flow.
ALLOWED = {"title", "course_id", "due_date", "assignment_type"}
ALLOWED = {"title", "due_date", "assignment_type"} # course_id no longer settable here
patch = {k: v for k, v in body.items() if k in ALLOWED}
if not patch:
return {"updated": False}

if "course_id" in patch and patch["course_id"] == "":
patch["course_id"] = None

# Scope the write by user_id too (defense in depth): the scoped SELECT above
# already 404s a non-owned id, but don't rely on that guard alone (#123).
table("assignments").update(
patch, filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
patch, filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"updated": True}


@router.delete("/assignments/{assignment_id}")
def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str = Query(...)):
require_self(user_id, request)
owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")
# Scope the delete by user_id too (defense in depth), not just the guard above.
table("assignments").delete(
filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"deleted": True}

Expand All@@ -237,16 +255,11 @@ def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str
def suggest_study_blocks(body: StudyBlockBody, request: FastAPIRequest):
require_self(body.user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
assignments = table("assignments").select(
"id,title,due_date,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{body.user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
)
assignments = _read_assignments(body.user_id, due_gte=today)
blocks = []
for a in assignments:
course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
cc = course.get("course_code") or ""
cn = course.get("course_name") or ""
cc = a.get("course_code") or ""
cn = a.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
blocks.append({
"topic": f"{course_label}{a['title']}" if course_label else a["title"],
Expand DownExpand Up@@ -330,43 +343,39 @@ def sync_to_google(body: SyncBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
if not owned:
return {"synced_count": 0}
in_clause = f"in.({','.join(owned)})"
unsynced = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "is.null",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "is.null"},
)
# Also catch empty-string google_event_id
unsynced += table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "eq.",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "eq."},
)

enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
cache = {}
synced = 0
for a in unsynced:
if not a.get("due_date"):
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")

meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
"description": decrypt_if_present(a.get("notes")) or "",
"start": {"date": a["due_date"]},
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth), matching export.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{a['id']}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{a['id']}", "enrollment_id": in_clause},
)
synced += 1

Expand All@@ -381,18 +390,24 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
in_clause = f"in.({','.join(owned)})" if owned else "in.()"
cache = {}

exported = 0
skipped = 0
for aid in body.assignment_ids:
# #123: scope by user_id, not just id. Without this an authenticated
# caller could pass another user's assignment UUIDs to read+decrypt
# their private notes, push them into the caller's calendar, and stamp
# google_event_id onto the victim's row. Every sibling endpoint
# (update/delete/sync) already scopes by user_id; a non-owned id now
# returns no row and is skipped.
# #123: scope by enrollment_id membership, not just id. Without this an
# authenticated caller could pass another user's assignment UUIDs to
# read+decrypt their private notes, push them into the caller's calendar,
# and stamp google_event_id onto the victim's row. Scoping to the caller's
# own enrollment ids means a non-owned id returns no row and is skipped.
if not owned:
continue
rows = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
if not rows:
continue
Expand All@@ -402,10 +417,10 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
skipped += 1
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")
meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")

event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
Expand All@@ -414,11 +429,11 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth): never stamp
# google_event_id onto a row the caller doesn't own.
# Scope the write-back by enrollment_id membership (defense in depth):
# never stamp google_event_id onto a row the caller doesn't own.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
exported += 1

Expand Down
4 changes: 2 additions & 2 deletions backend/routes/documents.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,7 +472,7 @@ def _save_orchestrator_syllabus(*, user_id: str, course_id: str, filename: str,
})
if legacy:
try:
save_assignments_to_db(user_id, legacy)
save_assignments_to_db(user_id, legacy, source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand DownExpand Up@@ -985,7 +985,7 @@ async def _legacy_upload_pipeline(
try:
for a in ai["assignments"]:
a["course_id"] = course_id
save_assignments_to_db(user_id, ai["assignments"])
save_assignments_to_db(user_id, ai["assignments"], source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
215 changes: 115 additions & 100 deletions backend/routes/calendar.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
)
from db.connection import table
from models import SaveAssignmentsBody, StudyBlockBody, ExportBody, SyncBody
from services import academics
from services.auth_guard import require_self, get_session_user_id
from services.calendar_service import extract_assignments_from_file, insert_new_assignments
from services.encryption import encrypt, encrypt_if_present, decrypt, decrypt_if_present
Expand DownExpand Up@@ -87,6 +88,62 @@ def _require_google_creds(user_id: str) -> "Credentials":
return _get_refreshed_credentials(token_rows[0])


def _course_meta_cached(offering_id, cache):
if not offering_id:
return {}
if offering_id not in cache:
course_id = academics.offering_course_id(offering_id)
course = {}
if course_id:
rows = table("courses").select(
"id,course_code,course_name",
filters={"id": f"eq.{course_id}"}, limit=1,
)
course = rows[0] if rows else {}
cache[offering_id] = {
"course_id": course_id,
"course_code": course.get("course_code"),
"course_name": course.get("course_name"),
}
return cache[offering_id]


def _owned_enrollment_ids(user_id) -> set:
return {e["id"] for e in academics.user_enrollment_ids(user_id)}


def _read_assignments(user_id, *, due_gte=None, limit=None):
enrollments = academics.user_enrollment_ids(user_id)
if not enrollments:
return []
offering_by_enrollment = {e["id"]: e.get("offering_id") for e in enrollments}
ids = ",".join(offering_by_enrollment.keys())
filters = {"enrollment_id": f"in.({ids})"}
if due_gte:
filters["due_date"] = f"gte.{due_gte}"
rows = table("assignments").select(
"id,enrollment_id,title,due_date,assignment_type,notes,google_event_id,source",
filters=filters, order="due_date.asc", limit=limit,
)
cache = {}
out = []
for r in rows:
meta = _course_meta_cached(offering_by_enrollment.get(r.get("enrollment_id")), cache)
out.append({
"id": r["id"],
"user_id": user_id,
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": meta.get("course_id"),
"course_code": meta.get("course_code") or "",
"course_name": meta.get("course_name") or "",
})
return out


# ── Syllabus extraction ───────────────────────────────────────────────────────

@router.post("/extract")
Expand DownExpand Up@@ -125,67 +182,26 @@ def save_assignments(body: SaveAssignmentsBody, request: FastAPIRequest):
"course_id": a.course_id,
"due_date": a.due_date,
"assignment_type": a.assignment_type,
"notes": encrypt_if_present(a.notes),
"notes": a.notes, # raw; insert_new_assignments encrypts
}
for a in body.assignments
]
saved = insert_new_assignments(body.user_id, payload)
saved = insert_new_assignments(body.user_id, payload, source="manual")
return {"saved_count": saved}


@router.get("/upcoming/{user_id}")
def get_upcoming(user_id: str, request: FastAPIRequest):
require_self(user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
limit=20,
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id, due_gte=today, limit=20)}


@router.get("/all/{user_id}")
def get_all_assignments(user_id: str, request: FastAPIRequest):
"""Return all assignments for a user (past and future) for the calendar view."""
require_self(user_id, request)
rows = table("assignments").select(
"id,user_id,title,due_date,assignment_type,notes,google_event_id,course_id,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{user_id}"},
order="due_date.asc",
)
assignments = []
for r in rows:
course = r.get("courses", {}) if isinstance(r.get("courses"), dict) else {}
assignments.append({
"id": r["id"],
"user_id": r["user_id"],
"title": r["title"],
"due_date": r["due_date"],
"assignment_type": r.get("assignment_type"),
"notes": decrypt_if_present(r.get("notes")),
"google_event_id": r.get("google_event_id"),
"course_id": r.get("course_id"),
"course_code": course.get("course_code") or "",
"course_name": course.get("course_name") or "",
})
return {"assignments": assignments}
return {"assignments": _read_assignments(user_id)}


@router.patch("/assignments/{assignment_id}")
Expand All@@ -195,40 +211,42 @@ def update_assignment(assignment_id: str, body: dict, request: FastAPIRequest):
raise HTTPException(status_code=400, detail="user_id is required")
require_self(user_id, request)

owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")

# Whitelist non-sensitive fields. `notes` is excluded; edit notes via /save flow.
ALLOWED = {"title", "course_id", "due_date", "assignment_type"}
ALLOWED = {"title", "due_date", "assignment_type"} # course_id no longer settable here
patch = {k: v for k, v in body.items() if k in ALLOWED}
if not patch:
return {"updated": False}

if "course_id" in patch and patch["course_id"] == "":
patch["course_id"] = None

# Scope the write by user_id too (defense in depth): the scoped SELECT above
# already 404s a non-owned id, but don't rely on that guard alone (#123).
table("assignments").update(
patch, filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
patch, filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"updated": True}


@router.delete("/assignments/{assignment_id}")
def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str = Query(...)):
require_self(user_id, request)
owned = _owned_enrollment_ids(user_id)
if not owned:
raise HTTPException(status_code=404, detail="Assignment not found")
existing = table("assignments").select(
"id", filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}, limit=1,
"id",
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"},
limit=1,
)
if not existing:
raise HTTPException(status_code=404, detail="Assignment not found")
# Scope the delete by user_id too (defense in depth), not just the guard above.
table("assignments").delete(
filters={"id": f"eq.{assignment_id}", "user_id": f"eq.{user_id}"}
filters={"id": f"eq.{assignment_id}", "enrollment_id": f"in.({','.join(owned)})"}
)
return {"deleted": True}

Expand All@@ -237,16 +255,11 @@ def delete_assignment(assignment_id: str, request: FastAPIRequest, user_id: str
def suggest_study_blocks(body: StudyBlockBody, request: FastAPIRequest):
require_self(body.user_id, request)
today = datetime.utcnow().strftime("%Y-%m-%d")
assignments = table("assignments").select(
"id,title,due_date,courses!left(course_code,course_name)",
filters={"user_id": f"eq.{body.user_id}", "due_date": f"gte.{today}"},
order="due_date.asc",
)
assignments = _read_assignments(body.user_id, due_gte=today)
blocks = []
for a in assignments:
course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
cc = course.get("course_code") or ""
cn = course.get("course_name") or ""
cc = a.get("course_code") or ""
cn = a.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
blocks.append({
"topic": f"{course_label}{a['title']}" if course_label else a["title"],
Expand DownExpand Up@@ -330,43 +343,39 @@ def sync_to_google(body: SyncBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
if not owned:
return {"synced_count": 0}
in_clause = f"in.({','.join(owned)})"
unsynced = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "is.null",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "is.null"},
)
# Also catch empty-string google_event_id
unsynced += table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={
"user_id": f"eq.{body.user_id}",
"google_event_id": "eq.",
},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"enrollment_id": in_clause, "google_event_id": "eq."},
)

enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
cache = {}
synced = 0
for a in unsynced:
if not a.get("due_date"):
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")

meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")
event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
"description": decrypt_if_present(a.get("notes")) or "",
"start": {"date": a["due_date"]},
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth), matching export.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{a['id']}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{a['id']}", "enrollment_id": in_clause},
)
synced += 1

Expand All@@ -381,18 +390,24 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
creds = _require_google_creds(body.user_id)
service = build("calendar", "v3", credentials=creds)

owned = _owned_enrollment_ids(body.user_id)
enr_to_offering = {e["id"]: e.get("offering_id") for e in academics.user_enrollment_ids(body.user_id)}
in_clause = f"in.({','.join(owned)})" if owned else "in.()"
cache = {}

exported = 0
skipped = 0
for aid in body.assignment_ids:
# #123: scope by user_id, not just id. Without this an authenticated
# caller could pass another user's assignment UUIDs to read+decrypt
# their private notes, push them into the caller's calendar, and stamp
# google_event_id onto the victim's row. Every sibling endpoint
# (update/delete/sync) already scopes by user_id; a non-owned id now
# returns no row and is skipped.
# #123: scope by enrollment_id membership, not just id. Without this an
# authenticated caller could pass another user's assignment UUIDs to
# read+decrypt their private notes, push them into the caller's calendar,
# and stamp google_event_id onto the victim's row. Scoping to the caller's
# own enrollment ids means a non-owned id returns no row and is skipped.
if not owned:
continue
rows = table("assignments").select(
"id,title,due_date,notes,google_event_id,courses!left(course_code,course_name)",
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
"id,enrollment_id,title,due_date,notes,google_event_id",
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
if not rows:
continue
Expand All@@ -402,10 +417,10 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
skipped += 1
continue

course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
course_code = course.get("course_code") or ""
course_name = course.get("course_name") or ""
course_label = f"[{course_code}] " if course_code else (f"{course_name}: " if course_name else "")
meta = _course_meta_cached(enr_to_offering.get(a.get("enrollment_id")), cache)
cc = meta.get("course_code") or ""
cn = meta.get("course_name") or ""
course_label = f"[{cc}] " if cc else (f"{cn}: " if cn else "")

event = {
"summary": f"{course_label}{a['title']}" if course_label else a["title"],
Expand All@@ -414,11 +429,11 @@ def export_to_google(body: ExportBody, request: FastAPIRequest):
"end": {"date": a["due_date"]},
}
created = service.events().insert(calendarId="primary", body=event).execute()
# Scope the write-back by user_id too (defense in depth): never stamp
# google_event_id onto a row the caller doesn't own.
# Scope the write-back by enrollment_id membership (defense in depth):
# never stamp google_event_id onto a row the caller doesn't own.
table("assignments").update(
{"google_event_id": created["id"]},
filters={"id": f"eq.{aid}", "user_id": f"eq.{body.user_id}"},
filters={"id": f"eq.{aid}", "enrollment_id": in_clause},
)
exported += 1

Expand Down
4 changes: 2 additions & 2 deletions backend/routes/documents.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -472,7 +472,7 @@ def _save_orchestrator_syllabus(*, user_id: str, course_id: str, filename: str,
})
if legacy:
try:
save_assignments_to_db(user_id, legacy)
save_assignments_to_db(user_id, legacy, source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand DownExpand Up@@ -985,7 +985,7 @@ async def _legacy_upload_pipeline(
try:
for a in ai["assignments"]:
a["course_id"] = course_id
save_assignments_to_db(user_id, ai["assignments"])
save_assignments_to_db(user_id, ai["assignments"], source="syllabus")
except Exception:
logger.exception("Assignment save failed for '%s' (best-effort)", filename)

Expand Down
Loading
Loading