Skip to content

Commit a3bffc0

Browse files
committed
feat: add manual auth0 user linking
1 parent 75de729 commit a3bffc0

File tree

5 files changed

+444
-1
lines changed

5 files changed

+444
-1
lines changed
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import type { User, Membership } from "@dotkomonline/types"
2+
import { z } from "zod"
3+
import type { DBHandle, Prisma } from "@dotkomonline/db"
4+
5+
// This file implements the logic for merging two user accounts.
6+
//
7+
// It is split into three parts:
8+
// 1. Field classification (how they will be merged)
9+
// 2. A type check to make sure every field is classified (exhaustiveness check)
10+
// 3. The merge implementation
11+
//
12+
// In general, we keep everything from the survivor user, and backfill from the consumed user only when the survivor has
13+
// null/empty values. We have some custom logic for memberships and other fields.
14+
15+
// FIELD CLASSIFICATION
16+
17+
/** These fields are omitted during the merge. */
18+
const OMITTED_FIELDS = [
19+
"id", //
20+
"createdAt",
21+
"updatedAt",
22+
] as const satisfies UserKeys[]
23+
24+
/** Keep the survivor's value; if it is null, backfill from the consumed user. */
25+
const BACKFILL_SCALAR_FIELDS = [
26+
"name",
27+
"email",
28+
"imageUrl",
29+
"biography",
30+
"phone",
31+
"gender",
32+
"dietaryRestrictions",
33+
"ntnuUsername",
34+
"workspaceUserId",
35+
] as const satisfies UserKeys[]
36+
37+
/**
38+
* Keep the survivor's value; if it is null, backfill from the consumed user.
39+
*
40+
* These fields should be cleaned if orphaned after the merge. Remember to update BACKFILL_RELATION_NAMES as well.
41+
*/
42+
const BACKFILL_RELATION_FK_FIELDS = [
43+
"privacyPermissionsId", //
44+
"notificationPermissionsId",
45+
] as const satisfies UserKeys[]
46+
/** Needed for type checking */
47+
const BACKFILL_RELATION_NAMES = [
48+
"privacyPermissions", //
49+
"notificationPermissions",
50+
] as const satisfies UserKeys[]
51+
52+
/** Fields with custom merge logic. */
53+
const CUSTOM_MERGE_FIELDS = [
54+
"profileSlug", //
55+
"flags",
56+
] as const satisfies UserKeys[]
57+
58+
/** These relations are reassigned from the consumed user to the survivor. */
59+
const REASSIGN_RELATION_NAMES = [
60+
"attendee",
61+
"personalMark",
62+
"givenMarks",
63+
"attendeesRefunded",
64+
"auditLogs",
65+
"deregisterReasons",
66+
"notificationsUpdated",
67+
"notificationsReceived",
68+
"notificationsCreated",
69+
] as const satisfies UserKeys[]
70+
71+
/** Relations with custom merge logic. */
72+
const CUSTOM_MERGE_RELATION_NAMES = [
73+
"memberships", //
74+
"groupMemberships",
75+
] as const satisfies UserKeys[]
76+
77+
// EXHAUSTIVENESS CHECK
78+
79+
type AllAccountedFields =
80+
| (typeof OMITTED_FIELDS)[number]
81+
| (typeof BACKFILL_SCALAR_FIELDS)[number]
82+
| (typeof BACKFILL_RELATION_FK_FIELDS)[number]
83+
| (typeof BACKFILL_RELATION_NAMES)[number]
84+
| (typeof CUSTOM_MERGE_FIELDS)[number]
85+
| (typeof REASSIGN_RELATION_NAMES)[number]
86+
| (typeof CUSTOM_MERGE_RELATION_NAMES)[number]
87+
88+
// This is all keys of the Prisma User model, including relations (which are not present on the User type)
89+
type UserKeys = keyof Prisma.$UserPayload["objects"] | keyof Prisma.$UserPayload["scalars"]
90+
91+
type MissingFromClassification = Exclude<UserKeys, AllAccountedFields>
92+
type ExtraInClassification = Exclude<AllAccountedFields, UserKeys>
93+
94+
// These will cause a compile error if they are anything other than `never`
95+
// IF YOU COME HERE FROM TYPE ERROR: You need to classify the field(s) you added in the above arrays.
96+
const _assertNoMissingFields: MissingFromClassification extends never ? true : MissingFromClassification = true
97+
const _assertNoExtraFields: ExtraInClassification extends never ? true : ExtraInClassification = true
98+
99+
// IMPLEMENTATION
100+
101+
const isUuid = (value: string) => z.string().uuid().safeParse(value).success
102+
103+
const buildMembershipDeduplicationKey = (membership: Membership) => {
104+
return `${membership.type}:${membership.specialization ?? "null"}:${membership.semester ?? "null"}`
105+
}
106+
107+
export const mergeUsers = async (handle: DBHandle, survivorUser: User, consumedUser: User): Promise<void> => {
108+
// SCALAR BACKFILL
109+
// Fills null fields on survivor from consumed
110+
const scalarUpdates: Record<string, unknown> = {}
111+
112+
for (const field of BACKFILL_SCALAR_FIELDS) {
113+
if (survivorUser[field] === null && consumedUser[field] !== null) {
114+
scalarUpdates[field] = consumedUser[field]
115+
}
116+
}
117+
118+
for (const field of BACKFILL_RELATION_FK_FIELDS) {
119+
if (survivorUser[field] === null && consumedUser[field] !== null) {
120+
scalarUpdates[field] = consumedUser[field]
121+
}
122+
}
123+
124+
// profileSlug rule: adopt consumed user's slug only if survivor's is a UUID and consumed's is not
125+
if (isUuid(survivorUser.profileSlug) && !isUuid(consumedUser.profileSlug)) {
126+
scalarUpdates.profileSlug = consumedUser.profileSlug
127+
}
128+
129+
// flags rule: concat and deduplicate
130+
scalarUpdates.flags = [...new Set([...survivorUser.flags, ...consumedUser.flags])]
131+
132+
await handle.user.update({
133+
where: {
134+
id: survivorUser.id,
135+
},
136+
data: scalarUpdates,
137+
})
138+
139+
// MERGE MEMBERSHIPS
140+
// We want to avoid duplicate memberships.
141+
const survivorMembershipKeys = new Set(survivorUser.memberships.map(buildMembershipDeduplicationKey))
142+
143+
const membershipIdsToTransfer = consumedUser.memberships
144+
.filter((membership) => !survivorMembershipKeys.has(buildMembershipDeduplicationKey(membership)))
145+
.map((membership) => membership.id)
146+
147+
if (membershipIdsToTransfer.length > 0) {
148+
await handle.membership.updateMany({
149+
where: {
150+
id: {
151+
in: membershipIdsToTransfer,
152+
},
153+
},
154+
data: {
155+
userId: survivorUser.id,
156+
},
157+
})
158+
}
159+
160+
// Delete remaining duplicate memberships still owned by the consumed user
161+
await handle.membership.deleteMany({
162+
where: { userId: consumedUser.id },
163+
})
164+
165+
// MERGE GROUP MEMBERSHIPS
166+
const survivorGroupMemberships = await handle.groupMembership.findMany({
167+
where: {
168+
userId: survivorUser.id,
169+
},
170+
})
171+
const consumedGroupMemberships = await handle.groupMembership.findMany({
172+
where: {
173+
userId: consumedUser.id,
174+
},
175+
})
176+
177+
const survivorActiveGroupIds = new Set(
178+
survivorGroupMemberships
179+
.filter((groupMembership) => groupMembership.end === null)
180+
.map((groupMembership) => groupMembership.groupId)
181+
)
182+
183+
// Transfer group memberships unless both users have an active membership in the same group
184+
const groupMembershipIdsToTransfer = consumedGroupMemberships
185+
.filter((groupMembership) => {
186+
const isActive = groupMembership.end === null
187+
const survivorAlreadyActiveInGroup = survivorActiveGroupIds.has(groupMembership.groupId)
188+
return !(isActive && survivorAlreadyActiveInGroup)
189+
})
190+
.map((groupMembership) => groupMembership.id)
191+
192+
if (groupMembershipIdsToTransfer.length > 0) {
193+
await handle.groupMembership.updateMany({
194+
where: {
195+
id: {
196+
in: groupMembershipIdsToTransfer,
197+
},
198+
},
199+
data: {
200+
userId: survivorUser.id,
201+
},
202+
})
203+
}
204+
205+
// Delete remaining duplicate group memberships still owned by the consumed user
206+
await handle.groupMembership.deleteMany({
207+
where: {
208+
userId: consumedUser.id,
209+
},
210+
})
211+
212+
// REASSIGN ALL RELATIONS TO SURVIVOR
213+
// Attendees (both "attended" and "refunded by" relations)
214+
await handle.attendee.updateMany({
215+
where: {
216+
userId: consumedUser.id,
217+
},
218+
data: {
219+
userId: survivorUser.id,
220+
},
221+
})
222+
await handle.attendee.updateMany({
223+
where: {
224+
paymentRefundedById: consumedUser.id,
225+
},
226+
data: {
227+
paymentRefundedById: survivorUser.id,
228+
},
229+
})
230+
231+
// Personal marks (received and given)
232+
await handle.personalMark.updateMany({
233+
where: {
234+
userId: consumedUser.id,
235+
},
236+
data: {
237+
userId: survivorUser.id,
238+
},
239+
})
240+
await handle.personalMark.updateMany({
241+
where: {
242+
givenById: consumedUser.id,
243+
},
244+
data: {
245+
givenById: survivorUser.id,
246+
},
247+
})
248+
249+
// Audit logs
250+
await handle.auditLog.updateMany({
251+
where: {
252+
userId: consumedUser.id,
253+
},
254+
data: {
255+
userId: survivorUser.id,
256+
},
257+
})
258+
259+
// Deregister reasons
260+
await handle.deregisterReason.updateMany({
261+
where: {
262+
userId: consumedUser.id,
263+
},
264+
data: {
265+
userId: survivorUser.id,
266+
},
267+
})
268+
269+
// Notifications (received, created, updated)
270+
await handle.notificationRecipient.updateMany({
271+
where: {
272+
userId: consumedUser.id,
273+
},
274+
data: {
275+
userId: survivorUser.id,
276+
},
277+
})
278+
await handle.notification.updateMany({
279+
where: {
280+
createdById: consumedUser.id,
281+
},
282+
data: {
283+
createdById: survivorUser.id,
284+
},
285+
})
286+
await handle.notification.updateMany({
287+
where: {
288+
lastUpdatedById: consumedUser.id,
289+
},
290+
data: {
291+
lastUpdatedById: survivorUser.id,
292+
},
293+
})
294+
295+
// DELETE ORPHANED RELATIONS
296+
// If we adopted the consumed user's privacy/notification permissions via
297+
// the FK backfill, they are already pointing at the survivor. If not,
298+
// we need to delete the consumed user's orphaned permission records.
299+
if (consumedUser.privacyPermissionsId !== null && survivorUser.privacyPermissionsId !== null) {
300+
await handle.privacyPermissions.delete({
301+
where: {
302+
id: consumedUser.privacyPermissionsId,
303+
},
304+
})
305+
}
306+
307+
if (consumedUser.notificationPermissionsId !== null && survivorUser.notificationPermissionsId !== null) {
308+
await handle.notificationPermissions.delete({
309+
where: {
310+
id: consumedUser.notificationPermissionsId,
311+
},
312+
})
313+
}
314+
315+
// CONSUME THE USER
316+
await handle.user.delete({
317+
where: {
318+
id: consumedUser.id,
319+
},
320+
})
321+
}

0 commit comments

Comments
 (0)