Skip to content

Collapse the two duplicate pairs onto the correct-NUSNET account#51

Closed
patrick-steve wants to merge 1 commit into
mainfrom
worktree-fix-nusnet-duplicates
Closed

Collapse the two duplicate pairs onto the correct-NUSNET account#51
patrick-steve wants to merge 1 commit into
mainfrom
worktree-fix-nusnet-duplicates

Conversation

@patrick-steve

Copy link
Copy Markdown
Collaborator

merge-by-canonical left two pairs untouched because both members already share a User.userID. That field is not the identity anything is keyed on — roles, bookings and gates all key on canonicalUserID(email) (I-1) — so each pair is two identities wearing one label:

user keep drop
Keri-ann e1523366@u.nus.eduE1523366 (5 bookings) e153366@u.nus.eduE153366 (0 rows)
Danvern e1525917@u.nus.eduE1525917 (2 bookings) danvern@u.nus.eduDANVERN (0 rows)

Per the owner: keep the account with the correct NUSNET id. That is also where the dependent data already lives, so no Bookings move. The full display name ("Keri-ann Shi", "Danvern Poo Han Yew") is lifted onto the survivor, which currently carries only a nickname.

Before deleting anything the script asserts the doomed canonical owns zero rows across Bookings/BookingLogs/Posts/Order/UserCCA/Gym/CcaHead/UserMatric/ProfileCompletion, and that its UserRole is baseline ["resident"].

Dry run is clean and verify-identity-parity.mjs passes. Not yet run with --commit — that deletes production rows and is the owner's call.

🤖 Generated with Claude Code

https://claude.ai/code/session_016ebVcP5QpHkhDkXfu9gnqt

Keri-ann and Danvern each have two User rows that share User.userID but not
email. Every role, booking and gate key is canonicalUserID(email), so those
pairs are two identities under one label -- and the bookings already sit on
the correct-NUSNET side, so "keep the one with the right NUSNET id" is also
the choice that moves no data.

The script asserts the doomed canonical owns nothing and that its UserRole is
baseline before deleting anything; the full name is lifted onto the survivor
explicitly rather than by heuristic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ebVcP5QpHkhDkXfu9gnqt
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

You don't have permission to create a Preview Deployment for this Vercel project: rh-app-2-0.

View Documentation: https://vercel.com/docs/accounts/team-members-and-roles

@patrick-steve
patrick-steve marked this pull request as ready for review July 19, 2026 14:22
Copilot AI review requested due to automatic review settings July 19, 2026 14:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a targeted remediation script to resolve two “duplicate identity” user pairs that merge-by-canonical could not automatically collapse, ensuring each pair is collapsed onto the account whose email-derived canonicalUserID matches the correct NUSNET id, while asserting the doomed canonical owns no dependent data before deletion.

Changes:

  • Introduce fix-nusnet-survivor.mjs to (dry-run by default) validate two hard-coded user pairs, verify the doomed canonical has zero dependent rows, and (on --commit) update survivor displayName + delete the duplicate User row.
  • Add safety checks around dependent collections and baseline UserRole before allowing deletion.
  • Persist a JSON “before-state” backup for auditability.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +84 to +96
const keepCanon = canonicalUserID(pair.keepEmail);
const dropCanon = canonicalUserID(pair.dropEmail);

if (keepCanon !== pair.userID) {
return abort(
`${pair.keepEmail} canonicalises to ${keepCanon}, not ${pair.userID}`,
);
}
if (dropCanon === pair.userID) {
return abort(
`${pair.dropEmail} canonicalises to ${pair.userID} — it is not the odd one out`,
);
}
Comment on lines +62 to +77
/** Collections keyed on the canonical id. The doomed canonical must own zero
* rows in every one of these, or deleting its account orphans real data. */
const REQUIRE_EMPTY = [
["Bookings", (c) => db.bookings.count({ where: { userID: c } })],
["BookingLogs", (c) => db.bookingLogs.count({ where: { userID: c } })],
["Posts", (c) => db.posts.count({ where: { userID: c } })],
["Order", (c) => db.order.count({ where: { userID: c } })],
["UserCCA", (c) => db.userCCA.count({ where: { userID: c } })],
["Gym", (c) => db.gym.count({ where: { userID: c } })],
["CcaHead", (c) => db.ccaHead.count({ where: { userID: c } })],
["UserMatric", (c) => db.userMatric.count({ where: { userID: c } })],
[
"ProfileCompletion",
(c) => db.profileCompletion.count({ where: { userID: c } }),
],
];
Comment on lines +98 to +103
const rows = await db.user.findMany({
where: { email: { in: [pair.keepEmail, pair.dropEmail] } },
});
const survivor = rows.find((r) => r.email === pair.keepEmail);
const loser = rows.find((r) => r.email === pair.dropEmail);
if (!survivor || !loser)
Comment on lines +166 to +176
for (const { pair, survivor, loser, dropCanon, staleRole } of plan) {
await db.user.update({
where: { id: survivor.id },
data: { displayName: pair.name },
});
await db.user.delete({ where: { id: loser.id } });
if (staleRole) await db.userRole.delete({ where: { userID: dropCanon } });
console.log(
`${pair.userID}: survivor updated, ${loser.email} deleted, role key ${dropCanon} cleared`,
);
}
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.

3 participants