diff --git a/.github/workflows/assign_random_reviewer.yaml b/.github/workflows/assign_random_reviewer.yaml deleted file mode 100644 index d28682a1c..000000000 --- a/.github/workflows/assign_random_reviewer.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: "Assign Random Reviewer" -on: - pull_request_target: - types: [opened, ready_for_review, reopened] - -jobs: - assign_reviewer: - runs-on: ubuntu-latest - if: github.event.pull_request.draft == false - steps: - - uses: actions/checkout@v3 - - uses: uesteibar/reviewer-lottery@v2 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/deploy_production.yml b/.github/workflows/deploy_production.yml index 9be62d43a..77bbd15dc 100644 --- a/.github/workflows/deploy_production.yml +++ b/.github/workflows/deploy_production.yml @@ -45,6 +45,7 @@ env: HOSTED_ZONE_ID: ${{ secrets.HOSTED_ZONE_ID }} CERTIFICATE_ARN: ${{ secrets.CERTIFICATE_ARN }} PR_NUM: ${{ github.event.pull_request.number }} + STAGE: prod jobs: # Build production version of the frontend and upload the artifacts. diff --git a/.github/workflows/deploy_staging.yml b/.github/workflows/deploy_staging.yml index b7940602d..c6f665ff5 100644 --- a/.github/workflows/deploy_staging.yml +++ b/.github/workflows/deploy_staging.yml @@ -45,6 +45,7 @@ env: HOSTED_ZONE_ID: ${{ secrets.HOSTED_ZONE_ID }} CERTIFICATE_ARN: ${{ secrets.CERTIFICATE_ARN }} PR_NUM: ${{ github.event.pull_request.number }} + STAGE: dev jobs: get_staging_configuration: diff --git a/.github/workflows/destroy_staging.yml b/.github/workflows/destroy_staging.yml index 1a8c26a85..31e201ede 100644 --- a/.github/workflows/destroy_staging.yml +++ b/.github/workflows/destroy_staging.yml @@ -42,6 +42,7 @@ env: HOSTED_ZONE_ID: ${{ secrets.HOSTED_ZONE_ID }} CERTIFICATE_ARN: ${{ secrets.CERTIFICATE_ARN }} PR_NUM: ${{ github.event.pull_request.number }} + STAGE: dev jobs: destroy_staging_frontend: diff --git a/apps/antalmanac/src/actions/ActionTypesStore.ts b/apps/antalmanac/src/actions/ActionTypesStore.ts index c57d018a3..0284234ef 100644 --- a/apps/antalmanac/src/actions/ActionTypesStore.ts +++ b/apps/antalmanac/src/actions/ActionTypesStore.ts @@ -59,7 +59,7 @@ export interface ClearScheduleAction { export interface CopyScheduleAction { type: 'copySchedule'; - to: number; + newScheduleName: string; } export interface ChangeCourseColorAction { @@ -159,7 +159,7 @@ class ActionTypesStore extends EventEmitter { AppStore.schedule.clearCurrentSchedule(); break; case 'copySchedule': - AppStore.schedule.copySchedule(action.to); + AppStore.schedule.copySchedule(action.newScheduleName); break; default: break; diff --git a/apps/antalmanac/src/actions/AppStoreActions.ts b/apps/antalmanac/src/actions/AppStoreActions.ts index 78bbf2c55..e54a5be49 100644 --- a/apps/antalmanac/src/actions/AppStoreActions.ts +++ b/apps/antalmanac/src/actions/AppStoreActions.ts @@ -11,8 +11,8 @@ import { removeLocalStorageUserId, setLocalStorageUserId } from '$lib/localStora import AppStore from '$stores/AppStore'; export interface CopyScheduleOptions { - onSuccess: (index: number) => unknown; - onError: (index: number) => unknown; + onSuccess: (scheduleName: string) => unknown; + onError: (scheduleName: string) => unknown; } export const addCourse = ( @@ -250,17 +250,17 @@ export const changeCourseColor = (sectionCode: string, term: string, newColor: s AppStore.changeCourseColor(sectionCode, term, newColor); }; -export const copySchedule = (to: number, options?: CopyScheduleOptions) => { +export const copySchedule = (newScheduleName: string, options?: CopyScheduleOptions) => { logAnalytics({ category: analyticsEnum.addedClasses.title, action: analyticsEnum.addedClasses.actions.COPY_SCHEDULE, }); try { - AppStore.copySchedule(to); - options?.onSuccess(to); + AppStore.copySchedule(newScheduleName); + options?.onSuccess(newScheduleName); } catch (error) { - options?.onError(to); + options?.onError(newScheduleName); } }; diff --git a/apps/antalmanac/src/components/dialogs/AddSchedule.tsx b/apps/antalmanac/src/components/dialogs/AddSchedule.tsx index 3bf91f87a..ae4bc6305 100644 --- a/apps/antalmanac/src/components/dialogs/AddSchedule.tsx +++ b/apps/antalmanac/src/components/dialogs/AddSchedule.tsx @@ -1,6 +1,6 @@ import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material'; import type { DialogProps } from '@mui/material'; -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { addSchedule } from '$actions/AppStoreActions'; import AppStore from '$stores/AppStore'; @@ -12,7 +12,9 @@ import { useThemeStore } from '$stores/SettingsStore'; function AddScheduleDialog({ onClose, onKeyDown, ...props }: DialogProps) { const isDark = useThemeStore((store) => store.isDark); - const [name, setName] = useState(AppStore.getDefaultScheduleName()); + const [name, setName] = useState( + AppStore.getNextScheduleName(AppStore.getDefaultScheduleName(), AppStore.getScheduleNames().length) + ); const handleCancel = () => { onClose?.({}, 'escapeKeyDown'); @@ -24,7 +26,6 @@ function AddScheduleDialog({ onClose, onKeyDown, ...props }: DialogProps) { const submitName = () => { addSchedule(name); - setName(AppStore.schedule.getDefaultScheduleName()); onClose?.({}, 'escapeKeyDown'); }; @@ -46,6 +47,17 @@ function AddScheduleDialog({ onClose, onKeyDown, ...props }: DialogProps) { } }; + const handleScheduleNamesChange = useCallback(() => { + setName(AppStore.getNextScheduleName(AppStore.getDefaultScheduleName(), AppStore.getScheduleNames().length)); + }, []); + + useEffect(() => { + AppStore.on('scheduleNamesChange', handleScheduleNamesChange); + return () => { + AppStore.off('scheduleNamesChange', handleScheduleNamesChange); + }; + }, [handleScheduleNamesChange]); + return ( Add Schedule @@ -60,7 +72,7 @@ function AddScheduleDialog({ onClose, onKeyDown, ...props }: DialogProps) { - diff --git a/apps/antalmanac/src/components/dialogs/CopySchedule.tsx b/apps/antalmanac/src/components/dialogs/CopySchedule.tsx index c0722d461..5a9da35d5 100644 --- a/apps/antalmanac/src/components/dialogs/CopySchedule.tsx +++ b/apps/antalmanac/src/components/dialogs/CopySchedule.tsx @@ -5,11 +5,9 @@ import { DialogActions, DialogContent, DialogTitle, - MenuItem, - Select, + TextField, type DialogProps, } from '@mui/material'; -import { SelectChangeEvent } from '@mui/material'; import { useState, useEffect, useCallback } from 'react'; import { copySchedule } from '$actions/AppStoreActions'; @@ -22,11 +20,10 @@ interface CopyScheduleDialogProps extends DialogProps { function CopyScheduleDialog(props: CopyScheduleDialogProps) { const { index } = props; const { onClose } = props; // destructured separately for memoization. - const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); - const [selectedSchedule, setSelectedSchedule] = useState(0); + const [name, setName] = useState(`Copy of ${AppStore.getScheduleNames()[index]}`); - const handleScheduleChange = useCallback((event: SelectChangeEvent) => { - setSelectedSchedule(event.target.value as number); + const handleNameChange = useCallback((event: React.ChangeEvent) => { + setName(event.target.value); }, []); const handleCancel = useCallback(() => { @@ -34,25 +31,16 @@ function CopyScheduleDialog(props: CopyScheduleDialogProps) { }, [onClose]); const handleCopy = useCallback(() => { - if (selectedSchedule !== scheduleNames.length) { - copySchedule(selectedSchedule); - } else { - scheduleNames.forEach((_, scheduleIndex) => { - if (scheduleIndex !== index) { - copySchedule(scheduleIndex); - } - }); - } + copySchedule(name); onClose?.({}, 'escapeKeyDown'); - }, [index, onClose, selectedSchedule, scheduleNames]); + }, [onClose, name]); const handleScheduleNamesChange = useCallback(() => { - setScheduleNames([...AppStore.getScheduleNames()]); - }, []); + setName(`Copy of ${AppStore.getScheduleNames()[index]}`); + }, [index]); useEffect(() => { AppStore.on('scheduleNamesChange', handleScheduleNamesChange); - return () => { AppStore.off('scheduleNamesChange', handleScheduleNamesChange); }; @@ -60,27 +48,18 @@ function CopyScheduleDialog(props: CopyScheduleDialogProps) { return ( - Copy To Schedule - + Copy Schedule - + - - diff --git a/apps/antalmanac/src/components/dialogs/DeleteSchedule.tsx b/apps/antalmanac/src/components/dialogs/DeleteSchedule.tsx index 2bcf7ed96..402bf1d40 100644 --- a/apps/antalmanac/src/components/dialogs/DeleteSchedule.tsx +++ b/apps/antalmanac/src/components/dialogs/DeleteSchedule.tsx @@ -7,7 +7,7 @@ import { DialogTitle, type DialogProps, } from '@mui/material'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { deleteSchedule } from '$actions/AppStoreActions'; import AppStore from '$stores/AppStore'; @@ -35,10 +35,7 @@ function DeleteScheduleDialog(props: ScheduleNameDialogProps) { * This is destructured separately for memoization. */ const { onClose } = props; - - const scheduleName = useMemo(() => { - return AppStore.schedule.getScheduleName(index); - }, [index]); + const [name, setName] = useState(AppStore.getScheduleNames()[index]); const handleCancel = useCallback(() => { onClose?.({}, 'escapeKeyDown'); @@ -49,12 +46,24 @@ function DeleteScheduleDialog(props: ScheduleNameDialogProps) { onClose?.({}, 'escapeKeyDown'); }, [index, onClose]); + const handleScheduleNamesChange = useCallback(() => { + setName(AppStore.getScheduleNames()[index]); + }, [index]); + + useEffect(() => { + AppStore.on('scheduleNamesChange', handleScheduleNamesChange); + + return () => { + AppStore.off('scheduleNamesChange', handleScheduleNamesChange); + }; + }, [handleScheduleNamesChange]); + return ( Delete Schedule - Are you sure you want to delete "{scheduleName}"? + Are you sure you want to delete "{name}"? diff --git a/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx b/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx index 0d3611a2a..a365413d3 100644 --- a/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx +++ b/apps/antalmanac/src/components/dialogs/RenameSchedule.tsx @@ -8,7 +8,7 @@ import { TextField, type DialogProps, } from '@mui/material'; -import { useCallback, useState, useEffect, useMemo } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import { renameSchedule } from '$actions/AppStoreActions'; import AppStore from '$stores/AppStore'; @@ -34,19 +34,11 @@ function RenameScheduleDialog(props: ScheduleNameDialogProps) { * This is destructured separately for memoization. */ const { onClose } = props; - - const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames()); - - const [name, setName] = useState(scheduleNames[index]); - - const disabled = useMemo(() => { - return name?.trim() === ''; - }, [name]); + const [name, setName] = useState(AppStore.getScheduleNames()[index]); const handleCancel = useCallback(() => { onClose?.({}, 'escapeKeyDown'); - setName(scheduleNames[index]); - }, [onClose, scheduleNames, index]); + }, [onClose, index]); const handleNameChange = useCallback((event: React.ChangeEvent) => { setName(event.target.value); @@ -75,8 +67,8 @@ function RenameScheduleDialog(props: ScheduleNameDialogProps) { ); const handleScheduleNamesChange = useCallback(() => { - setScheduleNames(AppStore.getScheduleNames()); - }, []); + setName(AppStore.getScheduleNames()[index]); + }, [index]); useEffect(() => { AppStore.on('scheduleNamesChange', handleScheduleNamesChange); @@ -100,7 +92,7 @@ function RenameScheduleDialog(props: ScheduleNameDialogProps) { - diff --git a/apps/antalmanac/src/routes/ErrorPage.tsx b/apps/antalmanac/src/routes/ErrorPage.tsx index 2982659ef..45b049bcb 100644 --- a/apps/antalmanac/src/routes/ErrorPage.tsx +++ b/apps/antalmanac/src/routes/ErrorPage.tsx @@ -1,43 +1,61 @@ -import { Typography, Button, Stack } from '@mui/material'; -import { Link, useRouteError } from 'react-router-dom'; +import { ExpandMore } from '@mui/icons-material'; +import { Box, Accordion, AccordionDetails, AccordionSummary, Typography, Button, Stack } from '@mui/material'; +import { Link, useLocation, useRouteError } from 'react-router-dom'; export const ErrorPage = () => { const error = useRouteError(); + const location = useLocation(); return ( - - - Oops! Something went wrong. - - - - This error may be caused by your browser having an out of date version of AntAlmanac. - - - Try refreshing the page. If the error persists, please submit a{' '} - bug report with the provided error. + + + + Oops! Something went wrong. + + + This error may be caused by your browser having an out of date version of AntAlmanac. + + + Try refreshing the page. If the error persists, please submit a{' '} + bug report with the provided error. + + + + + + + }> + View Error Message + + + Route: {location.pathname} + + {error instanceof Error ? error.stack : 'No error stack provided.'} + + + - - - -
- View Error Message -

