diff --git a/.gitignore b/.gitignore index 0cc73a08..867fbd9d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules vite.config.js.timestamp-* vite.config.ts.timestamp-* .VSCODECounter +"Engine API" \ No newline at end of file diff --git a/Engine API/Courses/Get all courses for a specific term with instructor information.bru b/Engine API/Courses/Get all courses for a specific term with instructor information.bru new file mode 100644 index 00000000..3d8f0222 --- /dev/null +++ b/Engine API/Courses/Get all courses for a specific term with instructor information.bru @@ -0,0 +1,15 @@ +meta { + name: Get all courses for a specific term with instructor information + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/courses/:term + body: none + auth: inherit +} + +params:path { + term: +} diff --git a/Engine API/Courses/Get all courses.bru b/Engine API/Courses/Get all courses.bru new file mode 100644 index 00000000..61862ae8 --- /dev/null +++ b/Engine API/Courses/Get all courses.bru @@ -0,0 +1,16 @@ +meta { + name: Get all courses + type: http + seq: 3 +} + +get { + url: + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/Engine API/Courses/Get all sections for a specific course.bru b/Engine API/Courses/Get all sections for a specific course.bru new file mode 100644 index 00000000..eaebfbdf --- /dev/null +++ b/Engine API/Courses/Get all sections for a specific course.bru @@ -0,0 +1,15 @@ +meta { + name: Get all sections for a specific course + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/courses/:courseId/sections + body: none + auth: inherit +} + +params:path { + courseId: +} diff --git a/Engine API/Courses/folder.bru b/Engine API/Courses/folder.bru new file mode 100644 index 00000000..c48f813b --- /dev/null +++ b/Engine API/Courses/folder.bru @@ -0,0 +1,7 @@ +meta { + name: Courses +} + +auth { + mode: inherit +} diff --git a/Engine API/Events/Create a new custom event.bru b/Engine API/Events/Create a new custom event.bru new file mode 100644 index 00000000..05fc0d4a --- /dev/null +++ b/Engine API/Events/Create a new custom event.bru @@ -0,0 +1,19 @@ +meta { + name: Create a new custom event + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/events/ + body: json + auth: inherit +} + +body:json { + { + "userId": "", + "title": "", + "times": {} + } +} diff --git a/Engine API/Events/Delete a custom event (cascades to delete event-schedule associations).bru b/Engine API/Events/Delete a custom event (cascades to delete event-schedule associations).bru new file mode 100644 index 00000000..15536d39 --- /dev/null +++ b/Engine API/Events/Delete a custom event (cascades to delete event-schedule associations).bru @@ -0,0 +1,15 @@ +meta { + name: Delete a custom event (cascades to delete event-schedule associations) + type: http + seq: 3 +} + +delete { + url: {{baseUrl}}/api/events/:eventId + body: none + auth: inherit +} + +params:path { + eventId: +} diff --git a/Engine API/Events/Get all schedule associations for an event.bru b/Engine API/Events/Get all schedule associations for an event.bru new file mode 100644 index 00000000..57e5f29a --- /dev/null +++ b/Engine API/Events/Get all schedule associations for an event.bru @@ -0,0 +1,15 @@ +meta { + name: Get all schedule associations for an event + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/api/events/:eventId/schedules + body: none + auth: inherit +} + +params:path { + eventId: +} diff --git a/Engine API/Events/Update a custom event.bru b/Engine API/Events/Update a custom event.bru new file mode 100644 index 00000000..5022bef4 --- /dev/null +++ b/Engine API/Events/Update a custom event.bru @@ -0,0 +1,22 @@ +meta { + name: Update a custom event + type: http + seq: 2 +} + +patch { + url: {{baseUrl}}/api/events/:eventId + body: json + auth: inherit +} + +params:path { + eventId: +} + +body:json { + { + "title": "", + "times": {} + } +} diff --git a/Engine API/Events/folder.bru b/Engine API/Events/folder.bru new file mode 100644 index 00000000..8e7af804 --- /dev/null +++ b/Engine API/Events/folder.bru @@ -0,0 +1,7 @@ +meta { + name: Events +} + +auth { + mode: inherit +} diff --git a/Engine API/Feedback/Submit user feedback.bru b/Engine API/Feedback/Submit user feedback.bru new file mode 100644 index 00000000..e018df9c --- /dev/null +++ b/Engine API/Feedback/Submit user feedback.bru @@ -0,0 +1,18 @@ +meta { + name: Submit user feedback + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/api/feedback/ + body: json + auth: inherit +} + +body:json { + { + "userId": "", + "feedback": "" + } +} diff --git a/Engine API/Feedback/folder.bru b/Engine API/Feedback/folder.bru new file mode 100644 index 00000000..35a309cf --- /dev/null +++ b/Engine API/Feedback/folder.bru @@ -0,0 +1,7 @@ +meta { + name: Feedback +} + +auth { + mode: inherit +} diff --git a/Engine API/Health/Basic health check.bru b/Engine API/Health/Basic health check.bru new file mode 100644 index 00000000..df1acf6c --- /dev/null +++ b/Engine API/Health/Basic health check.bru @@ -0,0 +1,11 @@ +meta { + name: Basic health check + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health/ + body: none + auth: inherit +} diff --git a/Engine API/Health/Detailed health check.bru b/Engine API/Health/Detailed health check.bru new file mode 100644 index 00000000..bad05d42 --- /dev/null +++ b/Engine API/Health/Detailed health check.bru @@ -0,0 +1,11 @@ +meta { + name: Detailed health check + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/health/detailed + body: none + auth: inherit +} diff --git a/Engine API/Health/folder.bru b/Engine API/Health/folder.bru new file mode 100644 index 00000000..618fb623 --- /dev/null +++ b/Engine API/Health/folder.bru @@ -0,0 +1,7 @@ +meta { + name: Health +} + +auth { + mode: inherit +} diff --git a/Engine API/Instructors/Get a specific instructor by netid.bru b/Engine API/Instructors/Get a specific instructor by netid.bru new file mode 100644 index 00000000..3e70e472 --- /dev/null +++ b/Engine API/Instructors/Get a specific instructor by netid.bru @@ -0,0 +1,15 @@ +meta { + name: Get a specific instructor by netid + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/instructors/:netid + body: none + auth: inherit +} + +params:path { + netid: +} diff --git a/Engine API/Instructors/Get all instructors.bru b/Engine API/Instructors/Get all instructors.bru new file mode 100644 index 00000000..d6990f1b --- /dev/null +++ b/Engine API/Instructors/Get all instructors.bru @@ -0,0 +1,11 @@ +meta { + name: Get all instructors + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/instructors/ + body: none + auth: inherit +} diff --git a/Engine API/Instructors/folder.bru b/Engine API/Instructors/folder.bru new file mode 100644 index 00000000..a77fe0d4 --- /dev/null +++ b/Engine API/Instructors/folder.bru @@ -0,0 +1,7 @@ +meta { + name: Instructors +} + +auth { + mode: inherit +} diff --git a/Engine API/Schedules/Add a course to a schedule.bru b/Engine API/Schedules/Add a course to a schedule.bru new file mode 100644 index 00000000..a119657a --- /dev/null +++ b/Engine API/Schedules/Add a course to a schedule.bru @@ -0,0 +1,24 @@ +meta { + name: Add a course to a schedule + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/schedules/:scheduleId/courses + body: json + auth: inherit +} + +params:path { + scheduleId: +} + +body:json { + { + "courseId": "", + "color": "", + "isComplete": false, + "confirms": {} + } +} diff --git a/Engine API/Schedules/Add an event to a schedule.bru b/Engine API/Schedules/Add an event to a schedule.bru new file mode 100644 index 00000000..d76bda6e --- /dev/null +++ b/Engine API/Schedules/Add an event to a schedule.bru @@ -0,0 +1,21 @@ +meta { + name: Add an event to a schedule + type: http + seq: 12 +} + +post { + url: {{baseUrl}}/api/schedules/:scheduleId/events + body: json + auth: inherit +} + +params:path { + scheduleId: +} + +body:json { + { + "eventId": "" + } +} diff --git a/Engine API/Schedules/Bulk add multiple courses to a schedule.bru b/Engine API/Schedules/Bulk add multiple courses to a schedule.bru new file mode 100644 index 00000000..5febf401 --- /dev/null +++ b/Engine API/Schedules/Bulk add multiple courses to a schedule.bru @@ -0,0 +1,28 @@ +meta { + name: Bulk add multiple courses to a schedule + type: http + seq: 8 +} + +post { + url: {{baseUrl}}/api/schedules/:scheduleId/courses/bulk + body: json + auth: inherit +} + +params:path { + scheduleId: +} + +body:json { + { + "courses": [ + { + "courseId": "", + "color": "", + "isComplete": false, + "confirms": {} + } + ] + } +} diff --git a/Engine API/Schedules/Clear all courses from a schedule.bru b/Engine API/Schedules/Clear all courses from a schedule.bru new file mode 100644 index 00000000..ae23c58e --- /dev/null +++ b/Engine API/Schedules/Clear all courses from a schedule.bru @@ -0,0 +1,15 @@ +meta { + name: Clear all courses from a schedule + type: http + seq: 7 +} + +delete { + url: {{baseUrl}}/api/schedules/:scheduleId/courses + body: none + auth: inherit +} + +params:path { + scheduleId: +} diff --git a/Engine API/Schedules/Clear all events from a schedule.bru b/Engine API/Schedules/Clear all events from a schedule.bru new file mode 100644 index 00000000..2b3c73c6 --- /dev/null +++ b/Engine API/Schedules/Clear all events from a schedule.bru @@ -0,0 +1,15 @@ +meta { + name: Clear all events from a schedule + type: http + seq: 13 +} + +delete { + url: {{baseUrl}}/api/schedules/:scheduleId/events + body: none + auth: inherit +} + +params:path { + scheduleId: +} diff --git a/Engine API/Schedules/Create a new schedule.bru b/Engine API/Schedules/Create a new schedule.bru new file mode 100644 index 00000000..0d8c7f8c --- /dev/null +++ b/Engine API/Schedules/Create a new schedule.bru @@ -0,0 +1,21 @@ +meta { + name: Create a new schedule + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/schedules/ + body: json + auth: inherit +} + +body:json { + { + "userId": "", + "term": "", + "title": "", + "relativeId": "", + "isPublic": false + } +} diff --git a/Engine API/Schedules/Delete a schedule (cascades to delete associated courses-events).bru b/Engine API/Schedules/Delete a schedule (cascades to delete associated courses-events).bru new file mode 100644 index 00000000..2212a387 --- /dev/null +++ b/Engine API/Schedules/Delete a schedule (cascades to delete associated courses-events).bru @@ -0,0 +1,15 @@ +meta { + name: Delete a schedule (cascades to delete associated courses/events) + type: http + seq: 3 +} + +delete { + url: {{baseUrl}}/api/schedules/:scheduleId + body: none + auth: inherit +} + +params:path { + scheduleId: +} diff --git a/Engine API/Schedules/Get a specific schedule by ID.bru b/Engine API/Schedules/Get a specific schedule by ID.bru new file mode 100644 index 00000000..4711ccde --- /dev/null +++ b/Engine API/Schedules/Get a specific schedule by ID.bru @@ -0,0 +1,15 @@ +meta { + name: Get a specific schedule by ID + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/schedules/:scheduleId + body: none + auth: inherit +} + +params:path { + scheduleId: +} diff --git a/Engine API/Schedules/Get all course associations for a schedule.bru b/Engine API/Schedules/Get all course associations for a schedule.bru new file mode 100644 index 00000000..e595b40b --- /dev/null +++ b/Engine API/Schedules/Get all course associations for a schedule.bru @@ -0,0 +1,15 @@ +meta { + name: Get all course associations for a schedule + type: http + seq: 5 +} + +get { + url: {{baseUrl}}/api/schedules/:scheduleId/courses + body: none + auth: inherit +} + +params:path { + scheduleId: +} diff --git a/Engine API/Schedules/Get all event associations for a schedule.bru b/Engine API/Schedules/Get all event associations for a schedule.bru new file mode 100644 index 00000000..50ff4e16 --- /dev/null +++ b/Engine API/Schedules/Get all event associations for a schedule.bru @@ -0,0 +1,15 @@ +meta { + name: Get all event associations for a schedule + type: http + seq: 11 +} + +get { + url: {{baseUrl}}/api/schedules/:scheduleId/events + body: none + auth: inherit +} + +params:path { + scheduleId: +} diff --git a/Engine API/Schedules/Remove a course from a schedule.bru b/Engine API/Schedules/Remove a course from a schedule.bru new file mode 100644 index 00000000..0f00d30b --- /dev/null +++ b/Engine API/Schedules/Remove a course from a schedule.bru @@ -0,0 +1,16 @@ +meta { + name: Remove a course from a schedule + type: http + seq: 10 +} + +delete { + url: {{baseUrl}}/api/schedules/:scheduleId/courses/:courseId + body: none + auth: inherit +} + +params:path { + scheduleId: + courseId: +} diff --git a/Engine API/Schedules/Remove an event from a schedule.bru b/Engine API/Schedules/Remove an event from a schedule.bru new file mode 100644 index 00000000..92c0884d --- /dev/null +++ b/Engine API/Schedules/Remove an event from a schedule.bru @@ -0,0 +1,16 @@ +meta { + name: Remove an event from a schedule + type: http + seq: 14 +} + +delete { + url: {{baseUrl}}/api/schedules/:scheduleId/events/:eventId + body: none + auth: inherit +} + +params:path { + scheduleId: + eventId: +} diff --git a/Engine API/Schedules/Update a schedule's title.bru b/Engine API/Schedules/Update a schedule's title.bru new file mode 100644 index 00000000..82d27d77 --- /dev/null +++ b/Engine API/Schedules/Update a schedule's title.bru @@ -0,0 +1,21 @@ +meta { + name: Update a schedule's title + type: http + seq: 2 +} + +patch { + url: {{baseUrl}}/api/schedules/:scheduleId + body: json + auth: inherit +} + +params:path { + scheduleId: +} + +body:json { + { + "title": "" + } +} diff --git a/Engine API/Schedules/Update course metadata (section selections).bru b/Engine API/Schedules/Update course metadata (section selections).bru new file mode 100644 index 00000000..1b4c60ad --- /dev/null +++ b/Engine API/Schedules/Update course metadata (section selections).bru @@ -0,0 +1,24 @@ +meta { + name: Update course metadata (section selections) + type: http + seq: 9 +} + +patch { + url: {{baseUrl}}/api/schedules/:scheduleId/courses/:courseId + body: json + auth: inherit +} + +params:path { + scheduleId: + courseId: +} + +body:json { + { + "color": "", + "isComplete": false, + "confirms": {} + } +} diff --git a/Engine API/Schedules/folder.bru b/Engine API/Schedules/folder.bru new file mode 100644 index 00000000..0daf4cbf --- /dev/null +++ b/Engine API/Schedules/folder.bru @@ -0,0 +1,7 @@ +meta { + name: Schedules +} + +auth { + mode: inherit +} diff --git a/Engine API/Sections/Get all sections for a specific term.bru b/Engine API/Sections/Get all sections for a specific term.bru new file mode 100644 index 00000000..5a4d8ad4 --- /dev/null +++ b/Engine API/Sections/Get all sections for a specific term.bru @@ -0,0 +1,15 @@ +meta { + name: Get all sections for a specific term + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/sections/:term + body: none + auth: inherit +} + +params:path { + term: +} diff --git a/Engine API/Sections/folder.bru b/Engine API/Sections/folder.bru new file mode 100644 index 00000000..607b7d09 --- /dev/null +++ b/Engine API/Sections/folder.bru @@ -0,0 +1,7 @@ +meta { + name: Sections +} + +auth { + mode: inherit +} diff --git a/Engine API/Users/Get all custom events for a user.bru b/Engine API/Users/Get all custom events for a user.bru new file mode 100644 index 00000000..46278be2 --- /dev/null +++ b/Engine API/Users/Get all custom events for a user.bru @@ -0,0 +1,15 @@ +meta { + name: Get all custom events for a user + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/users/:userId/events + body: none + auth: inherit +} + +params:path { + userId: +} diff --git a/Engine API/Users/Get all schedules for a user, optionally filtered by term.bru b/Engine API/Users/Get all schedules for a user, optionally filtered by term.bru new file mode 100644 index 00000000..9290b0f9 --- /dev/null +++ b/Engine API/Users/Get all schedules for a user, optionally filtered by term.bru @@ -0,0 +1,19 @@ +meta { + name: Get all schedules for a user, optionally filtered by term + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/users/:userId/schedules + body: none + auth: inherit +} + +params:query { + ~term: +} + +params:path { + userId: +} diff --git a/Engine API/Users/folder.bru b/Engine API/Users/folder.bru new file mode 100644 index 00000000..19a3250a --- /dev/null +++ b/Engine API/Users/folder.bru @@ -0,0 +1,7 @@ +meta { + name: Users +} + +auth { + mode: inherit +} diff --git a/Engine API/bruno.json b/Engine API/bruno.json new file mode 100644 index 00000000..64047361 --- /dev/null +++ b/Engine API/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "Engine API", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/Engine API/collection.bru b/Engine API/collection.bru new file mode 100644 index 00000000..afdc67a5 --- /dev/null +++ b/Engine API/collection.bru @@ -0,0 +1,7 @@ +meta { + name: Engine API +} + +auth { + mode: none +} diff --git a/Engine API/environments/Local server.bru b/Engine API/environments/Local server.bru new file mode 100644 index 00000000..a2c090a9 --- /dev/null +++ b/Engine API/environments/Local server.bru @@ -0,0 +1,3 @@ +vars { + baseUrl: http://0.0.0.0:3000 +} diff --git a/Engine API/get -openapi.json.bru b/Engine API/get -openapi.json.bru new file mode 100644 index 00000000..4ef965c4 --- /dev/null +++ b/Engine API/get -openapi.json.bru @@ -0,0 +1,11 @@ +meta { + name: get /openapi.json + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/openapi.json + body: none + auth: inherit +} diff --git a/apps/engine/src/main.ts b/apps/engine/src/main.ts index f981d9f4..098c91c6 100644 --- a/apps/engine/src/main.ts +++ b/apps/engine/src/main.ts @@ -1,57 +1,110 @@ // src/main.ts // Author(s): Joshua Lau -import Fastify, { type FastifyInstance } from "fastify"; -import fp from "fastify-plugin"; -import sensible from "@fastify/sensible"; -import swagger from "@fastify/swagger"; -import swaggerUI from "@fastify/swagger-ui"; - -import healthRoutes from "./routes/health.ts"; - -async function build(): Promise { - const app = Fastify({ logger: true }); - - // Global plugins (apply everywhere) - app.register( - fp(async (app) => { - await app.register(sensible); - }) - ); - - // Swagger documentation - await app.register(swagger, { - openapi: { - openapi: "3.0.0", - info: { - title: "Engine API", - version: "1.0.0", - description: "API documentation for the TigerJunction Engine (backend)", - }, - servers: [{ url: "http://localhost:3000", description: "Local server" }], - tags: [{ name: "Health", description: "Health check endpoints" }], - }, - }); +import Fastify from "fastify"; +import fastifySwagger from "@fastify/swagger"; +import fastifySwaggerUI from "@fastify/swagger-ui"; +import fastifySensible from "@fastify/sensible"; +import fastifyWebsocket from "@fastify/websocket"; +import dotenv from "dotenv"; + +// Import route handlers +import healthRoutes from "./routes/health.js"; +import coursesRoutes from "./routes/api/courses.js"; +import eventsRoutes from "./routes/api/events.js"; +import feedbackRoutes from "./routes/api/feedback.js"; +import instructorsRoutes from "./routes/api/instructors.js"; +import schedulesRoutes from "./routes/api/schedules/index.js"; +import scheduleCoursesRoutes from "./routes/api/schedules/courses.js"; +import scheduleEventsRoutes from "./routes/api/schedules/events.js"; +import sectionsRoutes from "./routes/api/sections.js"; +import usersRoutes from "./routes/api/users.js"; + +// Load environment variables +dotenv.config(); - await app.register(swaggerUI, { - routePrefix: "/docs", - uiConfig: { - docExpansion: "full", - deepLinking: false, +const app = Fastify({ + logger: { + level: process.env.LOG_LEVEL || "info", + transport: + process.env.NODE_ENV === "development" + ? { + target: "pino-pretty", + options: { + translateTime: "HH:MM:ss Z", + ignore: "pid,hostname", + }, + } + : undefined, + }, +}); + +// Register plugins +await app.register(fastifySensible); +await app.register(fastifyWebsocket); + +// Register Swagger documentation +await app.register(fastifySwagger, { + openapi: { + info: { + title: "Tiger Junction API", + description: "API documentation for Tiger Junction course management system", + version: "1.0.0", }, - staticCSP: true, - transformStaticCSP: (header) => header, - }); + servers: [ + { + url: "http://localhost:3000", + description: "Development server", + }, + ], + tags: [ + { name: "Health", description: "Health check endpoints" }, + { name: "Courses", description: "Course management endpoints" }, + { name: "Events", description: "Event management endpoints" }, + { name: "Feedback", description: "User feedback endpoints" }, + { name: "Instructors", description: "Instructor information endpoints" }, + { name: "Schedules", description: "Schedule management endpoints" }, + { name: "Sections", description: "Course section endpoints" }, + { name: "Users", description: "User management endpoints" }, + ], + }, +}); - // Route groups - app.register(healthRoutes, { prefix: "/health" }); +await app.register(fastifySwaggerUI, { + routePrefix: "/docs", + uiConfig: { + docExpansion: "list", + deepLinking: true, + }, +}); - return app; -} +// Register routes +await app.register(healthRoutes, { prefix: "/health" }); +await app.register(coursesRoutes, { prefix: "/api/courses" }); +await app.register(eventsRoutes, { prefix: "/api/events" }); +await app.register(feedbackRoutes, { prefix: "/api/feedback" }); +await app.register(instructorsRoutes, { prefix: "/api/instructors" }); +await app.register(schedulesRoutes, { prefix: "/api/schedules" }); +await app.register(scheduleCoursesRoutes, { prefix: "/api/schedules" }); +await app.register(scheduleEventsRoutes, { prefix: "/api/schedules" }); +await app.register(sectionsRoutes, { prefix: "/api/sections" }); +await app.register(usersRoutes, { prefix: "/api/users" }); -const app = await build(); +// Add an endpoint to get the OpenAPI JSON +app.get("/openapi.json", async () => { + return app.swagger(); +}); -app.listen({ port: 3000 }).catch((err) => { +// Start the server +const PORT = Number(process.env.PORT) || 3000; +const HOST = process.env.HOST || "0.0.0.0"; + +try { + await app.listen({ port: PORT, host: HOST }); + console.log(`Server is running on http://${HOST}:${PORT}`); + console.log(`API documentation available at http://${HOST}:${PORT}/docs`); + console.log(`OpenAPI spec available at http://${HOST}:${PORT}/openapi.json`); +} catch (err) { app.log.error(err); process.exit(1); -}); +} diff --git a/apps/engine/src/routes/api/courses.ts b/apps/engine/src/routes/api/courses.ts new file mode 100644 index 00000000..a1439675 --- /dev/null +++ b/apps/engine/src/routes/api/courses.ts @@ -0,0 +1,372 @@ +// src/routes/api/courses.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../db/index.js"; +import * as schema from "../../db/schema.js"; +import { eq, asc, sql } from "drizzle-orm"; + +const coursesRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 as any).redis; + + const loader = async () => { + const rows = await 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 as any).redis; + + const loader = async () => { + // Get courses with instructors + const coursesWithInstructors = await 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 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; \ No newline at end of file diff --git a/apps/engine/src/routes/api/events.ts b/apps/engine/src/routes/api/events.ts new file mode 100644 index 00000000..9e87add1 --- /dev/null +++ b/apps/engine/src/routes/api/events.ts @@ -0,0 +1,312 @@ +// src/routes/api/events.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../db/index.js"; +import * as schema from "../../db/schema.js"; +import { eq, and } from "drizzle-orm"; + +const eventsRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 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 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 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 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..e6ba4299 --- /dev/null +++ b/apps/engine/src/routes/api/feedback.ts @@ -0,0 +1,94 @@ +// src/routes/api/feedback.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../db/index.js"; +import * as schema from "../../db/schema.js"; + +const feedbackRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 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..d4c1641f --- /dev/null +++ b/apps/engine/src/routes/api/instructors.ts @@ -0,0 +1,156 @@ +// src/routes/api/instructors.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../db/index.js"; +import * as schema from "../../db/schema.js"; +import { asc, eq } from "drizzle-orm"; + +const instructorsRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 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 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..52297ee1 --- /dev/null +++ b/apps/engine/src/routes/api/schedules.ts @@ -0,0 +1,1114 @@ +// src/routes/api/schedules.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../db/index.js"; +import * as schema from "../../db/schema.js"; +import { eq, and, asc } from "drizzle-orm"; + +const schedulesRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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/schedules/courses.ts b/apps/engine/src/routes/api/schedules/courses.ts new file mode 100644 index 00000000..77e44768 --- /dev/null +++ b/apps/engine/src/routes/api/schedules/courses.ts @@ -0,0 +1,512 @@ +// src/routes/api/schedules/courses.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../../db/index.js"; +import * as schema from "../../../db/schema.js"; +import { eq, and } from "drizzle-orm"; + +const scheduleCoursesRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 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 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 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 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 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 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" + }); + } + }); +}; + +export default scheduleCoursesRoutes; diff --git a/apps/engine/src/routes/api/schedules/events.ts b/apps/engine/src/routes/api/schedules/events.ts new file mode 100644 index 00000000..c7ee7ccc --- /dev/null +++ b/apps/engine/src/routes/api/schedules/events.ts @@ -0,0 +1,285 @@ +// src/routes/api/schedules/events.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../../db/index.js"; +import * as schema from "../../../db/schema.js"; +import { eq, and } from "drizzle-orm"; + +const scheduleEventsRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 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 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 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 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 scheduleEventsRoutes; diff --git a/apps/engine/src/routes/api/schedules/index.ts b/apps/engine/src/routes/api/schedules/index.ts new file mode 100644 index 00000000..d69781a8 --- /dev/null +++ b/apps/engine/src/routes/api/schedules/index.ts @@ -0,0 +1,343 @@ +// src/routes/api/schedules/index.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../../db/index.js"; +import * as schema from "../../../db/schema.js"; +import { eq } from "drizzle-orm"; + +const schedulesRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 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 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 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 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" + }); + } + }); +}; + +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..8ee276ac --- /dev/null +++ b/apps/engine/src/routes/api/sections.ts @@ -0,0 +1,114 @@ +// src/routes/api/sections.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../db/index.js"; +import * as schema from "../../db/schema.js"; +import { eq, asc } from "drizzle-orm"; + +const sectionsRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 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..38c9ff48 --- /dev/null +++ b/apps/engine/src/routes/api/users.ts @@ -0,0 +1,161 @@ +// src/routes/api/users.ts +// Author(s): Joshua Lau + +import { type FastifyPluginAsync } from "fastify"; +import DB from "../../db/index.js"; +import * as schema from "../../db/schema.js"; +import { eq, and, asc } from "drizzle-orm"; + +const usersRoutes: FastifyPluginAsync = async (app) => { + const db = new DB(); + + // 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 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 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/supabase_to_postgres_migrations/readme.md b/apps/engine/supabase_to_postgres_migrations/readme.md new file mode 100644 index 00000000..8ae3ff1f --- /dev/null +++ b/apps/engine/supabase_to_postgres_migrations/readme.md @@ -0,0 +1,61 @@ +# Database Migrations! + +Main Schemas in Supabase: +- public: Application domain tables and views. +- auth: Authentication schema (users, sessions, identities). +- storage: File storage objects (buckets, objects). + +This has the chunk of the data we need to put into +the PostgreSQL database. + +## Migration Scripts + +Script 1: Populate the PostgreSQL user table + + Some more details. We should probably take the + Supabase-User-ID and put it into the PostgreSQL + to make it easier to fetch from Supabase in + other scripts. + +Script 2: Populate the schedule table and link it to +the users + +## Running the Migration Script + +### Prerequisites + +1. Add your Supabase service role key to the `.env` file in this directory: + ```env + SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here + ``` + + Get this key from: Supabase Dashboard > Settings > API > `service_role` key + + **Important:** The service role key is required to access the `auth.users` table. The anon key does not have permission to read from the auth schema. + +### Running script.ts + +From the engine app directory: +```bash +cd /Users/sainallani/Projects/tiger-junction/apps/engine +bun run supabase_to_postgres_migrations/script.ts +``` + +Or from this directory: +```bash +bun run script.ts +``` + +### What the script does + +- Connects to Supabase using the service role key +- Fetches all users from the `auth.users` table using the Admin API +- Displays user count and sample user data +- Returns user data for further processing/insertion into PostgreSQL + +### Next steps + +After fetching the users, you'll need to: +1. Insert users into the PostgreSQL `user` table +2. Store the Supabase user ID in PostgreSQL for linking in subsequent migration scripts +3. Run additional scripts to migrate schedules, events, and feedback data diff --git a/apps/engine/supabase_to_postgres_migrations/script.ts b/apps/engine/supabase_to_postgres_migrations/script.ts new file mode 100644 index 00000000..df4a3025 --- /dev/null +++ b/apps/engine/supabase_to_postgres_migrations/script.ts @@ -0,0 +1,99 @@ +import { createClient } from '@supabase/supabase-js'; +import { config } from 'dotenv'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// Get __dirname equivalent in ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Load environment variables from the .env file in this directory +config({ path: resolve(__dirname, '.env') }); + +const supabaseUrl = process.env.PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('Missing required environment variables:'); + if (!supabaseUrl) console.error(' - PUBLIC_SUPABASE_URL'); + if (!supabaseServiceKey) console.error(' - SUPABASE_SERVICE_ROLE_KEY'); + console.error('\nNote: Accessing auth.users requires SUPABASE_SERVICE_ROLE_KEY, not the anon key.'); + process.exit(1); +} + +// Create Supabase client with service role key to access auth schema +const supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } +}); + +async function fetchAuthUsers() { + console.log('Connecting to Supabase...'); + console.log(`URL: ${supabaseUrl}\n`); + + try { + // Fetch ALL users from auth.users table with pagination + // Note: This requires service role key permissions + const allUsers = []; + let page = 1; + const perPage = 10000; // Max per page + + while (true) { + const { data, error } = await supabase.auth.admin.listUsers({ + page, + perPage + }); + + if (error) { + console.error('Error fetching users:', error.message); + process.exit(1); + } + + allUsers.push(...data.users); + console.log(`Fetched page ${page}: ${data.users.length} users (total so far: ${allUsers.length})`); + + // Break if we got fewer users than requested (last page) + if (data.users.length < perPage) { + break; + } + + page++; + } + + console.log(`\nSuccessfully fetched ${allUsers.length} users from auth.users`); + console.log('\nSample user data:'); + + // Display first user as sample (if any exist) + if (allUsers.length > 0) { + const sampleUser = allUsers[0]; + console.log({ + id: sampleUser.id, + email: sampleUser.email, + created_at: sampleUser.created_at, + last_sign_in_at: sampleUser.last_sign_in_at, + // Add other fields as needed + }); + } + + // Return all users for further processing + return allUsers; + } catch (err) { + console.error('Unexpected error:', err); + process.exit(1); + } +} + +// Run the script +fetchAuthUsers() + .then(users => { + console.log(`\nTotal users: ${users.length}`); + console.log('\nNext steps:'); + console.log('- Process these users and insert into PostgreSQL user table'); + console.log('- Store Supabase user ID for linking data in other migration scripts'); + }) + .catch(err => { + console.error('Fatal error:', err); + process.exit(1); + }); diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index 2ffac3e8..00000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -PUBLIC_SUPABASE_URL= -PUBLIC_SUPABASE_ANON_KEY= -REDIS_PASSWORD= -API_ACCESS_TOKEN= -SERVICE_KEY= \ No newline at end of file diff --git a/apps/web/src/lib/scripts/ReCal+/fetchDb.ts b/apps/web/src/lib/scripts/ReCal+/fetchDb.ts deleted file mode 100644 index 0219c0d0..00000000 --- a/apps/web/src/lib/scripts/ReCal+/fetchDb.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - currentSchedule, - rawCourseData, - searchCourseData -} from "$lib/stores/recal"; -import { schedules } from "$lib/changeme"; -import { initSchedule } from "$lib/stores/rpool"; -import type { CourseData } from "$lib/types/dbTypes"; -import type { RawCourseData } from "$lib/changeme"; -import type { SupabaseClient } from "@supabase/supabase-js"; - -/** - * Fetch the raw course data for a given term - * @param supabase - * @param term - * @returns true if the data was fetched successfully, false if there - * was an error, and null if the data was already loaded - */ -const fetchRawCourseData = async ( - supabase: SupabaseClient, - term: number -): Promise => { - if (rawCourseData.check(term)) return null; - - // Fetch course data from Redis - const data = await fetch("/api/client/courses/" + term); - if (!data.ok) return false; - const json = await data.json(); - - // Calculate adjusted rating - json.forEach((x: any) => { - const adj_evals = (x.num_evals + 1) * 1.5; - x.adj_rating = - x.rating !== null && x.num_evals !== null - ? Math.round( - ((x.rating * adj_evals + 5) / (adj_evals + 2)) * 100 - ) / 100 - : 0; - }); - - // Update raw course data store - rawCourseData.update(x => { - x[term as keyof RawCourseData] = json as CourseData[]; - return x; - }); - - // Update search course data store - searchCourseData.reset(term); - return true; -}; - -/** - * Fetch the user's schedule id and titles for a given term - * @param supabase - * @param term - * @returns true if the schedules were fetched successfully, false if - * there was an error, and null if the schedules were already loaded - */ -const fetchUserSchedules = async ( - supabase: SupabaseClient, - term: number -): Promise => { - // Check if schedules for the given term are already loaded - let loaded = false; - schedules.subscribe(x => { - if (x[term as keyof RawCourseData].length > 0) loaded = true; - })(); - if (loaded) return null; - - // Check if user is logged in - const user = await supabase.auth.getUser(); - if (!user || !user.data || !user.data.user) return false; - - // Fetch schedules - const { data, error } = await supabase - .from("schedules") - .select("id, title") - .eq("user_id", user.data.user.id) - .eq("term", term) - .order("id", { ascending: true }); - - if (error) return false; - - if (data.length === 0) { - // Create default schedule - const { data: data2, error } = await supabase - .from("schedules") - .insert([ - { user_id: user.data.user.id, term, title: "My Schedule" } - ]) - .select() - .single(); - - if (error) return false; - - // Update schedules store - schedules.update(x => { - x[term as keyof RawCourseData] = [ - { - id: data2.id, - title: data2.title - } - ]; - return x; - }); - - currentSchedule.set(data2.id); - return true; - } - - const ids = data.map(x => { - return { - id: x.id, - title: x.title - }; - }); - - // Update schedules store - schedules.update(x => { - x[term as keyof RawCourseData] = ids; - return x; - }); - - currentSchedule.set(ids[0].id); - - return true; -}; - -/** - * Populate the saved course pools for a given term - * @param supabase - * @param scheduleIds - * @param term - */ -const populatePools = async ( - supabase: SupabaseClient, - term: number -): Promise => { - // Get raw course data and schedule ids - let scheduleIds: number[] = []; - schedules.subscribe(x => { - scheduleIds = x[term as keyof RawCourseData].map(y => y.id); - })(); - - for (const id of scheduleIds) await initSchedule(supabase, id, term); -}; - -export { fetchRawCourseData, fetchUserSchedules, populatePools }; diff --git a/apps/web/src/lib/stores/rpool.ts b/apps/web/src/lib/stores/rpool.ts deleted file mode 100644 index 3d6cc379..00000000 --- a/apps/web/src/lib/stores/rpool.ts +++ /dev/null @@ -1,454 +0,0 @@ -// Stores for Saved (Course Pools for ReCal+) - -import { getCurrentTerm } from "$lib/scripts/ReCal+/getters"; -import type { CourseData } from "$lib/types/dbTypes"; -import type { SupabaseClient } from "@supabase/supabase-js"; -import type { Invalidator, Subscriber, Unsubscriber } from "svelte/motion"; -import { writable } from "svelte/store"; -import { - rawCourseData, - scheduleCourseMeta, - searchCourseData, - type ScheduleCourseMetadata -} from "./recal"; -import { sectionData, type SectionData } from "./rsections"; - -// Course pool type -type CoursePool = { - set: (this: void, value: Record) => void; - update: ( - this: void, - updater: ( - value: Record - ) => Record - ) => void; - subscribe: ( - this: void, - run: Subscriber>, - invalidate?: Invalidator> - ) => Unsubscriber; - add: ( - supabase: SupabaseClient, - scheduleId: number, - course: CourseData, - SCD?: boolean - ) => Promise; - remove: ( - supabase: SupabaseClient, - scheduleId: number, - course: CourseData, - SCD?: boolean - ) => Promise; - clear: (supabase: SupabaseClient, scheduleId: number) => Promise; -}; - -//---------------------------------------------------------------------- -// Helpers -//---------------------------------------------------------------------- - -/** - * Add default metadata to a course - * @param course - * @param scheduleId - */ -export const addCourseMetadata = async ( - supabase: SupabaseClient, - course: CourseData, - scheduleId: number -): Promise => { - // Create default metadata - const meta: any = { - complete: false, - color: undefined, - sections: [], - confirms: {} - }; - - // * Handle color - // Get other saved course colors - let otherIds: number[] = []; - const otherColors: number[] = []; - savedCourses.subscribe(x => { - otherIds = x[scheduleId].map(y => y.id); - })(); - scheduleCourseMeta.subscribe(x => { - if (!Object.prototype.hasOwnProperty.call(x, scheduleId)) - x[scheduleId] = {}; - for (let i = 0; i < otherIds.length; i++) { - otherColors.push(x[scheduleId][otherIds[i]].color); - } - })(); - - const colorMap: Record = {}; - for (const o of otherColors) { - if (Object.prototype.hasOwnProperty.call(colorMap, o)) colorMap[o] += 1; - else colorMap[o] = 1; - } - - // Find the first unused color - for (let i = 0; i < 7; i++) { - if (!Object.prototype.hasOwnProperty.call(colorMap, i)) { - meta.color = i; - break; - } - } - - // If no color found, default to least used color - if (meta.color === undefined) { - let min = 0; - for (const [k, v] of Object.entries(colorMap)) { - if (v < colorMap[min]) min = k as unknown as number; - } - meta.color = min; - } - - // * Handle Sections - // Check if section data is already populated - let sections: SectionData[] = []; - sectionData.subscribe(x => { - sections = x[course.term][course.id] ? x[course.term][course.id] : []; - }); - - // Sections are already loaded - if (sections.length > 0) { - const categories = sections.map(x => x.category); - const uniqueCategories = [...new Set(categories)]; - meta.sections = uniqueCategories.sort(); - - // Sections are not loaded - } else { - // Load section data - const res = await sectionData.add( - supabase, - getCurrentTerm(), - course.id - ); - - if (!res) return false; - - // Get section data - sectionData.subscribe(x => { - sections = x[course.term][course.id] - ? x[course.term][course.id] - : []; - })(); - - // Add categories - const categories = sections.map(x => x.category); - const uniqueCategories = [...new Set(categories)]; - meta.sections = uniqueCategories.sort(); - } - - // Auto-Add if only one section in a category and check if complete - meta.complete = true; - for (let i = 0; i < meta.sections.length; i++) { - const category = meta.sections[i]; - const categorySections = sections.filter(x => x.category === category); - if (categorySections.length === 1) { - meta.confirms[category] = categorySections[0].title; - } else { - meta.complete = false; - } - } - - // * Update scheduleCourseMeta - scheduleCourseMeta.update(x => { - if (!Object.prototype.hasOwnProperty.call(x, scheduleId)) - x[scheduleId] = {}; - x[scheduleId][course.id] = meta as ScheduleCourseMetadata; - return x; - }); - - return true; -}; -/** - * Get the current pool for a given schedule - * @param pool - * @param scheduleId - * @returns course data array for the current pool - */ -const getCurrentPool = (pool: CoursePool, scheduleId: number): CourseData[] => { - let data: CourseData[] = []; - pool.subscribe(x => { - if (Object.prototype.hasOwnProperty.call(x, scheduleId)) - data = x[scheduleId]; - else data = []; - })(); - return data; -}; - -/** - * Initialize a course pool - * @param supabase - * @param scheduleId - * @param term - */ -export const initSchedule = async ( - supabase: SupabaseClient, - scheduleId: number, - term: number -) => { - let loaded = false; - savedCourses.update(x => { - if (Object.prototype.hasOwnProperty.call(x, scheduleId)) loaded = true; - else x[scheduleId] = []; - - return x; - }); - if (loaded) return; - - const rawCourses = rawCourseData.get(term); - - // Fetch course-schedule-associations - const { data, error } = await supabase - .from("course_schedule_associations") - .select("course_id, metadata") - .eq("schedule_id", scheduleId); - - if (error) { - console.log(error); - return; - } - if (!data) return; - - searchCourseData.reset(term); - - for (let i = 0; i < data.length; i++) { - const x = data[i]; - - // Find Course - const cur = rawCourses.find(y => y.id === x.course_id) as CourseData; - - // Add metadata - scheduleCourseMeta.update(y => { - if (!Object.prototype.hasOwnProperty.call(y, scheduleId)) - y[scheduleId] = {}; - y[scheduleId][cur.id] = x.metadata; - return y; - }); - - // Load section data - await sectionData.add(supabase, term, cur.id); - - // Update pool - const pool = savedCourses; - pool.update(x => { - x[scheduleId] = [...x[scheduleId], cur]; - return x; - }); - } -}; - -/** - * Add a course to a pool - * @param supabase - * @param pool - * @param scheduleId - * @param course - * @param SCD - * @returns true if successful, false if failure - */ -const addCourse = async ( - supabase: SupabaseClient, - pool: CoursePool, - scheduleId: number, - course: CourseData, - SCD?: boolean -): Promise => { - // Get current pool courses - const currentPool: CourseData[] = getCurrentPool(pool, scheduleId); - - // Add metadata - let meta: ScheduleCourseMetadata | object = {}; - if (pool === savedCourses) { - addCourseMetadata(supabase, course, scheduleId); - scheduleCourseMeta.subscribe(x => { - meta = x[scheduleId][course.id]; - })(); - } - - // Update store - pool.update(x => { - if (currentPool.length === 0) x[scheduleId] = [course]; - else x[scheduleId] = [...x[scheduleId], course]; - return x; - }); - - if (SCD) searchCourseData.remove(getCurrentTerm(), [course]); - - // Update course-schedule-associations table - const { error } = await supabase - .from("course_schedule_associations") - .insert({ - course_id: course.id, - schedule_id: scheduleId, - metadata: meta - }); - - // Revert if error - if (error) { - console.log(error); - pool.update(x => { - x[scheduleId] = currentPool; - return x; - }); - if (SCD) searchCourseData.add(getCurrentTerm(), [course]); - return false; - } - return true; -}; - -/** - * Remove a course from a pool - * @param supabase - * @param pool - * @param scheduleId - * @param course - * @param SCD - * @returns true if successful, false if failure - */ -const removeCourse = async ( - supabase: SupabaseClient, - pool: CoursePool, - scheduleId: number, - course: CourseData, - SCD?: boolean -): Promise => { - // Get current pool courses - const currentPool: CourseData[] = getCurrentPool(pool, scheduleId); - - if (currentPool.length === 0) return false; - - // Update store - pool.update(x => { - x[scheduleId] = x[scheduleId].filter(y => y.id !== course.id); - return x; - }); - - if (SCD) searchCourseData.add(getCurrentTerm(), [course]); - - // Update course-schedule-associations table - const { error } = await supabase - .from("course_schedule_associations") - .delete() - .eq("course_id", course.id) - .eq("schedule_id", scheduleId); - - // Revert if error - if (error) { - console.log(error); - pool.update(x => { - x[scheduleId] = currentPool; - return x; - }); - if (SCD) searchCourseData.remove(getCurrentTerm(), [course]); - return false; - } - return true; -}; - -/** - * Clear a pool - * @param supabase - * @param pool - * @param scheduleId - * @returns true if successful, false if failure - */ -const clearPool = async ( - supabase: SupabaseClient, - pool: CoursePool, - scheduleId: number -): Promise => { - // Get current pool courses - const currentPool: CourseData[] = getCurrentPool(savedCourses, scheduleId); - - // Update store - pool.update(x => { - x[scheduleId] = []; - return x; - }); - - searchCourseData.add(getCurrentTerm(), currentPool); - - // Update course-schedule-associations table - const { error } = await supabase - .from("course_schedule_associations") - .delete() - .eq("schedule_id", scheduleId); - - // Revert if error - if (error) { - console.log(error); - pool.update(x => { - x[scheduleId] = currentPool; - return x; - }); - searchCourseData.remove(getCurrentTerm(), currentPool); - return false; - } - return true; -}; - -//---------------------------------------------------------------------- -// Saved courses -//---------------------------------------------------------------------- - -const { - set: setSave, - update: updateSave, - subscribe: subscribeSave -} = writable>({}); - -export const savedCourses: CoursePool = { - set: setSave, - update: updateSave, - subscribe: subscribeSave, - - /** - * - * @param supabase - * @param scheduleId - * @param course - */ - add: async ( - supabase: SupabaseClient, - scheduleId: number, - course: CourseData, - SCD?: boolean - ): Promise => { - return await addCourse(supabase, savedCourses, scheduleId, course, SCD); - }, - - /** - * - * @param supabase - * @param scheduleId - * @param course - */ - remove: async ( - supabase: SupabaseClient, - scheduleId: number, - course: CourseData, - SCD?: boolean - ): Promise => { - return await removeCourse( - supabase, - savedCourses, - scheduleId, - course, - SCD - ); - }, - - /** - * - * @param supabase - * @param scheduleId - */ - clear: async ( - supabase: SupabaseClient, - scheduleId: number - ): Promise => { - return await clearPool(supabase, savedCourses, scheduleId); - } -};