diff --git a/.gitignore b/.gitignore index 0cc73a08..f3e38032 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ node_modules vite.config.js.timestamp-* vite.config.ts.timestamp-* .VSCODECounter +Engine API +.vscode/settings.json +apps/engine/src/cache/rediscache.ts.timestamp-* +apps/engine/.env \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 11063bb2..b24bdf18 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,11 +3,11 @@ "editor.defaultFormatter": "svelte.svelte-vscode" }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "vscode.typescript-language-features" }, "editor.formatOnSave": true, "dbmlERDPreviewer.preferredTheme": "light", "cSpell.words": [ "emplid" ] -} +} \ No newline at end of file diff --git a/apps/database/bun.lockb b/apps/database/bun.lockb index 882dbad3..26bf3d1f 100755 Binary files a/apps/database/bun.lockb and b/apps/database/bun.lockb differ diff --git a/apps/engine/.gitignore b/apps/engine/.gitignore deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/engine/bun.lockb b/apps/engine/bun.lockb index ec1b799d..6b18e7da 100755 Binary files a/apps/engine/bun.lockb and b/apps/engine/bun.lockb differ diff --git a/apps/engine/docker-compose.yml b/apps/engine/docker-compose.yml index de94f58e..1551a622 100644 --- a/apps/engine/docker-compose.yml +++ b/apps/engine/docker-compose.yml @@ -30,4 +30,4 @@ services: retries: 5 volumes: postgres_data: - redis_data: \ No newline at end of file + redis_data: diff --git a/apps/engine/drizzle/meta/0000_snapshot.json b/apps/engine/drizzle/meta/0000_snapshot.json index b7de490a..b907a692 100644 --- a/apps/engine/drizzle/meta/0000_snapshot.json +++ b/apps/engine/drizzle/meta/0000_snapshot.json @@ -53,12 +53,8 @@ "name": "analytics_user_id_users_id_fk", "tableFrom": "analytics", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -92,12 +88,8 @@ "name": "course_department_map_course_id_courses_id_fk", "tableFrom": "course_department_map", "tableTo": "courses", - "columnsFrom": [ - "course_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["course_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -105,12 +97,8 @@ "name": "course_department_map_department_code_departments_code_fk", "tableFrom": "course_department_map", "tableTo": "departments", - "columnsFrom": [ - "department_code" - ], - "columnsTo": [ - "code" - ], + "columnsFrom": ["department_code"], + "columnsTo": ["code"], "onDelete": "no action", "onUpdate": "no action" } @@ -118,10 +106,7 @@ "compositePrimaryKeys": { "course_department_map_course_id_department_code_pk": { "name": "course_department_map_course_id_department_code_pk", - "columns": [ - "course_id", - "department_code" - ] + "columns": ["course_id", "department_code"] } }, "uniqueConstraints": {}, @@ -152,12 +137,8 @@ "name": "course_instructor_map_course_id_courses_id_fk", "tableFrom": "course_instructor_map", "tableTo": "courses", - "columnsFrom": [ - "course_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["course_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -165,12 +146,8 @@ "name": "course_instructor_map_instructor_id_instructors_netid_fk", "tableFrom": "course_instructor_map", "tableTo": "instructors", - "columnsFrom": [ - "instructor_id" - ], - "columnsTo": [ - "netid" - ], + "columnsFrom": ["instructor_id"], + "columnsTo": ["netid"], "onDelete": "no action", "onUpdate": "no action" } @@ -178,10 +155,7 @@ "compositePrimaryKeys": { "course_instructor_map_course_id_instructor_id_pk": { "name": "course_instructor_map_course_id_instructor_id_pk", - "columns": [ - "course_id", - "instructor_id" - ] + "columns": ["course_id", "instructor_id"] } }, "uniqueConstraints": {}, @@ -299,12 +273,8 @@ "name": "custom_events_user_id_users_id_fk", "tableFrom": "custom_events", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -399,12 +369,8 @@ "name": "evaluations_course_id_courses_id_fk", "tableFrom": "evaluations", "tableTo": "courses", - "columnsFrom": [ - "course_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["course_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -458,12 +424,8 @@ "name": "feedback_user_id_users_id_fk", "tableFrom": "feedback", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -509,12 +471,8 @@ "name": "icals_user_id_users_id_fk", "tableFrom": "icals", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -522,12 +480,8 @@ "name": "icals_schedule_id_schedules_id_fk", "tableFrom": "icals", "tableTo": "schedules", - "columnsFrom": [ - "schedule_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -654,12 +608,8 @@ "name": "schedule_course_map_schedule_id_schedules_id_fk", "tableFrom": "schedule_course_map", "tableTo": "schedules", - "columnsFrom": [ - "schedule_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -667,12 +617,8 @@ "name": "schedule_course_map_course_id_courses_id_fk", "tableFrom": "schedule_course_map", "tableTo": "courses", - "columnsFrom": [ - "course_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["course_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -680,10 +626,7 @@ "compositePrimaryKeys": { "schedule_course_map_schedule_id_course_id_pk": { "name": "schedule_course_map_schedule_id_course_id_pk", - "columns": [ - "schedule_id", - "course_id" - ] + "columns": ["schedule_id", "course_id"] } }, "uniqueConstraints": {}, @@ -714,12 +657,8 @@ "name": "schedule_event_map_schedule_id_schedules_id_fk", "tableFrom": "schedule_event_map", "tableTo": "schedules", - "columnsFrom": [ - "schedule_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -727,12 +666,8 @@ "name": "schedule_event_map_custom_event_id_custom_events_id_fk", "tableFrom": "schedule_event_map", "tableTo": "custom_events", - "columnsFrom": [ - "custom_event_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["custom_event_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -740,10 +675,7 @@ "compositePrimaryKeys": { "schedule_event_map_schedule_id_custom_event_id_pk": { "name": "schedule_event_map_schedule_id_custom_event_id_pk", - "columns": [ - "schedule_id", - "custom_event_id" - ] + "columns": ["schedule_id", "custom_event_id"] } }, "uniqueConstraints": {}, @@ -799,12 +731,8 @@ "name": "schedules_user_id_users_id_fk", "tableFrom": "schedules", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -894,12 +822,8 @@ "name": "sections_course_id_courses_id_fk", "tableFrom": "sections", "tableTo": "courses", - "columnsFrom": [ - "course_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["course_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -966,11 +890,7 @@ "public.status": { "name": "status", "schema": "public", - "values": [ - "open", - "closed", - "canceled" - ] + "values": ["open", "closed", "canceled"] } }, "schemas": {}, @@ -983,4 +903,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/apps/engine/drizzle/meta/_journal.json b/apps/engine/drizzle/meta/_journal.json index 4702667f..3d5c6ccd 100644 --- a/apps/engine/drizzle/meta/_journal.json +++ b/apps/engine/drizzle/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/engine/package.json b/apps/engine/package.json index 0966f61b..4a4fb3a6 100644 --- a/apps/engine/package.json +++ b/apps/engine/package.json @@ -2,6 +2,7 @@ "type": "module", "scripts": { "start": "bun ./src/main.ts", + "dev": "bun ./src/main.ts", "cli": "bun ./src/cli.ts", "format": "prettier --write .", "lint": "eslint .", @@ -9,12 +10,12 @@ "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", "db:schema": "bun src/db/generate-dbml.ts", - "docker:up": "docker-compose up -d", - "docker:down": "docker-compose down", - "docker:logs": "docker-compose logs -f", - "docker:restart": "docker-compose restart", - "docker:ps": "docker-compose ps", - "docker:clean": "docker-compose down -v" + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:logs": "docker compose logs -f", + "docker:restart": "docker compose restart", + "docker:ps": "docker compose ps", + "docker:clean": "docker compose down -v" }, "dependencies": { "@fastify/sensible": "^6.0.3", @@ -26,7 +27,8 @@ "fastify": "^5.5.0", "fastify-plugin": "^5.0.1", "jsdom": "^26.1.0", - "pg": "^8.16.3" + "pg": "^8.16.3", + "redis": "^4.6.0" }, "devDependencies": { "@eslint/js": "^9.34.0", diff --git a/apps/engine/src/cache/rediscache.ts b/apps/engine/src/cache/rediscache.ts new file mode 100644 index 00000000..8507d02c --- /dev/null +++ b/apps/engine/src/cache/rediscache.ts @@ -0,0 +1,109 @@ +import { createClient, RedisClientType } from "redis"; + +type RedisValue = string | Buffer | object | number | boolean | null; + +/** + * Simple OOP Redis cache wrapper using node-redis. + * - Default TTLs are 24 hours (86400 seconds). + * - Uses `REDIS_URL` environment variable by default. + * - Stores values as JSON for objects; preserves strings/buffers. + */ +export default class RedisCache { + private client?: RedisClientType; + private url?: string; + private defaultTtlSeconds: number; + + constructor(opts?: { url?: string; defaultTtlSeconds?: number }) { + this.url = opts?.url || process.env.REDIS_URL; + this.defaultTtlSeconds = opts?.defaultTtlSeconds ?? 6 * 60 * 60; // 6 hours + } + + async connect(): Promise { + if (this.client) return; + if (!this.url) { + throw new Error("REDIS_URL not configured; cannot connect to Redis"); + } + + this.client = createClient({ url: this.url }); + this.client.on("error", (err) => { + // Avoid throwing inside the event handler; log instead. + // Consumers can hook up monitoring as needed. + // eslint-disable-next-line no-console + console.error("Redis Client Error:", err); + }); + + await this.client.connect(); + } + + async disconnect(): Promise { + if (!this.client) return; + try { + await this.client.quit(); + } finally { + this.client = undefined; + } + } + + private ensureClient(): RedisClientType { + if (!this.client) throw new Error("Redis client is not connected"); + return this.client; + } + + async get(key: string): Promise { + const c = this.ensureClient(); + const raw = await c.get(key); + if (raw === null) return null; + try { + return JSON.parse(raw) as T; + } catch (_e) { + // Not JSON — return raw as unknown + return raw as unknown as T; + } + } + + async set(key: string, value: RedisValue, ttlSeconds?: number): Promise { + const c = this.ensureClient(); + let stored: string | Buffer; + if (typeof value === "string" || value instanceof Buffer) { + stored = value as string | Buffer; + } else { + stored = JSON.stringify(value); + } + + const ttl = ttlSeconds ?? this.defaultTtlSeconds; + if (ttl > 0) { + await c.set(key, stored as string, { EX: ttl }); + } else { + await c.set(key, stored as string); + } + } + + async del(key: string): Promise { + const c = this.ensureClient(); + return c.del(key); + } + + async expire(key: string, seconds: number): Promise { + const c = this.ensureClient(); + return c.expire(key, seconds); + } + + /** + * Helper to fetch JSON from cache or compute and store it. + * loader should return a JSON-serializable value. + */ + async getOrSetJson(key: string, loader: () => Promise, ttlSeconds?: number): Promise { + const existing = await this.get(key); + if (existing !== null) return existing; + const data = await loader(); + await this.set(key, data as unknown as RedisValue, ttlSeconds); + return data; + } +} + +/* +Usage notes: +- Key patterns: `courses:all`, `courses:term:{term}`, `course:{courseId}:sections`. +- TTL defaults to 24 hours; override in constructor or per-set call. +- Configure `REDIS_URL` in environment (or pass `url` to constructor). +*/ diff --git a/apps/engine/src/main.ts b/apps/engine/src/main.ts index ebb488dc..b06c8801 100644 --- a/apps/engine/src/main.ts +++ b/apps/engine/src/main.ts @@ -1,5 +1,5 @@ // src/main.ts -// Author(s): Joshua Lau +// Author(s): Joshua Lau '26, Sai Nallani '29 import Fastify, { type FastifyInstance } from "fastify"; import fp from "fastify-plugin"; @@ -8,8 +8,18 @@ import swagger from "@fastify/swagger"; import swaggerUI from "@fastify/swagger-ui"; import healthRoutes from "./routes/health.ts"; +import coursesRoutes from "./routes/api/courses.ts"; +import sectionsRoutes from "./routes/api/sections.ts"; +import schedulesRoutes from "./routes/api/schedules.ts"; +import usersRoutes from "./routes/api/users.ts"; +import eventsRoutes from "./routes/api/events.ts"; +import feedbackRoutes from "./routes/api/feedback.ts"; +import instructorsRoutes from "./routes/api/instructors.ts"; +import redisPlugin from "./plugins/redis.ts"; +import dbPlugin from "./plugins/db.ts"; import snatchRoutes from "./routes/snatch.ts"; + async function build(): Promise { const app = Fastify({ logger: true }); @@ -32,6 +42,13 @@ async function build(): Promise { servers: [{ url: "http://localhost:3000", description: "Local server" }], tags: [ { name: "Health", description: "Health check endpoints" }, + { name: "Courses", description: "Course data endpoints" }, + { name: "Sections", description: "Section data endpoints" }, + { name: "Schedules", description: "Schedule management endpoints" }, + { name: "Users", description: "User-related endpoints" }, + { name: "Events", description: "Custom event endpoints" }, + { name: "Feedback", description: "User feedback endpoints" }, + { name: "Instructors", description: "Instructor data endpoints" }, { name: "Snatch", description: "TigerSnatch integration endpoints" }, ], }, @@ -47,8 +64,31 @@ async function build(): Promise { transformStaticCSP: (header) => header, }); + // Expose raw OpenAPI JSON for external tooling (e.g., Bruno) + // NOTE: auth should be applied here if your environment requires it. + app.get("/openapi.json", async (request, reply) => { + // `app.swagger()` is provided by @fastify/swagger after registration + // Use `as any` to avoid TypeScript complaints in case types are not merged. + const spec = app.swagger && app.swagger(); + if (!spec) { + return reply.code(500).send({ error: "OpenAPI spec not available" }); + } + return reply.send(spec); + }); + + // Register plugins so routes can use `app.redis` and `app.db` + await app.register(dbPlugin); + await app.register(redisPlugin); + // Route groups app.register(healthRoutes, { prefix: "/health" }); + app.register(coursesRoutes, { prefix: "/api/courses" }); + app.register(sectionsRoutes, { prefix: "/api/sections" }); + app.register(schedulesRoutes, { prefix: "/api/schedules" }); + app.register(usersRoutes, { prefix: "/api/users" }); + app.register(eventsRoutes, { prefix: "/api/events" }); + app.register(feedbackRoutes, { prefix: "/api/feedback" }); + app.register(instructorsRoutes, { prefix: "/api/instructors" }); app.register(snatchRoutes, { prefix: "/snatch" }); return app; diff --git a/apps/engine/src/plugins/db.ts b/apps/engine/src/plugins/db.ts new file mode 100644 index 00000000..81bade69 --- /dev/null +++ b/apps/engine/src/plugins/db.ts @@ -0,0 +1,23 @@ +import fp from "fastify-plugin"; +import type { FastifyPluginAsync } from "fastify"; +import DB from "../db/index.js"; + +declare module "fastify" { + interface FastifyInstance { + db: DB; + } +} + +const dbPlugin: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // Test connection at startup; fail fast if database is unreachable + await db.testConnection(); + + // Attach to Fastify instance for handlers to use + app.decorate("db", db); +}; + +export default fp(dbPlugin, { + name: "db-plugin", +}); diff --git a/apps/engine/src/plugins/redis.ts b/apps/engine/src/plugins/redis.ts new file mode 100644 index 00000000..52bf0817 --- /dev/null +++ b/apps/engine/src/plugins/redis.ts @@ -0,0 +1,34 @@ +import fp from "fastify-plugin"; +import type { FastifyPluginAsync } from "fastify"; +import type RedisCache from "../cache/rediscache.ts"; +import RedisCacheClass from "../cache/rediscache.ts"; + +declare module "fastify" { + interface FastifyInstance { + redis: RedisCache; + } +} + +const redisPlugin: FastifyPluginAsync = async (app) => { + const cache = new RedisCacheClass(); + + // Connect at startup; if connect fails, fail fast so deploys notice. + await cache.connect(); + + // Attach to Fastify instance for handlers to use + app.decorate("redis", cache); + + // Ensure proper cleanup on close (async hook should not use the `done` callback) + app.addHook("onClose", async (_instance) => { + try { + await cache.disconnect(); + } catch (err) { + // eslint-disable-next-line no-console + console.error("Error disconnecting Redis:", err); + } + }); +}; + +export default fp(redisPlugin, { + name: "redis-plugin", +}); diff --git a/apps/engine/src/routes/api/courses.ts b/apps/engine/src/routes/api/courses.ts new file mode 100644 index 00000000..6afc85b8 --- /dev/null +++ b/apps/engine/src/routes/api/courses.ts @@ -0,0 +1,379 @@ +// src/routes/api/courses.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import * as schema from "../../db/schema.js"; +import { eq, asc } from "drizzle-orm"; + +const coursesRoutes: FastifyPluginAsync = async (app) => { + // GET /api/courses/all - Get all courses across all terms with instructors + app.get( + "/all", + { + schema: { + description: "Get all courses across all terms with instructor information", + tags: ["Courses"], + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + listingId: { type: "string" }, + term: { type: "number" }, + code: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string" }, + dists: { type: "array", items: { type: "string" }, nullable: true }, + gradingBasis: { type: "string" }, + hasFinal: { type: "boolean", nullable: true }, + instructors: { + type: "array", + items: { + type: "object", + properties: { + netid: { type: "string" }, + name: { type: "string" }, + email: { type: "string", nullable: true }, + }, + }, + }, + }, + }, + }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + try { + const cache = app.redis; + + const loader = async () => { + const rows = await app.db.db + .select({ + id: schema.courses.id, + listingId: schema.courses.listingId, + term: schema.courses.term, + code: schema.courses.code, + title: schema.courses.title, + description: schema.courses.description, + status: schema.courses.status, + dists: schema.courses.dists, + gradingBasis: schema.courses.gradingBasis, + hasFinal: schema.courses.hasFinal, + instructorNetid: schema.instructors.netid, + instructorName: schema.instructors.name, + instructorEmail: schema.instructors.email, + }) + .from(schema.courses) + .leftJoin( + schema.courseInstructorMap, + eq(schema.courses.id, schema.courseInstructorMap.courseId) + ) + .leftJoin( + schema.instructors, + eq(schema.courseInstructorMap.instructorId, schema.instructors.netid) + ) + .orderBy(asc(schema.courses.code)); + + const coursesMap = new Map(); + for (const row of rows) { + if (!coursesMap.has(row.id)) { + coursesMap.set(row.id, { + id: row.id, + listingId: row.listingId, + term: row.term, + code: row.code, + title: row.title, + description: row.description, + status: row.status, + dists: row.dists, + gradingBasis: row.gradingBasis, + hasFinal: row.hasFinal, + instructors: [], + }); + } + + if (row.instructorNetid) { + coursesMap.get(row.id).instructors.push({ + netid: row.instructorNetid, + name: row.instructorName, + email: row.instructorEmail, + }); + } + } + + return Array.from(coursesMap.values()); + }; + + if (cache) { + const courses = await cache.getOrSetJson("courses:all", loader); + return reply.code(200).send({ success: true, count: courses.length, data: courses }); + } + + // DB fallback + const courses = await loader(); + return reply.code(200).send({ success: true, count: courses.length, data: courses }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ success: false, error: "Failed to fetch courses" }); + } + } + ); + + // GET /api/courses/:term - Get all courses for a specific term with instructors + app.get( + "/:term", + { + schema: { + description: "Get all courses for a specific term with instructor information", + tags: ["Courses"], + params: { + type: "object", + properties: { + term: { type: "number", description: "Term code (e.g., 1262)" }, + }, + required: ["term"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + listingId: { type: "string" }, + term: { type: "number" }, + code: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string" }, + dists: { type: "array", items: { type: "string" }, nullable: true }, + gradingBasis: { type: "string" }, + hasFinal: { type: "boolean", nullable: true }, + instructors: { + type: "array", + items: { + type: "object", + properties: { + netid: { type: "string" }, + name: { type: "string" }, + email: { type: "string", nullable: true }, + }, + }, + }, + }, + }, + }, + }, + }, + 400: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { term } = request.params as { term: number }; + + if (!term || isNaN(term)) { + return reply.code(400).send({ + success: false, + error: "Invalid term parameter", + }); + } + + try { + const cache = app.redis; + + const loader = async () => { + // Get courses with instructors + const coursesWithInstructors = await app.db.db + .select({ + id: schema.courses.id, + listingId: schema.courses.listingId, + term: schema.courses.term, + code: schema.courses.code, + title: schema.courses.title, + description: schema.courses.description, + status: schema.courses.status, + dists: schema.courses.dists, + gradingBasis: schema.courses.gradingBasis, + hasFinal: schema.courses.hasFinal, + instructorNetid: schema.instructors.netid, + instructorName: schema.instructors.name, + instructorEmail: schema.instructors.email, + }) + .from(schema.courses) + .leftJoin( + schema.courseInstructorMap, + eq(schema.courses.id, schema.courseInstructorMap.courseId) + ) + .leftJoin( + schema.instructors, + eq(schema.courseInstructorMap.instructorId, schema.instructors.netid) + ) + .where(eq(schema.courses.term, term)) + .orderBy(asc(schema.courses.code)); + + // Group instructors by course + const coursesMap = new Map(); + for (const row of coursesWithInstructors) { + if (!coursesMap.has(row.id)) { + coursesMap.set(row.id, { + id: row.id, + listingId: row.listingId, + term: row.term, + code: row.code, + title: row.title, + description: row.description, + status: row.status, + dists: row.dists, + gradingBasis: row.gradingBasis, + hasFinal: row.hasFinal, + instructors: [], + }); + } + + if (row.instructorNetid) { + coursesMap.get(row.id).instructors.push({ + netid: row.instructorNetid, + name: row.instructorName, + email: row.instructorEmail, + }); + } + } + + return Array.from(coursesMap.values()); + }; + + const cacheKey = `courses:term:${term}`; + + if (cache) { + const courses = await cache.getOrSetJson(cacheKey, loader); + return reply.code(200).send({ success: true, count: courses.length, data: courses }); + } + + // DB fallback + const courses = await loader(); + return reply.code(200).send({ success: true, count: courses.length, data: courses }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ success: false, error: "Failed to fetch courses" }); + } + } + ); + + // GET /api/courses/:courseId/sections - Get sections for a specific course + app.get( + "/:courseId/sections", + { + schema: { + description: "Get all sections for a specific course", + tags: ["Courses"], + params: { + type: "object", + properties: { + courseId: { type: "string", description: "Course ID (e.g., '123-1262')" }, + }, + required: ["courseId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number" }, + courseId: { type: "string" }, + title: { type: "string" }, + num: { type: "string" }, + room: { type: "string", nullable: true }, + tot: { type: "number" }, + cap: { type: "number" }, + days: { type: "number" }, + startTime: { type: "number" }, + endTime: { type: "number" }, + status: { type: "string" }, + }, + }, + }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { courseId } = request.params as { courseId: string }; + + try { + const sections = await app.db.db + .select() + .from(schema.sections) + .where(eq(schema.sections.courseId, courseId)) + .orderBy(asc(schema.sections.id)); + + return reply.code(200).send({ + success: true, + count: sections.length, + data: sections, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch sections", + }); + } + } + ); +}; + +export default coursesRoutes; diff --git a/apps/engine/src/routes/api/events.ts b/apps/engine/src/routes/api/events.ts new file mode 100644 index 00000000..a718120b --- /dev/null +++ b/apps/engine/src/routes/api/events.ts @@ -0,0 +1,325 @@ +// src/routes/api/events.ts +// Author(s): Sai Nallani '29 + +import { type FastifyPluginAsync } from "fastify"; +import * as schema from "../../db/schema.js"; +import { eq } from "drizzle-orm"; + +const eventsRoutes: FastifyPluginAsync = async (app) => { + // POST /api/events - Create a new custom event + app.post( + "/", + { + schema: { + description: "Create a new custom event", + tags: ["Events"], + body: { + type: "object", + properties: { + userId: { type: "number" }, + title: { type: "string" }, + times: { type: "object" }, + }, + required: ["userId", "title", "times"], + }, + response: { + 201: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + id: { type: "number" }, + userId: { type: "number" }, + title: { type: "string" }, + times: { type: "object" }, + }, + }, + }, + }, + 400: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { userId, title, times } = request.body as { + userId: number; + title: string; + times: any; + }; + + if (!userId || !title || !times) { + return reply.code(400).send({ + success: false, + error: "Missing required fields: userId, title, times", + }); + } + + try { + const newEvent = await app.db.db + .insert(schema.customEvents) + .values({ + userId, + title, + times, + }) + .returning(); + + return reply.code(201).send({ + success: true, + data: newEvent[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to create event", + }); + } + } + ); + + // PATCH /api/events/:eventId - Update a custom event + app.patch( + "/:eventId", + { + schema: { + description: "Update a custom event", + tags: ["Events"], + params: { + type: "object", + properties: { + eventId: { type: "number" }, + }, + required: ["eventId"], + }, + body: { + type: "object", + properties: { + title: { type: "string" }, + times: { type: "object" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + id: { type: "number" }, + userId: { type: "number" }, + title: { type: "string" }, + times: { type: "object" }, + }, + }, + }, + }, + 404: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { eventId } = request.params as { eventId: number }; + const updateData = request.body as { + title?: string; + times?: any; + }; + + try { + const updated = await app.db.db + .update(schema.customEvents) + .set(updateData) + .where(eq(schema.customEvents.id, eventId)) + .returning(); + + if (updated.length === 0) { + return reply.code(404).send({ + success: false, + error: "Event not found", + }); + } + + return reply.code(200).send({ + success: true, + data: updated[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to update event", + }); + } + } + ); + + // DELETE /api/events/:eventId - Delete a custom event + app.delete( + "/:eventId", + { + schema: { + description: "Delete a custom event (cascades to delete event-schedule associations)", + tags: ["Events"], + params: { + type: "object", + properties: { + eventId: { type: "number" }, + }, + required: ["eventId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 404: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { eventId } = request.params as { eventId: number }; + + try { + const deleted = await app.db.db + .delete(schema.customEvents) + .where(eq(schema.customEvents.id, eventId)) + .returning({ id: schema.customEvents.id }); + + if (deleted.length === 0) { + return reply.code(404).send({ + success: false, + error: "Event not found", + }); + } + + return reply.code(200).send({ + success: true, + message: "Event deleted successfully", + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to delete event", + }); + } + } + ); + + // GET /api/events/:eventId/schedules - Get event associations for schedules + app.get( + "/:eventId/schedules", + { + schema: { + description: "Get all schedule associations for an event", + tags: ["Events", "Schedules"], + params: { + type: "object", + properties: { + eventId: { type: "number" }, + }, + required: ["eventId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + scheduleId: { type: "number" }, + customEventId: { type: "number" }, + }, + }, + }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { eventId } = request.params as { eventId: number }; + + try { + const associations = await app.db.db + .select() + .from(schema.scheduleEventMap) + .where(eq(schema.scheduleEventMap.customEventId, eventId)); + + return reply.code(200).send({ + success: true, + count: associations.length, + data: associations, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch event associations", + }); + } + } + ); +}; + +export default eventsRoutes; diff --git a/apps/engine/src/routes/api/feedback.ts b/apps/engine/src/routes/api/feedback.ts new file mode 100644 index 00000000..3d66432f --- /dev/null +++ b/apps/engine/src/routes/api/feedback.ts @@ -0,0 +1,95 @@ +// src/routes/api/feedback.ts +// Author(s): Sai Nallani '29 + +import { type FastifyPluginAsync } from "fastify"; +import * as schema from "../../db/schema.js"; + +const feedbackRoutes: FastifyPluginAsync = async (app) => { + // POST /api/feedback - Submit feedback + app.post( + "/", + { + schema: { + description: "Submit user feedback", + tags: ["Feedback"], + body: { + type: "object", + properties: { + userId: { type: "number" }, + feedback: { type: "string" }, + }, + required: ["userId", "feedback"], + }, + response: { + 201: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + id: { type: "number" }, + userId: { type: "number" }, + feedback: { type: "string" }, + isResolved: { type: "boolean" }, + createdAt: { type: "string" }, + }, + }, + }, + }, + 400: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { userId, feedback: feedbackText } = request.body as { + userId: number; + feedback: string; + }; + + if (!userId || !feedbackText) { + return reply.code(400).send({ + success: false, + error: "Missing required fields: userId, feedback", + }); + } + + try { + const newFeedback = await app.db.db + .insert(schema.feedback) + .values({ + userId, + feedback: feedbackText, + }) + .returning(); + + return reply.code(201).send({ + success: true, + data: newFeedback[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to submit feedback", + }); + } + } + ); +}; + +export default feedbackRoutes; diff --git a/apps/engine/src/routes/api/instructors.ts b/apps/engine/src/routes/api/instructors.ts new file mode 100644 index 00000000..d7ab72cc --- /dev/null +++ b/apps/engine/src/routes/api/instructors.ts @@ -0,0 +1,161 @@ +// src/routes/api/instructors.ts +// Author(s): Sai Nallani '29 + +import { type FastifyPluginAsync } from "fastify"; +import * as schema from "../../db/schema.js"; +import { asc, eq } from "drizzle-orm"; + +const instructorsRoutes: FastifyPluginAsync = async (app) => { + // GET /api/instructors - Get all instructors + app.get( + "/", + { + schema: { + description: "Get all instructors", + tags: ["Instructors"], + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + netid: { type: "string" }, + emplid: { type: "string" }, + name: { type: "string" }, + fullName: { type: "string" }, + department: { type: "string", nullable: true }, + email: { type: "string", nullable: true }, + office: { type: "string", nullable: true }, + rating: { type: "number", nullable: true }, + ratingUncertainty: { type: "number", nullable: true }, + numRatings: { type: "number", nullable: true }, + }, + }, + }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + try { + const instructors = await app.db.db + .select() + .from(schema.instructors) + .orderBy(asc(schema.instructors.name)); + + return reply.code(200).send({ + success: true, + count: instructors.length, + data: instructors, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch instructors", + }); + } + } + ); + + // GET /api/instructors/:netid - Get a specific instructor + app.get( + "/:netid", + { + schema: { + description: "Get a specific instructor by netid", + tags: ["Instructors"], + params: { + type: "object", + properties: { + netid: { type: "string" }, + }, + required: ["netid"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + netid: { type: "string" }, + emplid: { type: "string" }, + name: { type: "string" }, + fullName: { type: "string" }, + department: { type: "string", nullable: true }, + email: { type: "string", nullable: true }, + office: { type: "string", nullable: true }, + rating: { type: "number", nullable: true }, + ratingUncertainty: { type: "number", nullable: true }, + numRatings: { type: "number", nullable: true }, + }, + }, + }, + }, + 404: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { netid } = request.params as { netid: string }; + + try { + const instructor = await app.db.db + .select() + .from(schema.instructors) + .where(eq(schema.instructors.netid, netid)) + .limit(1); + + if (instructor.length === 0) { + return reply.code(404).send({ + success: false, + error: "Instructor not found", + }); + } + + return reply.code(200).send({ + success: true, + data: instructor[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch instructor", + }); + } + } + ); +}; + +export default instructorsRoutes; diff --git a/apps/engine/src/routes/api/schedules.ts b/apps/engine/src/routes/api/schedules.ts new file mode 100644 index 00000000..2316dace --- /dev/null +++ b/apps/engine/src/routes/api/schedules.ts @@ -0,0 +1,1167 @@ +// src/routes/api/schedules.ts +// Author(s): Sai Nallani '29 + +import { type FastifyPluginAsync } from "fastify"; +import * as schema from "../../db/schema.js"; +import { eq, and } from "drizzle-orm"; + +const schedulesRoutes: FastifyPluginAsync = async (app) => { + // GET /api/schedules/:scheduleId - Get a specific schedule + app.get( + "/:scheduleId", + { + schema: { + description: "Get a specific schedule by ID", + tags: ["Schedules"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + id: { type: "number" }, + relativeId: { type: "number" }, + userId: { type: "number" }, + title: { type: "string" }, + isPublic: { type: "boolean" }, + term: { type: "number" }, + }, + }, + }, + }, + 404: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + + try { + const schedule = await app.db.db + .select() + .from(schema.schedules) + .where(eq(schema.schedules.id, scheduleId)) + .limit(1); + + if (schedule.length === 0) { + return reply.code(404).send({ + success: false, + error: "Schedule not found", + }); + } + + return reply.code(200).send({ + success: true, + data: schedule[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch schedule", + }); + } + } + ); + + // POST /api/schedules - Create a new schedule + app.post( + "/", + { + schema: { + description: "Create a new schedule", + tags: ["Schedules"], + body: { + type: "object", + properties: { + userId: { type: "number" }, + term: { type: "number" }, + title: { type: "string" }, + relativeId: { type: "number" }, + isPublic: { type: "boolean" }, + }, + required: ["userId", "term", "title", "relativeId"], + }, + response: { + 201: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + id: { type: "number" }, + relativeId: { type: "number" }, + userId: { type: "number" }, + title: { type: "string" }, + isPublic: { type: "boolean" }, + term: { type: "number" }, + }, + }, + }, + }, + 400: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { userId, term, title, relativeId, isPublic } = request.body as { + userId: number; + term: number; + title: string; + relativeId: number; + isPublic?: boolean; + }; + + if (!userId || !term || !title || relativeId === undefined) { + return reply.code(400).send({ + success: false, + error: "Missing required fields: userId, term, title, relativeId", + }); + } + + try { + const newSchedule = await app.db.db + .insert(schema.schedules) + .values({ + userId, + term, + title, + relativeId, + isPublic: isPublic ?? false, + }) + .returning(); + + return reply.code(201).send({ + success: true, + data: newSchedule[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to create schedule", + }); + } + } + ); + + // PATCH /api/schedules/:scheduleId - Update schedule title + app.patch( + "/:scheduleId", + { + schema: { + description: "Update a schedule's title", + tags: ["Schedules"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + body: { + type: "object", + properties: { + title: { type: "string" }, + }, + required: ["title"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + id: { type: "number" }, + title: { type: "string" }, + }, + }, + }, + }, + 400: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 404: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + const { title } = request.body as { title: string }; + + if (!title) { + return reply.code(400).send({ + success: false, + error: "Title is required", + }); + } + + try { + const updated = await app.db.db + .update(schema.schedules) + .set({ title }) + .where(eq(schema.schedules.id, scheduleId)) + .returning({ id: schema.schedules.id, title: schema.schedules.title }); + + if (updated.length === 0) { + return reply.code(404).send({ + success: false, + error: "Schedule not found", + }); + } + + return reply.code(200).send({ + success: true, + data: updated[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to update schedule", + }); + } + } + ); + + // DELETE /api/schedules/:scheduleId - Delete a schedule + app.delete( + "/:scheduleId", + { + schema: { + description: "Delete a schedule (cascades to delete associated courses/events)", + tags: ["Schedules"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 404: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + + try { + const deleted = await app.db.db + .delete(schema.schedules) + .where(eq(schema.schedules.id, scheduleId)) + .returning({ id: schema.schedules.id }); + + if (deleted.length === 0) { + return reply.code(404).send({ + success: false, + error: "Schedule not found", + }); + } + + return reply.code(200).send({ + success: true, + message: "Schedule deleted successfully", + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to delete schedule", + }); + } + } + ); + + // GET /api/schedules/:scheduleId/courses - Get course associations for a schedule + app.get( + "/:scheduleId/courses", + { + schema: { + description: "Get all course associations for a schedule", + tags: ["Schedules", "Courses"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + scheduleId: { type: "number" }, + courseId: { type: "string" }, + color: { type: "number" }, + isComplete: { type: "boolean" }, + confirms: { type: "object" }, + }, + }, + }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + + try { + const associations = await app.db.db + .select() + .from(schema.scheduleCourseMap) + .where(eq(schema.scheduleCourseMap.scheduleId, scheduleId)); + + return reply.code(200).send({ + success: true, + count: associations.length, + data: associations, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch course associations", + }); + } + } + ); + + // POST /api/schedules/:scheduleId/courses - Add a course to a schedule + app.post( + "/:scheduleId/courses", + { + schema: { + description: "Add a course to a schedule", + tags: ["Schedules", "Courses"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + body: { + type: "object", + properties: { + courseId: { type: "string" }, + color: { type: "number" }, + isComplete: { type: "boolean" }, + confirms: { type: "object" }, + }, + required: ["courseId"], + }, + response: { + 201: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + scheduleId: { type: "number" }, + courseId: { type: "string" }, + color: { type: "number" }, + isComplete: { type: "boolean" }, + confirms: { type: "object" }, + }, + }, + }, + }, + 400: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + const { courseId, color, isComplete, confirms } = request.body as { + courseId: string; + color?: number; + isComplete?: boolean; + confirms?: Record; + }; + + if (!courseId) { + return reply.code(400).send({ + success: false, + error: "courseId is required", + }); + } + + try { + const newAssociation = await app.db.db + .insert(schema.scheduleCourseMap) + .values({ + scheduleId, + courseId, + color: color ?? 0, + isComplete: isComplete ?? false, + confirms: confirms ?? {}, + }) + .returning(); + + return reply.code(201).send({ + success: true, + data: newAssociation[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to add course to schedule", + }); + } + } + ); + + // POST /api/schedules/:scheduleId/courses/bulk - Bulk add courses to a schedule + app.post( + "/:scheduleId/courses/bulk", + { + schema: { + description: "Bulk add multiple courses to a schedule", + tags: ["Schedules", "Courses"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + body: { + type: "object", + properties: { + courses: { + type: "array", + items: { + type: "object", + properties: { + courseId: { type: "string" }, + color: { type: "number" }, + isComplete: { type: "boolean" }, + confirms: { type: "object" }, + }, + required: ["courseId"], + }, + }, + }, + required: ["courses"], + }, + response: { + 201: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + scheduleId: { type: "number" }, + courseId: { type: "string" }, + color: { type: "number" }, + isComplete: { type: "boolean" }, + confirms: { type: "object" }, + }, + }, + }, + }, + }, + 400: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + const { courses } = request.body as { + courses: Array<{ + courseId: string; + color?: number; + isComplete?: boolean; + confirms?: Record; + }>; + }; + + if (!courses || courses.length === 0) { + return reply.code(400).send({ + success: false, + error: "courses array is required and cannot be empty", + }); + } + + try { + const associations = courses.map((course) => ({ + scheduleId, + courseId: course.courseId, + color: course.color ?? 0, + isComplete: course.isComplete ?? false, + confirms: course.confirms ?? {}, + })); + + const inserted = await app.db.db + .insert(schema.scheduleCourseMap) + .values(associations) + .returning(); + + return reply.code(201).send({ + success: true, + count: inserted.length, + data: inserted, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to bulk add courses to schedule", + }); + } + } + ); + + // PATCH /api/schedules/:scheduleId/courses/:courseId - Update course metadata + app.patch( + "/:scheduleId/courses/:courseId", + { + schema: { + description: "Update course metadata (section selections)", + tags: ["Schedules", "Courses"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + courseId: { type: "string" }, + }, + required: ["scheduleId", "courseId"], + }, + body: { + type: "object", + properties: { + color: { type: "number" }, + isComplete: { type: "boolean" }, + confirms: { type: "object" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + scheduleId: { type: "number" }, + courseId: { type: "string" }, + color: { type: "number" }, + isComplete: { type: "boolean" }, + confirms: { type: "object" }, + }, + }, + }, + }, + 404: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId, courseId } = request.params as { scheduleId: number; courseId: string }; + const updateData = request.body as { + color?: number; + isComplete?: boolean; + confirms?: Record; + }; + + try { + const updated = await app.db.db + .update(schema.scheduleCourseMap) + .set(updateData) + .where( + and( + eq(schema.scheduleCourseMap.scheduleId, scheduleId), + eq(schema.scheduleCourseMap.courseId, courseId) + ) + ) + .returning(); + + if (updated.length === 0) { + return reply.code(404).send({ + success: false, + error: "Course association not found", + }); + } + + return reply.code(200).send({ + success: true, + data: updated[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to update course metadata", + }); + } + } + ); + + // DELETE /api/schedules/:scheduleId/courses/:courseId - Remove a course from schedule + app.delete( + "/:scheduleId/courses/:courseId", + { + schema: { + description: "Remove a course from a schedule", + tags: ["Schedules", "Courses"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + courseId: { type: "string" }, + }, + required: ["scheduleId", "courseId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 404: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId, courseId } = request.params as { scheduleId: number; courseId: string }; + + try { + const deleted = await app.db.db + .delete(schema.scheduleCourseMap) + .where( + and( + eq(schema.scheduleCourseMap.scheduleId, scheduleId), + eq(schema.scheduleCourseMap.courseId, courseId) + ) + ) + .returning(); + + if (deleted.length === 0) { + return reply.code(404).send({ + success: false, + error: "Course association not found", + }); + } + + return reply.code(200).send({ + success: true, + message: "Course removed from schedule", + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to remove course from schedule", + }); + } + } + ); + + // DELETE /api/schedules/:scheduleId/courses - Clear all courses from schedule + app.delete( + "/:scheduleId/courses", + { + schema: { + description: "Clear all courses from a schedule", + tags: ["Schedules", "Courses"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + count: { type: "number" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + + try { + const deleted = await app.db.db + .delete(schema.scheduleCourseMap) + .where(eq(schema.scheduleCourseMap.scheduleId, scheduleId)) + .returning(); + + return reply.code(200).send({ + success: true, + message: "All courses cleared from schedule", + count: deleted.length, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to clear courses from schedule", + }); + } + } + ); + + // GET /api/schedules/:scheduleId/events - Get event associations for a schedule + app.get( + "/:scheduleId/events", + { + schema: { + description: "Get all event associations for a schedule", + tags: ["Schedules", "Events"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + scheduleId: { type: "number" }, + customEventId: { type: "number" }, + }, + }, + }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + + try { + const associations = await app.db.db + .select() + .from(schema.scheduleEventMap) + .where(eq(schema.scheduleEventMap.scheduleId, scheduleId)); + + return reply.code(200).send({ + success: true, + count: associations.length, + data: associations, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch event associations", + }); + } + } + ); + + // POST /api/schedules/:scheduleId/events - Add an event to a schedule + app.post( + "/:scheduleId/events", + { + schema: { + description: "Add an event to a schedule", + tags: ["Schedules", "Events"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + body: { + type: "object", + properties: { + eventId: { type: "number" }, + }, + required: ["eventId"], + }, + response: { + 201: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + scheduleId: { type: "number" }, + customEventId: { type: "number" }, + }, + }, + }, + }, + 400: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + const { eventId } = request.body as { eventId: number }; + + if (!eventId) { + return reply.code(400).send({ + success: false, + error: "eventId is required", + }); + } + + try { + const newAssociation = await app.db.db + .insert(schema.scheduleEventMap) + .values({ + scheduleId, + customEventId: eventId, + }) + .returning(); + + return reply.code(201).send({ + success: true, + data: newAssociation[0], + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to add event to schedule", + }); + } + } + ); + + // DELETE /api/schedules/:scheduleId/events/:eventId - Remove an event from schedule + app.delete( + "/:scheduleId/events/:eventId", + { + schema: { + description: "Remove an event from a schedule", + tags: ["Schedules", "Events"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + eventId: { type: "number" }, + }, + required: ["scheduleId", "eventId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + 404: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId, eventId } = request.params as { scheduleId: number; eventId: number }; + + try { + const deleted = await app.db.db + .delete(schema.scheduleEventMap) + .where( + and( + eq(schema.scheduleEventMap.scheduleId, scheduleId), + eq(schema.scheduleEventMap.customEventId, eventId) + ) + ) + .returning(); + + if (deleted.length === 0) { + return reply.code(404).send({ + success: false, + error: "Event association not found", + }); + } + + return reply.code(200).send({ + success: true, + message: "Event removed from schedule", + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to remove event from schedule", + }); + } + } + ); + + // DELETE /api/schedules/:scheduleId/events - Clear all events from schedule + app.delete( + "/:scheduleId/events", + { + schema: { + description: "Clear all events from a schedule", + tags: ["Schedules", "Events"], + params: { + type: "object", + properties: { + scheduleId: { type: "number" }, + }, + required: ["scheduleId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + count: { type: "number" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { scheduleId } = request.params as { scheduleId: number }; + + try { + const deleted = await app.db.db + .delete(schema.scheduleEventMap) + .where(eq(schema.scheduleEventMap.scheduleId, scheduleId)) + .returning(); + + return reply.code(200).send({ + success: true, + message: "All events cleared from schedule", + count: deleted.length, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to clear events from schedule", + }); + } + } + ); +}; + +export default schedulesRoutes; diff --git a/apps/engine/src/routes/api/sections.ts b/apps/engine/src/routes/api/sections.ts new file mode 100644 index 00000000..578f125a --- /dev/null +++ b/apps/engine/src/routes/api/sections.ts @@ -0,0 +1,115 @@ +// src/routes/api/sections.ts +// Author(s): Sai Nallani '29 + +import { type FastifyPluginAsync } from "fastify"; +import * as schema from "../../db/schema.js"; +import { eq, asc } from "drizzle-orm"; + +const sectionsRoutes: FastifyPluginAsync = async (app) => { + // GET /api/sections/:term - Get all sections for a specific term + app.get( + "/:term", + { + schema: { + description: "Get all sections for a specific term", + tags: ["Sections"], + params: { + type: "object", + properties: { + term: { type: "number", description: "Term code (e.g., 1262)" }, + }, + required: ["term"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number" }, + courseId: { type: "string" }, + title: { type: "string" }, + num: { type: "string" }, + room: { type: "string", nullable: true }, + tot: { type: "number" }, + cap: { type: "number" }, + days: { type: "number" }, + startTime: { type: "number" }, + endTime: { type: "number" }, + status: { type: "string" }, + }, + }, + }, + }, + }, + 400: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { term } = request.params as { term: number }; + + if (!term || isNaN(term)) { + return reply.code(400).send({ + success: false, + error: "Invalid term parameter", + }); + } + + try { + // Get all sections for courses in the specified term + const sections = await app.db.db + .select({ + id: schema.sections.id, + courseId: schema.sections.courseId, + title: schema.sections.title, + num: schema.sections.num, + room: schema.sections.room, + tot: schema.sections.tot, + cap: schema.sections.cap, + days: schema.sections.days, + startTime: schema.sections.startTime, + endTime: schema.sections.endTime, + status: schema.sections.status, + }) + .from(schema.sections) + .innerJoin(schema.courses, eq(schema.sections.courseId, schema.courses.id)) + .where(eq(schema.courses.term, term)) + .orderBy(asc(schema.sections.id)); + + return reply.code(200).send({ + success: true, + count: sections.length, + data: sections, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch sections", + }); + } + } + ); +}; + +export default sectionsRoutes; diff --git a/apps/engine/src/routes/api/users.ts b/apps/engine/src/routes/api/users.ts new file mode 100644 index 00000000..017916f3 --- /dev/null +++ b/apps/engine/src/routes/api/users.ts @@ -0,0 +1,163 @@ +// src/routes/api/users.ts +// Author(s): Sai Nallani '29 + +import { type FastifyPluginAsync } from "fastify"; +import * as schema from "../../db/schema.js"; +import { eq, and, asc } from "drizzle-orm"; + +const usersRoutes: FastifyPluginAsync = async (app) => { + // GET /api/users/:userId/schedules - Get user's schedules (optionally filtered by term) + app.get( + "/:userId/schedules", + { + schema: { + description: "Get all schedules for a user, optionally filtered by term", + tags: ["Users", "Schedules"], + params: { + type: "object", + properties: { + userId: { type: "number" }, + }, + required: ["userId"], + }, + querystring: { + type: "object", + properties: { + term: { type: "number", description: "Optional term filter" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number" }, + relativeId: { type: "number" }, + userId: { type: "number" }, + title: { type: "string" }, + isPublic: { type: "boolean" }, + term: { type: "number" }, + }, + }, + }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { userId } = request.params as { userId: number }; + const { term } = request.query as { term?: number }; + + try { + const whereConditions = term + ? and(eq(schema.schedules.userId, userId), eq(schema.schedules.term, term)) + : eq(schema.schedules.userId, userId); + + const schedules = await app.db.db + .select() + .from(schema.schedules) + .where(whereConditions) + .orderBy(asc(schema.schedules.id)); + + return reply.code(200).send({ + success: true, + count: schedules.length, + data: schedules, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch user schedules", + }); + } + } + ); + + // GET /api/users/:userId/events - Get user's custom events + app.get( + "/:userId/events", + { + schema: { + description: "Get all custom events for a user", + tags: ["Users", "Events"], + params: { + type: "object", + properties: { + userId: { type: "number" }, + }, + required: ["userId"], + }, + response: { + 200: { + type: "object", + properties: { + success: { type: "boolean" }, + count: { type: "number" }, + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number" }, + userId: { type: "number" }, + title: { type: "string" }, + times: { type: "object" }, + }, + }, + }, + }, + }, + 500: { + type: "object", + properties: { + success: { type: "boolean" }, + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + // TODO: Add authentication/authorization check + const { userId } = request.params as { userId: number }; + + try { + const events = await app.db.db + .select() + .from(schema.customEvents) + .where(eq(schema.customEvents.userId, userId)) + .orderBy(asc(schema.customEvents.id)); + + return reply.code(200).send({ + success: true, + count: events.length, + data: events, + }); + } catch (error) { + app.log.error(error); + return reply.code(500).send({ + success: false, + error: "Failed to fetch user events", + }); + } + } + ); +}; + +export default usersRoutes; diff --git a/apps/engine/src/routes/health.ts b/apps/engine/src/routes/health.ts index 23086b22..669ebae5 100644 --- a/apps/engine/src/routes/health.ts +++ b/apps/engine/src/routes/health.ts @@ -1,5 +1,5 @@ // src/routes/health.ts -// Author(s): Joshua Lau +// Author(s): Joshua Lau '26 import { type FastifyPluginAsync } from "fastify"; import os from "os"; diff --git a/apps/web/src/routes/auth/callback/+server.ts b/apps/web/src/routes/auth/callback/+server.ts index ca63d9ba..9e5c190c 100644 --- a/apps/web/src/routes/auth/callback/+server.ts +++ b/apps/web/src/routes/auth/callback/+server.ts @@ -1,9 +1,9 @@ -import { redirect } from "@sveltejs/kit"; +import { redirect, type RequestEvent } from "@sveltejs/kit"; /** * Exchange the code for a session. */ -export const GET = async ({ url, locals: { supabase } }) => { +export const GET = async ({ url, locals: { supabase } }: RequestEvent) => { const code = url.searchParams.get("code"); if (code) await supabase.auth.exchangeCodeForSession(code); throw redirect(303, "/recalplus");