-
Notifications
You must be signed in to change notification settings - Fork 109
Expand file tree
/
Copy pathrds.ts
More file actions
699 lines (617 loc) · 25.8 KB
/
Copy pathrds.ts
File metadata and controls
699 lines (617 loc) · 25.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
import { ShortCourse, ShortCourseSchedule, User, RepeatingCustomEvent, Notification } from '@packages/antalmanac-types';
import { db } from '@packages/db/src/index';
import * as schema from '@packages/db/src/schema';
import {
schedules,
users,
accounts,
coursesInSchedule,
customEvents,
AccountType,
Schedule,
CourseInSchedule,
CustomEvent,
sessions,
Account,
Session,
} from '@packages/db/src/schema';
import { and, eq, ExtractTablesWithRelations, gt } from 'drizzle-orm';
import { PgTransaction, PgQueryResultHKT } from 'drizzle-orm/pg-core';
import { NotificationRDS } from './notification-rds';
type Transaction = PgTransaction<PgQueryResultHKT, typeof schema, ExtractTablesWithRelations<typeof schema>>;
type DatabaseOrTransaction = Omit<typeof db, '$client'> | Transaction;
export class RDS {
/**
* If a guest user with the specified name exists, return their ID, otherwise return null.
*/
private static async guestUserIdWithNameOrNull(tx: Transaction, name: string): Promise<string | null> {
return tx
.select({ id: accounts.userId })
.from(accounts)
.where(and(eq(accounts.accountType, 'GUEST'), eq(accounts.providerAccountId, name)))
.limit(1)
.then((xs) => xs[0]?.id ?? null);
}
/**
* Creates a guest user if they don't already exist.
*
* @param tx Database or transaction object
* @param name Guest user's name, to be used as providerAccountID and username
* @returns The new/existing user's ID
*/
private static async createGuestUserOptional(tx: Transaction, name: string) {
const maybeUserId = await RDS.guestUserIdWithNameOrNull(tx, name);
const userId = maybeUserId
? maybeUserId
: await tx
.insert(users)
.values({ name })
.returning({ id: users.id })
.then((users) => users[0].id);
if (userId === undefined) {
throw new Error(`Failed to create guest user for ${name}`);
}
await tx
.insert(accounts)
.values({ userId, accountType: 'GUEST', providerAccountId: name })
.onConflictDoNothing()
.execute();
return userId;
}
/**
* Retrieves an account with the specified user ID and account type.
*
* @param db - The database or transaction object.
* @param userId - The ID of the user whose account is to be retrieved.
* @returns A promise that resolves to the account object if found, otherwise null.
*/
static async getAccountByProviderId(
db: DatabaseOrTransaction,
accountType: Account['accountType'],
providerId: string
): Promise<Account | null> {
return db.transaction((tx) =>
tx
.select()
.from(accounts)
.where(and(eq(accounts.accountType, accountType), eq(accounts.providerAccountId, providerId)))
.limit(1)
.then((res) => res[0] ?? null)
);
}
static async getGuestAccountAndUserByName(db: DatabaseOrTransaction, name: string) {
return db.transaction((tx) =>
tx
.select()
.from(accounts)
.innerJoin(users, eq(accounts.userId, users.id))
.where(and(eq(users.name, name), eq(accounts.accountType, 'GUEST')))
.execute()
.then((res) => {
return { users: res[0].users, accounts: res[0].accounts };
})
);
}
/**
* Retrieves a user by their ID from the database.
*
* @param db - The database or transaction object to use for the query.
* @param userId - The ID of the user to retrieve.
* @returns A promise that resolves to the user object if found, otherwise undefined.
*/
static async getUserById(db: DatabaseOrTransaction, userId: string) {
return db.transaction((tx) =>
tx
.select()
.from(users)
.where(eq(users.id, userId))
.then((res) => res[0])
);
}
static async getUserByEmail(db: DatabaseOrTransaction, email: string) {
return db.transaction((tx) =>
tx
.select()
.from(users)
.where(eq(users.email, email))
.then((res) => res[0])
);
}
/**
* Retrieves a google ID by their user ID from the database.
*
* @param db - The database to use for the query.
* @param userId - The ID of the user to retrieve.
* @returns The google ID if found, otherwise null.
*/
static async getGoogleIdByUserId(db: DatabaseOrTransaction, userId: string): Promise<string | null> {
return db.transaction((tx) =>
tx
.select({ providerAccountId: accounts.providerAccountId })
.from(accounts)
.where(eq(accounts.userId, userId))
.limit(1)
.then((res) => (res.length > 0 ? res[0].providerAccountId : null))
);
}
/**
* Creates a new user and an associated account with the specified provider ID.
*
* @param db - The database or transaction object.
* @param providerId - The provider account ID for the new account.
* @returns A promise that resolves to the newly created account object.
*/
static async registerUserAccount(
db: DatabaseOrTransaction,
accountType: Account['accountType'],
providerId: string,
name?: string,
email?: string,
avatar?: string
) {
// ! TODO @KevinWu098
// ! Auth uses hardcoded migration logic to handle cases in which stale userIDs
// ! still contain non OIDC google ids. This is not correct and needs to be fixed.
// ! Auth and operations upon users and accounts should not depend on localStorage. This is a hack.
const oidcProviderId = providerId.startsWith('google_') ? providerId : `google_${providerId}`;
if (accountType !== 'OIDC') {
throw new Error('Invalid account type. Must be OIDC.');
}
// First check if an account with OIDC providerId already exists
const existingAccount = await this.getAccountByProviderId(db, accountType, oidcProviderId);
if (existingAccount && accountType === 'OIDC') {
return { ...existingAccount, newUser: false };
}
const existingUser = email ? await this.getUserByEmail(db, email) : null;
if (!existingUser) {
const result = await db
.insert(users)
.values({
avatar: avatar ?? '',
name: name,
email: email ?? '',
})
.returning({ userId: users.id })
.then((res) => res[0]);
const newUserId = result.userId;
const account = await db
.insert(accounts)
.values({ userId: newUserId, providerAccountId: oidcProviderId, accountType })
.returning()
.then((res) => res[0]);
return { ...account, newUser: true };
}
await db
.update(users)
.set({
name: name,
email: email ?? '',
avatar: avatar ?? existingUser.avatar,
lastUpdated: new Date(),
})
.where(eq(users.id, existingUser.id));
const newAccount = await db
.insert(accounts)
.values({ userId: existingUser.id, providerAccountId: oidcProviderId, accountType })
.returning()
.then((res) => res[0]);
return { ...newAccount, newUser: false };
}
/**
* Creates a new schedule if one with its name doesn't already exist
* and replaces its courses and custom events with the ones provided.
*
*
* @returns The ID of the new/existing schedule
*/
private static async upsertScheduleAndContents(
tx: Transaction,
userId: string,
schedule: ShortCourseSchedule,
index: number
) {
// Add schedule
const dbSchedule = {
userId,
name: schedule.scheduleName,
notes: schedule.scheduleNote,
index,
lastUpdated: new Date(),
};
const scheduleResult = await tx.insert(schedules).values(dbSchedule).returning({ id: schedules.id });
const scheduleId = scheduleResult[0].id;
if (scheduleId === undefined) {
throw new Error(`Failed to insert schedule for ${userId}`);
}
// Add courses and custom events
await Promise.all([
this.upsertCourses(tx, scheduleId, schedule.courses).catch((error) => {
throw new Error(`Failed to insert courses for ${schedule.scheduleName}: ${error}`);
}),
this.upsertCustomEvents(tx, scheduleId, schedule.customEvents).catch((error) => {
throw new Error(`Failed to insert custom events for ${schedule.scheduleName}: ${error}`);
}),
]);
return scheduleId;
}
/**
* Does the same thing as `insertGuestUserData`, but also updates the user's schedules and courses if they exist.
*
* @param db The Drizzle client or transaction object
* @param userData The object of data containing the user's schedules and courses
* @returns The user's ID
*/
static async upsertUserData(db: DatabaseOrTransaction, userData: User): Promise<string> {
return db.transaction(async (tx) => {
const account = await this.registerUserAccount(
db,
'OIDC',
userData.id,
userData.name,
userData.email,
userData.avatar
);
const userId = account.userId;
if (!account) {
throw new Error(`Failed to create user`);
}
// Add schedules and courses
const scheduleIds = await this.upsertSchedulesAndContents(tx, userId, userData.userData.schedules);
// Update user's current schedule index
const scheduleIndex = userData.userData.scheduleIndex;
const currentScheduleId =
scheduleIndex === undefined || scheduleIndex >= scheduleIds.length ? null : scheduleIds[scheduleIndex];
if (currentScheduleId !== null) {
await tx.update(users).set({ currentScheduleId: currentScheduleId }).where(eq(users.id, userId));
}
return userId;
});
}
/** Deletes and recreates all of the user's schedules and contents */
private static async upsertSchedulesAndContents(
tx: Transaction,
userId: string,
scheduleArray: ShortCourseSchedule[]
): Promise<string[]> {
// Drop all schedules, which will cascade to courses and custom events
await tx.delete(schedules).where(eq(schedules.userId, userId));
return Promise.all(
scheduleArray.map((schedule, index) => this.upsertScheduleAndContents(tx, userId, schedule, index))
);
}
/**
* Drops all courses in the schedule and re-add them,
* deduplicating by section code and term.
* */
private static async upsertCourses(tx: Transaction, scheduleId: string, courses: ShortCourse[]) {
await tx.delete(coursesInSchedule).where(eq(coursesInSchedule.scheduleId, scheduleId));
if (courses.length === 0) {
return;
}
const coursesUnique: Set<string> = new Set();
const dbCourses = courses.map((course) => ({
scheduleId,
sectionCode: parseInt(course.sectionCode),
term: course.term,
color: course.color,
lastUpdated: new Date(),
}));
const dbCoursesUnique = dbCourses.filter((course) => {
const key = `${course.sectionCode}-${course.term}`;
if (coursesUnique.has(key)) {
return false;
}
coursesUnique.add(key);
return true;
});
await tx.insert(coursesInSchedule).values(dbCoursesUnique);
}
private static async upsertCustomEvents(
tx: Transaction,
scheduleId: string,
repeatingCustomEvents: RepeatingCustomEvent[]
) {
if (repeatingCustomEvents.length === 0) {
return;
}
const dbCustomEvents = repeatingCustomEvents.map((event) => ({
scheduleId,
title: event.title,
start: event.start,
end: event.end,
days: event.days.map((day) => (day ? '1' : '0')).join(''),
color: event.color,
building: event.building,
lastUpdated: new Date(),
}));
await tx.insert(customEvents).values(dbCustomEvents);
}
/**
* Retrieves user data by user ID, including schedules and custom events.
*
* @param db - The database or transaction object to use for the query.
* @param userId - The unique identifier of the user.
* @returns A promise that resolves to a User object containing user data and schedules, or null if the user is not found.
*/
static async getUserDataByUid(db: DatabaseOrTransaction, userId: string): Promise<User | null> {
return db.transaction(async (tx) => {
const user = await tx
.select()
.from(users)
.where(eq(users.id, userId))
.then((res) => res[0]);
if (!user) {
return null;
}
const sectionResults = await tx
.select()
.from(schedules)
.where(eq(schedules.userId, userId))
.leftJoin(coursesInSchedule, eq(schedules.id, coursesInSchedule.scheduleId));
const customEventResults = await tx
.select()
.from(schedules)
.where(eq(schedules.userId, userId))
.leftJoin(customEvents, eq(schedules.id, customEvents.scheduleId));
const userSchedules = RDS.aggregateUserData(sectionResults, customEventResults);
const scheduleIndex = user.currentScheduleId
? userSchedules.findIndex((schedule) => schedule.id === user.currentScheduleId)
: userSchedules.length;
return {
id: userId,
userData: {
schedules: userSchedules,
scheduleIndex,
},
};
});
}
private static async getUserAndAccount(
db: DatabaseOrTransaction,
accountType: AccountType,
providerAccountId: string
) {
const res = await db
.select()
.from(accounts)
.where(and(eq(accounts.accountType, accountType), eq(accounts.providerAccountId, providerAccountId)))
.leftJoin(users, eq(accounts.userId, users.id))
.limit(1);
if (res.length === 0 || res[0].users === null || res[0].accounts === null) {
return null;
}
return { user: res[0].users, account: res[0].accounts };
}
/**
* Aggregates the user's schedule data from the results of two queries.
*/
private static aggregateUserData(
sectionResults: { schedules: Schedule; coursesInSchedule: CourseInSchedule | null }[],
customEventResults: { schedules: Schedule; customEvents: CustomEvent | null }[]
): (ShortCourseSchedule & { id: string; index: number })[] {
// Map from schedule ID to schedule data
const schedulesMapping: Record<string, ShortCourseSchedule & { id: string; index: number }> = {};
// Add courses to schedules
sectionResults.forEach(({ schedules: schedule, coursesInSchedule: course }) => {
const scheduleId = schedule.id;
const scheduleAggregate = schedulesMapping[scheduleId] || {
id: scheduleId,
scheduleName: schedule.name,
scheduleNote: schedule.notes,
courses: [],
customEvents: [],
index: schedule.index,
};
if (course) {
scheduleAggregate.courses.push({
sectionCode: course.sectionCode.toString(),
term: course.term,
color: course.color,
});
}
schedulesMapping[scheduleId] = scheduleAggregate;
});
// Add custom events to schedules
customEventResults.forEach(({ schedules: schedule, customEvents: customEvent }) => {
const scheduleId = schedule.id;
const scheduleAggregate = schedulesMapping[scheduleId] || {
scheduleName: schedule.name,
scheduleNote: schedule.notes,
courses: [],
customEvents: [],
};
if (customEvent) {
scheduleAggregate.customEvents.push({
customEventID: customEvent.id,
title: customEvent.title,
start: customEvent.start,
end: customEvent.end,
days: customEvent.days.split('').map((day) => day === '1'),
color: customEvent.color ?? undefined,
building: customEvent.building ?? undefined,
});
}
schedulesMapping[scheduleId] = scheduleAggregate;
});
// Sort schedules by index
return Object.values(schedulesMapping).sort((a, b) => a.index - b.index);
}
/**
* Retrieves the current session from the database using the provided refresh token.
*
* @param db - The database or transaction object to use for the query.
* @param refreshToken - The refresh token to search for in the sessions table.
* @returns A promise that resolves to the current session object if found, or null if not found.
*/
static async getCurrentSession(db: DatabaseOrTransaction, refreshToken: string) {
return db.transaction((tx) =>
tx
.select()
.from(sessions)
.where(eq(sessions.refreshToken, refreshToken))
.then((res) => res[0] ?? null)
);
}
/**
* Creates a new session for a user in the database.
*
* @param db - The database or transaction object to use for the operation.
* @param userID - The ID of the user for whom the session is being created.
* @returns A promise that resolves to the created session object or null if the creation failed.
*/
static async createSession(tx: Transaction, userID: string): Promise<Session | null> {
return tx
.insert(sessions)
.values({
userId: userID,
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
})
.returning()
.then((res) => res[0] ?? null);
}
/**
* Removes a session from the database for a given user and refresh token.
*
* @param db - The database or transaction object to perform the operation.
* @param userId - The ID of the user whose session is to be removed.
* @param refreshToken - The refresh token of the session to be removed. If null, no action is taken.
* @returns A promise that resolves when the session is removed.
*/
static async removeSession(db: DatabaseOrTransaction, userId: string, refreshToken: string | null) {
if (refreshToken) {
await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.refreshToken, refreshToken)));
}
}
/**
* Upserts a session for a user. If a session with the given refresh token already exists,
* it returns the current session. Otherwise, it creates a new session for the user.
*
* @param db - The database or transaction object to use for the operation.
* @param userId - The ID of the user for whom the session is being upserted.
* @param refreshToken - The refresh token to check for an existing session.
* @returns A promise that resolves to the current session if it exists, or a new session if it was created, or null if the operation fails.
*/
static async upsertSession(
db: DatabaseOrTransaction,
userId: string,
refreshToken?: string
): Promise<Session | null> {
return db.transaction(async (tx) => {
const currentSession = await tx
.select()
.from(sessions)
.where(eq(sessions.refreshToken, refreshToken ?? ''))
.then((res) => res[0] ?? null);
if (currentSession) return currentSession;
return await RDS.createSession(tx, userId);
});
}
private static async getUserDataWithSession(tx: Transaction, refreshToken: string) {
return tx
.select()
.from(users)
.leftJoin(sessions, eq(users.id, sessions.userId))
.where(and(eq(sessions.refreshToken, refreshToken), gt(sessions.expires, new Date())))
.then((res) => res[0].users);
}
/**
* Fetches user data associated with a valid session using a refresh token.
*
* This function initiates a database transaction to retrieve user information
* based on the provided refresh token. If a user is found, it gathers the user's
* schedules and custom events, aggregates them, and determines the current schedule index.
*
* @param db - The database or transaction object to perform the operation.
* @param refreshToken - The refresh token used to identify the session.
* @returns A promise that resolves to an object containing the user's ID and user data,
* including schedules and the current schedule index, or null if no user is found.
*/
static async fetchUserDataWithSession(db: DatabaseOrTransaction, refreshToken: string) {
return db.transaction(async (tx) => {
const user = await this.getUserDataWithSession(tx, refreshToken);
if (user) {
const sectionResults = await tx
.select()
.from(schedules)
.where(eq(schedules.userId, user.id))
.leftJoin(coursesInSchedule, eq(schedules.id, coursesInSchedule.scheduleId));
const customEventResults = await tx
.select()
.from(schedules)
.where(eq(schedules.userId, user.id))
.leftJoin(customEvents, eq(schedules.id, customEvents.scheduleId));
const userSchedules = RDS.aggregateUserData(sectionResults, customEventResults);
const scheduleIndex = user.currentScheduleId
? userSchedules.findIndex((schedule) => schedule.id === user.currentScheduleId)
: userSchedules.length;
return {
id: user.id,
userData: {
schedules: userSchedules,
scheduleIndex,
},
};
}
return null;
});
}
static async getUserAndAccountBySessionToken(db: DatabaseOrTransaction, refreshToken: string) {
return db.transaction((tx) =>
tx
.select()
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.innerJoin(accounts, eq(users.id, accounts.userId))
.where(eq(sessions.refreshToken, refreshToken))
.execute()
.then((res) => {
return { users: res[0].users, accounts: res[0].accounts };
})
);
}
/**
* Flags a user as imported based on the provided provider ID.
*
* This function checks if a user associated with the given provider ID has already been flagged as imported.
* If not, it updates the user's record to set the imported flag to true.
*
* @param db The database or transaction object used to perform the operation.
* @param providerId The provider ID used to identify the user.
* @returns A promise that resolves to true if the user was successfully flagged as imported, or false if the user
* was already flagged or if an error occurred during the operation.
*/
static async flagImportedUser(db: DatabaseOrTransaction, providerId: string) {
try {
const { users: user, accounts } = await this.getGuestAccountAndUserByName(db, providerId);
if (user.imported) {
return false;
}
await db.transaction((tx) =>
tx.update(users).set({ imported: true }).where(eq(users.id, accounts.userId)).execute()
);
return true;
} catch (error) {
return false;
}
}
static async retrieveNotifications(db: DatabaseOrTransaction, userId: string) {
return NotificationRDS.retrieveNotifications(db, userId);
}
static async upsertNotification(
db: DatabaseOrTransaction,
userId: string,
notification: Notification,
environmentValue?: string | null
) {
return NotificationRDS.upsertNotification(db, userId, notification, environmentValue);
}
static async updateAllNotifications(db: DatabaseOrTransaction, notification: Notification) {
return NotificationRDS.updateAllNotifications(db, notification);
}
static async deleteNotification(db: DatabaseOrTransaction, notification: Notification, userId: string) {
return NotificationRDS.deleteNotification(db, notification, userId);
}
static async deleteAllNotifications(db: DatabaseOrTransaction, userId: string) {
return NotificationRDS.deleteAllNotifications(db, userId);
}
}