diff --git a/apps/api/src/routes/v1/graphql/resolvers.ts b/apps/api/src/routes/v1/graphql/resolvers.ts index 434bacf8..4e4f8108 100644 --- a/apps/api/src/routes/v1/graphql/resolvers.ts +++ b/apps/api/src/routes/v1/graphql/resolvers.ts @@ -8,6 +8,7 @@ export const resolvers: ApolloServerOptions["resolvers"] = { course: proxyRestApi("/v1/rest/courses", { pathArg: "courseId" }), courses: proxyRestApi("/v1/rest/courses", { argsTransform: geTransform }), allCourses: proxyRestApi("/v1/rest/courses/all"), + enrollmentHistory: proxyRestApi("/v1/rest/enrollmentHistory"), rawGrades: proxyRestApi("/v1/rest/grades/raw"), aggregateGrades: proxyRestApi("/v1/rest/grades/aggregate"), gradesOptions: proxyRestApi("/v1/rest/grades/options"), diff --git a/apps/api/src/routes/v1/graphql/schema/enrollmentHistory.graphql b/apps/api/src/routes/v1/graphql/schema/enrollmentHistory.graphql new file mode 100644 index 00000000..1b8e695e --- /dev/null +++ b/apps/api/src/routes/v1/graphql/schema/enrollmentHistory.graphql @@ -0,0 +1,33 @@ +type EnrollmentHistory { + year: String! + quarter: Quarter! + sectionCode: String! + department: String! + courseNumber: String! + sectionType: SectionType! + sectionNum: String! + units: String + instructors: [String!]! + meetings: [String!]! + finalExam: String + dates: [String!]! + maxCapacityHistory: [String!]! + totalEnrolledHistory: [String!]! + waitlistHistory: [String!]! + waitlistCapHistory: [String!]! + requestedHistory: [String!]! + newOnlyReservedHistory: [String!]! + statusHistory: [String!]! +} + +extend type Query { + enrollmentHistory( + year: String + quarter: Quarter + instructor: String + department: String + courseNumber: String + sectionCode: String + sectionType: SectionType + ): [EnrollmentHistory!]! +} diff --git a/apps/api/src/routes/v1/rest/enrollmentHistory/+endpoint.ts b/apps/api/src/routes/v1/rest/enrollmentHistory/+endpoint.ts new file mode 100644 index 00000000..a25db7d8 --- /dev/null +++ b/apps/api/src/routes/v1/rest/enrollmentHistory/+endpoint.ts @@ -0,0 +1,33 @@ +import { PrismaClient } from "@libs/db"; +import { createHandler } from "@libs/lambda"; +import type { EnrollmentHistory } from "@peterportal-api/types"; + +import { QuerySchema } from "./schema"; + +const prisma = new PrismaClient(); + +export const GET = createHandler(async (event, context, res) => { + const { headers, queryStringParameters: query } = event; + const { awsRequestId: requestId } = context; + + const maybeParsed = QuerySchema.safeParse(query); + if (!maybeParsed.success) { + return res.createErrorResult(400, maybeParsed.error, requestId); + } + const { + data: { instructor, ...data }, + } = maybeParsed; + + return res.createOKResult( + ( + await prisma.websocEnrollmentHistory.findMany({ + where: { ...data, instructors: { array_contains: instructor } }, + }) + ).map((x) => { + const { timestamp: _, ...obj } = x; + return obj as unknown as EnrollmentHistory; + }), + headers, + requestId, + ); +}); diff --git a/apps/api/src/routes/v1/rest/enrollmentHistory/schema.ts b/apps/api/src/routes/v1/rest/enrollmentHistory/schema.ts new file mode 100644 index 00000000..2edd7112 --- /dev/null +++ b/apps/api/src/routes/v1/rest/enrollmentHistory/schema.ts @@ -0,0 +1,23 @@ +import { quarters, sectionTypes } from "@peterportal-api/types"; +import { z } from "zod"; + +export const QuerySchema = z.object({ + year: z + .string() + .regex(/^\d{4}$/, { message: "Invalid year provided" }) + .optional(), + quarter: z.enum(quarters, { invalid_type_error: "Invalid quarter provided" }).optional(), + instructor: z.string().optional(), + department: z.string().optional(), + courseNumber: z.string().optional(), + sectionCode: z + .string() + .regex(/^\d{5}$/, { message: "Invalid sectionCode provided" }) + .transform((x) => Number.parseInt(x, 10)) + .optional(), + sectionType: z + .enum(sectionTypes, { invalid_type_error: "Invalid sectionType provided" }) + .optional(), +}); + +export type Query = z.infer; diff --git a/apps/docs/docs/developers-guide/rest-api/reference/enrollment-history.md b/apps/docs/docs/developers-guide/rest-api/reference/enrollment-history.md new file mode 100644 index 00000000..6e81a5cb --- /dev/null +++ b/apps/docs/docs/developers-guide/rest-api/reference/enrollment-history.md @@ -0,0 +1,132 @@ +--- +pagination_prev: null +pagination_next: null +--- + +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +# Enrollment History + +The enrollment history endpoint allows users to view the changes in enrollment data (total enrolled, max capacity, waitlist, etc.) for a specific course's sections. + +## Query parameters for all endpoints + +#### `year` string + +The year to include. + +#### `quarter` Fall | Winter | Spring | Summer1 | Summer10wk | Summer2 + +The quarter to include. Case-sensitive. + +#### `instructor` string + +The shortened name of the instructor to include. (Ex.: `SHINDLER, M.`) + +#### `courseNumber` string + +The course number to include. (Ex.: 161) + +#### `sectionCode` string + +The five-digit section code to include. + +#### `sectionType` | Act | Col | Dis | Fld | Lab | Lec | Qiz | Res | Sem | Stu | Tap | Tut + +The section type code. Case-sensitive. + +### Code sample + + + + +```bash +curl "https://api-next.peterportal.org/v1/rest/enrollmentHistory?year=2022&quarter=Fall&department=I%26C%20SCI&courseNumber=46" +``` + + + + +### Response + + + + +```json +[ + { + "year": "2022", + "quarter": "Fall", + "sectionCode": 35730, + "department": "I&C SCI", + "courseNumber": "46", + "sectionType": "Lec", + "sectionNum": "A", + "units": "4", + "instructors": ["SHINDLER, M.", "GARZA RODRIGUE, A.", "GILA, O."], + "meetings": [{ "days": "MWF", "time": "8:00- 8:50", "bldg": ["EH 1200"] }], + "finalExam": "Mon, Dec 5, 8:00-10:00am", + "dates": ["2022-05-17", "2022-05-18", "..."], + "maxCapacityHistory": ["220", "220", "220", "..."], + "totalEnrolledHistory": ["5", "5", "7", "..."], + "waitlistHistory": ["n/a", "n/a", "n/a", "..."], + "waitlistCapHistory": ["0", "0", "0", "..."], + "requestedHistory": ["7", "8", "11", "..."], + "newOnlyReservedHistory": ["0", "0", "0", "..."], + "statusHistory": ["OPEN", "OPEN", "OPEN", "..."] + }, + { + "year": "2022", + "quarter": "Fall", + "sectionCode": 35740, + "department": "I&C SCI", + "courseNumber": "46", + "sectionType": "Lec", + "sectionNum": "B", + "units": "4", + "instructors": ["SHINDLER, M.", "DICKERSON, M."], + "meetings": [{ "days": "MWF", "time": "10:00-10:50", "bldg": ["SSLH 100"] }], + "finalExam": "Mon, Dec 5, 10:30-12:30pm", + "dates": ["2022-05-17", "2022-05-18", "..."], + "maxCapacityHistory": ["220", "220", "220", "..."], + "totalEnrolledHistory": ["38", "44", "58", "..."], + "waitlistHistory": ["n/a", "n/a", "n/a", "..."], + "waitlistCapHistory": ["0", "0", "0", "..."], + "requestedHistory": ["41", "49", "66", "..."], + "newOnlyReservedHistory": ["0", "0", "0", "..."], + "statusHistory": ["OPEN", "OPEN", "OPEN", "..."] + } +] +``` + + + + +```typescript +// https://github.com/icssc/peterportal-api-next/blob/main/packages/peterportal-api-next-types/types/calendar.ts +type EnrollmentHistory = { + year: string; + quarter: Quarter; + sectionCode: string; + department: string; + courseNumber: string; + sectionType: string; + sectionNum: string; + units: string; + instructors: string[]; + meetings: string[]; + finalExam: string; + dates: string[]; + maxCapacityHistory: string[]; + totalEnrolledHistory: string[]; + waitlistHistory: string[]; + waitlistCapHistory: string[]; + requestedHistory: string[]; + newOnlyReservedHistory: string[]; + statusHistory: string[]; +}; +``` + + + diff --git a/apps/docs/sidebars.js b/apps/docs/sidebars.js index 72c5cce9..c8f126a6 100644 --- a/apps/docs/sidebars.js +++ b/apps/docs/sidebars.js @@ -22,6 +22,7 @@ const sidebars = { items: [ "developers-guide/rest-api/reference/calendar", "developers-guide/rest-api/reference/courses", + "developers-guide/rest-api/reference/enrollment-history", "developers-guide/rest-api/reference/grades", "developers-guide/rest-api/reference/instructors", "developers-guide/rest-api/reference/larc", diff --git a/libs/db/prisma/schema.prisma b/libs/db/prisma/schema.prisma index cf917d71..8a35d765 100644 --- a/libs/db/prisma/schema.prisma +++ b/libs/db/prisma/schema.prisma @@ -159,6 +159,32 @@ model GradesSection { @@unique([year, quarter, sectionCode], name: "idx") } +model WebsocEnrollmentHistory { + year String + quarter Quarter + sectionCode Int + timestamp DateTime + department String + courseNumber String + sectionType WebsocSectionType + sectionNum String + units String + instructors Json + meetings Json + finalExam String + dates Json + maxCapacityHistory Json + totalEnrolledHistory Json + waitlistHistory Json + waitlistCapHistory Json + requestedHistory Json + newOnlyReservedHistory Json + statusHistory Json + + @@id([year, quarter, sectionCode, timestamp]) + @@unique([year, quarter, sectionCode, timestamp], name: "idx") +} + model WebsocSectionInstructor { id Int @id @default(autoincrement()) year String diff --git a/packages/types/index.ts b/packages/types/index.ts index c6140a01..deeba13c 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -1,6 +1,7 @@ export * from "./types/calendar"; export * from "./types/constants"; export * from "./types/courses"; +export * from "./types/enrollmentHistory"; export * from "./types/grades"; export * from "./types/instructor"; export * from "./types/larc"; diff --git a/packages/types/types/enrollmentHistory.ts b/packages/types/types/enrollmentHistory.ts new file mode 100644 index 00000000..5a4adad2 --- /dev/null +++ b/packages/types/types/enrollmentHistory.ts @@ -0,0 +1,23 @@ +import { Quarter, SectionType } from "./constants"; + +export type EnrollmentHistory = { + year: string; + quarter: Quarter; + sectionCode: string; + department: string; + courseNumber: string; + sectionType: SectionType; + sectionNum: string; + units: string; + instructors: string[]; + meetings: string[]; + finalExam: string; + dates: string[]; + maxCapacityHistory: string[]; + totalEnrolledHistory: string[]; + waitlistHistory: string[]; + waitlistCapHistory: string[]; + requestedHistory: string[]; + newOnlyReservedHistory: string[]; + statusHistory: string[]; +}; diff --git a/services/websoc-scraper-v2/index.ts b/services/websoc-scraper-v2/index.ts index 989f3f19..31aab04b 100644 --- a/services/websoc-scraper-v2/index.ts +++ b/services/websoc-scraper-v2/index.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from "@libs/db"; +import { Prisma, PrismaClient } from "@libs/db"; import { getTermDateData } from "@libs/uc-irvine-api/registrar"; import type { GE, @@ -473,6 +473,88 @@ async function scrape(name: string, term: Term) { }, }; + const enrollmentHistory = Object.fromEntries( + (await prisma.websocEnrollmentHistory.findMany(params)).map((x) => [ + `${x.year}-${x.quarter}-${x.sectionCode}`, + x, + ]), + ); + + for (const { data } of Object.values(res)) { + const key = `${data.year}-${data.quarter}-${data.sectionCode}`; + if (key in enrollmentHistory) { + const rawData = (data.data as WebsocAPIResponse).schools[0].departments[0].courses[0] + .sections[0]; + enrollmentHistory[key].timestamp = timestamp; + (enrollmentHistory[key].dates as string[]).push( + `${timestamp.getFullYear()}-${timestamp.getMonth() + 1}-${timestamp.getDate()}`, + ); + (enrollmentHistory[key].maxCapacityHistory as string[]).push(data.maxCapacity.toString(10)); + (enrollmentHistory[key].totalEnrolledHistory as string[]).push( + rawData.numCurrentlyEnrolled.totalEnrolled, + ); + (enrollmentHistory[key].waitlistHistory as string[]).push(rawData.numOnWaitlist); + (enrollmentHistory[key].waitlistCapHistory as string[]).push(rawData.numWaitlistCap); + (enrollmentHistory[key].requestedHistory as string[]).push(rawData.numRequested); + (enrollmentHistory[key].newOnlyReservedHistory as string[]).push(rawData.numNewOnlyReserved); + (enrollmentHistory[key].statusHistory as string[]).push(rawData.status); + } else { + const { + year, + quarter, + sectionCode, + timestamp, + department, + courseNumber, + sectionType, + units, + } = data; + const { + sectionNum, + instructors, + meetings, + finalExam, + maxCapacity, + numCurrentlyEnrolled, + numOnWaitlist, + numWaitlistCap, + numRequested, + numNewOnlyReserved, + status, + } = (data.data as WebsocAPIResponse).schools[0].departments[0].courses[0].sections[0]; + enrollmentHistory[key] = { + year, + quarter, + sectionCode, + timestamp, + department, + courseNumber, + sectionType, + sectionNum, + units, + instructors, + meetings, + finalExam, + dates: [`${timestamp.getFullYear()}-${timestamp.getMonth() + 1}-${timestamp.getDate()}`], + maxCapacityHistory: [maxCapacity], + totalEnrolledHistory: [numCurrentlyEnrolled.totalEnrolled], + waitlistHistory: [numOnWaitlist], + waitlistCapHistory: [numWaitlistCap], + requestedHistory: [numRequested], + newOnlyReservedHistory: [numNewOnlyReserved], + statusHistory: [status], + }; + } + } + + await prisma.$transaction([ + prisma.websocEnrollmentHistory.createMany({ + data: Object.values(enrollmentHistory) as Prisma.WebsocEnrollmentHistoryCreateManyInput[], + skipDuplicates: true, + }), + prisma.websocEnrollmentHistory.deleteMany(params), + ]); + const [instructorsDeleted, buildingsDeleted, meetingsDeleted, sectionsDeleted] = await prisma.$transaction([ prisma.websocSectionInstructor.deleteMany(params),