diff --git a/Dockerfile b/Dockerfile index 2e4a67390..315a22ea2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,9 @@ COPY --from=datapuller-builder /datapuller/out/package-lock.json ./package-lock. RUN ["npm", "install"] COPY --from=datapuller-builder /datapuller/out/full/ . -ENTRYPOINT ["turbo", "run", "main", "--filter=datapuller", "--"] -CMD ["--puller=main"] -FROM datapuller-dev AS datapuller-prod -WORKDIR /datapuller -ENTRYPOINT ["turbo", "run", "main", "--filter=datapuller", "--env-mode=loose", "--"] +ENTRYPOINT ["turbo", "run", "main", "--filter=datapuller"] +CMD ["--", "--puller=decals"] # backend FROM base AS backend-builder diff --git a/apps/backend/src/bootstrap/loaders/express.ts b/apps/backend/src/bootstrap/loaders/express.ts index 32b43ba2e..7585d9a0c 100644 --- a/apps/backend/src/bootstrap/loaders/express.ts +++ b/apps/backend/src/bootstrap/loaders/express.ts @@ -26,7 +26,7 @@ export default async ( origin: [ config.url, "http://localhost:8080", - "http://localhost:5173", + "http://localhost:3000", // TODO: Remove "https://gn980r4n-8080.usw3.devtunnels.ms", ], diff --git a/apps/backend/src/modules/class/controller.ts b/apps/backend/src/modules/class/controller.ts index cda486048..db7d40f6e 100644 --- a/apps/backend/src/modules/class/controller.ts +++ b/apps/backend/src/modules/class/controller.ts @@ -1,71 +1,84 @@ import { ClassModel, + DecalModel, IClassItem, ISectionItem, SectionModel, } from "@repo/common"; -import { formatClass, formatSection } from "./formatter"; +import { formatClass, formatDecal, formatSection } from "./formatter"; -export const getClass = async ( - year: number, - semester: string, - sessionId: string, - subject: string, - courseNumber: string, - number: string -) => { +/** + * Get a class + * @default sessionId "1" + */ +export const getClass = async ({ + sessionId = "1", + ...options +}: { + year: number; + semester: string; + sessionId: string; + subject: string; + courseNumber: string; + number: string; +}) => { const _class = await ClassModel.findOne({ - year, - semester, - sessionId: sessionId ? sessionId : "1", - subject, - courseNumber, - number, + sessionId, + ...options, }).lean(); - console.log(await ClassModel.countDocuments({})); - if (!_class) return null; return formatClass(_class as IClassItem); }; -export const getSecondarySections = async ( - year: number, - semester: string, - sessionId: string, - subject: string, - courseNumber: string, - number: string -) => { +/** + * Get the secondary sections for a class + * - Includes any 999 sections + * - Includes any sections prefixed by the primary section + * - Does not include the primary section + * @default sessionId "1" + */ +export const getSecondarySections = async ({ + sessionId = "1", + number, + ...options +}: { + year: number; + semester: string; + sessionId: string; + subject: string; + courseNumber: string; + number: string; +}) => { const sections = await SectionModel.find({ - year, - semester, - sessionId: sessionId ? sessionId : "1", - subject, - courseNumber, - number: { $regex: `^(${number[number.length - 1]}|999)` }, + ...options, + sessionId, + number: { $regex: `^(?:${number[number.length - 1]}\d\d|999)$` }, }).lean(); return sections.map((section) => formatSection(section as ISectionItem)); }; -export const getPrimarySection = async ( - year: number, - semester: string, - sessionId: string, - subject: string, - courseNumber: string, - number: string -) => { +/** + * Get the primary section for a class + * @default sessionId "1" + */ +export const getPrimarySection = async ({ + sessionId = "1", + ...options +}: { + year: number; + semester: string; + sessionId: string; + subject: string; + courseNumber: string; + number: string; +}) => { const section = await SectionModel.findOne({ - year, - semester, - sessionId: sessionId ? sessionId : "1", - subject, - courseNumber, - number, + ...options, + sessionId, primary: true, }).lean(); @@ -74,24 +87,41 @@ export const getPrimarySection = async ( return formatSection(section as ISectionItem); }; -export const getSection = async ( - year: number, - semester: string, - sessionId: string, - subject: string, - courseNumber: string, - number: string -) => { +/** + * Get a section + * @default sessionId "1" + */ +export const getSection = async ({ + sessionId = "1", + ...options +}: { + year: number; + semester: string; + sessionId?: string; + subject: string; + courseNumber: string; + number: string; +}) => { const section = await SectionModel.findOne({ - year, - semester, - sessionId: sessionId ? sessionId : "1", - subject, - courseNumber, - number, + ...options, + sessionId, }).lean(); if (!section) return null; return formatSection(section as ISectionItem); }; + +export const getDecal = async (options: { + year: number; + semester: string; + subject: string; + courseNumber: string; + number: string; +}) => { + const decal = await DecalModel.findOne(options).lean(); + + if (!decal) return null; + + return formatDecal(decal); +}; diff --git a/apps/backend/src/modules/class/formatter.ts b/apps/backend/src/modules/class/formatter.ts index f089827f4..0a163c70e 100644 --- a/apps/backend/src/modules/class/formatter.ts +++ b/apps/backend/src/modules/class/formatter.ts @@ -1,4 +1,4 @@ -import { IClassItem, ISectionItem } from "@repo/common"; +import { DecalType, IClassItem, IDecal, ISectionItem } from "@repo/common"; import { ClassModule } from "./generated-types/module-types"; @@ -36,6 +36,7 @@ export const formatClass = (_class: IClassItem) => { primarySection: null, sections: null, gradeDistribution: null, + decal: null, } as IntermediateClass; return output; @@ -69,3 +70,9 @@ export const formatSection = (section: ISectionItem) => { return output; }; + +export const formatDecal = (decal: DecalType) => { + if (!decal) return null; + + return decal as IDecal; +}; diff --git a/apps/backend/src/modules/class/resolver.ts b/apps/backend/src/modules/class/resolver.ts index 0ed13fe0e..8c4e2d503 100644 --- a/apps/backend/src/modules/class/resolver.ts +++ b/apps/backend/src/modules/class/resolver.ts @@ -8,6 +8,7 @@ import { getTerm } from "../term/controller"; import { TermModule } from "../term/generated-types/module-types"; import { getClass, + getDecal, getPrimarySection, getSecondarySections, getSection, @@ -65,14 +66,14 @@ const resolvers: ClassModule.Resolvers = { _, { year, semester, sessionId, subject, courseNumber, number } ) => { - const _class = await getClass( + const _class = await getClass({ year, semester, sessionId, subject, courseNumber, - number - ); + number, + }); return _class as unknown as ClassModule.Class; }, @@ -81,14 +82,14 @@ const resolvers: ClassModule.Resolvers = { _, { year, semester, sessionId, subject, courseNumber, number } ) => { - const section = await getSection( + const section = await getSection({ year, semester, sessionId, subject, courseNumber, - number - ); + number, + }); return section as unknown as ClassModule.Section; }, @@ -114,14 +115,17 @@ const resolvers: ClassModule.Resolvers = { primarySection: async (parent: IntermediateClass | ClassModule.Class) => { if (parent.primarySection) return parent.primarySection; - const primarySection = await getPrimarySection( - parent.year, - parent.semester, - parent.sessionId, - parent.subject, - parent.courseNumber, - parent.number - ); + const { year, semester, sessionId, subject, courseNumber, number } = + parent; + + const primarySection = await getPrimarySection({ + year, + semester, + sessionId, + subject, + courseNumber, + number, + }); return primarySection as unknown as ClassModule.Section; }, @@ -129,14 +133,17 @@ const resolvers: ClassModule.Resolvers = { sections: async (parent: IntermediateClass | ClassModule.Class) => { if (parent.sections) return parent.sections; - const secondarySections = await getSecondarySections( - parent.year, - parent.semester, - parent.sessionId, - parent.subject, - parent.courseNumber, - parent.number - ); + const { year, semester, sessionId, subject, courseNumber, number } = + parent; + + const secondarySections = await getSecondarySections({ + year, + semester, + sessionId, + subject, + courseNumber, + number, + }); return secondarySections as unknown as ClassModule.Section[]; }, @@ -157,6 +164,22 @@ const resolvers: ClassModule.Resolvers = { return gradeDistribution; }, + + decal: async (parent: IntermediateClass | ClassModule.Class) => { + if (parent.decal) return parent.decal; + + const { year, semester, subject, courseNumber, number } = parent; + + const decal = await getDecal({ + year, + semester, + subject, + courseNumber, + number, + }); + + return decal; + }, }, Section: { @@ -179,14 +202,17 @@ const resolvers: ClassModule.Resolvers = { class: async (parent: IntermediateSection | ClassModule.Section) => { if (parent.class) return parent.class; - const _class = await getClass( - parent.year, - parent.semester, - parent.sessionId, - parent.subject, - parent.courseNumber, - parent.number - ); + const { year, semester, sessionId, subject, courseNumber, number } = + parent; + + const _class = await getClass({ + year, + semester, + sessionId, + subject, + courseNumber, + number, + }); return _class as unknown as ClassModule.Class; }, diff --git a/apps/backend/src/modules/class/typedefs/class.ts b/apps/backend/src/modules/class/typedefs/class.ts index 4c29b9849..d38da4b94 100644 --- a/apps/backend/src/modules/class/typedefs/class.ts +++ b/apps/backend/src/modules/class/typedefs/class.ts @@ -22,6 +22,26 @@ export default gql` ): Section } + type Decal { + "Identifiers" + termId: TermIdentifier! + subject: String! + courseNumber: CourseNumber! + number: ClassNumber! + + "Attributes" + externalId: String! + title: String + description: String + category: String + units: String + website: String + application: String + enroll: String + date: String + contact: String + } + type Class { "Identifiers" termId: TermIdentifier! @@ -37,6 +57,7 @@ export default gql` primarySection: Section! sections: [Section!]! gradeDistribution: GradeDistribution! + decal: Decal "Attributes" year: Int! diff --git a/apps/backend/src/modules/decal/controller.ts b/apps/backend/src/modules/decal/controller.ts new file mode 100644 index 000000000..7f0478469 --- /dev/null +++ b/apps/backend/src/modules/decal/controller.ts @@ -0,0 +1,45 @@ +import { ClassModel, DecalModel, IClassItem } from "@repo/common"; + +import { formatClassForDecal } from "./formatter"; + +// semester and year +export const getDecalsByYearSemester = async ( + semester: string, + year: string +) => { + const decals = await DecalModel.find({ semester, year }); + + const decalsClass = await Promise.all( + decals.map(async (decal) => { + if ( + !decal.subject || + !decal.courseNumber || + !decal.semester || + !decal.year + ) { + return { + ...decal.toObject(), + _id: decal._id.toString(), + class: null, + }; + } + + const matchedClassDoc = await ClassModel.findOne({ + subject: decal.subject, + courseNumber: decal.courseNumber, + semester: decal.semester, + year: parseInt(decal.year), + }); + + return { + ...decal.toObject(), + _id: decal._id.toString(), + class: matchedClassDoc + ? formatClassForDecal(matchedClassDoc as IClassItem) + : null, + }; + }) + ); + + return decalsClass; +}; diff --git a/apps/backend/src/modules/decal/formatter.ts b/apps/backend/src/modules/decal/formatter.ts new file mode 100644 index 000000000..8f66b4f1b --- /dev/null +++ b/apps/backend/src/modules/decal/formatter.ts @@ -0,0 +1,36 @@ +import { DecalType, IClassItem } from "@repo/common"; + +import { ClassModule } from "../class/generated-types/module-types"; + +export const formatClassForDecal = (_class: IClassItem): ClassModule.Class => { + const output = { + ..._class, + + unitsMax: _class.allowedUnits?.maximum || 0, + unitsMin: _class.allowedUnits?.minimum || 0, + + term: {} as any, + course: {} as any, + primarySection: {} as any, + sections: [], + gradeDistribution: {} as any, + decal: null, + }; + return output as ClassModule.Class; +}; + +export const formatDecalInfo = (decal: DecalType) => { + if (!decal) return null; + + return { + id: String(decal.externalId) || "", + title: decal.title || "", + description: decal.description || "", + category: decal.category || "", + units: String(decal.units) || "", + website: decal.website || "", + application: decal.application || "", + enroll: decal.enroll || "", + contact: decal.contact || "", + }; +}; diff --git a/apps/backend/src/modules/decal/index.ts b/apps/backend/src/modules/decal/index.ts new file mode 100644 index 000000000..c13a2e7b0 --- /dev/null +++ b/apps/backend/src/modules/decal/index.ts @@ -0,0 +1,7 @@ +import resolver from "./resolver"; +import typeDef from "./typedefs/decal"; + +export default { + resolver, + typeDef, +}; diff --git a/apps/backend/src/modules/decal/resolver.ts b/apps/backend/src/modules/decal/resolver.ts new file mode 100644 index 000000000..691c6247d --- /dev/null +++ b/apps/backend/src/modules/decal/resolver.ts @@ -0,0 +1,13 @@ +import { getDecalsByYearSemester } from "./controller"; +import { DecalModule } from "./generated-types/module-types"; + +const resolvers: DecalModule.Resolvers = { + Query: { + decals: async (_, args: { semester: string; year: string }) => { + const { semester, year } = args; + return await getDecalsByYearSemester(semester, year); + }, + }, +}; + +export default resolvers; diff --git a/apps/backend/src/modules/decal/typedefs/decal.ts b/apps/backend/src/modules/decal/typedefs/decal.ts new file mode 100644 index 000000000..58dc9c296 --- /dev/null +++ b/apps/backend/src/modules/decal/typedefs/decal.ts @@ -0,0 +1,24 @@ +import { gql } from "graphql-tag"; + +export default gql` + type Query { + decals(semester: String!, year: String!): [Decal!]! + } + + type Decal { + _id: ID! + title: String + description: String + category: String + units: String + website: String + application: String + enroll: String + contact: String + subject: String + courseNumber: CourseNumber + semester: String + year: String + class: Class + } +`; diff --git a/apps/backend/src/modules/index.ts b/apps/backend/src/modules/index.ts index 406f69a7d..001b60779 100644 --- a/apps/backend/src/modules/index.ts +++ b/apps/backend/src/modules/index.ts @@ -4,6 +4,7 @@ import Catalog from "./catalog"; import Class from "./class"; import Common from "./common"; import Course from "./course"; +import Decal from "./decal"; import Enrollment from "./enrollment"; import GradeDistribution from "./grade-distribution"; import Schedule from "./schedule"; @@ -20,6 +21,7 @@ const modules = [ Course, Class, Enrollment, + Decal, ]; export const resolvers = merge(modules.map((module) => module.resolver)); diff --git a/apps/datapuller/package.json b/apps/datapuller/package.json index 73c33ca09..f4beeb218 100644 --- a/apps/datapuller/package.json +++ b/apps/datapuller/package.json @@ -2,7 +2,7 @@ "name": "datapuller", "private": true, "scripts": { - "build": "tsc --noEmit", + "build": "tsc", "main": "tsx src/main.ts" }, "devDependencies": { @@ -15,7 +15,9 @@ "@aws-sdk/client-s3": "^3.758.0", "@repo/common": "*", "@repo/sis-api": "*", + "@types/jsdom": "^21.1.7", "dotenv": "^16.4.7", + "jsdom": "^26.0.0", "papaparse": "^5.5.2", "tslog": "^4.9.3" } diff --git a/apps/datapuller/src/lib/decals.ts b/apps/datapuller/src/lib/decals.ts new file mode 100644 index 000000000..b8f0cf86c --- /dev/null +++ b/apps/datapuller/src/lib/decals.ts @@ -0,0 +1,229 @@ +import { JSDOM } from "jsdom"; + +interface Section { + title: string | null; + facilitators: string | null; + size: string | null; + location: string | null; + time: string | null; + starts: string | null; + status: string | null; + ccn: string | null; +} + +export interface Decal { + id: string; + category: string | null; + units: string | null; + date: string | null; + title?: string; + description?: string; + website?: string; + application?: string; + sections?: Section[]; + enroll?: string; + contact?: string; + course?: string; + semester: string; +} + +const getSections = (document: Document) => { + const children = getElementFromXPath( + document, + "/html/body/div[1]/div[5]/div/table/tbody" + )?.children; + + if (!children) return []; + + const rows = Array.from(children).slice(1); + + return rows.map((row) => { + const cells = Array.from(row.children); + + return { + title: cells[0].textContent, + // Parse facilitators as array + facilitators: cells[1].textContent, + size: cells[2].textContent, + location: cells[3].textContent, + // Parse time as array of days and times + time: cells[4].textContent, + starts: cells[5].textContent, + status: cells[6].textContent, + // Parse CCN from title if not found + ccn: cells[8].textContent, + }; + }); +}; + +const getPartialDecals = (document: Document) => { + const children = getElementFromXPath( + document, + "/html/body/div[1]/div/div[2]/table/tbody" + )?.children; + + if (!children) return []; + + const rows = Array.from(children); + + return rows.map((row) => { + const cells = Array.from(row.children); + + const id = (cells[0].firstChild as HTMLAnchorElement).href.slice( + "/courses/".length + ); + + return { + id, + category: cells[1].textContent, + units: cells[2].textContent, + date: cells[5].textContent, + }; + }); +}; + +const getElementFromXPath = (document: Document, path: string) => { + const element = document.evaluate( + path, + document, + null, + 9, + null + ).singleNodeValue; + + return element as T | null; +}; + +const getTextContentFromXPath = (document: Document, path: string) => { + const element = getElementFromXPath(document, path); + + return element?.textContent?.trim(); +}; + +const getDecal = async (id: string) => { + try { + const response = await fetch( + `https://decal.studentorg.berkeley.edu/courses/${id}` + ); + + if (!response.ok || response.redirected) return; + + const text = await response.text(); + + const { + window: { document }, + } = new JSDOM(text); + + const title = getTextContentFromXPath( + document, + "/html/body/div[1]/div[1]/div[1]/h3" + ); + + const description = getTextContentFromXPath( + document, + "/html/body/div[1]/div[3]/div/div" + ); + + const website = getTextContentFromXPath( + document, + "/html/body/div[1]/div[1]/div[3]/a" + ); + + const application = getElementFromXPath( + document, + "/html/body/div[1]/div[4]/form" + )?.action; + + const enroll = getTextContentFromXPath( + document, + "/html/body/div[1]/div[4]/div/div" + ); + + const contact = getTextContentFromXPath( + document, + "/html/body/div[1]/div[1]/div[2]/text()[5]" + ); + + const course = getTextContentFromXPath( + document, + "/html/body/div[1]/div[1]/div[2]/text()[2]" + ); + + const sections = getSections(document); + + return { + title, + description, + website, + // Parse from enroll if not found + application, + sections, + enroll, + contact, + course, + }; + } catch { + return; + } +}; + +export const getDecals = async (semester: string) => { + try { + const response = await fetch( + `https://decal.studentorg.berkeley.edu/courses?utf8=✓&semester=${semester}&start_time=&end_time=&sort=&button=` + ); + + const text = await response.text(); + + const { + window: { document }, + } = new JSDOM(text); + + const partialDecals = getPartialDecals(document); + if (partialDecals.length === 0) return []; + + const requests = partialDecals.map(async (partialDecal) => { + const decal = await getDecal(partialDecal.id); + if (!decal) return; + + return { + ...partialDecal, + ...decal, + semester, + }; + }); + + const decals = await Promise.all(requests); + + return decals.filter((decal) => !!decal); + } catch { + return []; + } +}; + +export const getDecalSemesters = async () => { + try { + const response = await fetch( + "https://decal.studentorg.berkeley.edu/courses" + ); + + const text = await response.text(); + + const { + window: { document }, + } = new JSDOM(text); + + const children = getElementFromXPath( + document, + `/html/body/div[1]/div/div[1]/form/div/div[1]/div[1]/select` + )?.children; + + if (!children) return []; + + const options = Array.from(children) as HTMLOptionElement[]; + + return options.map((option) => option.value); + } catch { + return []; + } +}; diff --git a/apps/datapuller/src/main.ts b/apps/datapuller/src/main.ts index 8a0cb0ad4..e5b86eb3d 100644 --- a/apps/datapuller/src/main.ts +++ b/apps/datapuller/src/main.ts @@ -2,6 +2,7 @@ import { parseArgs } from "node:util"; import classesPuller from "./pullers/classes"; import coursesPuller from "./pullers/courses"; +import { updateDecals } from "./pullers/decals"; import enrollmentHistoriesPuller from "./pullers/enrollment"; import gradeDistributionsPuller from "./pullers/grade-distributions"; import sectionsPuller from "./pullers/sections"; @@ -9,14 +10,8 @@ import termsPuller from "./pullers/terms"; import setup from "./shared"; import { Config } from "./shared/config"; -const cliArgs = { - puller: { - type: "string" as const, - }, -} as const; - const pullerMap: { - [key: string]: (config: Config, ...arg: any) => Promise; + [key: string]: (config: Config, ...arg: unknown[]) => Promise; } = { courses: coursesPuller.updateCourses, "sections-active": sectionsPuller.activeTerms, @@ -28,33 +23,41 @@ const pullerMap: { enrollments: enrollmentHistoriesPuller.updateEnrollmentHistories, "terms-all": termsPuller.allTerms, "terms-nearby": termsPuller.nearbyTerms, + decals: updateDecals, } as const; -const runPuller = async () => { - const { values: args } = parseArgs({ options: cliArgs }); +const main = async () => { + const { values: args } = parseArgs({ + options: { + puller: { type: "string" }, + }, + }); if (!args.puller || !pullerMap[args.puller]) { throw new Error( - "Please specify a valid puller argument: " + - Object.keys(pullerMap).join(", ") + "Please specify a valid puller: " + Object.keys(pullerMap).join(", ") ); } const { config } = await setup(); - const logger = config.log.getSubLogger({ name: "PullerRunner" }); - try { - logger.info( - `Starting ${args.puller} puller with args: ${JSON.stringify(args)}` - ); + const logger = config.log.getSubLogger({ name: "Puller" }); + + logger.info( + `Starting ${args.puller} puller with args: ${JSON.stringify(args)}` + ); + + try { await pullerMap[args.puller](config); logger.trace(`${args.puller} puller completed successfully`); + process.exit(0); - } catch (error: any) { - logger.error(`${args.puller} puller failed: ${error.message}`); + } catch (error) { + logger.error(`${args.puller} puller failed: ${error}`); + process.exit(1); } }; -runPuller(); +main(); diff --git a/apps/datapuller/src/pullers/decals.ts b/apps/datapuller/src/pullers/decals.ts new file mode 100644 index 000000000..c97c98c6c --- /dev/null +++ b/apps/datapuller/src/pullers/decals.ts @@ -0,0 +1,108 @@ +import { + ClassModel, + DecalModel, + IDecal, + SectionModel, + TermModel, +} from "@repo/common"; + +import { getDecals } from "../lib/decals"; + +// TODO: Logs +export const updateDecals = async () => { + const currentTerm = await TermModel.findOne({ + temporalPosition: "Current", + }).lean(); + + if (!currentTerm) return; + + const primarySections = await SectionModel.find({ + termId: currentTerm.id, + primary: true, + }).lean(); + + const classes = await ClassModel.find({ + termId: currentTerm.id, + }).lean(); + + const [year, semester] = currentTerm.name.split(" "); + const decals = await getDecals(`${semester}+${year}`); + + const filteredDecals = decals.reduce((acc, decal) => { + const section = decal.sections?.find((section) => section.ccn); + + // Match by primary section CCN + section: if (section) { + const filteredPrimarySections = primarySections.filter( + (primarySection) => primarySection.number === section.ccn + ); + + if (filteredPrimarySections.length === 0) break section; + + const primarySection = filteredPrimarySections[0]; + + acc.push({ + subject: primarySection.subject, + courseNumber: primarySection.courseNumber, + number: primarySection.number, + year, + semester, + termId: currentTerm.id, + title: decal.title, + externalId: decal.id, + category: decal.category, + units: decal.units, + date: decal.date, + description: decal.description, + website: decal.website, + application: decal.application, + enroll: decal.enroll, + contact: decal.contact, + }); + + return acc; + } + + // Match by class title + const filteredClasses = classes.filter((_class) => { + return ( + _class.title === decal.title || _class.title === `${decal.title} DeCal` + ); + }); + + if (filteredClasses.length === 0) return acc; + + const filteredClass = filteredClasses[0]; + + acc.push({ + subject: filteredClass.subject, + courseNumber: filteredClass.courseNumber, + number: filteredClass.number, + year, + semester, + termId: currentTerm.id, + title: decal.title, + externalId: decal.id, + category: decal.category, + units: decal.units, + date: decal.date, + description: decal.description, + website: decal.website, + application: decal.application, + enroll: decal.enroll, + contact: decal.contact, + }); + + return acc; + }, [] as IDecal[]); + + if (filteredDecals.length === 0) return; + + await DecalModel.deleteMany({ + termId: currentTerm.id, + }); + + await DecalModel.insertMany(filteredDecals, { + ordered: false, + }); +}; diff --git a/apps/frontend/src/components/Class/Class.module.scss b/apps/frontend/src/components/Class/Class.module.scss index e17ea0570..0c1d79e06 100644 --- a/apps/frontend/src/components/Class/Class.module.scss +++ b/apps/frontend/src/components/Class/Class.module.scss @@ -18,6 +18,23 @@ "cv12" on, "cv06" on; color: var(--heading-color); + + .decalTag { + width: 54px; + height: 28px; + background-color: var(--blue-100); + color: var(--blue-500); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + margin-left: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + margin-left: 16px; + } } .description { diff --git a/apps/frontend/src/components/Class/Enrollment/Enrollment.module.scss b/apps/frontend/src/components/Class/Enrollment/Enrollment.module.scss index 298dcc4d7..560fed1ea 100644 --- a/apps/frontend/src/components/Class/Enrollment/Enrollment.module.scss +++ b/apps/frontend/src/components/Class/Enrollment/Enrollment.module.scss @@ -39,4 +39,25 @@ font-weight: 400; } } + + .decalContent { + font-size: 14px; + } + + .decalLink { + color: #2a7be4; + text-decoration: underline; + } + + .decalLabel { + margin-top: 24px; + color: var(--label-color); + line-height: 1; + } + + .decalDescription { + color: var(--paragraph-color); + margin-top: 8px; + line-height: 1.5; + } } diff --git a/apps/frontend/src/components/Class/Enrollment/index.tsx b/apps/frontend/src/components/Class/Enrollment/index.tsx index 888e4fcb2..a185a86f6 100644 --- a/apps/frontend/src/components/Class/Enrollment/index.tsx +++ b/apps/frontend/src/components/Class/Enrollment/index.tsx @@ -9,6 +9,8 @@ import { YAxis, } from "recharts"; +import useClass from "@/hooks/useClass"; + import styles from "./Enrollment.module.scss"; // import Reservations from "./Reservations"; @@ -59,75 +61,121 @@ const series = [ ]; export default function Enrollment() { + const { class: _class } = useClass(); + return (
-
-
-
- Fall 2024 -
-
-
- Average + {_class.decal ? ( +
+

Application

+ {_class.decal.website || _class.decal.application ? ( + <> + {_class.decal.website && ( +

+ Course Website{" "} + + {_class.decal.website} + +

+ )} + {_class.decal.application && ( +

+ Enroll Now!{" "} + + {_class.decal.application} + +

+ )} + + ) : ( +

N/A

+ )} +

Enrollment Instructions

+

+ {_class.decal.enroll || "Please contact the instructor."} +

-
-
- - - - - - - } - /> - value.toLocaleString()} - tickMargin={8} - label={
} - /> - - - - -
- {/* */} + ) : ( + <> +
+
+
+ Fall 2024 +
+
+
+ Average +
+
+
+ + + + + + + } + /> + value.toLocaleString()} + tickMargin={8} + label={
} + /> + + + + +
+ {/* */} + + )}
); } diff --git a/apps/frontend/src/components/Class/Overview/index.tsx b/apps/frontend/src/components/Class/Overview/index.tsx index 5e38b9486..ca4c455be 100644 --- a/apps/frontend/src/components/Class/Overview/index.tsx +++ b/apps/frontend/src/components/Class/Overview/index.tsx @@ -11,21 +11,57 @@ export default function Overview() { return ( - -
- + {_class.decal ? ( + +

Contact

+ {_class.decal.website && ( +

+ Course Website{" "} + + {_class.decal.website} + +

+ )} + {_class.decal.contact && ( +

+ Contact Information{" "} + + {_class.decal.contact} + +

+ )}

Description

- {_class.description ?? _class.course.description} + {_class.decal.description || "N/A"}

- {_class.course.requirements && ( + ) : ( + +
-

Prerequisites

-

{_class.course.requirements}

+

Description

+

+ {_class.description ?? _class.course.description} +

- )} - + {_class.course.requirements && ( + +

Prerequisites

+

+ {_class.course.requirements} +

+
+ )} + + )} ); diff --git a/apps/frontend/src/components/Class/index.tsx b/apps/frontend/src/components/Class/index.tsx index ec703bf40..9051e8223 100644 --- a/apps/frontend/src/components/Class/index.tsx +++ b/apps/frontend/src/components/Class/index.tsx @@ -359,6 +359,9 @@ export default function Class({

{_class.subject} {_class.courseNumber} #{_class.number} + {_class.decal && ( + Decal + )}

{_class.title || _class.course.title} diff --git a/apps/frontend/src/components/ClassBrowser/List/Class/Class.module.scss b/apps/frontend/src/components/ClassBrowser/List/Class/Class.module.scss index 60eea31bb..b6cc8cc07 100644 --- a/apps/frontend/src/components/ClassBrowser/List/Class/Class.module.scss +++ b/apps/frontend/src/components/ClassBrowser/List/Class/Class.module.scss @@ -54,5 +54,22 @@ margin-top: 12px; align-items: center; } + + .decalTag { + width: 54px; + height: 28px; + background-color: var(--blue-100); + color: var(--blue-500); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + margin-left: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + margin-left: 16px; + } } } diff --git a/apps/frontend/src/components/ClassBrowser/List/Class/index.tsx b/apps/frontend/src/components/ClassBrowser/List/Class/index.tsx index 39e62d443..26496019b 100644 --- a/apps/frontend/src/components/ClassBrowser/List/Class/index.tsx +++ b/apps/frontend/src/components/ClassBrowser/List/Class/index.tsx @@ -28,6 +28,7 @@ export default function Class({ primarySection: { enrollment }, unitsMax, unitsMin, + decal, index, ...props }: ClassProps & Omit, keyof ClassProps>) { @@ -36,6 +37,7 @@ export default function Class({

{subject} {courseNumber} #{number} + {decal && Decal}

{title ?? courseTitle}

diff --git a/apps/frontend/src/lib/api/classes.ts b/apps/frontend/src/lib/api/classes.ts index aec538746..2f9de1f4a 100644 --- a/apps/frontend/src/lib/api/classes.ts +++ b/apps/frontend/src/lib/api/classes.ts @@ -174,6 +174,18 @@ export interface IMeeting { instructors: IInstructor[]; } +export interface IClassDecalInfo { + id: number; + title: string; + description: string; + category: string; + units: string; + website: string; + application: string; + enroll: string; + contact: string; +} + export interface IClass { // Identifiers termId: string; @@ -199,6 +211,7 @@ export interface IClass { title: string | null; unitsMax: number; unitsMin: number; + decal: IClassDecalInfo | null; } export interface ReadClassResponse { @@ -331,6 +344,28 @@ export const READ_CLASS = gql` endTime } } + decal { + id + title + description + category + units + website + application + enroll + contact + } + decal { + id + title + description + category + units + website + application + enroll + contact + } } } `; @@ -348,6 +383,9 @@ export const GET_CATALOG = gql` unitsMin finalExam gradingBasis + decal { + id + } primarySection { component online diff --git a/apps/frontend/src/lib/api/courses.ts b/apps/frontend/src/lib/api/courses.ts index a65e73ddd..442c6d580 100644 --- a/apps/frontend/src/lib/api/courses.ts +++ b/apps/frontend/src/lib/api/courses.ts @@ -101,3 +101,43 @@ export const GET_COURSES = gql` } } `; + +export interface GetClassesResponse { + catalog: ICourse[]; +} + +export const GET_CLASSES = gql` + query GetClasses($year: Int!, $semester: Semester!) { + catalog(year: $year, semester: $semester) { + subject + number + title + gradeDistribution { + average + } + academicCareer + classes { + subject + courseNumber + number + title + unitsMax + unitsMin + finalExam + gradingBasis + primarySection { + component + online + open + enrollCount + enrollMax + waitlistCount + waitlistMax + meetings { + days + } + } + } + } + } +`; diff --git a/docker-compose.yml b/docker-compose.yml index 6f5415238..00549923c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,19 @@ networks: bt: name: bt services: + datapuller: + build: + context: . + target: datapuller-dev + depends_on: + - redis + - mongodb + networks: + - bt + restart: always + volumes: + - ./.env:/datapuller/apps/datapuller/.env + - ./apps/datapuller/src:/datapuller/apps/datapuller/src backend: build: context: . diff --git a/package-lock.json b/package-lock.json index 13e17d275..a1ee31732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,7 +128,9 @@ "@aws-sdk/client-s3": "^3.758.0", "@repo/common": "*", "@repo/sis-api": "*", + "@types/jsdom": "^21.1.7", "dotenv": "^16.4.7", + "jsdom": "^26.0.0", "papaparse": "^5.5.2", "tslog": "^4.9.3" }, @@ -544,6 +546,25 @@ "graphql": "*" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.1.1.tgz", + "integrity": "sha512-hpRD68SV2OMcZCsrbdkccTw5FXjNDLo5OuqSHyHZfwweGsDWZwDJ2+gONyNAbazZclobMirACLw0lk8WVxIqxA==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.2", + "@csstools/css-color-parser": "^3.0.8", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "license": "Apache-2.0", @@ -2806,6 +2827,116 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.2.tgz", + "integrity": "sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.8.tgz", + "integrity": "sha512-pdwotQjCCnRPuNi06jFuP68cykU1f3ZWExLe/8MQ1LOs8Xq+fTkYgd+2V8mWUWMrOn9iS2HftPVaMZDaXzGbhQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", + "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", + "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -6889,6 +7020,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "dev": true, @@ -7081,6 +7223,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "license": "MIT" @@ -7455,7 +7603,6 @@ }, "node_modules/agent-base": { "version": "7.1.3", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -8643,6 +8790,19 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.3.0.tgz", + "integrity": "sha512-6r0NiY0xizYqfBvWp1G7WXJ06/bZyrk7Dc6PHql82C/pKGUTKu4yAX4Y8JPamb1ob9nBKuxWzCGTRuGwU3yxJQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.1.1", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "license": "MIT" @@ -8754,6 +8914,28 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/dataloader": { "version": "2.2.3", "license": "MIT" @@ -8810,6 +8992,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "license": "MIT" @@ -8998,6 +9186,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "dev": true, @@ -10247,6 +10447,18 @@ "node": ">=10" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.0", "license": "MIT", @@ -10263,7 +10475,6 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.2", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -10280,7 +10491,6 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -10595,6 +10805,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-relative": { "version": "1.0.0", "dev": true, @@ -10720,6 +10936,55 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.0.0.tgz", + "integrity": "sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.1", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "license": "MIT", @@ -11665,6 +11930,12 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "license": "MIT" + }, "node_modules/oas-kit-common": { "version": "1.0.8", "dev": true, @@ -11997,6 +12268,18 @@ "node": ">= 0.10" } }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "license": "MIT", @@ -13232,6 +13515,12 @@ "linux" ] }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, "node_modules/run-async": { "version": "2.4.1", "dev": true, @@ -13319,6 +13608,18 @@ "license": "ISC", "optional": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.25.0", "license": "MIT" @@ -14144,6 +14445,12 @@ "node": ">=0.10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/sync-fetch": { "version": "0.6.0-2", "dev": true, @@ -14211,6 +14518,24 @@ "tslib": "^2.0.3" } }, + "node_modules/tldts": { + "version": "6.1.85", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.85.tgz", + "integrity": "sha512-gBdZ1RjCSevRPFix/hpaUWeak2/RNUZB4/8frF1r5uYMHjFptkiT0JXIebWvgI/0ZHXvxaUDDJshiA0j6GdL3w==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.85" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.85", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.85.tgz", + "integrity": "sha512-DTjUVvxckL1fIoPSb3KE7ISNtkWSawZdpfxGxwiIrZoO6EbHVDXXUIlIuWympPaeS+BLGyggozX/HTMsRAdsoA==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.0.33", "license": "MIT", @@ -14238,6 +14563,18 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "5.0.0", "license": "MIT", @@ -14832,6 +15169,18 @@ "pbf": "^3.2.1" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "dev": true, @@ -14855,6 +15204,30 @@ "node": ">=12" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "license": "MIT", @@ -14973,7 +15346,6 @@ }, "node_modules/ws": { "version": "8.18.1", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -14991,6 +15363,21 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "license": "MIT", diff --git a/packages/common/src/models/decal.ts b/packages/common/src/models/decal.ts new file mode 100644 index 000000000..a4105ba55 --- /dev/null +++ b/packages/common/src/models/decal.ts @@ -0,0 +1,59 @@ +import mongoose, { InferSchemaType, Schema } from "mongoose"; + +import { schemaOptions } from "../lib/common"; + +export interface IDecal { + // identifiers + semester: string; + year: string; + courseNumber: string; + subject: string; + number: string; + termId: string; + externalId: string; + + // attributes + category: string | null; + units: string | null; + date: string | null; + title?: string; + description?: string; + website?: string; + application?: string; + enroll?: string; + contact?: string; +} + +const decalSchemaObject = { + semester: { type: String, required: true }, + year: { type: String, required: true }, + courseNumber: { type: String, required: true }, + subject: { type: String, required: true }, + number: { type: String, required: true }, + termId: { type: String, required: true }, + externalId: { + type: String, + required: true, + }, + category: { + type: String, + }, + units: { + type: String, + }, + date: { + type: String, + }, + title: { type: String }, + description: { type: String }, + website: { type: String }, + application: { type: String }, + enroll: { type: String }, + contact: { type: String }, +}; + +// TODO: Index + +export const decalSchema = new Schema(decalSchemaObject, schemaOptions); +export const DecalModel = mongoose.model("Decal", decalSchema); +export type DecalType = InferSchemaType; diff --git a/packages/common/src/models/index.ts b/packages/common/src/models/index.ts index 093715fb0..48f0f2546 100644 --- a/packages/common/src/models/index.ts +++ b/packages/common/src/models/index.ts @@ -6,3 +6,4 @@ export * from "./course"; export * from "./section"; export * from "./grade-distribution"; export * from "./enrollment-history"; +export * from "./decal";