Skip to content

fix(api): stop logging an error when an anonymous user logs in to an existing account - #3041

Draft
cqnykamp wants to merge 1 commit into
Doenet:mainfrom
cqnykamp:fix/anonymous-upgrade-existing-email
Draft

cqnykamp wants to merge 1 commit into
Doenet:mainfrom
cqnykamp:fix/anonymous-upgrade-existing-email

Conversation

@cqnykamp

@cqnykamp cqnykamp commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

The error

Production logs a P2002 stack trace, several times a day:

Error upgrading anonymous user PrismaClientKnownRequestError:
Invalid `prisma.users.update()` invocation:
Unique constraint failed on the constraint: `users_email_key`
  at async upgradeAnonymousUser (apps/api/dist/src/query/user.js:110:18)

upgradeAnonymousUser sets users.email, which is unique. So it fails whenever
the email someone authenticates with already belongs to an account — that is,
every time a returning user browses anonymously and then logs in. It is an
ordinary login, not a failure.

serializeUser already produced the right outcome (catch, fall back to
findOrCreateUser, log them in to their real account), but the catch swallowed
every other error the same way, so a genuine database failure was
indistinguishable from this in the logs.

The change

  • upgradeAnonymousUser returns null for the two no-op cases — P2002 (email
    already taken) and P2025 (account is not anonymous, or does not exist) — and
    rethrows everything else. The caller's existing if (!u) fallback covers
    both.
  • Both serializeUser branches drop the blanket try/catch + console.log.
    A real database failure now reaches asyncPassportdone(err) and fails
    the login instead of passing silently. This is the one behavior change worth
    watching after deploy.
  • New auth/parseFromAnonymous takes over the thing the blanket catch was
    legitimately guarding: toUUID throws on a malformed fromAnonymous value,
    and a bad cookie must not fail a login. It also folds in the magic-link " "
    placeholder check that was inline.
  • The Google branch now runs updateUser only when the upgrade actually
    happened. Previously it sat inside the same try, so a failure there
    discarded an otherwise successful upgrade and hid the error.

Not in scope

Anonymous work is still not merged into the existing account. A student who
takes an assignment anonymously and then logs in stays two rows in
assignmentScores, so they appear twice in the instructor's student list
(apps/api/src/query/assign.ts:908 lists every row, with no anonymity filter).
Merging is a product decision about gradebook data and deserves its own change.

Tests

The single happy-path test moves out of test/users.test.ts into
auth/anonymousUpgrade.test.ts and grows to pin all five outcomes: unused email
(same userId, so the session survives), email taken → null, email taken →
both accounts untouched, account not anonymous → null and email unchanged,
user missing → null. Plus 3 unit tests for parseFromAnonymous.

Full API suite: 32 files, 415 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T6R3jTUpCKjeAKV64cd6FC

…existing account

`upgradeAnonymousUser` sets `users.email`, so it hits the `users_email_key`
unique constraint whenever the email someone authenticates with already has an
account -- i.e. every time a returning user browses anonymously and then logs
in. Production logged a P2002 stack trace for each one.

`serializeUser` already handled this by catching everything and falling back to
`findOrCreateUser`, which is the right outcome but hid genuine failures behind
the same `console.log`.

Make the no-op cases part of the contract instead: `upgradeAnonymousUser`
returns `null` for P2002 (email taken) and P2025 (not anonymous, or no such
user) and rethrows anything else, so a real database failure now fails the
login rather than passing silently. `parseFromAnonymous` takes over the one
thing the blanket catch was legitimately guarding -- `toUUID` throwing on a
malformed `fromAnonymous` value -- and folds in the magic-link `" "`
placeholder check.

The Google branch now runs `updateUser` only when the upgrade actually
happened; previously a failure there discarded the successful upgrade.

Anonymous work is still not merged into the existing account, so a student who
takes an assignment anonymously and then logs in remains two rows in the
instructor's list. That is a separate decision.

Tests move to `auth/anonymousUpgrade.test.ts` and pin all five outcomes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6R3jTUpCKjeAKV64cd6FC
@cqnykamp
cqnykamp marked this pull request as draft September 1, 2026 14:37
@cqnykamp

cqnykamp commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

/deploy-dev

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

✅ Deployed refs/pull/3041/merge to dev3https://dev3.doenet.org
(first page load wakes it if the lights are off).

dev3 is shared: the next push to main overwrites this. To restore main:
gh workflow run dev-deploy.yml --ref main -f deploy_ref=main

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant