diff --git a/core/db.py b/core/db.py index 9434b6a..bab9ba3 100644 --- a/core/db.py +++ b/core/db.py @@ -526,6 +526,32 @@ def remove_user(email: str) -> None: cur.execute(f"DELETE FROM users WHERE email = {_P}", (email,)) +@_retry_on_deadlock +def reclaim_ontrack_username(username: str, keep_user_id: int) -> list[int]: + """Enforce one OnTrack login per account: an OnTrack username uniquely + identifies a student, but `username` isn't a DB unique key, so two Clerk + accounts linking the same login create two rows. /ingest resolves by username + alone and can then write to the wrong row, so the snapshot (resolved by Clerk + id) shows nothing. When an account (re)links a username, evict every *other* + row holding it — last verified OnTrack linker wins — and delete that row's + captured tasks/projects. Returns the evicted user ids so the caller can drop + their scheduled brief jobs.""" + with _connection() as conn: + cur = conn.cursor() + cur.execute( + f"SELECT id FROM users WHERE username = {_P} AND id <> {_P}", + (username, keep_user_id), + ) + evicted = [row[0] for row in cur.fetchall()] + if not evicted: + return [] + placeholders = ",".join([_P] * len(evicted)) + cur.execute(f"DELETE FROM tasks WHERE user_id IN ({placeholders})", tuple(evicted)) + cur.execute(f"DELETE FROM projects WHERE user_id IN ({placeholders})", tuple(evicted)) + cur.execute(f"DELETE FROM users WHERE id IN ({placeholders})", tuple(evicted)) + return evicted + + # --------------------------------------------------------------------------- # Deterministic-brief storage — captured OnTrack tasks/deadlines (no OnTrack call # at read time). See docs/DETERMINISTIC_BRIEF_PLAN.md. ON CONFLICT … DO UPDATE is diff --git a/routes/main.py b/routes/main.py index 603ef13..ec5490a 100644 --- a/routes/main.py +++ b/routes/main.py @@ -20,6 +20,7 @@ get_user_by_username, link_clerk_id_by_email, prune_ended_projects, + reclaim_ontrack_username, reset_token_fail, set_refresh_token, set_subscribed, @@ -435,6 +436,23 @@ def link_ontrack(): if body_refresh_token: set_refresh_token(username, body_refresh_token) + # Enforce one OnTrack login per account. If another Clerk account previously + # linked this same OnTrack username, it left a duplicate row; /ingest (keyed on + # username alone) could then write to it instead of this account, so the + # snapshot — resolved by Clerk id — would show nothing. Evict those stale rows + # (and their captured data) now that this account holds the username, and drop + # their orphaned brief jobs. Idempotent: a no-op once there's a single row. + for evicted_id in reclaim_ontrack_username(username, user_id): + job_id = f"brief_{evicted_id}" + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) + log.info( + "link-ontrack: reclaimed username %s for user %s — evicted duplicate account %s", + username, + user_id, + evicted_id, + ) + # Apply deliberate brief-window / send-time changes from the Settings panel in a # single UPDATE. Each value is None unless explicitly provided, so the auto # re-link (which omits them) can't clobber a saved choice; folding them into one