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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading