Skip to content

Commit

Permalink
feat: degree programs and their requirements (#95)
Browse files Browse the repository at this point in the history
## Description

We add `/v2/rest/programs/{major,minor,specialization}`, three endpoints
to retrieve data on an individual major, minor, or specialization
respectively. As documented in the new OpenAPI specification, a display
name and course requirements are provided. We also add
`/v2/rest/programs/{majors,minors,specializations}` to list all possible
options. This also links majors and specializations to one another. We
also introduce the six corresponding GraphQL resources: `major`,
`minor`, `specialization`, `majors`, `minors`, `specializations`.

We also rearrange some fields concerning the representation of course
requirements in SQL; the scraper now presents requirement display names
(as written by a university department) as a field within a requirement
object instead of its key. The model otherwise remains the same.
**However, this invalidates existing DegreeWorks scraping results.**

Reviewers should particularly scrutinize the handling of the circular
dependency concerning course requirements; a requirements object is a
union of variants including a group requirement, which contains an array
of requirements objects. The current solution is with `z.lazy` in
OpenAPI and by specifying the aforementioned array as `JSON!` in
GraphQL, forgoing any attempt at a schema.

- [x] ~~**This is blocked by the companion PR, not yet opened, resolving
#68**. That PR will handle the endpoints enumerating majors, minors, and
associated specializations. Without those endpoints, the endpoints we
present here are largely meaningless.~~ @waterkimchi is working directly
on this PR.
- [x] @waterkimchi will finish GraphQL resources to list majors, minors,
and specializations.
- [x] We should consider whether any reference to specializations should
also return the ID of their associated major. I argue this is
unnecessary and the relation from major to specialization is sufficient
for most use-cases, including our primary customers on PeterPortal.

## Related Issue

Closes #1 (lol) and closes #68.

## Motivation and Context

hey man they told me to ship this so imma ship it

## How Has This Been Tested?

As mentioned previously the tables derived from DegreeWorks were purged
and re-populated with the new scraper. Manual examination of REST and
GraphQL updates were performed on a local development copy of Anteater
API.

## Screenshots (if appropriate):

This PR establishes six new REST routes and six new GraphQL resources.
Their code is strongly similar and their paths converge fully for core
database logic. For completeness, however, all twelve will be shown:

### REST

`.../programs/major`:


![image](https://github.com/user-attachments/assets/d7cf36ec-0dea-4732-84d5-7c5bdef9549d)

`.../programs/minor`:


![Screenshot_20250125_002245](https://github.com/user-attachments/assets/7a75b6a4-3eb5-408e-9b83-7fc64cae595b)

`.../programs/specialization`:


![Screenshot_20250125_002319](https://github.com/user-attachments/assets/6da9e99a-a8aa-48b4-9d46-32ac72ac95a7)

`.../programs/majors`:


![Screenshot_20250125_192305](https://github.com/user-attachments/assets/3ba1fea7-ea96-4f13-b440-817107b65f12)

`.../programs/minors`:


![Screenshot_20250125_192347](https://github.com/user-attachments/assets/cc5ba42b-5073-4427-b29b-67514516bab8)

`.../programs/specializations`:


![Screenshot_20250125_192413](https://github.com/user-attachments/assets/fbe9eaa5-5b54-4d20-9860-4e98aab6d43e)

### GraphQL

`major`:

![Screenshot_20250125_002753](https://github.com/user-attachments/assets/17539b93-4879-46e3-ae45-aa07fa894439)

`minor`:

![Screenshot_20250125_002837](https://github.com/user-attachments/assets/f7507bb2-1131-4779-9516-81fd76e82f5a)

`specialization`:

![Screenshot_20250125_002958](https://github.com/user-attachments/assets/b55ad698-d7d7-49fd-bb5d-6317144c9a98)

`majors`:

![Screenshot_20250126_222913](https://github.com/user-attachments/assets/2a3ee41a-1e7e-4f8e-8420-6d95f1d0dba3)

`minors`:

![Screenshot_20250126_222938](https://github.com/user-attachments/assets/cf6ac74f-5d68-4d97-9571-b40b18554302)

`specializations`:

![Screenshot_20250126_222952](https://github.com/user-attachments/assets/f7a13d21-299a-4945-93b3-b4cc671298a6)

## Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)

## Checklist:

- [x] My code involves a change to the database schema. (The type on a
JSON field has changed, which is not visible to SQL.)
- [ ] My code requires a change to the documentation.

---------

Co-authored-by: Hyunsu Lim <[email protected]>
  • Loading branch information
laggycomputer and waterkimchi authored Jan 27, 2025
1 parent ba8e0de commit d9f9e26
Show file tree
Hide file tree
Showing 13 changed files with 778 additions and 59 deletions.
4 changes: 3 additions & 1 deletion apps/api/src/graphql/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { enrollmentHistoryResolvers } from "$graphql/resolvers/enrollment-histor
import { gradesResolvers } from "$graphql/resolvers/grades";
import { instructorsResolvers } from "$graphql/resolvers/instructors";
import { larcResolvers } from "$graphql/resolvers/larc.ts";
import { programResolvers } from "$graphql/resolvers/programs.ts";
import { searchResolvers } from "$graphql/resolvers/search";
import { studyRoomsResolvers } from "$graphql/resolvers/study-rooms";
import { websocResolvers } from "$graphql/resolvers/websoc";
Expand All @@ -16,9 +17,10 @@ export const resolvers = mergeResolvers([
enrollmentHistoryResolvers,
gradesResolvers,
instructorsResolvers,
larcResolvers,
programResolvers,
searchResolvers,
websocResolvers,
weekResolvers,
studyRoomsResolvers,
larcResolvers,
]);
83 changes: 83 additions & 0 deletions apps/api/src/graphql/resolvers/programs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { GraphQLContext } from "$graphql/graphql-context";
import {
majorRequirementsQuerySchema,
minorRequirementsQuerySchema,
specializationRequirementsQuerySchema,
} from "$schema";
import { ProgramsService } from "$services";
import { GraphQLError } from "graphql/error";

export const programResolvers = {
Query: {
major: async (_: unknown, args: { query?: unknown }, { db }: GraphQLContext) => {
const parsedArgs = majorRequirementsQuerySchema.parse(args?.query);
const service = new ProgramsService(db);
const res = await service.getMajorRequirements(parsedArgs);
if (!res)
throw new GraphQLError(`Major ${parsedArgs.programId} not found`, {
extensions: { code: "NOT_FOUND" },
});
return res;
},
minor: async (_: unknown, args: { query?: unknown }, { db }: GraphQLContext) => {
const parsedArgs = minorRequirementsQuerySchema.parse(args?.query);
const service = new ProgramsService(db);
const res = await service.getMinorRequirements(parsedArgs);
if (!res)
throw new GraphQLError(`Minor ${parsedArgs.programId} not found`, {
extensions: { code: "NOT_FOUND" },
});
return res;
},
specialization: async (_: unknown, args: { query?: unknown }, { db }: GraphQLContext) => {
const parsedArgs = specializationRequirementsQuerySchema.parse(args?.query);
const service = new ProgramsService(db);
const res = await service.getSpecializationRequirements(parsedArgs);
if (!res)
throw new GraphQLError(`Specialization ${parsedArgs.programId} not found`, {
extensions: { code: "NOT_FOUND" },
});
return res;
},
majors: async (_: unknown, args: unknown, { db }: GraphQLContext) => {
const service = new ProgramsService(db);
const res = await service.getMajors();
if (!res)
throw new GraphQLError("Major data not found", {
extensions: { code: "NOT_FOUND" },
});
return res;
},
minors: async (_: unknown, args: unknown, { db }: GraphQLContext) => {
const service = new ProgramsService(db);
const res = await service.getMinors();
if (!res)
throw new GraphQLError("Minor data not found", {
extensions: { code: "NOT_FOUND" },
});
return res;
},
specializations: async (_: unknown, args: unknown, { db }: GraphQLContext) => {
const service = new ProgramsService(db);
const res = await service.getSpecializations();
if (!res)
throw new GraphQLError("Specializations data not found", {
extensions: { code: "NOT_FOUND" },
});
return res;
},
},
ProgramRequirement: {
// x outside this typehint is malformed data; meh
__resolveType: (x: { requirementType: "Course" | "Unit" | "Group" }) => {
switch (x?.requirementType) {
case "Course":
return "ProgramCourseRequirement";
case "Unit":
return "ProgramUnitRequirement";
case "Group":
return "ProgramGroupRequirement";
}
},
},
};
4 changes: 3 additions & 1 deletion apps/api/src/graphql/schema/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { programsSchema } from "$graphql/schema/programs.ts";
import { mergeTypeDefs } from "@graphql-tools/merge";
import { calendarSchema } from "./calendar";
import { coursesSchema } from "./courses";
Expand Down Expand Up @@ -37,9 +38,10 @@ export const typeDefs = mergeTypeDefs([
enrollmentHistorySchema,
gradesSchema,
instructorsSchema,
larcSchema,
programsSchema,
searchSchema,
websocSchema,
weekSchema,
studyRoomsGraphQLSchema,
larcSchema,
]);
77 changes: 77 additions & 0 deletions apps/api/src/graphql/schema/programs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export const programsSchema = `#graphql
enum ProgramDivision {
Undergraduate
Graduate
}
interface ProgramPreview {
id: String!
name: String!
}
type MajorPreview implements ProgramPreview @cacheControl(maxAge: 86400) {
id: String!
name: String!
type: String!
division: ProgramDivision!
specializations: [String!]!
}
type MinorPreview implements ProgramPreview @cacheControl(maxAge: 86400) {
id: String!
name: String!
}
type SpecializationPreview implements ProgramPreview @cacheControl(maxAge: 86400) {
id: String!
majorId: String!
name: String!
}
interface ProgramRequirementBase {
label: String!
}
type ProgramCourseRequirement implements ProgramRequirementBase @cacheControl(maxAge: 86400) {
label: String!
requirementType: String!
courseCount: Int!
courses: [String!]!
}
type ProgramUnitRequirement implements ProgramRequirementBase @cacheControl(maxAge: 86400) {
label: String!
requirementType: String!
unitCount: Int!
courses: [String!]!
}
type ProgramGroupRequirement implements ProgramRequirementBase @cacheControl(maxAge: 86400) {
label: String!
requirementType: String!
requirementCount: Int!
# circular
requirements: JSON!
}
union ProgramRequirement = ProgramCourseRequirement | ProgramUnitRequirement | ProgramGroupRequirement
type Program @cacheControl(maxAge: 86400) {
id: String!
name: String!
requirements: [ProgramRequirement]!
}
input ProgramRequirementsQuery {
programId: String!
}
extend type Query {
majors: [MajorPreview!]!
minors: [MinorPreview!]!
specializations: [SpecializationPreview!]!
major(query: ProgramRequirementsQuery!): Program!
minor(query: ProgramRequirementsQuery!): Program!
specialization(query: ProgramRequirementsQuery!): Program!
}
`;
2 changes: 2 additions & 0 deletions apps/api/src/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { gradesRouter } from "./routes/grades";
import { instructorsRouter } from "./routes/instructors";
import { larcRouter } from "./routes/larc.ts";
import { pingRouter } from "./routes/ping";
import { programsRouter } from "./routes/programs.ts";
import { searchRouter } from "./routes/search";
import { studyRoomsRouter } from "./routes/study-rooms";
import { websocRouter } from "./routes/websoc";
Expand All @@ -20,6 +21,7 @@ restRouter.route("/enrollmentHistory", enrollmentHistoryRouter);
restRouter.route("/grades", gradesRouter);
restRouter.route("/instructors", instructorsRouter);
restRouter.route("/ping", pingRouter);
restRouter.route("/programs", programsRouter);
restRouter.route("/search", searchRouter);
restRouter.route("/websoc", websocRouter);
restRouter.route("/week", weekRouter);
Expand Down
Loading

0 comments on commit d9f9e26

Please sign in to comment.