Skip to content

Commit

Permalink
Read from RDS (#1060)
Browse files Browse the repository at this point in the history
Co-authored-by: Eddy Chen <[email protected]>
Co-authored-by: Isaac Nguyen <[email protected]>
  • Loading branch information
3 people authored Dec 12, 2024
1 parent d011ebc commit 67d9d36
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 43 deletions.
4 changes: 2 additions & 2 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,10 @@
"arktype": "1.0.14-alpha",
"aws-lambda": "^1.0.7",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"drizzle-orm": "^0.36.3",
"fuzzysort": "3.1.0",
"envalid": "^7.3.1",
"express": "^4.18.2",
"fuzzysort": "3.1.0",
"postgres": "^3.4.4",
"superjson": "^1.12.3",
"zod": "3.23.8"
Expand All @@ -42,6 +41,7 @@
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"concurrently": "^8.0.1",
"dotenv": "^16.0.3",
"drizzle-kit": "^0.28.1",
"esbuild": "^0.17.19",
"eslint": "^8.34.0",
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/db/schema/auth/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const accountTypeEnum = pgEnum(
accountTypes
);

export type AccountType = typeof accountTypes[number];

// Each user can have multiple accounts, each account is associated with a provider.
// A user without an account is a username-only user.
export const accounts = pgTable(
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/db/schema/schedule/custom_event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ export const customEvents = pgTable(
lastUpdated: timestamp('last_updated', { withTimezone: true }).defaultNow(),
}
);

export type CustomEvent = typeof customEvents.$inferSelect;
122 changes: 121 additions & 1 deletion apps/backend/src/lib/rds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { ShortCourse, ShortCourseSchedule, User, RepeatingCustomEvent } from '@p

import { and, eq } from 'drizzle-orm';
import type { Database } from '$db/index';
import { schedules, users, accounts, coursesInSchedule, customEvents } from '$db/schema';
import {
schedules, users, accounts, coursesInSchedule, customEvents,
AccountType, Schedule, CourseInSchedule, CustomEvent
} from '$db/schema';

type DatabaseOrTransaction = Omit<Database, '$client'>;

Expand Down Expand Up @@ -217,4 +220,121 @@ export class RDS {

await db.transaction(async (tx) => await tx.insert(customEvents).values(dbCustomEvents));
}

static async getGuestUserData(
db: DatabaseOrTransaction, guestId: string
): Promise<User | null> {
const userAndAccount = await RDS.getUserAndAccount(db, 'GUEST', guestId);
if (!userAndAccount) {
return null;
}

const userId = userAndAccount.user.id;

const sectionResults = await db
.select()
.from(schedules)
.where(eq(schedules.userId, userId))
.leftJoin(coursesInSchedule, eq(schedules.id, coursesInSchedule.scheduleId))

const customEventResults = await db
.select()
.from(schedules)
.where(eq(schedules.userId, userId))
.leftJoin(customEvents, eq(schedules.id, customEvents.scheduleId));

const userSchedules = RDS.aggregateUserData(sectionResults, customEventResults);

const scheduleIndex = userAndAccount.user.currentScheduleId
? userSchedules.findIndex((schedule) => schedule.id === userAndAccount.user.currentScheduleId)
: userSchedules.length;

return {
id: guestId,
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);
}
}
53 changes: 17 additions & 36 deletions apps/backend/src/routers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,14 @@ import { type } from 'arktype';
import { UserSchema } from '@packages/antalmanac-types';

import { db } from 'src/db';
import { ddbClient } from 'src/db/ddb';
import { mangleDupliateScheduleNames as mangleDuplicateScheduleNames } from 'src/lib/formatting';
import { mangleDupliateScheduleNames } from 'src/lib/formatting';
import { RDS } from 'src/lib/rds';
import { TRPCError } from '@trpc/server';
import { procedure, router } from '../trpc';
import { ddbClient } from '$db/ddb';

const userInputSchema = type([{ userId: 'string' }, '|', { googleId: 'string' }]);

const viewInputSchema = type({
/**
* ID of the user who's requesting to view another user's schedule.
*/
requesterId: 'string',

/**
* ID of the user whose schedule is being requested.
*/
requesteeId: 'string',
});
const userInputSchema = type([{ userId: 'string' }, '|', { googleId: 'string' }]);

const saveInputSchema = type({
/**
Expand All @@ -43,9 +33,12 @@ const usersRouter = router({
*/
getUserData: procedure.input(userInputSchema.assert).query(async ({ input }) => {
if ('googleId' in input) {
return await ddbClient.getGoogleUserData(input.googleId);
throw new TRPCError({
code: 'NOT_IMPLEMENTED',
message: 'Google login not implemented',
})
}
return await ddbClient.getUserData(input.userId);
return await RDS.getGuestUserData(db, input.userId);
}),

/**
Expand All @@ -58,33 +51,21 @@ const usersRouter = router({
const data = input.data;

// Mangle duplicate schedule names
data.userData.schedules = mangleDuplicateScheduleNames(data.userData.schedules);
// Await both, but only throw if DDB save fails.
data.userData.schedules = mangleDupliateScheduleNames(data.userData.schedules);

// Await both, but only throw if RDS save fails.
const results = await Promise.allSettled([
ddbClient.insertItem(data),
ddbClient.insertItem(data)
.catch((error) => console.error('DDB Failed to save user data:', error)),
RDS.upsertGuestUserData(db, data)
.catch((error) => console.error('Failed to upsert user data:', error))
.catch((error) => console.error('RDS Failed to upsert user data:', error))
]);

if (results[0].status === 'rejected') {
throw results[0].reason;
if (results[1].status === 'rejected') {
throw results[1].reason;
}
}
),

/**
* Users can view other users' schedules, even anonymously.
* Visibility permissions are used to determine if a user can view another user's schedule.
*
* Visibility values:
* - (default) private: Only the owner can view and edit.
* - public: Other users can view, but can't edit, i.e. "read-only".
* - open: Anybody can view and edit.
*/
viewUserData: procedure.input(viewInputSchema.assert).query(async ({ input }) => {
return await ddbClient.viewUserData(input.requesterId, input.requesteeId);
}),
});

export default usersRouter;
2 changes: 1 addition & 1 deletion packages/types/src/customevent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const RepeatingCustomEventSchema = type({
start: 'string',
end: 'string',
days: 'boolean[]',
customEventID: 'number | parsedNumber', // Unique only within the schedule.
customEventID: 'string | number', // Unique only within the schedule.
'color?': 'string',
'building?': 'string | undefined',
});
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 67d9d36

Please sign in to comment.