Skip to content
Draft
7 changes: 6 additions & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "nrwl.angular-console", "esbenp.prettier-vscode"]
"recommendations": [
"dbaeumer.vscode-eslint",
"nrwl.angular-console",
"esbenp.prettier-vscode",
"prisma.prisma"
]
}
4 changes: 4 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getFCEs } from "~/controllers/fces";
import { getInstructors } from "~/controllers/instructors";
import { getGeneds } from "~/controllers/geneds";
import { getSchedules } from "~/controllers/schedules";
import { getSyllabi, getAllSyllabi } from "~/controllers/syllabi";

const app = express();
const port = process.env.PORT || 3000;
Expand All @@ -33,6 +34,9 @@ app.route("/schedules").get(getSchedules);
app.route("/geneds").get(getGeneds);
app.route("/geneds").post(isUser, getGeneds);

app.route("/syllabi").get(getSyllabi)
app.route("/syllabi/all").get(getAllSyllabi);

// the next parameter is needed!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
Expand Down
119 changes: 119 additions & 0 deletions apps/backend/src/controllers/syllabi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { RequestHandler } from "express";
import { cleanID, PrismaReturn, SingleOrArray, singleToArray } from "~/util";
import db from "@cmucourses/db";

export interface Syllabus {
id: string;
season: string;
year: number;
number: string;
url: string | null;
}

export interface GetSyllabi {
params: unknown;
resBody: Syllabus[];
reqBody: unknown;
query: { number?: string | string[] };
}

export const getSyllabi: RequestHandler<
GetSyllabi["params"],
GetSyllabi["resBody"],
GetSyllabi["reqBody"],
GetSyllabi["query"]
> = async (req, res, next) => {
console.log(req.query.number);
const numbers = singleToArray(req.query.number).map((n) => n);

console.log("Fetching syllabi for numbers:", numbers);

try {
const syllabi = await db.syllabi.findMany({
where: {
number: { in: numbers },
},
select: {
id: true,
season: true,
year: true,
number: true,
section: true,
url: true,
},
});

console.log(`Found ${syllabi.length} syllabi`);
console.log("Syllabi:", syllabi);
res.json(syllabi);
} catch (e) {
console.error("Error fetching syllabi:", e);
res.json([]);
}
};

// TODO: use a better caching system
const allSyllabiEntry = {
allSyllabi: [] as Syllabus[],
lastCached: new Date(1970),
};

const getAllSyllabiDbQuery = {
select: {
id: true,
season: true,
year: true,
number: true,
url: true,
},
};

export interface GetAllSyllabi {
params: unknown;
resBody: Syllabus[];
reqBody: unknown;
query: { number?: string | string[] };
}

export const getAllSyllabi: RequestHandler<
GetAllSyllabi["params"],
GetAllSyllabi["resBody"],
GetAllSyllabi["reqBody"],
GetAllSyllabi["query"]
> = async (req, res, next) => {
const filter: Record<string, any> = {};

// // Add filtering logic if needed
// if (req.query.number) {
// filter.number = Array.isArray(req.query.number)
// ? { in: req.query.number }
// : req.query.number;
// }

if (new Date().valueOf() - allSyllabiEntry.lastCached.valueOf() > 1000 * 60 * 60 * 24) {
try {
const syllabiFromDB = await db.syllabi.findMany({
where: filter,
select: getAllSyllabiDbQuery.select,
});

const syllabi: Syllabus[] = syllabiFromDB.map((s) => ({
id: s.id,
season: s.season ?? "",
year: s.year ?? 0,
number: s.number ?? "",
url: s.url ?? null,
}));

allSyllabiEntry.lastCached = new Date();
allSyllabiEntry.allSyllabi = syllabi;

res.json(syllabi);
} catch (e) {
console.error("Error fetching all syllabi:", e);
res.json([] as Syllabus[]);
}
} else {
res.json(allSyllabiEntry.allSyllabi);
}
};
4 changes: 4 additions & 0 deletions apps/backend/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const standardizeID = (id: string): string => {
return id;
};

export const cleanID = (id: string): string => {
return id.replace("-", "");
}

export type SingleOrArray<T> = T | T[];

export function singleToArray<T>(param: SingleOrArray<T>): T[] {
Expand Down
144 changes: 144 additions & 0 deletions apps/frontend/src/app/api/syllabi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import axios from "axios";
import { useQuery, useQueries, keepPreviousData } from "@tanstack/react-query";
import { create, windowScheduler, keyResolver } from "@yornaath/batshit";
import { STALE_TIME } from "~/app/constants";
import { Syllabus } from "~/app/types";

export type FetchAllSyllabiResult = Syllabus[];

// const fetchSyllabi = async (numbers: string[]): Promise<Syllabus[]> => {
// try {
// const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/syllabi`;
// console.log("the ", numbers);
// const params = new URLSearchParams(
// numbers.map((number) => ["number", number])
// );

// const response = await axios.get(url, {
// headers: {
// "Content-Type": "application/json",
// },
// params,
// });

// return response.data;
// } catch (error) {
// console.error("Error fetching syllabi:", error);
// return [];
// }
// };

const fetchSyllabi = async (numbers: string[]): Promise<Syllabus[]> => {
try {
const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/syllabi`;

console.log("Original numbers (with types):", numbers.map(n => `${n} (${typeof n})`));

// Ensure numbers are always strings and padded to 5 digits
const paddedNumbers = numbers.map(num => String(num).padStart(5, '0'));

console.log("Padded numbers:", paddedNumbers);

const params = new URLSearchParams(
paddedNumbers.map((number) => ["number", number])
);

const response = await axios.get(url, {
headers: {
"Content-Type": "application/json",
},
params,
});

return response.data;
} catch (error) {
console.error("Error fetching syllabi:", error);
return [];
}
};

export const useFetchSyllabi = (numbers: string[]) => {
return useQuery({
queryKey: ["syllabi", numbers],
queryFn: () => fetchSyllabi(numbers),
staleTime: STALE_TIME,
enabled: numbers.length > 0,
});
};

const fetchSyllabusBatcher = create({
fetcher: async (syllabusNumbers: string[]): Promise<Syllabus[]> => {
try {
const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/syllabi`;
const params = new URLSearchParams(
syllabusNumbers.map((number) => ["number", number])
);

const response = await axios.get(url, {
headers: {
"Content-Type": "application/json",
},
params,
});

return response.data;
} catch (error) {
console.error("Error in syllabus batcher:", error);
return [];
}
},
resolver: keyResolver("number"),
scheduler: windowScheduler(10),
});

export const useFetchSyllabus = (number: string) => {
return useQuery({
queryKey: ["syllabus", { number }],
queryFn: () => fetchSyllabusBatcher.fetch(number),
staleTime: STALE_TIME,
enabled: !!number,
});
};

export const useFetchMultipleSyllabi = (numbers: string[]) => {
return useQueries({
queries: numbers.map((number) => ({
queryKey: ["syllabus", { number }],
queryFn: () => fetchSyllabusBatcher.fetch(number),
staleTime: STALE_TIME,
enabled: !!number,
})),
combine: (result) => {
return result.reduce((acc, { data }) => {
if (data) acc.push(data);
return acc;
}, [] as Syllabus[]);
},
});
};

const fetchAllSyllabi = async (): Promise<FetchAllSyllabiResult> => {
try {
const url = `${process.env.NEXT_PUBLIC_BACKEND_URL || ""}/syllabi/all`;

const response = await axios.get(url, {
headers: {
"Content-Type": "application/json",
},
});

return response.data;
} catch (error) {
console.error("Error fetching all syllabi:", error);
return [];
}
};

export const useFetchAllSyllabi = () => {
return useQuery({
queryKey: ["allSyllabi"],
queryFn: fetchAllSyllabi,
staleTime: STALE_TIME,
retry: false,
});
};
16 changes: 16 additions & 0 deletions apps/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
8 changes: 8 additions & 0 deletions apps/frontend/src/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export interface Course {
fces?: FCE[];
}

export interface Syllabus {
season: string;
year: number;
number: string;
section: string;
url: string;
}

export interface Time {
days: number[];
begin: string;
Expand Down
41 changes: 40 additions & 1 deletion apps/frontend/src/app/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import reactStringReplace from "react-string-replace";
import Link from "~/components/Link";
import { FCE, Schedule, Session, Time } from "./types";
import { FCE, Schedule, Session, Time, Syllabus } from "./types";
import { AggregateFCEsOptions, filterFCEs } from "./fce";
import {
DEPARTMENT_MAP_NAME,
Expand All @@ -24,6 +24,10 @@ export const standardizeIdsInString = (str: string) => {
return str.replaceAll(courseIdRegex, standardizeId);
};

export const cleanID = (id: string): string => {
return id.replace("-", "");
}

export const sessionToString = (sessionInfo: Session | FCE | Schedule) => {
if (!sessionInfo) return "";

Expand Down Expand Up @@ -283,3 +287,38 @@ export const getCalendarColorLight = (color: string) => {
const index = CALENDAR_COLORS.indexOf(color);
return CALENDAR_COLORS_LIGHT[index];
};

export const getCanvasLinkFromDownloadLink = (downloadLink: string): string => {
if (!downloadLink || downloadLink === "#") return "#";
try {
const fileIdMatch = downloadLink.match(/\/files\/(\d+)/);
const verifierMatch = downloadLink.match(/verifier=([^&]+)/);

if (fileIdMatch && verifierMatch) {
const fileId = fileIdMatch[1];
const verifier = verifierMatch[1];
return `https://canvas.cmu.edu/files/${fileId}/?verifier=${verifier}`;
}
return downloadLink;
} catch (e) {
console.error("Error parsing download link:", e);
return downloadLink;
}
};

export const groupSyllabiByNumber = (syllabi: Syllabus[]) => {
return syllabi.reduce((groups, syllabus) => {
const num = syllabus.number?.trim().toLowerCase() || '';
if (!groups[num]) groups[num] = [];
groups[num].push(syllabus);
return groups;
}, {} as Record<string, Syllabus[]>);
};

export const sortSyllabi = (syllabi: Syllabus[]) => {
return [...syllabi].sort((a, b) => {
if (a.year !== b.year) return b.year - a.year;
const seasonOrder: Record<string, number> = { "spring": 1, "fall": 0 };
return (seasonOrder[b.season.toLowerCase()] || 0) - (seasonOrder[a.season.toLowerCase()] || 0);
});
};
Loading
Loading