{error instanceof Error &&

{error.stack}
}

-
-
+ ); }; diff --git a/apps/antalmanac/src/stores/AppStore.ts b/apps/antalmanac/src/stores/AppStore.ts index bfcd43456..b9ac5c496 100644 --- a/apps/antalmanac/src/stores/AppStore.ts +++ b/apps/antalmanac/src/stores/AppStore.ts @@ -71,6 +71,10 @@ class AppStore extends EventEmitter { } } + getNextScheduleName(newScheduleName: string, scheduleIndex: number) { + return this.schedule.getNextScheduleName(newScheduleName, scheduleIndex); + } + getDefaultScheduleName() { return this.schedule.getDefaultScheduleName(); } @@ -291,14 +295,17 @@ class AppStore extends EventEmitter { window.localStorage.removeItem('unsavedActions'); } - copySchedule(to: number) { - this.schedule.copySchedule(to); + copySchedule(newScheduleName: string) { + this.schedule.copySchedule(newScheduleName); this.unsavedChanges = true; const action: CopyScheduleAction = { type: 'copySchedule', - to: to, + newScheduleName: newScheduleName, }; actionTypesStore.autoSaveSchedule(action); + this.emit('scheduleNamesChange'); + this.emit('currentScheduleIndexChange'); + this.emit('scheduleNotesChange'); this.emit('addedCoursesChange'); this.emit('customEventsChange'); } diff --git a/apps/antalmanac/src/stores/Schedules.ts b/apps/antalmanac/src/stores/Schedules.ts index 0129082ff..9d14a983e 100644 --- a/apps/antalmanac/src/stores/Schedules.ts +++ b/apps/antalmanac/src/stores/Schedules.ts @@ -50,10 +50,20 @@ export class Schedules { this.skeletonSchedules = []; } + getNextScheduleName(newScheduleName: string, scheduleIndex: number) { + const scheduleNames = this.getScheduleNames(); + scheduleNames.splice(scheduleIndex, 1); + let nextScheduleName = newScheduleName; + let counter = 1; + + while (scheduleNames.includes(nextScheduleName)) { + nextScheduleName = `${newScheduleName}(${counter++})`; + } + return nextScheduleName; + } + getDefaultScheduleName() { - const termName = termData[0].shortName.replaceAll(' ', '-'); - const countSameScheduleNames = this.getScheduleNames().filter((name) => name.includes(termName)).length; - return `${termName + (countSameScheduleNames == 0 ? '' : '(' + countSameScheduleNames + ')')}`; + return termData[0].shortName.replaceAll(' ', '-'); } getCurrentScheduleIndex() { @@ -92,12 +102,13 @@ export class Schedules { /** * Create an empty schedule. + * @param newScheduleName The name of the new schedule. If a schedule with the same name already exists, a number will be appended to the name. */ addNewSchedule(newScheduleName: string) { this.addUndoState(); const scheduleNoteId = Math.random(); this.schedules.push({ - scheduleName: newScheduleName, + scheduleName: this.getNextScheduleName(newScheduleName, this.getNumberOfSchedules()), courses: [], customEvents: [], scheduleNoteId: scheduleNoteId, @@ -109,10 +120,11 @@ export class Schedules { /** * Rename schedule with the specified index. + * @param newScheduleName The name of the new schedule. If a schedule with the same name already exists, a number will be appended to the name. */ renameSchedule(newScheduleName: string, scheduleIndex: number) { this.addUndoState(); - this.schedules[scheduleIndex].scheduleName = newScheduleName; + this.schedules[scheduleIndex].scheduleName = this.getNextScheduleName(newScheduleName, scheduleIndex); } /** @@ -134,25 +146,19 @@ export class Schedules { } /** - * Append all courses from current schedule to the schedule with the target index. - * @param to Index of the schedule to append courses to. If equal to number of schedules, will append courses to all schedules. + * Copy the current schedule to a newly created schedule with the specified name. */ - copySchedule(to: number) { - this.addUndoState(); + copySchedule(newScheduleName: string) { + this.addNewSchedule(newScheduleName); + this.currentScheduleIndex = this.previousStates[this.previousStates.length - 1].scheduleIndex; // return to previous schedule index for copying + const to = this.getNumberOfSchedules() - 1; + for (const course of this.getCurrentCourses()) { - if (to === this.getNumberOfSchedules()) { - this.addCourseToAllSchedules(course); - } else { - this.addCourse(course, to, false); - } + this.addCourse(course, to, false); } for (const customEvent of this.getCurrentCustomEvents()) { - if (to === this.getNumberOfSchedules()) { - this.addCustomEvent(customEvent, [...Array(to).keys()]); - } else { - this.addCustomEvent(customEvent, [to]); - } + this.addCustomEvent(customEvent, [to]); } } diff --git a/apps/backend/drizzle.config.ts b/apps/backend/drizzle.config.ts index 9eb0a9a76..7e03e4481 100644 --- a/apps/backend/drizzle.config.ts +++ b/apps/backend/drizzle.config.ts @@ -1,8 +1,8 @@ import { defineConfig } from "drizzle-kit"; -import env from "./src/env"; +import { rdsEnvSchema } from "./src/env"; -const { DB_URL } = env; +const { DB_URL } = rdsEnvSchema.parse(process.env); export default defineConfig({ dialect: "postgresql", diff --git a/apps/backend/src/db/ddb.ts b/apps/backend/src/db/ddb.ts index 9bd55d69c..f1536c852 100644 --- a/apps/backend/src/db/ddb.ts +++ b/apps/backend/src/db/ddb.ts @@ -6,8 +6,7 @@ import { UserSchema, type ScheduleSaveState, } from '@packages/antalmanac-types'; - -import env from '../env'; +import {backendEnvSchema} from "../env"; /** * TODO: enforce this in the schema too, or just leave it as an arbitrary string? @@ -18,6 +17,8 @@ export const VISIBILITY = { OPEN: 'open', }; +const env = backendEnvSchema.parse(process.env); + class DDBClient>> { private tableName: string; @@ -28,6 +29,10 @@ class DDBClient>> { documentClient: DynamoDBDocument; constructor(tableName: string, schema: T) { + if (!tableName) { + throw new Error('DDBClient(): tableName must be defined'); + } + this.tableName = tableName; this.schema = schema; this.client = new DynamoDB({ diff --git a/apps/backend/src/db/index.ts b/apps/backend/src/db/index.ts index 91c1cf9d4..7314f60dd 100644 --- a/apps/backend/src/db/index.ts +++ b/apps/backend/src/db/index.ts @@ -1,12 +1,10 @@ import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; -import env from '../env'; +import { rdsEnvSchema } from "../env"; import * as schema from './schema/index.js'; -const { DB_URL } = env; - -if (!DB_URL) throw new Error("DB_URL not defined") +const { DB_URL } = rdsEnvSchema.parse(process.env); export const client = postgres(DB_URL); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index c0cc84296..c0cc68a58 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -1,18 +1,41 @@ -import { type } from 'arktype'; -import * as dotenv from 'dotenv'; +import {z} from "zod"; +import * as dotenv from "dotenv"; dotenv.config(); -const Environment = type({ - 'NODE_ENV?': "'development' | 'production' | 'staging'", - USERDATA_TABLE_NAME: 'string', - AWS_REGION: 'string', - MAPBOX_ACCESS_TOKEN: 'string', - 'PR_NUM?': 'number', - DB_URL: 'string', - STAGE: "string", -}); -const env = Environment.assert({ ...process.env }); +/** + * Environment variables required by the backend during deploy time. + */ +export const deployEnvSchema = z.object({ + DB_URL: z.string(), + STAGE: z.string(), + MAPBOX_ACCESS_TOKEN: z.string(), +}) -export default env; +/** + * Environment variables required by the backend to connect to the RDS instance. + */ +export const rdsEnvSchema = z.object({ + DB_URL: z.string(), + NODE_ENV: z.string().optional(), +}) + +/** + * Environment variables required by the backend to connect to the DynamoDB table. + * + * This will be removed once we complete migration to RDS. + */ +export const ddbEnvSchema = z.object({ + USERDATA_TABLE_NAME: z.string(), + AWS_REGION: z.string(), + NODE_ENV: z.string().optional(), +}) + +/** + * Environment variables required by the backend during runtime. + */ +export const backendEnvSchema = z.intersection( + deployEnvSchema, + z.intersection(rdsEnvSchema, ddbEnvSchema) +) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 32a591b73..95134c6f0 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -4,7 +4,7 @@ import type { CorsOptions } from 'cors'; import { createExpressMiddleware } from '@trpc/server/adapters/express'; import AppRouter from './routers'; import createContext from './context'; -import env from './env'; +import { backendEnvSchema } from "./env"; const corsOptions: CorsOptions = { origin: ['https://antalmanac.com', 'https://www.antalmanac.com', 'https://icssc-projects.github.io/AntAlmanac'], @@ -15,6 +15,7 @@ const MAPBOX_API_URL = 'https://api.mapbox.com'; const PORT = 3000; export async function start(corsEnabled = false) { + const env = backendEnvSchema.parse(process.env) const app = express(); app.use(cors(corsEnabled ? corsOptions : undefined)); app.use(express.json()); diff --git a/apps/backend/src/lambda.ts b/apps/backend/src/lambda.ts index 939892b8c..cff1c76d2 100644 --- a/apps/backend/src/lambda.ts +++ b/apps/backend/src/lambda.ts @@ -1,12 +1,13 @@ import serverlessExpress from '@vendia/serverless-express'; import type { Context, Handler } from 'aws-lambda'; -import env from './env'; +import {backendEnvSchema} from './env'; import { start } from '.'; let cachedHandler: Handler; export async function handler(event: any, context: Context, callback: any) { + const env = backendEnvSchema.parse(process.env) if (!cachedHandler) { const app = await start(env.NODE_ENV === 'production'); cachedHandler = serverlessExpress({ app }); diff --git a/apps/backend/src/lib/rds.ts b/apps/backend/src/lib/rds.ts index 355dd59f2..e1a6fa395 100644 --- a/apps/backend/src/lib/rds.ts +++ b/apps/backend/src/lib/rds.ts @@ -4,8 +4,7 @@ import { and, eq } from 'drizzle-orm'; import type { Database } from '$db/index'; import { schedules, users, accounts, coursesInSchedule, customEvents, - Schedule, CourseInSchedule, CustomEvent, - AccountType + AccountType, Schedule, CourseInSchedule, CustomEvent } from '$db/schema'; type DatabaseOrTransaction = Omit; @@ -162,6 +161,66 @@ export class RDS { ); } + /** + * Drops all courses in the schedule and re-add them, + * deduplicating by section code and term. + * */ + private static async upsertCourses(db: DatabaseOrTransaction, scheduleId: string, courses: ShortCourse[]) { + await db.transaction((tx) => tx.delete(coursesInSchedule).where(eq(coursesInSchedule.scheduleId, scheduleId))); + + if (courses.length === 0) { + return; + } + + const coursesUnique: Set = 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 db.transaction((tx) => tx.insert(coursesInSchedule).values(dbCoursesUnique)); + } + + private static async upsertCustomEvents( + db: DatabaseOrTransaction, + scheduleId: string, + repeatingCustomEvents: RepeatingCustomEvent[] + ) { + await db.transaction( + async (tx) => await tx.delete(customEvents).where(eq(customEvents.scheduleId, scheduleId)) + ); + + 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 db.transaction(async (tx) => await tx.insert(customEvents).values(dbCustomEvents)); + } + static async getGuestUserData( db: DatabaseOrTransaction, guestId: string ): Promise { @@ -278,64 +337,4 @@ export class RDS { // Sort schedules by index return Object.values(schedulesMapping).sort((a, b) => a.index - b.index); } - - /** - * Drops all courses in the schedule and re-add them, - * deduplicating by section code and term. - * */ - private static async upsertCourses(db: DatabaseOrTransaction, scheduleId: string, courses: ShortCourse[]) { - await db.transaction((tx) => tx.delete(coursesInSchedule).where(eq(coursesInSchedule.scheduleId, scheduleId))); - - if (courses.length === 0) { - return; - } - - const coursesUnique: Set = 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 db.transaction((tx) => tx.insert(coursesInSchedule).values(dbCoursesUnique)); - } - - private static async upsertCustomEvents( - db: DatabaseOrTransaction, - scheduleId: string, - repeatingCustomEvents: RepeatingCustomEvent[] - ) { - await db.transaction( - async (tx) => await tx.delete(customEvents).where(eq(customEvents.scheduleId, scheduleId)) - ); - - 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 db.transaction(async (tx) => await tx.insert(customEvents).values(dbCustomEvents)); - } } diff --git a/apps/backend/src/routers/users.ts b/apps/backend/src/routers/users.ts index 75aad0e45..73817a84e 100644 --- a/apps/backend/src/routers/users.ts +++ b/apps/backend/src/routers/users.ts @@ -1,5 +1,6 @@ import { type } from 'arktype'; + import { UserSchema } from '@packages/antalmanac-types'; import { db } from 'src/db'; @@ -10,18 +11,6 @@ import { procedure, router } from '../trpc'; 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 saveInputSchema = type({ /** * ID of the requester. diff --git a/apps/cdk/package.json b/apps/cdk/package.json index 36f2f503d..973ad2e54 100644 --- a/apps/cdk/package.json +++ b/apps/cdk/package.json @@ -24,7 +24,8 @@ "arktype": "1.0.14-alpha", "aws-cdk-lib": "^2.94.0", "constructs": "^10.2.70", - "dotenv": "^16.0.3" + "dotenv": "^16.0.3", + "zod": "3.23.8" }, "devDependencies": { "@types/node": "^20.11.5", diff --git a/apps/cdk/src/stacks/backend.ts b/apps/cdk/src/stacks/backend.ts index 40395d80a..e0d4590aa 100644 --- a/apps/cdk/src/stacks/backend.ts +++ b/apps/cdk/src/stacks/backend.ts @@ -1,4 +1,3 @@ -import { type } from 'arktype'; import { Stack, type StackProps, RemovalPolicy, Duration } from 'aws-cdk-lib'; import * as apigateway from 'aws-cdk-lib/aws-apigateway'; import * as acm from 'aws-cdk-lib/aws-certificatemanager'; @@ -7,27 +6,28 @@ import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as route53 from 'aws-cdk-lib/aws-route53'; import * as targets from 'aws-cdk-lib/aws-route53-targets'; import type { Construct } from 'constructs'; +import { z } from 'zod'; +import { deployEnvSchema } from '../../../backend/src/env'; import { zoneName } from '../lib/constants'; export class BackendStack extends Stack { + /** + * Env vars specifically for the CDK stack/deployment. + * + * If {@link env.PR_NUM} is defined, then {@link env.NODE_ENV} should be 'staging'. + */ + static readonly CDKEnvironment = z.object({ + CERTIFICATE_ARN: z.string(), + HOSTED_ZONE_ID: z.string(), + ANTEATER_API_KEY: z.string(), + NODE_ENV: z.string().optional(), + PR_NUM: z.string().optional(), + }); + constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); - - /** - * If {@link env.PR_NUM} is defined, then {@link env.NODE_ENV} should be 'staging'. - */ - const env = type({ - CERTIFICATE_ARN: 'string', - HOSTED_ZONE_ID: 'string', - MONGODB_URI_PROD: 'string', - GOOGLE_CLIENT_ID: 'string', - GOOGLE_CLIENT_SECRET: 'string', - 'MAPBOX_ACCESS_TOKEN?': 'string', - 'NODE_ENV?': 'string', - 'PR_NUM?': 'string', - ANTEATER_API_KEY: 'string', - }).assert({ ...process.env }); + const env = z.intersection(BackendStack.CDKEnvironment, deployEnvSchema).parse(process.env); /** * The domain that the backend API will be hosted on. @@ -56,10 +56,7 @@ export class BackendStack extends Stack { timeout: Duration.seconds(5), memorySize: 256, environment: { - ANTEATER_API_KEY: env.ANTEATER_API_KEY, - AA_MONGODB_URI: env.MONGODB_URI_PROD, - MAPBOX_ACCESS_TOKEN: env.MAPBOX_ACCESS_TOKEN ?? '', - STAGE: env.NODE_ENV ?? 'development', + ...env, USERDATA_TABLE_NAME: userDataDDB.tableName, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a103168a..f73333a81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 3.3.3 turbo: specifier: latest - version: 2.3.1 + version: 2.3.3 vitest: specifier: ^0.34.4 version: 0.34.6(jsdom@22.1.0) @@ -418,6 +418,9 @@ importers: dotenv: specifier: ^16.0.3 version: 16.4.5 + zod: + specifier: 3.23.8 + version: 3.23.8 devDependencies: '@types/node': specifier: ^20.11.5 @@ -4858,38 +4861,38 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - turbo-darwin-64@2.3.1: - resolution: {integrity: sha512-tjHfjW/Gs8Q9IO+9gPdIsSStZ8I09QYDRT/SyhFTPLnc7O2ZlxHPBVFfjUkHUjanHNYO8CpRGt+zdp1PaMCruw==} + turbo-darwin-64@2.3.3: + resolution: {integrity: sha512-bxX82xe6du/3rPmm4aCC5RdEilIN99VUld4HkFQuw+mvFg6darNBuQxyWSHZTtc25XgYjQrjsV05888w1grpaA==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.3.1: - resolution: {integrity: sha512-At1WStnxCfrBQ4M2g6ynre8WsusGwA11okhVolBxyFUemYozDTtbZwelr+IqNggjT251vviokxOkcFzzogbiFw==} + turbo-darwin-arm64@2.3.3: + resolution: {integrity: sha512-DYbQwa3NsAuWkCUYVzfOUBbSUBVQzH5HWUFy2Kgi3fGjIWVZOFk86ss+xsWu//rlEAfYwEmopigsPYSmW4X15A==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.3.1: - resolution: {integrity: sha512-COwEev7s9fsxLM2eoRCyRLPj+BXvZjFIS+GxzdAubYhoSoZit8B8QGKczyDl6448xhuFEWKrpHhcR9aBuwB4ag==} + turbo-linux-64@2.3.3: + resolution: {integrity: sha512-eHj9OIB0dFaP6BxB88jSuaCLsOQSYWBgmhy2ErCu6D2GG6xW3b6e2UWHl/1Ho9FsTg4uVgo4DB9wGsKa5erjUA==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.3.1: - resolution: {integrity: sha512-AP0uE15Rhxza2Jl+Q3gxdXRA92IIeFAYaufz6CMcZuGy9yZsBlLt9w6T47H6g7XQPzWuw8pzfjM1omcTKkkDpQ==} + turbo-linux-arm64@2.3.3: + resolution: {integrity: sha512-NmDE/NjZoDj1UWBhMtOPmqFLEBKhzGS61KObfrDEbXvU3lekwHeoPvAMfcovzswzch+kN2DrtbNIlz+/rp8OCg==} cpu: [arm64] os: [linux] - turbo-windows-64@2.3.1: - resolution: {integrity: sha512-HDSneq0dNZYZch74c2eygq+OiJE/JYDs7OsGM0yRYVj336383xkUnxz6W2I7qiyMCQXzp4UVUDZXvZhUYcX3BA==} + turbo-windows-64@2.3.3: + resolution: {integrity: sha512-O2+BS4QqjK3dOERscXqv7N2GXNcqHr9hXumkMxDj/oGx9oCatIwnnwx34UmzodloSnJpgSqjl8iRWiY65SmYoQ==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.3.1: - resolution: {integrity: sha512-7/2/sJZiquwoT/jWBCfV0qKq4NarsJPmDRjMcR9dDMIwCYsGM8ljomkDRTCtkNeFcUvYw54MiRWHehWgbcRPsw==} + turbo-windows-arm64@2.3.3: + resolution: {integrity: sha512-dW4ZK1r6XLPNYLIKjC4o87HxYidtRRcBeo/hZ9Wng2XM/MqqYkAyzJXJGgRMsc0MMEN9z4+ZIfnSNBrA0b08ag==} cpu: [arm64] os: [win32] - turbo@2.3.1: - resolution: {integrity: sha512-vHZe/e6k1HZVKiMQPQ1BWFn53vjVQDFKdkjUq/pBKlRWi1gw9LQO6ntH4qZCcHY1rH6TXgsRmexXdgWl96YvVQ==} + turbo@2.3.3: + resolution: {integrity: sha512-DUHWQAcC8BTiUZDRzAYGvpSpGLiaOQPfYXlCieQbwUvmml/LRGIe3raKdrOPOoiX0DYlzxs2nH6BoWJoZrj8hA==} hasBin: true type-check@0.4.0: @@ -7352,8 +7355,8 @@ snapshots: '@types/react@18.3.12': dependencies: - '@types/prop-types': 15.7.5 - csstype: 3.1.1 + '@types/prop-types': 15.7.13 + csstype: 3.1.3 '@types/reactcss@1.2.6': dependencies: @@ -10322,32 +10325,32 @@ snapshots: tunnel@0.0.6: {} - turbo-darwin-64@2.3.1: + turbo-darwin-64@2.3.3: optional: true - turbo-darwin-arm64@2.3.1: + turbo-darwin-arm64@2.3.3: optional: true - turbo-linux-64@2.3.1: + turbo-linux-64@2.3.3: optional: true - turbo-linux-arm64@2.3.1: + turbo-linux-arm64@2.3.3: optional: true - turbo-windows-64@2.3.1: + turbo-windows-64@2.3.3: optional: true - turbo-windows-arm64@2.3.1: + turbo-windows-arm64@2.3.3: optional: true - turbo@2.3.1: + turbo@2.3.3: optionalDependencies: - turbo-darwin-64: 2.3.1 - turbo-darwin-arm64: 2.3.1 - turbo-linux-64: 2.3.1 - turbo-linux-arm64: 2.3.1 - turbo-windows-64: 2.3.1 - turbo-windows-arm64: 2.3.1 + turbo-darwin-64: 2.3.3 + turbo-darwin-arm64: 2.3.3 + turbo-linux-64: 2.3.3 + turbo-linux-arm64: 2.3.3 + turbo-windows-64: 2.3.3 + turbo-windows-arm64: 2.3.3 type-check@0.4.0: dependencies: