Skip to content

Commit

Permalink
feat: ✨ implement degreeworks scraper and requirements endpoint (#140)
Browse files Browse the repository at this point in the history
Co-authored-by: Aponia <[email protected]>
  • Loading branch information
ecxyzzy and ap0nia authored May 19, 2024
1 parent 74889e2 commit 0b16a9c
Show file tree
Hide file tree
Showing 16 changed files with 1,090 additions and 92 deletions.
8 changes: 8 additions & 0 deletions apps/api/src/routes/v1/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export const resolvers: ApolloServerOptions<BaseContext>["resolvers"] = {
course: proxyRestApi("/v1/rest/courses", { pathArg: "courseId" }),
courses: proxyRestApi("/v1/rest/courses", { argsTransform: geTransform }),
allCourses: proxyRestApi("/v1/rest/courses/all"),
major: proxyRestApi("/v1/rest/degrees/majors"),
majors: proxyRestApi("/v1/rest/degrees/majors"),
minor: proxyRestApi("/v1/rest/degrees/minors"),
minors: proxyRestApi("/v1/rest/degrees/minors"),
specialization: proxyRestApi("/v1/rest/degrees/specializations"),
specializations: proxyRestApi("/v1/rest/degrees/specializations"),
specializationsByMajorId: proxyRestApi("/v1/rest/degrees/specializations"),
allDegrees: proxyRestApi("/v1/rest/degrees/all"),
enrollmentHistory: proxyRestApi("/v1/rest/enrollmentHistory"),
rawGrades: proxyRestApi("/v1/rest/grades/raw"),
aggregateGrades: proxyRestApi("/v1/rest/grades/aggregate"),
Expand Down
39 changes: 39 additions & 0 deletions apps/api/src/routes/v1/graphql/schema/degrees.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
type Specialization {
id: String!
majorId: String!
name: String!
requirements: JSON!
}

type Major {
id: String!
degreeId: String!
code: String!
name: String!
requirements: JSON!
specializations: [Specialization!]!
}

type Minor {
id: String!
name: String!
requirements: JSON!
}

type Degree {
id: String!
name: String!
division: DegreeDivision!
majors: [Major!]!
}

extend type Query {
major(id: String!): Major!
majors(degreeId: String, nameContains: String): [Major!]!
minor(id: String!): Minor!
minors(nameContains: String): [Minor!]!
specialization(id: String!): Specialization!
specializations(nameContains: String): [Specialization!]!
specializationsByMajorId(majorId: String!): [Specialization!]!
allDegrees: [Degree!]!
}
5 changes: 5 additions & 0 deletions apps/api/src/routes/v1/graphql/schema/enum.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,8 @@ enum WebsocSectionFinalExamStatus {
TBA_FINAL
SCHEDULED_FINAL
}
"The set of valid degree divisions."
enum DegreeDivision {
Undergraduate
Graduate
}
146 changes: 146 additions & 0 deletions apps/api/src/routes/v1/rest/degrees/{id}/+endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { PrismaClient } from "@libs/db";
import { createHandler } from "@libs/lambda";

import { ProgramSchema, SpecializationSchema } from "./schema";

const prisma = new PrismaClient();

async function onWarm() {
await prisma.$connect();
}

const degreeRepository = {
majors: {
findMany: async () => {
return await prisma.major.findMany({ include: { specializations: true } });
},
findFirstById: async (id: string) => {
return await prisma.major.findFirst({ where: { id }, include: { specializations: true } });
},
findManyNameContains: async (degreeId: string, contains?: string) => {
return await prisma.major.findMany({
where: {
degreeId,
name: { contains, mode: "insensitive" },
},
include: { specializations: true },
});
},
},
minors: {
findMany: async () => {
return await prisma.minor.findMany({});
},
findFirstById: async (id: string) => {
return await prisma.minor.findFirst({ where: { id } });
},
},
};

export const GET = createHandler(async (event, context, res) => {
const headers = event.headers;
const params = event.pathParameters ?? {};
const query = event.queryStringParameters ?? {};
const requestId = context.awsRequestId;

switch (params?.id) {
case "all":
return res.createOKResult(
await prisma.degree.findMany({
include: { majors: { include: { specializations: true } } },
}),
headers,
requestId,
);

case "majors": // falls through
case "minors": {
const parsedQuery = ProgramSchema.safeParse(query);

if (!parsedQuery.success) {
return res.createErrorResult(
400,
parsedQuery.error.issues.map((issue) => issue.message).join("; "),
requestId,
);
}

switch (parsedQuery.data.type) {
case "id": {
const result = await degreeRepository[params.id].findFirstById(parsedQuery.data.id);
return result
? res.createOKResult(result, headers, requestId)
: res.createErrorResult(
404,
`${params.id === "majors" ? "Major" : "Minor"} with ID ${parsedQuery.data.id} not found`,
requestId,
);
}

case "degreeOrName": {
const { degreeId, nameContains } = parsedQuery.data;

if (params.id === "minors" && degreeId != null) {
return res.createErrorResult(400, "Invalid input", requestId);
}

const result = await degreeRepository.majors.findManyNameContains(degreeId, nameContains);
return res.createOKResult(result, headers, requestId);
}

case "empty": {
const result = await degreeRepository[params.id].findMany();
return res.createOKResult(result, headers, requestId);
}
}
break;
}

case "specializations": {
const parsedQuery = SpecializationSchema.safeParse(query);

if (!parsedQuery.success) {
return res.createErrorResult(
400,
parsedQuery.error.issues.map((issue) => issue.message).join("; "),
requestId,
);
}

switch (parsedQuery.data.type) {
case "id": {
const row = await prisma.specialization.findFirst({ where: { id: parsedQuery.data.id } });

return row
? res.createOKResult(row, headers, requestId)
: res.createErrorResult(
404,
`Specialization with ID ${parsedQuery.data.id} not found`,
requestId,
);
}

case "major": {
const result = await prisma.specialization.findMany({
where: { majorId: parsedQuery.data.majorId },
});
return res.createOKResult(result, headers, requestId);
}

case "name": {
const result = await prisma.specialization.findMany({
where: { name: { contains: parsedQuery.data.nameContains, mode: "insensitive" } },
});
return res.createOKResult(result, headers, requestId);
}

case "empty": {
const result = await prisma.specialization.findMany();
return res.createOKResult(result, headers, requestId);
}
}
}
}

return res.createErrorResult(400, "Invalid endpoint", requestId);
}, onWarm);
42 changes: 42 additions & 0 deletions apps/api/src/routes/v1/rest/degrees/{id}/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { z } from "zod";

export const ProgramSchema = z
.union([
z.object({ id: z.string() }),
z.object({ degreeId: z.string().optional(), nameContains: z.string().optional() }),
z.object({}),
])
.transform((data) => {
if ("id" in data) {
return { type: "id" as const, ...data };
}

if ("degreeId" in data && data.degreeId != null) {
return { type: "degreeOrName" as const, degreeId: data.degreeId, ...data };
}

return { type: "empty" as const, ...data };
});

export const SpecializationSchema = z
.union([
z.object({ id: z.string() }),
z.object({ majorId: z.string() }),
z.object({ nameContains: z.string() }),
z.object({}),
])
.transform((data) => {
if ("id" in data) {
return { type: "id" as const, ...data };
}

if ("majorId" in data) {
return { type: "major" as const, ...data };
}

if ("nameContains" in data) {
return { type: "name" as const, ...data };
}

return { type: "empty" as const, ...data };
});
58 changes: 47 additions & 11 deletions libs/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ enum CourseLevel {
Graduate
}

enum Division {
Undergraduate
Graduate
}

enum Quarter {
Fall
Winter
Expand Down Expand Up @@ -112,17 +117,11 @@ model Course {
terms String[]
}

model Instructor {
ucinetid String @id
name String
shortenedName String
title String
email String
department String
schools String[]
relatedDepartments String[]
courseHistory Json
courses Json @default("[]")
model Degree {
id String @id
name String
division Division
majors Major[]
}

model GradesInstructor {
Expand Down Expand Up @@ -169,6 +168,43 @@ model GradesSection {
@@unique([year, quarter, sectionCode], name: "idx")
}

model Instructor {
ucinetid String @id
name String
shortenedName String
title String
email String
department String
schools String[]
relatedDepartments String[]
courseHistory Json
courses Json @default("[]")
}

model Major {
id String @id
degreeId String
degree Degree @relation(fields: [degreeId], references: [id])
code String
name String
requirements Json
specializations Specialization[]
}

model Minor {
id String @id
name String
requirements Json
}

model Specialization {
id String @id
majorId String
major Major @relation(fields: [majorId], references: [id])
name String
requirements Json
}

model WebsocEnrollmentHistoryEntry {
year String
quarter Quarter
Expand Down
Loading

0 comments on commit 0b16a9c

Please sign in to comment.