Skip to content

Commit 8542f48

Browse files
authored
feat(server)!: add course offerings corequisites (#44)
1 parent a521b9b commit 8542f48

File tree

14 files changed

+341
-214
lines changed

14 files changed

+341
-214
lines changed

apps/scraper/src/index.ts

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import type {
2-
ZUpsertPrerequisites,
3-
ZUpsertRequirements,
4-
} from "@dev-team-fall-25/server/convex/http";
51
import { eq } from "drizzle-orm";
62
import { Hono } from "hono";
73
import * as z from "zod/mini";
@@ -185,47 +181,33 @@ export default {
185181
case "program": {
186182
const res = await scrapeProgram(job.url, db, env);
187183

188-
const programId = await convex.upsertProgram(res.program);
184+
const programId = await convex.upsertProgramWithRequirements({
185+
...res.program,
186+
requirements: res.requirements,
187+
});
189188

190189
if (!programId) {
191190
throw new JobError(
192191
"Failed to upsert program: no ID returned",
193192
"validation",
194193
);
195194
}
196-
197-
// it is safe to assert the type here because the data will be validated before sending the request
198-
const newRequirements = res.requirements.map((req) => ({
199-
...req,
200-
programId: programId,
201-
})) as z.infer<typeof ZUpsertRequirements>;
202-
203-
if (res.requirements.length > 0) {
204-
await convex.upsertRequirements(newRequirements);
205-
}
206195
break;
207196
}
208197
case "course": {
209198
const res = await scrapeCourse(job.url, db, env);
210199

211-
const courseId = await convex.upsertCourse(res.course);
200+
const courseId = await convex.upsertCourseWithPrerequisites({
201+
...res.course,
202+
prerequisites: res.prerequisites,
203+
});
212204

213205
if (!courseId) {
214206
throw new JobError(
215207
"Failed to upsert course: no ID returned",
216208
"validation",
217209
);
218210
}
219-
220-
// it is safe to assert the type here because the data will be validated before sending the request
221-
const newPrerequisites = res.prerequisites.map((prereq) => ({
222-
...prereq,
223-
courseId: courseId,
224-
})) as z.infer<typeof ZUpsertPrerequisites>;
225-
226-
if (res.prerequisites.length > 0) {
227-
await convex.upsertPrerequisites(newPrerequisites);
228-
}
229211
break;
230212
}
231213
}

apps/scraper/src/lib/convex.ts

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ import type { internal } from "@dev-team-fall-25/server/convex/_generated/api";
22
import {
33
ZGetAppConfig,
44
type ZSetAppConfig,
5-
ZUpsertCourse,
65
ZUpsertCourseOffering,
7-
ZUpsertPrerequisites,
8-
ZUpsertProgram,
9-
ZUpsertRequirements,
6+
ZUpsertCourseWithPrerequisites,
7+
ZUpsertProgramWithRequirements,
108
} from "@dev-team-fall-25/server/convex/http";
119
import type { FunctionReturnType } from "convex/server";
1210
import * as z from "zod/mini";
@@ -57,35 +55,21 @@ export class ConvexApi {
5755
return response.json();
5856
}
5957

60-
async upsertCourse(data: z.infer<typeof ZUpsertCourse>) {
58+
async upsertCourseWithPrerequisites(
59+
data: z.infer<typeof ZUpsertCourseWithPrerequisites>,
60+
) {
6161
const res = await this.request<
6262
FunctionReturnType<typeof internal.courses.upsertCourseInternal>
63-
>("/api/courses/upsert", ZUpsertCourse, data);
63+
>("/api/courses/upsert", ZUpsertCourseWithPrerequisites, data);
6464
return res.data;
6565
}
6666

67-
async upsertProgram(data: z.infer<typeof ZUpsertProgram>) {
67+
async upsertProgramWithRequirements(
68+
data: z.infer<typeof ZUpsertProgramWithRequirements>,
69+
) {
6870
const res = await this.request<
6971
FunctionReturnType<typeof internal.programs.upsertProgramInternal>
70-
>("/api/programs/upsert", ZUpsertProgram, data);
71-
return res.data;
72-
}
73-
74-
async upsertRequirements(data: z.infer<typeof ZUpsertRequirements>) {
75-
const res = await this.request<
76-
FunctionReturnType<
77-
typeof internal.requirements.createRequirementsInternal
78-
>
79-
>("/api/requirements/upsert", ZUpsertRequirements, data);
80-
return res.data;
81-
}
82-
83-
async upsertPrerequisites(data: z.infer<typeof ZUpsertPrerequisites>) {
84-
const res = await this.request<
85-
FunctionReturnType<
86-
typeof internal.prerequisites.createPrerequisitesInternal
87-
>
88-
>("/api/prerequisites/upsert", ZUpsertPrerequisites, data);
72+
>("/api/programs/upsert", ZUpsertProgramWithRequirements, data);
8973
return res.data;
9074
}
9175

apps/scraper/src/modules/courses/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import type {
66
import type { DrizzleD1Database } from "drizzle-orm/d1";
77
import type * as z from "zod/mini";
88

9-
export type CoursePrerequisite = Omit<
10-
z.infer<typeof ZUpsertPrerequisites>[number],
11-
"courseId"
12-
>;
9+
type PrerequisiteItem = z.infer<typeof ZUpsertPrerequisites>[number];
10+
11+
export type CoursePrerequisite =
12+
| Omit<Extract<PrerequisiteItem, { type: "required" }>, "courseId">
13+
| Omit<Extract<PrerequisiteItem, { type: "alternative" }>, "courseId">
14+
| Omit<Extract<PrerequisiteItem, { type: "options" }>, "courseId">;
1315

1416
export async function discoverCourses(url: string): Promise<string[]> {
1517
// TODO: implement this function
Lines changed: 109 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { paginationOptsValidator } from "convex/server";
12
import { v } from "convex/values";
23
import { internalMutation } from "./_generated/server";
34
import { protectedQuery } from "./helpers/auth";
@@ -10,8 +11,39 @@ export const getCourseOfferingById = protectedQuery({
1011
},
1112
});
1213

13-
export const getCourseOfferingsByTerm = protectedQuery({
14+
export const getCourseOfferingsByCourseTerm = protectedQuery({
15+
args: {
16+
courseCodes: v.array(v.string()),
17+
term: v.union(
18+
v.literal("spring"),
19+
v.literal("summer"),
20+
v.literal("fall"),
21+
v.literal("j-term"),
22+
),
23+
year: v.number(),
24+
},
25+
handler: async (ctx, args) => {
26+
const results = await Promise.all(
27+
args.courseCodes.map((courseCode) =>
28+
ctx.db
29+
.query("courseOfferings")
30+
.withIndex("by_course_term", (q) =>
31+
q
32+
.eq("courseCode", courseCode)
33+
.eq("term", args.term)
34+
.eq("year", args.year),
35+
)
36+
.collect(),
37+
),
38+
);
39+
40+
return results.flat();
41+
},
42+
});
43+
44+
export const getCourseOfferingByClassNumber = protectedQuery({
1445
args: {
46+
classNumber: v.number(),
1547
term: v.union(
1648
v.literal("spring"),
1749
v.literal("summer"),
@@ -23,16 +55,19 @@ export const getCourseOfferingsByTerm = protectedQuery({
2355
handler: async (ctx, args) => {
2456
return await ctx.db
2557
.query("courseOfferings")
26-
.withIndex("by_term_year", (q) =>
27-
q.eq("term", args.term).eq("year", args.year),
58+
.withIndex("by_class_number", (q) =>
59+
q
60+
.eq("classNumber", args.classNumber)
61+
.eq("term", args.term)
62+
.eq("year", args.year),
2863
)
29-
.collect();
64+
.unique();
3065
},
3166
});
3267

33-
export const getCourseOfferingsByCourseTerm = protectedQuery({
68+
export const getCorequisitesByCourseCode = protectedQuery({
3469
args: {
35-
courseCode: v.string(),
70+
classNumber: v.number(),
3671
term: v.union(
3772
v.literal("spring"),
3873
v.literal("summer"),
@@ -44,34 +79,94 @@ export const getCourseOfferingsByCourseTerm = protectedQuery({
4479
handler: async (ctx, args) => {
4580
return await ctx.db
4681
.query("courseOfferings")
47-
.withIndex("by_course_term_section", (q) =>
82+
.withIndex("by_corequisite_of", (q) =>
4883
q
49-
.eq("courseCode", args.courseCode)
84+
.eq("corequisiteOf", args.classNumber)
5085
.eq("term", args.term)
5186
.eq("year", args.year),
5287
)
5388
.collect();
5489
},
5590
});
5691

92+
export const getCourseOfferings = protectedQuery({
93+
args: {
94+
query: v.optional(v.string()),
95+
term: v.union(
96+
v.literal("spring"),
97+
v.literal("summer"),
98+
v.literal("fall"),
99+
v.literal("j-term"),
100+
),
101+
year: v.number(),
102+
paginationOpts: paginationOptsValidator,
103+
},
104+
handler: async (ctx, { query, paginationOpts, term, year }) => {
105+
if (query) {
106+
return await ctx.db
107+
.query("courseOfferings")
108+
.withSearchIndex("search_title", (q) =>
109+
q
110+
.search("title", query)
111+
.eq("isCorequisite", false)
112+
.eq("term", term)
113+
.eq("year", year),
114+
)
115+
.paginate(paginationOpts);
116+
}
117+
118+
return await ctx.db
119+
.query("courseOfferings")
120+
.withIndex("by_term_year", (q) =>
121+
q.eq("isCorequisite", false).eq("term", term).eq("year", year),
122+
)
123+
.order("desc")
124+
.paginate(paginationOpts);
125+
},
126+
});
127+
57128
export const upsertCourseOfferingInternal = internalMutation({
58129
args: courseOfferings,
59130
handler: async (ctx, args) => {
60131
const existing = await ctx.db
61132
.query("courseOfferings")
62-
.withIndex("by_course_term_section", (q) =>
133+
.withIndex("by_class_number", (q) =>
63134
q
64-
.eq("courseCode", args.courseCode)
135+
.eq("classNumber", args.classNumber)
65136
.eq("term", args.term)
66-
.eq("year", args.year)
67-
.eq("section", args.section),
137+
.eq("year", args.year),
68138
)
69139
.unique();
70140

71141
if (existing) {
72142
return await ctx.db.patch(existing._id, args);
73-
} else {
74-
return await ctx.db.insert("courseOfferings", args);
75143
}
144+
return await ctx.db.insert("courseOfferings", args);
145+
},
146+
});
147+
148+
export const upsertCourseOfferingsInternal = internalMutation({
149+
args: { courseOfferings: v.array(v.object(courseOfferings)) },
150+
handler: async (ctx, args) => {
151+
const results = await Promise.all(
152+
args.courseOfferings.map(async (offering) => {
153+
const existing = await ctx.db
154+
.query("courseOfferings")
155+
.withIndex("by_class_number", (q) =>
156+
q
157+
.eq("classNumber", offering.classNumber)
158+
.eq("term", offering.term)
159+
.eq("year", offering.year),
160+
)
161+
.unique();
162+
163+
if (existing) {
164+
return await ctx.db.patch(existing._id, offering);
165+
}
166+
return await ctx.db.insert("courseOfferings", offering);
167+
}),
168+
);
169+
170+
return results;
76171
},
77172
});

packages/server/convex/courses.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { paginationOptsValidator } from "convex/server";
12
import { v } from "convex/values";
23
import { internalMutation } from "./_generated/server";
34
import { protectedQuery } from "./helpers/auth";
@@ -20,15 +21,27 @@ export const getCourseByCode = protectedQuery({
2021
},
2122
});
2223

23-
export const getCourseByProgramLevel = protectedQuery({
24-
args: { program: v.string(), level: v.number() },
24+
export const getCourses = protectedQuery({
25+
args: {
26+
level: v.number(),
27+
query: v.optional(v.string()),
28+
paginationOpts: paginationOptsValidator,
29+
},
2530
handler: async (ctx, args) => {
31+
if (args.query !== undefined) {
32+
return await ctx.db
33+
.query("courses")
34+
.withSearchIndex("search_title", (q) =>
35+
q.search("title", args.query as string).eq("level", args.level),
36+
)
37+
.paginate(args.paginationOpts);
38+
}
39+
2640
return await ctx.db
2741
.query("courses")
28-
.withIndex("by_program_level", (q) =>
29-
q.eq("program", args.program).eq("level", args.level),
30-
)
31-
.unique();
42+
.withIndex("by_level", (q) => q.eq("level", args.level))
43+
.order("desc")
44+
.paginate(args.paginationOpts);
3245
},
3346
});
3447

@@ -41,7 +54,8 @@ export const upsertCourseInternal = internalMutation({
4154
.unique();
4255

4356
if (existing) {
44-
return await ctx.db.patch(existing._id, args);
57+
await ctx.db.patch(existing._id, args);
58+
return existing._id;
4559
} else {
4660
return await ctx.db.insert("courses", args);
4761
}

0 commit comments

Comments
 (0)