diff --git a/package.json b/package.json index 6bba1b9e..53014d0f 100644 --- a/package.json +++ b/package.json @@ -57,11 +57,12 @@ "zod": "^3.22.4" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "^7.3.4", "@eslint/js": "^9.24.0", "@jest/globals": "^29.7.0", "@types/bcrypt": "^5.0.2", "@types/dotenv": "^8.2.0", - "@types/express": "^4.17.21", + "@types/express": "4.17.21", "@types/express-rate-limit": "^6.0.0", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", @@ -70,14 +71,19 @@ "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.15.2", "@types/supertest": "^6.0.2", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^10.0.0", "cross-env": "^7.0.3", "eslint": "^9.24.0", "globals": "^16.0.0", "jest": "^29.7.0", "nodemon": "^3.0.1", + "openapi-types": "^12.1.3", "prettier": "3.2.5", "supertest": "^7.0.0", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "ts-jest": "^29.1.4", "typescript-eslint": "^8.29.1" }, diff --git a/src/app.ts b/src/app.ts index 6695a4f6..615e18f1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,4 @@ -import express, { Request, Response } from "express"; +import express, { Request, Response, RequestHandler } from "express"; import { StatusCodes } from "http-status-codes"; import { Config, EnvironmentEnum } from "./config"; import { isTest } from "./utilities"; @@ -32,10 +32,82 @@ import leaderboardRouter from "./services/leaderboard/leaderboard-router"; import cors from "cors"; import { JwtPayloadValidator } from "./services/auth/auth-models"; +import swaggerJsdoc from "swagger-jsdoc"; +import { registry } from "./middleware/openapi-registry"; +import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; +import swaggerUi from "swagger-ui-express"; import { createServer } from "http"; import { WebSocketServer } from "ws"; +import path from "path"; const app = express(); + +// generate openapi schemas from zod +const generator = new OpenApiGeneratorV3(registry.definitions); +const openApiComponents = generator.generateComponents(); + +// set up swagger-ui docs +const swaggerOptions: swaggerJsdoc.Options = { + definition: { + openapi: "3.0.0", + info: { + title: "R|P API", + version: "1.0.0", + description: "Documentation for the Reflections|Projections API", + }, + // configures the "Authorize" button for JWTs + components: { + ...openApiComponents.components, + securitySchemes: { + USER: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + }, + STAFF: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "Requires the 'staff' role in the JWT payload", + }, + ADMIN: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "Requires the 'admin' role in the JWT payload", + }, + }, + }, + // sets default needed authorization for endpoints (in docs) + security: [ + { + bearerAuth: [], + }, + ], + }, + // Tells Swagger to look for JSDoc comments in all TypeScript files inside your services folder + apis: [path.join(__dirname, "./services/**/*-router.ts")], +}; + +// console.log("Swagger scanned APIs:", swaggerOptions.apis); + +const swaggerSpec = swaggerJsdoc(swaggerOptions); + +app.use( + "/docs", + swaggerUi.serve as unknown as RequestHandler, + swaggerUi.setup(swaggerSpec) as unknown as RequestHandler +); + +// do we only want to serve docs in development (in that case wrap the whole thing to avoid generating schemas etc) +// if (Config.ENV !== EnvironmentEnum.PRODUCTION) { +// app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +// app.get("/docs.json", (req, res) => { +// res.setHeader("Content-Type", "application/json"); +// res.send(swaggerSpec); +// }); +// } + const server = createServer(app); const wss = new WebSocketServer({ server }); diff --git a/src/middleware/openapi-registry.ts b/src/middleware/openapi-registry.ts new file mode 100644 index 00000000..582c3917 --- /dev/null +++ b/src/middleware/openapi-registry.ts @@ -0,0 +1,38 @@ +import { + extendZodWithOpenApi, + OpenAPIRegistry, +} from "@asteasolutions/zod-to-openapi"; +import z from "zod"; +// add .openapi to zod objects +extendZodWithOpenApi(z); +// create an openapi registry to register schemas to +export const registry = new OpenAPIRegistry(); + +// add an error type to the registry +export const ErrorSchema = registry.register( + "Error", + z + .object({ + error: z.string(), + }) + .openapi("Error") +); + +// const makeError = (code: string, example?: string) => +// ErrorSchema.extend({ +// error: z.literal(code), +// }).openapi({ +// example: { error: example ?? code }, +// }); + +// registry.register("DoesNotExistError", makeError("DoesNotExist")); +// registry.register("UserNotFoundError", makeError("UserNotFound")); +// registry.register("EventNotFoundError", makeError("EventNotFound")); +// registry.register( +// "TierAlreadyRedeemedError", +// makeError("Tier already redeemed") +// ); +// registry.register( +// "UserTierTooLowError", +// makeError("User tier too low for redemption") +// ); diff --git a/src/services/attendee/attendee-router.ts b/src/services/attendee/attendee-router.ts index 8e3b0a8d..524a81fe 100644 --- a/src/services/attendee/attendee-router.ts +++ b/src/services/attendee/attendee-router.ts @@ -24,6 +24,46 @@ const TIER_HIERARCHY = { [Tiers.Enum.TIER4]: 4, } as const; +/** + * @swagger + * /attendee/favorites/{eventId}: + * post: + * summary: Favorite an event + * description: | + * Adds an event to the authenticated attendee's favorites list and subscribes + * their device to the event's notification topic (if a device is registered). + * + * **Required roles: USER** + * tags: [Attendee] + * parameters: + * - name: eventId + * in: path + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Updated favorites list + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeFavoritesUpdateResponse' + * 404: + * description: Attendee or event not found + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * - $ref: '#/components/schemas/Error' + * example: + * error: "EventNotFound" + * security: + * - bearerAuth: [] + */ // Favorite an event for an attendee attendeeRouter.post( "/favorites/:eventId", @@ -86,6 +126,46 @@ attendeeRouter.post( } ); +/** + * @swagger + * /attendee/favorites/{eventId}: + * delete: + * summary: Unfavorite an event + * description: | + * Removes an event from the authenticated attendee's favorites list and + * unsubscribes their device from the event's notification topic (if registered). + * + * **Required roles: USER** + * tags: [Attendee] + * parameters: + * - name: eventId + * in: path + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Updated favorites list + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeFavoritesUpdateResponse' + * 404: + * description: Attendee or event not found + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * - $ref: '#/components/schemas/Error' + * example: + * error: "EventNotFound" + * security: + * - bearerAuth: [] + */ // Unfavorite an event for an attendee attendeeRouter.delete( "/favorites/:eventId", @@ -148,6 +228,34 @@ attendeeRouter.delete( } ); +/** + * @swagger + * /attendee/favorites: + * get: + * summary: Get favorited events + * description: | + * Returns the authenticated attendee's list of favorited event IDs. + * + * **Required roles: USER** + * tags: [Attendee] + * responses: + * 200: + * description: The attendee's favorited events + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeFavoritesView' + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ // Get favorite events for an attendee attendeeRouter.get( "/favorites", @@ -176,6 +284,27 @@ attendeeRouter.get( } ); +/** + * @swagger + * /attendee/qr/: + * get: + * summary: Get attendee QR code + * description: | + * Generates a time-limited (20-second) QR code hash for the authenticated attendee, + * used for check-in scanning. + * + * **Required roles: USER** + * tags: [Attendee] + * responses: + * 200: + * description: A short-lived QR code string + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeQrResponse' + * security: + * - bearerAuth: [] + */ // generates a unique QR code for each attendee attendeeRouter.get("/qr/", RoleChecker([Role.Enum.USER]), async (req, res) => { const payload = res.locals.payload; @@ -186,6 +315,34 @@ attendeeRouter.get("/qr/", RoleChecker([Role.Enum.USER]), async (req, res) => { return res.status(StatusCodes.OK).json({ qrCode: qrCodeString }); }); +/** + * @swagger + * /attendee/points: + * get: + * summary: Get attendee points + * description: | + * Returns the current point total for the authenticated attendee. + * + * **Required roles: USER** + * tags: [Attendee] + * responses: + * 200: + * description: The attendee's current points + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeePointsResponse' + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ attendeeRouter.get( "/points", RoleChecker([Role.Enum.USER]), @@ -210,6 +367,35 @@ attendeeRouter.get( } ); +/** + * @swagger + * /attendee/foodwave: + * get: + * summary: Get attendee food wave + * description: | + * Returns which meal wave (1 = priority, 2 = general) the authenticated attendee + * belongs to, based on priority flags and dietary restrictions. + * + * **Required roles: USER** + * tags: [Attendee] + * responses: + * 200: + * description: The attendee's food wave assignment + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeFoodwaveResponse' + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ attendeeRouter.get( "/foodwave", RoleChecker([Role.Enum.USER]), @@ -249,6 +435,34 @@ attendeeRouter.get( } ); +/** + * @swagger + * /attendee/: + * get: + * summary: Get current attendee profile + * description: | + * Returns the full profile record for the authenticated attendee. + * + * **Required roles: USER** + * tags: [Attendee] + * responses: + * 200: + * description: The attendee's profile + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeView' + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ attendeeRouter.get("/", RoleChecker([Role.Enum.USER]), async (req, res) => { const payload = res.locals.payload; const userId = payload.userId; @@ -268,6 +482,40 @@ attendeeRouter.get("/", RoleChecker([Role.Enum.USER]), async (req, res) => { return res.status(StatusCodes.OK).json(user); }); +/** + * @swagger + * /attendee/id/{userId}: + * get: + * summary: Get attendee by user ID + * description: | + * Returns the full profile record for any attendee by their user ID. + * + * **Required roles: STAFF | ADMIN** + * tags: [Attendee] + * parameters: + * - name: userId + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The requested attendee's profile + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeView' + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ // Get attendee info via user_id attendeeRouter.get( "/id/:userId", @@ -290,6 +538,28 @@ attendeeRouter.get( } ); +/** + * @swagger + * /attendee/emails: + * get: + * summary: Get all attendee emails + * description: | + * Returns a list of email, userId, and name for all registered attendees. + * + * **Required roles: STAFF | ADMIN** + * tags: [Attendee] + * responses: + * 200: + * description: List of attendee email records + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/AttendeeEmailEntry' + * security: + * - bearerAuth: [] + */ attendeeRouter.get( "/emails", RoleChecker([Role.Enum.STAFF, Role.Enum.ADMIN]), @@ -302,6 +572,41 @@ attendeeRouter.get( } ); +/** + * @swagger + * /attendee/redeemable/{userId}: + * get: + * summary: Get redeemable merchandise tiers for an attendee + * description: | + * Returns the attendee's current tier, already-redeemed tiers, and tiers + * still available to redeem. + * + * **Required roles: STAFF | ADMIN** + * tags: [Attendee] + * parameters: + * - name: userId + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Redeemable tier information for the attendee + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeRedeemableView' + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ attendeeRouter.get( "/redeemable/:userId", RoleChecker([Role.Enum.STAFF, Role.Enum.ADMIN]), @@ -342,6 +647,54 @@ attendeeRouter.get( } ); +/** + * @swagger + * /attendee/redeem: + * post: + * summary: Redeem a merchandise tier for an attendee + * description: | + * Marks a merchandise tier as redeemed for the specified attendee. + * Fails if the tier has already been redeemed or the attendee's current + * tier is too low. + * + * **Required roles: STAFF | ADMIN** + * tags: [Attendee] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeRedeemMerchValidator' + * responses: + * 200: + * description: Tier redeemed successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeRedeemResponse' + * 400: + * description: Redemption not allowed + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/Error' + * example: + * error: "TierAlreadyRedeemed" + * - $ref: '#/components/schemas/Error' + * example: + * error: "UserTierTooLow" + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ attendeeRouter.post( "/redeem", RoleChecker([Role.Enum.STAFF, Role.Enum.ADMIN]), @@ -394,6 +747,40 @@ attendeeRouter.post( } ); +/** + * @swagger + * /attendee/icon: + * patch: + * summary: Update attendee icon color + * description: | + * Updates the icon color for the authenticated attendee. + * + * **Required roles: USER** + * tags: [Attendee] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeIconUpdateValidator' + * responses: + * 200: + * description: Updated icon color + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeIconResponse' + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ // Update attendee icon attendeeRouter.patch( "/icon", @@ -425,6 +812,41 @@ attendeeRouter.patch( } ); +/** + * @swagger + * /attendee/tags: + * patch: + * summary: Update attendee interest tags + * description: | + * Replaces the authenticated attendee's interest tags and syncs Firebase + * notification topic subscriptions accordingly. + * + * **Required roles: USER** + * tags: [Attendee] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeTagsUpdateValidator' + * responses: + * 200: + * description: Updated tags list + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeeTagsResponse' + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ // Update attendee tags attendeeRouter.patch( "/tags", @@ -499,6 +921,40 @@ attendeeRouter.patch( } ); +/** + * @swagger + * /attendee/addPoints: + * patch: + * summary: Add points to an attendee + * description: | + * Adds a specified number of points to any attendee's total. + * + * **Required roles: ADMIN** + * tags: [Attendee] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeePointsUpdateValidator' + * responses: + * 200: + * description: Updated point total + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AttendeePointsResponse' + * 404: + * description: Attendee not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ // Custom add points to attendee // Request body: { userId: string, pointsToAdd: number } attendeeRouter.patch( diff --git a/src/services/attendee/attendee-schema.ts b/src/services/attendee/attendee-schema.ts index 8b936e46..f84b8f34 100644 --- a/src/services/attendee/attendee-schema.ts +++ b/src/services/attendee/attendee-schema.ts @@ -2,6 +2,7 @@ import { Schema } from "mongoose"; import { TierType, IconColorType } from "../../database"; import { Database } from "../../database.types"; import { z } from "zod"; +import { registry } from "../../middleware/openapi-registry"; export type DayKey = "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun"; @@ -9,12 +10,12 @@ export type DayKey = "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun"; export type AttendeeType = Database["public"]["Tables"]["attendees"]["Row"]; // Zod enums for runtime validation and .Enum access -export const Tiers = z.enum([ - "TIER1", - "TIER2", - "TIER3", - "TIER4", -]) satisfies z.ZodEnum<[TierType, ...TierType[]]>; +export const Tiers = registry.register( + "Tiers", + z + .enum(["TIER1", "TIER2", "TIER3", "TIER4"]) + .openapi("Tiers", { description: "Attendee merchandise tier" }) +) satisfies z.ZodEnum<[TierType, ...TierType[]]>; export const IconColors = z.enum([ "BLUE", @@ -25,6 +26,103 @@ export const IconColors = z.enum([ "ORANGE", ] as const) satisfies z.ZodEnum<[IconColorType, ...IconColorType[]]>; +// sub-schemas +const HasPriorityDaysView = z.object({ + Mon: z.boolean().default(false), + Tue: z.boolean().default(false), + Wed: z.boolean().default(false), + Thu: z.boolean().default(false), + Fri: z.boolean().default(false), + Sat: z.boolean().default(false), + Sun: z.boolean().default(false), +}); + +const MerchView = z.object({ + Tshirt: z.boolean(), + Button: z.boolean(), + Tote: z.boolean(), + Cap: z.boolean(), +}); + +// Main Attendee schema +export const AttendeeView = registry.register( + "AttendeeView", + z + .object({ + userId: z.string(), + name: z.string(), + email: z.string().email(), + events: z.array(z.string()).default([]), + dietaryRestrictions: z.array(z.string()), + allergies: z.array(z.string()), + points: z.number().default(0), + hasPriority: HasPriorityDaysView.default({ + Mon: false, + Tue: false, + Wed: false, + Thu: false, + Fri: false, + Sat: false, + Sun: false, + }), + hasRedeemedMerch: MerchView.default({ + Tshirt: false, + Button: false, + Tote: false, + Cap: false, + }), + isEligibleMerch: MerchView.default({ + Tshirt: true, + Button: false, + Tote: false, + Cap: false, + }), + favorites: z.array(z.string()).default([]), + puzzlesCompleted: z.array(z.string()).default([]), + }) + .openapi("AttendeeView", { + example: { + userId: "user_12345", + name: "Alice Johnson", + email: "alice@example.com", + + events: ["event_1", "event_2"], + + dietaryRestrictions: ["vegetarian"], + allergies: ["peanuts"], + + points: 120, + + hasPriority: { + Mon: true, + Tue: false, + Wed: false, + Thu: true, + Fri: false, + Sat: false, + Sun: false, + }, + + hasRedeemedMerch: { + Tshirt: true, + Button: false, + Tote: false, + Cap: false, + }, + + isEligibleMerch: { + Tshirt: true, + Button: true, + Tote: false, + Cap: false, + }, + + favorites: ["event_3", "event_7"], + puzzlesCompleted: ["puzzle_1", "puzzle_2"], + }, + }) +); + // Mongoose schema for attendee export const AttendeeSchema = new Schema({ userId: { type: String, required: true, unique: true }, diff --git a/src/services/attendee/attendee-validators.ts b/src/services/attendee/attendee-validators.ts index 088fbcb6..46e0ed0a 100644 --- a/src/services/attendee/attendee-validators.ts +++ b/src/services/attendee/attendee-validators.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { IconColorType } from "../../database"; import { Tiers } from "./attendee-schema"; +import { registry } from "../../middleware/openapi-registry"; // Zod schema for attendee export const AttendeeCreateValidator = z.object({ @@ -8,10 +9,17 @@ export const AttendeeCreateValidator = z.object({ tags: z.array(z.string()), }); -export const AttendeeRedeemMerchValidator = z.object({ - userId: z.string(), - tier: Tiers, -}); +export const AttendeeRedeemMerchValidator = registry.register( + "AttendeeRedeemMerchValidator", + z + .object({ + userId: z.string(), + tier: Tiers, + }) + .openapi("AttendeeRedeemMerchValidator", { + example: { userId: "abc123", tier: "TIER1" }, + }) +); export const EventIdValidator = z.object({ eventId: z.string().uuid(), @@ -26,15 +34,166 @@ const IconColorEnumValues: [IconColorType, ...IconColorType[]] = [ "ORANGE", ]; -export const AttendeeIconUpdateValidator = z.object({ - icon: z.enum(IconColorEnumValues), -}); +export const AttendeeIconUpdateValidator = registry.register( + "AttendeeIconUpdateValidator", + z + .object({ + icon: z.enum(IconColorEnumValues), + }) + .openapi("AttendeeIconUpdateValidator", { + example: { icon: "BLUE" }, + }) +); -export const AttendeeTagsUpdateValidator = z.object({ - tags: z.array(z.string()), -}); +export const AttendeeTagsUpdateValidator = registry.register( + "AttendeeTagsUpdateValidator", + z + .object({ + tags: z.array(z.string()), + }) + .openapi("AttendeeTagsUpdateValidator", { + example: { tags: ["AI", "Systems"] }, + }) +); -export const AttendeePointsUpdateValidator = z.object({ - userId: z.string(), - pointsToAdd: z.number().int().min(1), -}); +export const AttendeePointsUpdateValidator = registry.register( + "AttendeePointsUpdateValidator", + z + .object({ + userId: z.string(), + pointsToAdd: z.number().int().min(1), + }) + .openapi("AttendeePointsUpdateValidator", { + example: { userId: "abc123", pointsToAdd: 10 }, + }) +); + +// Response schemas + +export const AttendeeFavoritesView = registry.register( + "AttendeeFavoritesView", + z + .object({ + userId: z.string(), + favoriteEvents: z.array(z.string()), + }) + .openapi("AttendeeFavoritesView", { + example: { + userId: "abc123", + favoriteEvents: ["3a72d491-c2f9-4baf-af5a-55713621d978"], + }, + }) +); + +export const AttendeeFavoritesUpdateResponse = registry.register( + "AttendeeFavoritesUpdateResponse", + z + .object({ + favorites: z.array(z.string()), + }) + .openapi("AttendeeFavoritesUpdateResponse", { + example: { favorites: ["3a72d491-c2f9-4baf-af5a-55713621d978"] }, + }) +); + +export const AttendeeQrResponse = registry.register( + "AttendeeQrResponse", + z + .object({ + qrCode: z.string(), + }) + .openapi("AttendeeQrResponse", { + example: { qrCode: "abc123:1711483200" }, + }) +); + +export const AttendeePointsResponse = registry.register( + "AttendeePointsResponse", + z + .object({ + points: z.number(), + }) + .openapi("AttendeePointsResponse", { example: { points: 42 } }) +); + +export const AttendeeFoodwaveResponse = registry.register( + "AttendeeFoodwaveResponse", + z + .object({ + foodwave: z.number().int().min(1).max(2), + }) + .openapi("AttendeeFoodwaveResponse", { example: { foodwave: 1 } }) +); + +export const AttendeeEmailEntry = registry.register( + "AttendeeEmailEntry", + z + .object({ + email: z.string(), + userId: z.string(), + name: z.string(), + }) + .openapi("AttendeeEmailEntry", { + example: { + email: "jane@example.com", + userId: "abc123", + name: "Jane Doe", + }, + }) +); + +export const AttendeeRedeemableView = registry.register( + "AttendeeRedeemableView", + z + .object({ + userId: z.string(), + currentTier: Tiers, + redeemedTiers: z.array(Tiers), + redeemableTiers: z.array(Tiers), + }) + .openapi("AttendeeRedeemableView", { + example: { + userId: "abc123", + currentTier: "TIER2", + redeemedTiers: ["TIER1"], + redeemableTiers: ["TIER2"], + }, + }) +); + +export const AttendeeRedeemResponse = registry.register( + "AttendeeRedeemResponse", + z + .object({ + message: z.string(), + userId: z.string(), + tier: Tiers, + }) + .openapi("AttendeeRedeemResponse", { + example: { + message: "Tier redeemed successfully!", + userId: "abc123", + tier: "TIER1", + }, + }) +); + +export const AttendeeIconResponse = registry.register( + "AttendeeIconResponse", + z + .object({ + icon: z.enum(IconColorEnumValues), + }) + .openapi("AttendeeIconResponse", { example: { icon: "BLUE" } }) +); + +export const AttendeeTagsResponse = registry.register( + "AttendeeTagsResponse", + z + .object({ + tags: z.array(z.string()), + }) + .openapi("AttendeeTagsResponse", { + example: { tags: ["AI", "Systems"] }, + }) +); diff --git a/src/services/auth/auth-router.ts b/src/services/auth/auth-router.ts index f6c0a6a3..c4ad1f68 100644 --- a/src/services/auth/auth-router.ts +++ b/src/services/auth/auth-router.ts @@ -35,6 +35,40 @@ const oauthClients = { authRouter.use("/sponsor", authSponsorRouter); +/** + * @swagger + * /auth/: + * delete: + * summary: Remove a role from a user + * description: | + * Removes a specific role from the specified user. + * + * **Required roles: SUPER_ADMIN** + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthRoleChangeRequest' + * responses: + * 200: + * description: The deleted role record + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthRoleView' + * 404: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ // Remove role from userId (super admin only endpoint) authRouter.delete( "/", @@ -65,7 +99,40 @@ authRouter.delete( } ); -// Add role to userId (super admin only endpoint) +/** + * @swagger + * /auth/: + * put: + * summary: Add a role to a user + * description: | + * Adds (or upserts) a specific role for the specified user. + * + * **Required roles: SUPER_ADMIN** + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthRoleChangeRequest' + * responses: + * 200: + * description: The upserted role record + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthRoleView' + * 404: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ authRouter.put("/", RoleChecker([Role.Enum.SUPER_ADMIN]), async (req, res) => { const { userId, role } = AuthRoleChangeRequest.parse(req.body); @@ -122,6 +189,48 @@ const getAuthPayloadFromCode = async ( } }; +/** + * @swagger + * /auth/login/{PLATFORM}: + * post: + * summary: Log in with Google OAuth + * description: | + * Exchanges a Google OAuth authorization code for a signed JWT. + * The request body shape varies by platform: web omits `codeVerifier`, + * iOS and Android require it. + * + * **Required roles: none** + * tags: [Auth] + * parameters: + * - name: PLATFORM + * in: path + * required: true + * schema: + * type: string + * enum: [WEB, IOS, ANDROID] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthLoginValidator' + * responses: + * 200: + * description: A signed JWT for the authenticated user + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthJwtResponse' + * 400: + * description: Login failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "InvalidToken" + * security: [] + */ authRouter.post("/login/:PLATFORM", async (req, res) => { try { const validatedData = AuthLoginValidator.parse({ @@ -170,6 +279,28 @@ authRouter.post("/login/:PLATFORM", async (req, res) => { } }); +/** + * @swagger + * /auth/corporate: + * get: + * summary: Get all corporate sponsors + * description: | + * Returns all registered corporate sponsor records. + * + * **Required roles: ADMIN** + * tags: [Auth] + * responses: + * 200: + * description: List of corporate sponsor records + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/CorporateValidator' + * security: + * - bearerAuth: [] + */ authRouter.get( "/corporate", RoleChecker([Role.Enum.ADMIN]), @@ -180,6 +311,40 @@ authRouter.get( } ); +/** + * @swagger + * /auth/corporate: + * post: + * summary: Add a corporate sponsor + * description: | + * Creates a new corporate sponsor record. + * + * **Required roles: ADMIN** + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CorporateValidator' + * responses: + * 201: + * description: The newly created corporate sponsor + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CorporateValidator' + * 400: + * description: Sponsor already exists + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "AlreadyExists" + * security: + * - bearerAuth: [] + */ authRouter.post( "/corporate", RoleChecker([Role.Enum.ADMIN]), @@ -202,6 +367,36 @@ authRouter.post( } ); +/** + * @swagger + * /auth/corporate: + * delete: + * summary: Remove a corporate sponsor + * description: | + * Deletes a corporate sponsor record by email. + * + * **Required roles: ADMIN** + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CorporateDeleteRequest' + * responses: + * 204: + * description: Sponsor successfully deleted + * 400: + * description: Sponsor not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "NotFound" + * security: + * - bearerAuth: [] + */ authRouter.delete( "/corporate", RoleChecker([Role.Enum.ADMIN]), @@ -222,6 +417,26 @@ authRouter.delete( } ); +/** + * @swagger + * /auth/info: + * get: + * summary: Get current user info + * description: | + * Returns the authenticated user's profile and assigned roles. + * + * **Required roles: none** + * tags: [Auth] + * responses: + * 200: + * description: The authenticated user's profile with roles + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RoleValidator' + * security: + * - bearerAuth: [] + */ authRouter.get("/info", RoleChecker([]), async (req, res) => { const userId = res.locals.payload.userId; const { data: info } = await SupabaseDB.AUTH_INFO.select() @@ -238,6 +453,37 @@ authRouter.get("/info", RoleChecker([]), async (req, res) => { return res.status(StatusCodes.OK).json(user); }); +/** + * @swagger + * /auth/team: + * get: + * summary: Get all staff and admin team members + * description: | + * Returns all users who have been assigned the STAFF or ADMIN role, + * including their full profile and roles list. + * + * **Required roles: ADMIN** + * tags: [Auth] + * responses: + * 200: + * description: List of team members with roles + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/RoleValidator' + * 500: + * description: Failed to fetch team members + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Failed to fetch team members" + * security: + * - bearerAuth: [] + */ // Get team members (users with STAFF or ADMIN roles) authRouter.get("/team", RoleChecker([Role.Enum.ADMIN]), async (req, res) => { try { @@ -282,6 +528,27 @@ authRouter.get("/team", RoleChecker([Role.Enum.ADMIN]), async (req, res) => { } }); +/** + * @swagger + * /auth/staff: + * get: + * summary: Get all staff user IDs + * description: | + * Returns a list of user IDs for all users with the STAFF role. + * Intended for resume book access by corporate sponsors. + * + * **Required roles: CORPORATE | STAFF** + * tags: [Auth] + * responses: + * 200: + * description: List of staff user IDs + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UserIdsResponse' + * security: + * - bearerAuth: [] + */ // Get staff user ids for resume book authRouter.get( "/staff", @@ -295,6 +562,32 @@ authRouter.get( } ); +/** + * @swagger + * /auth/{ROLE}: + * get: + * summary: Get user IDs by role + * description: | + * Returns a list of user IDs for all users assigned the specified role. + * + * **Required roles: STAFF** + * tags: [Auth] + * parameters: + * - name: ROLE + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: List of user IDs with the given role + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UserIdsResponse' + * security: + * - bearerAuth: [] + */ // Get a list of user ids by role (staff only endpoint) authRouter.get("/:ROLE", RoleChecker([Role.Enum.STAFF]), async (req, res) => { // Validate the role using Zod schema diff --git a/src/services/auth/auth-schema.ts b/src/services/auth/auth-schema.ts index 57f3aa7c..7b73e026 100644 --- a/src/services/auth/auth-schema.ts +++ b/src/services/auth/auth-schema.ts @@ -2,37 +2,98 @@ import { Schema } from "mongoose"; import { z } from "zod"; import { Platform, Role } from "./auth-models"; import { Database } from "../../database.types"; +import { registry } from "../../middleware/openapi-registry"; -export const RoleValidator = z.object({ - userId: z.coerce.string(), - displayName: z.coerce.string(), - email: z.coerce.string().email(), - roles: z.array(Role).default([]), -}); +export const RoleValidator = registry.register( + "RoleValidator", + z + .object({ + userId: z.coerce.string(), + displayName: z.coerce.string(), + email: z.coerce.string().email(), + roles: z.array(Role).default([]), + }) + .openapi("RoleValidator", { + example: { + userId: "abc123", + displayName: "Jane Doe", + email: "jane@example.com", + roles: ["USER"], + }, + }) +); -export const AuthLoginValidator = z.union([ - // Web platform - no codeVerifier needed - z.object({ - code: z.string(), - redirectUri: z.string(), - platform: z.literal(Platform.WEB), - }), - // iOS/Android - codeVerifier is required - z.object({ - code: z.string(), - redirectUri: z.string(), - codeVerifier: z.string(), - platform: z.union([ - z.literal(Platform.IOS), - z.literal(Platform.ANDROID), - ]), - }), -]); +export const AuthLoginValidator = registry.register( + "AuthLoginValidator", + z + .union([ + // Web platform - no codeVerifier needed + z.object({ + code: z.string(), + redirectUri: z.string(), + platform: z.literal(Platform.WEB), + }), + // iOS/Android - codeVerifier is required + z.object({ + code: z.string(), + redirectUri: z.string(), + codeVerifier: z.string(), + platform: z.union([ + z.literal(Platform.IOS), + z.literal(Platform.ANDROID), + ]), + }), + ]) + .openapi("AuthLoginValidator", { + description: + "Login request body. Web omits codeVerifier; iOS/Android require it.", + example: { + code: "4/0AfJohXk...", + redirectUri: "https://app.example.com/auth/callback", + platform: Platform.WEB, + }, + }) +); -export const AuthRoleChangeRequest = z.object({ - userId: z.string(), - role: Role, -}); +export const AuthRoleChangeRequest = registry.register( + "AuthRoleChangeRequest", + z + .object({ + userId: z.string(), + role: Role, + }) + .openapi("AuthRoleChangeRequest", { + example: { userId: "abc123", role: "STAFF" }, + }) +); + +// Response schemas +export const AuthJwtResponse = registry.register( + "AuthJwtResponse", + z.object({ token: z.string() }).openapi("AuthJwtResponse", { + example: { token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }, + }) +); + +export const AuthRoleView = registry.register( + "AuthRoleView", + z + .object({ + userId: z.string(), + role: Role, + }) + .openapi("AuthRoleView", { + example: { userId: "abc123", role: "STAFF" }, + }) +); + +export const UserIdsResponse = registry.register( + "UserIdsResponse", + z.array(z.string()).openapi("UserIdsResponse", { + description: "List of user IDs", + example: ["abc123", "def456"], + }) +); export const RoleSchema = new Schema( { diff --git a/src/services/auth/corporate-schema.ts b/src/services/auth/corporate-schema.ts index 2e9aa9da..7356d552 100644 --- a/src/services/auth/corporate-schema.ts +++ b/src/services/auth/corporate-schema.ts @@ -1,16 +1,31 @@ import { InferSchemaType, Schema } from "mongoose"; import { z } from "zod"; +import { registry } from "../../middleware/openapi-registry"; // Zod schema -export const CorporateValidator = z.object({ - name: z.string(), - email: z.string(), -}); +export const CorporateValidator = registry.register( + "CorporateValidator", + z + .object({ + name: z.string(), + email: z.string(), + }) + .openapi("CorporateValidator", { + example: { name: "Acme Corp", email: "sponsor@acme.com" }, + }) +); // Zod schema -export const CorporateDeleteRequest = z.object({ - email: z.string(), -}); +export const CorporateDeleteRequest = registry.register( + "CorporateDeleteRequest", + z + .object({ + email: z.string(), + }) + .openapi("CorporateDeleteRequest", { + example: { email: "sponsor@acme.com" }, + }) +); // Mongoose schema export const CorporateSchema = new Schema({ diff --git a/src/services/auth/sponsor/sponsor-router.ts b/src/services/auth/sponsor/sponsor-router.ts index 9094fefa..2666446a 100644 --- a/src/services/auth/sponsor/sponsor-router.ts +++ b/src/services/auth/sponsor/sponsor-router.ts @@ -17,6 +17,30 @@ import { SupabaseDB } from "../../../database"; const authSponsorRouter = Router(); +/** + * @swagger + * /auth/sponsor/login: + * post: + * summary: Request a sponsor verification code + * description: | + * Sends a 6-digit email verification code to the given corporate sponsor + * email address. The email must already exist in the corporate sponsors list. + * + * **Required roles: none** + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthSponsorLoginValidator' + * responses: + * 201: + * description: Verification code sent successfully + * 401: + * description: Email not found in corporate sponsors list + * security: [] + */ authSponsorRouter.post("/login", async (req, res) => { const { email } = AuthSponsorLoginValidator.parse(req.body); const { data: existing } = await SupabaseDB.CORPORATE.select() @@ -51,6 +75,40 @@ authSponsorRouter.post("/login", async (req, res) => { return res.sendStatus(StatusCodes.CREATED); }); +/** + * @swagger + * /auth/sponsor/verify: + * post: + * summary: Verify a sponsor code and receive a JWT + * description: | + * Verifies the 6-digit code sent to the sponsor's email. Returns a signed + * JWT with the CORPORATE role on success. + * + * **Required roles: none** + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthSponsorVerifyValidator' + * responses: + * 200: + * description: A signed JWT with the CORPORATE role + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthJwtResponse' + * 401: + * description: Invalid or expired verification code + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "InvalidCode" + * security: [] + */ authSponsorRouter.post("/verify", async (req, res) => { const { email, sixDigitCode } = AuthSponsorVerifyValidator.parse(req.body); const { data: sponsorData } = await SupabaseDB.AUTH_CODES.delete() diff --git a/src/services/auth/sponsor/sponsor-schema.ts b/src/services/auth/sponsor/sponsor-schema.ts index b5dd55a0..e94d4185 100644 --- a/src/services/auth/sponsor/sponsor-schema.ts +++ b/src/services/auth/sponsor/sponsor-schema.ts @@ -1,5 +1,6 @@ import mongoose from "mongoose"; import { z } from "zod"; +import { registry } from "../../../middleware/openapi-registry"; export const SponsorAuthSchema = new mongoose.Schema({ email: { type: String, required: true, unique: true }, @@ -13,11 +14,25 @@ export const SponsorAuthValidator = z.object({ expTime: z.number().int(), }); -export const AuthSponsorLoginValidator = z.object({ - email: z.string().email(), -}); +export const AuthSponsorLoginValidator = registry.register( + "AuthSponsorLoginValidator", + z + .object({ + email: z.string().email(), + }) + .openapi("AuthSponsorLoginValidator", { + example: { email: "sponsor@acme.com" }, + }) +); -export const AuthSponsorVerifyValidator = z.object({ - email: z.string().email(), - sixDigitCode: z.string().length(6), -}); +export const AuthSponsorVerifyValidator = registry.register( + "AuthSponsorVerifyValidator", + z + .object({ + email: z.string().email(), + sixDigitCode: z.string().length(6), + }) + .openapi("AuthSponsorVerifyValidator", { + example: { email: "sponsor@acme.com", sixDigitCode: "123456" }, + }) +); diff --git a/src/services/checkin/checkin-router.ts b/src/services/checkin/checkin-router.ts index dff271e9..4144ae45 100644 --- a/src/services/checkin/checkin-router.ts +++ b/src/services/checkin/checkin-router.ts @@ -11,6 +11,49 @@ import { validateQrHash, checkInUserToEvent } from "./checkin-utils"; const checkinRouter = Router(); +/** + * @swagger + * /checkin/scan/staff: + * post: + * summary: Check in an attendee via QR code + * description: | + * Validates a time-limited QR code and checks the attendee into the + * specified event. Rejects expired codes and duplicate check-ins. + * + * **Required roles: ADMIN | STAFF** + * tags: [Checkin] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ScanValidator' + * responses: + * 200: + * description: The checked-in user's ID + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CheckinUserIdResponse' + * 401: + * description: QR code has expired + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "QR code has expired" + * 403: + * description: Attendee already checked in to this event + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "IsDuplicate" + * security: + * - bearerAuth: [] + */ checkinRouter.post( "/scan/staff", RoleChecker([Role.Enum.ADMIN, Role.Enum.STAFF]), @@ -41,6 +84,41 @@ checkinRouter.post( } ); +/** + * @swagger + * /checkin/event: + * post: + * summary: Manually check in an attendee by user ID + * description: | + * Checks an attendee into an event using their user ID directly, + * without requiring a QR code. Rejects duplicate check-ins. + * + * **Required roles: ADMIN | STAFF** + * tags: [Checkin] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EventValidator' + * responses: + * 200: + * description: The checked-in user's ID + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CheckinUserIdResponse' + * 403: + * description: Attendee already checked in to this event + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "IsDuplicate" + * security: + * - bearerAuth: [] + */ checkinRouter.post( "/event", RoleChecker([Role.Enum.ADMIN, Role.Enum.STAFF]), @@ -61,6 +139,41 @@ checkinRouter.post( } ); +/** + * @swagger + * /checkin/scan/merch: + * post: + * summary: Validate a QR code for merchandise pickup + * description: | + * Validates a time-limited QR code and returns the attendee's user ID + * for use in merchandise redemption flows. + * + * **Required roles: ADMIN | STAFF** + * tags: [Checkin] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/MerchScanValidator' + * responses: + * 200: + * description: The attendee's user ID + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CheckinUserIdResponse' + * 401: + * description: QR code has expired + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "QR code has expired" + * security: + * - bearerAuth: [] + */ checkinRouter.post( "/scan/merch", RoleChecker([Role.Enum.ADMIN, Role.Enum.STAFF]), diff --git a/src/services/checkin/checkin-schema.ts b/src/services/checkin/checkin-schema.ts index 229e88f8..67e014ee 100644 --- a/src/services/checkin/checkin-schema.ts +++ b/src/services/checkin/checkin-schema.ts @@ -1,21 +1,57 @@ import { z } from "zod"; +import { registry } from "../../middleware/openapi-registry"; export type ScanPayload = z.infer; export type MerchScanPayload = z.infer; export type CheckinEventPayload = z.infer; -const ScanValidator = z.object({ - eventId: z.string().min(1, { message: "Event ID cannot be empty" }), - qrCode: z.string().min(1, { message: "QR Code cannot be empty" }), -}); +const ScanValidator = registry.register( + "ScanValidator", + z + .object({ + eventId: z.string().min(1, { message: "Event ID cannot be empty" }), + qrCode: z.string().min(1, { message: "QR Code cannot be empty" }), + }) + .openapi("ScanValidator", { + example: { + eventId: "3a72d491-c2f9-4baf-af5a-55713621d978", + qrCode: "abc123:1711483200", + }, + }) +); -const MerchScanValidator = z.object({ - qrCode: z.string().min(1, { message: "QR Code cannot be empty" }), -}); +const MerchScanValidator = registry.register( + "MerchScanValidator", + z + .object({ + qrCode: z.string().min(1, { message: "QR Code cannot be empty" }), + }) + .openapi("MerchScanValidator", { + example: { qrCode: "abc123:1711483200" }, + }) +); -const EventValidator = z.object({ - eventId: z.string().min(1, { message: "Event ID cannot be empty" }), - userId: z.string().min(1, { message: "User ID cannot be empty" }), -}); +const EventValidator = registry.register( + "EventValidator", + z + .object({ + eventId: z.string().min(1, { message: "Event ID cannot be empty" }), + userId: z.string().min(1, { message: "User ID cannot be empty" }), + }) + .openapi("EventValidator", { + example: { + eventId: "3a72d491-c2f9-4baf-af5a-55713621d978", + userId: "user_abc123", + }, + }) +); + +export const CheckinUserIdResponse = registry.register( + "CheckinUserIdResponse", + z.string().openapi("CheckinUserIdResponse", { + description: "The checked-in user's ID", + example: "user_abc123", + }) +); export { ScanValidator, MerchScanValidator, EventValidator }; diff --git a/src/services/dashboard/dashboard-router.ts b/src/services/dashboard/dashboard-router.ts index bbadd03d..77ba21ec 100644 --- a/src/services/dashboard/dashboard-router.ts +++ b/src/services/dashboard/dashboard-router.ts @@ -71,6 +71,28 @@ export function handleWs(ws: WebSocket) { pingForUpdate(); } +/** + * @swagger + * /dashboard/: + * get: + * summary: Get all connected displays + * description: | + * Returns metadata for all currently connected dashboard displays. + * + * **Required roles: ADMIN** + * tags: [Dashboard] + * responses: + * 200: + * description: List of connected displays + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/DisplaySchema' + * security: + * - bearerAuth: [] + */ dashboardRouter.get("/", RoleChecker([Role.Enum.ADMIN]), (req, res) => { // Displays can contain gaps - this endpoint just returns each display const displaysWithoutSpaces = displays.filter((display) => display); @@ -108,6 +130,26 @@ function send(message: object | ((id: number) => object)) { }; } +/** + * @swagger + * /dashboard/identify: + * post: + * summary: Broadcast identify message to all displays + * description: | + * Sends each display its own numeric ID so it can render it on screen. + * + * **Required roles: ADMIN** + * tags: [Dashboard] + * responses: + * 200: + * description: IDs of all displays the message was sent to + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardSentToResponse' + * security: + * - bearerAuth: [] + */ dashboardRouter.post( "/identify", RoleChecker([Role.Enum.ADMIN]), @@ -116,6 +158,40 @@ dashboardRouter.post( message: id.toString(), })) ); +/** + * @swagger + * /dashboard/identify/{id}: + * post: + * summary: Send identify message to a specific display + * description: | + * Sends the specified display its numeric ID so it can render it on screen. + * + * **Required roles: ADMIN** + * tags: [Dashboard] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: ID of the display the message was sent to + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardSentToResponse' + * 404: + * description: No display found with the given ID + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "NotFound" + * security: + * - bearerAuth: [] + */ dashboardRouter.post( "/identify/:id", RoleChecker([Role.Enum.ADMIN]), @@ -125,17 +201,97 @@ dashboardRouter.post( })) ); +/** + * @swagger + * /dashboard/reload: + * post: + * summary: Broadcast reload to all displays + * description: | + * Instructs all connected displays to reload. + * + * **Required roles: ADMIN** + * tags: [Dashboard] + * responses: + * 200: + * description: IDs of all displays that were reloaded + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardSentToResponse' + * security: + * - bearerAuth: [] + */ dashboardRouter.post( "/reload", RoleChecker([Role.Enum.ADMIN]), send({ type: "reload" }) ); +/** + * @swagger + * /dashboard/reload/{id}: + * post: + * summary: Reload a specific display + * description: | + * Instructs the specified display to reload. + * + * **Required roles: ADMIN** + * tags: [Dashboard] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: ID of the display that was reloaded + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardSentToResponse' + * 404: + * description: No display found with the given ID + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "NotFound" + * security: + * - bearerAuth: [] + */ dashboardRouter.post( "/reload/:id", RoleChecker([Role.Enum.ADMIN]), send({ type: "reload" }) ); +/** + * @swagger + * /dashboard/message: + * post: + * summary: Broadcast a message to all displays + * description: | + * Sends a text message or URL to all connected displays. + * + * **Required roles: ADMIN** + * tags: [Dashboard] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardMessageValidator' + * responses: + * 200: + * description: IDs of all displays the message was sent to + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardSentToResponse' + * security: + * - bearerAuth: [] + */ dashboardRouter.post("/message", RoleChecker([Role.Enum.ADMIN]), (req, res) => { const message = DashboardMessageValidator.parse(req.body); return send({ @@ -143,6 +299,46 @@ dashboardRouter.post("/message", RoleChecker([Role.Enum.ADMIN]), (req, res) => { ...message, })(req, res); }); +/** + * @swagger + * /dashboard/message/{id}: + * post: + * summary: Send a message to a specific display + * description: | + * Sends a text message or URL to the specified display. + * + * **Required roles: ADMIN** + * tags: [Dashboard] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardMessageValidator' + * responses: + * 200: + * description: ID of the display the message was sent to + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DashboardSentToResponse' + * 404: + * description: No display found with the given ID + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "NotFound" + * security: + * - bearerAuth: [] + */ dashboardRouter.post( "/message/:id", RoleChecker([Role.Enum.ADMIN]), diff --git a/src/services/dashboard/dashboard-schema.ts b/src/services/dashboard/dashboard-schema.ts index 8c4e3951..81d5e070 100644 --- a/src/services/dashboard/dashboard-schema.ts +++ b/src/services/dashboard/dashboard-schema.ts @@ -1,32 +1,85 @@ import { z } from "zod"; +import { registry } from "../../middleware/openapi-registry"; -export const DisplayMetadataSchema = z.object({ - screenWidth: z.number(), - screenHeight: z.number(), - devicePixelRatio: z.number(), - userAgent: z.string(), - platform: z.string(), - unixTime: z.number(), -}); +export const DisplayMetadataSchema = registry.register( + "DisplayMetadataSchema", + z + .object({ + screenWidth: z.number(), + screenHeight: z.number(), + devicePixelRatio: z.number(), + userAgent: z.string(), + platform: z.string(), + unixTime: z.number(), + }) + .openapi("DisplayMetadataSchema", { + example: { + screenWidth: 1920, + screenHeight: 1080, + devicePixelRatio: 2, + userAgent: "Mozilla/5.0...", + platform: "MacIntel", + unixTime: 1711483200000, + }, + }) +); export type DisplayMetadata = z.infer; export const DisplayId = z.coerce.number(); -export const DisplaySchema = z.object({ - id: DisplayId, - metadata: DisplayMetadataSchema.optional(), - lastUpdate: z.number(), -}); +export const DisplaySchema = registry.register( + "DisplaySchema", + z + .object({ + id: DisplayId, + metadata: DisplayMetadataSchema.optional(), + lastUpdate: z.number(), + }) + .openapi("DisplaySchema", { + example: { + id: 0, + lastUpdate: 1711483200000, + metadata: { + screenWidth: 1920, + screenHeight: 1080, + devicePixelRatio: 2, + userAgent: "Mozilla/5.0...", + platform: "MacIntel", + unixTime: 1711483200000, + }, + }, + }) +); export type Display = z.infer; -export const DashboardMessageValidator = z.union([ - z.object({ - message: z.string(), - }), - z.object({ - url: z.string(), - fullscreen: z.boolean().optional(), - iframe: z.boolean().optional(), - }), -]); +export const DashboardMessageValidator = registry.register( + "DashboardMessageValidator", + z + .union([ + z.object({ + message: z.string(), + }), + z.object({ + url: z.string(), + fullscreen: z.boolean().optional(), + iframe: z.boolean().optional(), + }), + ]) + .openapi("DashboardMessageValidator", { + description: + "Either a text message or a URL to display on the dashboard.", + example: { message: "Welcome to R|P 2025!" }, + }) +); export type DashboardMessage = z.infer; + +export const DashboardSentToResponse = registry.register( + "DashboardSentToResponse", + z + .object({ + sentTo: z.array(z.number()), + }) + .openapi("DashboardSentToResponse", { + example: { sentTo: [0, 1, 2] }, + }) +); diff --git a/src/services/events/events-router.ts b/src/services/events/events-router.ts index d63a77e7..4b53f837 100644 --- a/src/services/events/events-router.ts +++ b/src/services/events/events-router.ts @@ -14,6 +14,33 @@ import { isAdmin, isStaff } from "../auth/auth-utils"; const eventsRouter = Router(); +/** + * @swagger + * /events/currentOrNext/: + * get: + * summary: Get next event + * description: | + * The events checked are filtered based on what the current user can access. + * If the user is not Staff or Admin, non-visible events are skipped and + * it will return an externalEventView instead of an internalEventView. + * + * **Required roles: none** + * + * **Optional roles: STAFF | ADMIN** + * tags: [Events] + * responses: + * 200: + * description: The next event + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/InternalEventView' + * - $ref: '#/components/schemas/ExternalEventView' + * 204: + * description: No upcoming events exist + * security: [] + */ eventsRouter.get("/currentOrNext", RoleChecker([], true), async (req, res) => { const currentTime = new Date(); const payload = res.locals.payload; @@ -41,6 +68,33 @@ eventsRouter.get("/currentOrNext", RoleChecker([], true), async (req, res) => { } }); +/** + * @swagger + * /events/: + * get: + * summary: Get all events + * description: | + * The events returned are filtered based on what the current user can access. + * If the user is not Staff or Admin, only visible events will be shown and + * it will return externalEventViews instead of internalEventViews. + * + * **Required roles: none** + * + * **Optional roles: STAFF | ADMIN** + * tags: [Events] + * responses: + * 200: + * description: A list of all events + * content: + * application/json: + * schema: + * type: array + * items: + * oneOf: + * - $ref: '#/components/schemas/InternalEventView' + * - $ref: '#/components/schemas/ExternalEventView' + * security: [] + */ eventsRouter.get("/", RoleChecker([], true), async (req, res) => { const payload = res.locals.payload; @@ -64,6 +118,44 @@ eventsRouter.get("/", RoleChecker([], true), async (req, res) => { return res.status(StatusCodes.OK).json(filtered_events); }); +/** + * @swagger + * /events/{EVENTID}: + * get: + * summary: Get event by id + * description: | + * If the user is not Staff or Admin, only visible events can be accessed and + * it will return an externalEventView instead of an internalEventView. + * + * **Required roles: none** + * + * **Optional roles: STAFF | ADMIN** + * tags: [Events] + * parameters: + * - name: EVENTID + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The event requested + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/InternalEventView' + * - $ref: '#/components/schemas/ExternalEventView' + * 404: + * description: Couldn't find the requested event + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "DoesNotExist" + * security: [] + */ eventsRouter.get("/:EVENTID", RoleChecker([], true), async (req, res) => { const eventId = req.params.EVENTID; const payload = res.locals.payload; @@ -95,6 +187,32 @@ eventsRouter.get("/:EVENTID", RoleChecker([], true), async (req, res) => { return res.status(StatusCodes.OK).json(validatedData); }); +/** + * @swagger + * /events/: + * post: + * summary: Create an event + * description: | + * Creates a new event and adds it to the database + * + * **Required roles: STAFF | ADMIN** + * tags: [Events] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EventInfoValidator' + * responses: + * 201: + * description: The new event (added to the database) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/InternalEventView' + * security: + * - bearerAuth: [] + */ eventsRouter.post( "/", RoleChecker([Role.Enum.STAFF, Role.Enum.ADMIN]), @@ -127,6 +245,46 @@ eventsRouter.post( } ); +/** + * @swagger + * /events/{EVENTID}: + * put: + * summary: Update an event + * description: | + * Updates the data for a preexisting event + * + * **Required roles: STAFF | ADMIN** + * tags: [Events] + * parameters: + * - name: EVENTID + * in: path + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EventInfoValidator' + * responses: + * 200: + * description: The updated event data + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/InternalEventView' + * 404: + * description: Couldn't find the requested event + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "DoesNotExist" + * security: + * - bearerAuth: [] + */ eventsRouter.put( "/:EVENTID", RoleChecker([Role.Enum.STAFF, Role.Enum.ADMIN]), @@ -168,6 +326,36 @@ eventsRouter.put( } ); +/** + * @swagger + * /events/{EVENTID}: + * delete: + * summary: Delete an event + * description: | + * Deletes an event entry from the database + * + * **Required roles: ADMIN** + * tags: [Events] + * parameters: + * - name: EVENTID + * in: path + * required: true + * schema: + * type: string + * responses: + * 204: + * description: The event was successfully deleted + * 404: + * description: Couldn't find the requested event + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "DoesNotExist" + * security: + * - bearerAuth: [] + */ eventsRouter.delete( "/:EVENTID", RoleChecker([Role.Enum.ADMIN]), diff --git a/src/services/events/events-schema.ts b/src/services/events/events-schema.ts index ef50da4a..b81a20cd 100644 --- a/src/services/events/events-schema.ts +++ b/src/services/events/events-schema.ts @@ -1,6 +1,7 @@ import { Schema } from "mongoose"; import { z } from "zod"; import { v4 as uuidv4 } from "uuid"; +import { registry } from "../../middleware/openapi-registry"; export const EventType = z.enum([ "SPEAKER", @@ -14,24 +15,64 @@ export const EventType = z.enum([ export type InternalEvent = z.infer; export type EventInputPayload = z.infer; -export const externalEventView = z.object({ - eventId: z.coerce.string().default(() => uuidv4()), - name: z.string(), - startTime: z.coerce.date(), - endTime: z.coerce.date(), - points: z.number().min(0), - description: z.string(), - isVirtual: z.boolean(), - imageUrl: z.string().nullable(), - location: z.string().nullable(), - eventType: EventType, - tags: z.array(z.string()).default([]), -}); +export const externalEventView = registry.register( + "ExternalEventView", + z + .object({ + eventId: z.coerce.string().default(() => uuidv4()), + name: z.string(), + startTime: z.coerce.date().openapi({ format: "date-time" }), + endTime: z.coerce.date().openapi({ format: "date-time" }), + points: z.number().min(0), + description: z.string(), + isVirtual: z.boolean(), + imageUrl: z.string().nullable(), + location: z.string().nullable(), + eventType: EventType, + tags: z.array(z.string()).default([]), + }) + .openapi("ExternalEventView", { + example: { + eventId: "3a72d491-c2f9-4baf-af5a-55713621d978", + name: "Test Event", + startTime: new Date("2025-03-31T19:30:00Z"), + endTime: new Date("2025-03-31T23:30:00Z"), + points: 0, + description: "Awesome test event", + isVirtual: false, + imageUrl: "example.com/image.png", + location: "Siebel Center for CS", + eventType: "SPEAKER", + tags: [], + }, + }) +); -export const internalEventView = externalEventView.extend({ - attendanceCount: z.number(), - isVisible: z.boolean(), -}); +export const internalEventView = registry.register( + "InternalEventView", + externalEventView + .extend({ + attendanceCount: z.number(), + isVisible: z.boolean(), + }) + .openapi("InternalEventView", { + example: { + eventId: "3a72d491-c2f9-4baf-af5a-55713621d978", + name: "Test Event", + startTime: new Date("2025-03-31T19:30:00Z"), + endTime: new Date("2025-03-31T23:30:00Z"), + points: 0, + description: "Awesome test event", + isVirtual: false, + imageUrl: "example.com/image.png", + location: "Siebel Center for CS", + eventType: "SPEAKER", + tags: [], + attendanceCount: 0, + isVisible: true, + }, + }) +); // ApiResponseSchema objects used to create expected internal and external event objects const eventTimeExtension = { @@ -51,9 +92,28 @@ export type InternalEventApiResponse = z.infer< typeof internalEventApiResponseSchema >; -export const eventInfoValidator = internalEventView - .omit({ eventId: true }) - .strict(); +export const eventInfoValidator = registry.register( + "EventInfoValidator", + internalEventView + .omit({ eventId: true }) + .strict() + .openapi("EventInfoValidator", { + example: { + name: "Test Event", + startTime: new Date("2025-03-31T19:30:00Z"), + endTime: new Date("2025-03-31T23:30:00Z"), + points: 0, + description: "Awesome test event", + isVirtual: false, + imageUrl: "example.com/image.png", + location: "Siebel Center for CS", + eventType: "SPEAKER", + tags: [], + attendanceCount: 0, + isVisible: true, + }, + }) +); export const EventSchema = new Schema({ eventId: { diff --git a/src/services/leaderboard/leaderboard-router.ts b/src/services/leaderboard/leaderboard-router.ts index 4a2f1b2a..ceffccb0 100644 --- a/src/services/leaderboard/leaderboard-router.ts +++ b/src/services/leaderboard/leaderboard-router.ts @@ -23,12 +23,40 @@ import { const leaderboardRouter = Router(); /** - * GET /leaderboard/daily - * Get daily leaderboard for display in mobile app and admin preview - * Query params: day (YYYY-MM-DD), n (optional - number of winners, returns all if omitted) - * Authorization: None required + * @swagger + * /leaderboard/daily: + * get: + * summary: Get the daily leaderboard + * description: | + * Returns the leaderboard rankings for a specific day, optionally limited + * to the top N entries. + * + * **Required roles: none** + * tags: [Leaderboard] + * parameters: + * - name: day + * in: query + * required: true + * schema: + * type: string + * example: "2025-04-01" + * description: Date in YYYY-MM-DD format + * - name: n + * in: query + * required: false + * schema: + * type: integer + * minimum: 1 + * description: Number of top entries to return (returns all if omitted) + * responses: + * 200: + * description: Daily leaderboard results + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PreviewLeaderboardResponseValidator' + * security: [] */ - leaderboardRouter.get("/daily", async (req, res) => { const { day, n } = DailyLeaderboardRequestValidator.parse({ day: req.query.day, @@ -47,10 +75,32 @@ leaderboardRouter.get("/daily", async (req, res) => { }); /** - * GET /leaderboard/global - * Get global leaderboard showing total accumulated points for all attendees - * Query params: n (optional - number of winners, returns all if omitted) - * Authorization: None required + * @swagger + * /leaderboard/global: + * get: + * summary: Get the global leaderboard + * description: | + * Returns overall leaderboard rankings based on total accumulated points + * across all days, optionally limited to the top N entries. + * + * **Required roles: none** + * tags: [Leaderboard] + * parameters: + * - name: n + * in: query + * required: false + * schema: + * type: integer + * minimum: 1 + * description: Number of top entries to return (returns all if omitted) + * responses: + * 200: + * description: Global leaderboard results + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/GlobalLeaderboardResponseValidator' + * security: [] */ leaderboardRouter.get("/global", async (req, res) => { const { n } = GlobalLeaderboardRequestValidator.parse({ @@ -68,10 +118,33 @@ leaderboardRouter.get("/global", async (req, res) => { }); /** - * GET /leaderboard/submission-status - * Check if a leaderboard submission already exists for a specific day - * Query params: day (YYYY-MM-DD) - * Authorization: All authenticated users + * @swagger + * /leaderboard/submission-status: + * get: + * summary: Check if a daily leaderboard has been submitted + * description: | + * Returns whether a leaderboard submission already exists for the given day, + * and its metadata if it does. + * + * **Required roles: none** + * tags: [Leaderboard] + * parameters: + * - name: day + * in: query + * required: true + * schema: + * type: string + * example: "2025-04-01" + * description: Date in YYYY-MM-DD format + * responses: + * 200: + * description: Submission status for the given day + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CheckSubmissionResponseValidator' + * security: + * - bearerAuth: [] */ leaderboardRouter.get( "/submission-status", @@ -91,10 +164,40 @@ leaderboardRouter.get( ); /** - * POST /leaderboard/submit - * Submit and lock in daily leaderboard results, updating tier eligibility - * Body: { day: string, n: number } - * Authorization: SUPER ADMIN only (higher privilege than preview) + * @swagger + * /leaderboard/submit: + * post: + * summary: Submit and lock daily leaderboard results + * description: | + * Finalises the leaderboard for a given day, promotes qualifying users to + * the next tier, and records the submission. Fails if a submission already + * exists for that day. + * + * **Required roles: SUPER_ADMIN** + * tags: [Leaderboard] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SubmitLeaderboardRequestValidator' + * responses: + * 200: + * description: Submission recorded and tier promotions applied + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SubmitLeaderboardResponseValidator' + * 409: + * description: A submission already exists for the given day + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Leaderboard already submitted" + * security: + * - bearerAuth: [] */ leaderboardRouter.post( "/submit", diff --git a/src/services/leaderboard/leaderboard-schema.ts b/src/services/leaderboard/leaderboard-schema.ts index 1a9ba0d6..7b8c028b 100644 --- a/src/services/leaderboard/leaderboard-schema.ts +++ b/src/services/leaderboard/leaderboard-schema.ts @@ -1,79 +1,191 @@ import { z } from "zod"; import { Tiers, IconColors } from "../attendee/attendee-schema"; +import { registry } from "../../middleware/openapi-registry"; export const DayStringValidator = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { message: "Day must be in YYYY-MM-DD format", }); // Request validator for daily leaderboard GET endpoint (n is optional) -export const DailyLeaderboardRequestValidator = z.object({ - day: DayStringValidator, - n: z.coerce.number().int().min(1).optional(), -}); +export const DailyLeaderboardRequestValidator = registry.register( + "DailyLeaderboardRequestValidator", + z + .object({ + day: DayStringValidator, + n: z.coerce.number().int().min(1).optional(), + }) + .openapi("DailyLeaderboardRequestValidator", { + example: { day: "2025-04-01", n: 10 }, + }) +); // Request validator for leaderboard submission endpoint (n is required) -export const SubmitLeaderboardRequestValidator = z.object({ - day: DayStringValidator, - n: z.coerce.number().int().min(1), - userIdsToPromote: z.array(z.string()).optional(), -}); +export const SubmitLeaderboardRequestValidator = registry.register( + "SubmitLeaderboardRequestValidator", + z + .object({ + day: DayStringValidator, + n: z.coerce.number().int().min(1), + userIdsToPromote: z.array(z.string()).optional(), + }) + .openapi("SubmitLeaderboardRequestValidator", { + example: { day: "2025-04-01", n: 10 }, + }) +); // Request validator for global leaderboard endpoint (n is optional) -export const GlobalLeaderboardRequestValidator = z.object({ - n: z.coerce.number().int().min(1).optional(), -}); +export const GlobalLeaderboardRequestValidator = registry.register( + "GlobalLeaderboardRequestValidator", + z + .object({ + n: z.coerce.number().int().min(1).optional(), + }) + .openapi("GlobalLeaderboardRequestValidator", { + example: { n: 10 }, + }) +); // Request validator for checking submission status (day is required) -export const CheckSubmissionRequestValidator = z.object({ - day: DayStringValidator, -}); +export const CheckSubmissionRequestValidator = registry.register( + "CheckSubmissionRequestValidator", + z + .object({ + day: DayStringValidator, + }) + .openapi("CheckSubmissionRequestValidator", { + example: { day: "2025-04-01" }, + }) +); // Leaderboard entry - represents a single user in the leaderboard; reuse for global and daily -export const LeaderboardEntryValidator = z.object({ - rank: z.number().int().min(1), - userId: z.string(), - displayName: z.string(), - points: z.number().int().min(0), - currentTier: Tiers, - icon: IconColors, -}); +export const LeaderboardEntryValidator = registry.register( + "LeaderboardEntryValidator", + z + .object({ + rank: z.number().int().min(1), + userId: z.string(), + displayName: z.string(), + points: z.number().int().min(0), + currentTier: Tiers, + icon: IconColors, + }) + .openapi("LeaderboardEntryValidator", { + example: { + rank: 1, + userId: "abc123", + displayName: "Jane Doe", + points: 150, + currentTier: "TIER2", + icon: "BLUE", + }, + }) +); // GET /daily response (preview) -export const PreviewLeaderboardResponseValidator = z.object({ - leaderboard: z.array(LeaderboardEntryValidator), - day: z.string(), - count: z.number().int().min(0), -}); +export const PreviewLeaderboardResponseValidator = registry.register( + "PreviewLeaderboardResponseValidator", + z + .object({ + leaderboard: z.array(LeaderboardEntryValidator), + day: z.string(), + count: z.number().int().min(0), + }) + .openapi("PreviewLeaderboardResponseValidator", { + example: { + day: "2025-04-01", + count: 1, + leaderboard: [ + { + rank: 1, + userId: "abc123", + displayName: "Jane Doe", + points: 150, + currentTier: "TIER2", + icon: "BLUE", + }, + ], + }, + }) +); // GET /global response -export const GlobalLeaderboardResponseValidator = z.object({ - leaderboard: z.array(LeaderboardEntryValidator), - count: z.number().int().min(0), -}); +export const GlobalLeaderboardResponseValidator = registry.register( + "GlobalLeaderboardResponseValidator", + z + .object({ + leaderboard: z.array(LeaderboardEntryValidator), + count: z.number().int().min(0), + }) + .openapi("GlobalLeaderboardResponseValidator", { + example: { + count: 1, + leaderboard: [ + { + rank: 1, + userId: "abc123", + displayName: "Jane Doe", + points: 500, + currentTier: "TIER3", + icon: "GREEN", + }, + ], + }, + }) +); // POST /submit response -export const SubmitLeaderboardResponseValidator = z.object({ - leaderboard: z.array(LeaderboardEntryValidator), - day: z.string(), - count: z.number().int().min(0), - entriesProcessed: z.number().int().min(0), - submissionId: z.string().uuid(), - submittedAt: z.string(), - submittedBy: z.string(), -}); - -// GET /submission-status response -export const CheckSubmissionResponseValidator = z.object({ - exists: z.boolean(), - submission: z +export const SubmitLeaderboardResponseValidator = registry.register( + "SubmitLeaderboardResponseValidator", + z .object({ + leaderboard: z.array(LeaderboardEntryValidator), + day: z.string(), + count: z.number().int().min(0), + entriesProcessed: z.number().int().min(0), submissionId: z.string().uuid(), submittedAt: z.string(), submittedBy: z.string(), - count: z.number().int().min(0), }) - .optional(), -}); + .openapi("SubmitLeaderboardResponseValidator", { + example: { + day: "2025-04-01", + count: 10, + entriesProcessed: 10, + submissionId: "3a72d491-c2f9-4baf-af5a-55713621d978", + submittedAt: "2025-04-01T23:59:00Z", + submittedBy: "admin_user", + leaderboard: [], + }, + }) +); + +// GET /submission-status response +export const CheckSubmissionResponseValidator = registry.register( + "CheckSubmissionResponseValidator", + z + .object({ + exists: z.boolean(), + submission: z + .object({ + submissionId: z.string().uuid(), + submittedAt: z.string(), + submittedBy: z.string(), + count: z.number().int().min(0), + }) + .optional(), + }) + .openapi("CheckSubmissionResponseValidator", { + example: { + exists: true, + submission: { + submissionId: "3a72d491-c2f9-4baf-af5a-55713621d978", + submittedAt: "2025-04-01T23:59:00Z", + submittedBy: "admin_user", + count: 10, + }, + }, + }) +); // Type exports export type DailyLeaderboardRequest = z.infer< diff --git a/src/services/meetings/meetings-router.ts b/src/services/meetings/meetings-router.ts index 353cdb43..94116ced 100644 --- a/src/services/meetings/meetings-router.ts +++ b/src/services/meetings/meetings-router.ts @@ -12,6 +12,28 @@ import { MeetingType } from "./meetings-schema"; const meetingsRouter = Router(); +/** + * @swagger + * /meetings/: + * get: + * summary: Get all meetings + * description: | + * Returns a list of all scheduled committee meetings. + * + * **Required roles: STAFF | ADMIN** + * tags: [Meetings] + * responses: + * 200: + * description: A list of all meetings + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/MeetingView' + * security: + * - bearerAuth: [] + */ meetingsRouter.get( "/", RoleChecker([Role.enum.STAFF, Role.enum.ADMIN]), @@ -27,6 +49,40 @@ meetingsRouter.get( } ); +/** + * @swagger + * /meetings/{meetingId}: + * get: + * summary: Get a meeting by ID + * description: | + * Returns a single meeting by its ID. + * + * **Required roles: STAFF | ADMIN** + * tags: [Meetings] + * parameters: + * - name: meetingId + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The requested meeting + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/MeetingView' + * 404: + * description: Meeting not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Meeting not found" + * security: + * - bearerAuth: [] + */ meetingsRouter.get( "/:meetingId", RoleChecker([Role.enum.STAFF, Role.enum.ADMIN]), @@ -48,6 +104,32 @@ meetingsRouter.get( } ); +/** + * @swagger + * /meetings/: + * post: + * summary: Create a meeting + * description: | + * Creates a new committee meeting. + * + * **Required roles: ADMIN** + * tags: [Meetings] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateMeetingValidator' + * responses: + * 201: + * description: The newly created meeting + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/MeetingView' + * security: + * - bearerAuth: [] + */ meetingsRouter.post("/", RoleChecker([Role.enum.ADMIN]), async (req, res) => { const validatedData = createMeetingValidator.parse(req.body); @@ -66,6 +148,46 @@ meetingsRouter.post("/", RoleChecker([Role.enum.ADMIN]), async (req, res) => { res.status(StatusCodes.CREATED).json(responseMeeting); }); +/** + * @swagger + * /meetings/{meetingId}: + * put: + * summary: Update a meeting + * description: | + * Updates one or more fields of an existing meeting. + * + * **Required roles: ADMIN** + * tags: [Meetings] + * parameters: + * - name: meetingId + * in: path + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateMeetingValidator' + * responses: + * 200: + * description: The updated meeting + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/MeetingView' + * 404: + * description: Meeting not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Meeting not found" + * security: + * - bearerAuth: [] + */ meetingsRouter.put( "/:meetingId", RoleChecker([Role.enum.ADMIN]), @@ -93,6 +215,36 @@ meetingsRouter.put( } ); +/** + * @swagger + * /meetings/{meetingId}: + * delete: + * summary: Delete a meeting + * description: | + * Deletes a meeting by its ID. + * + * **Required roles: ADMIN** + * tags: [Meetings] + * parameters: + * - name: meetingId + * in: path + * required: true + * schema: + * type: string + * responses: + * 204: + * description: Meeting successfully deleted + * 404: + * description: Meeting not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * message: "Meeting not found" + * security: + * - bearerAuth: [] + */ meetingsRouter.delete( "/:meetingId", RoleChecker([Role.enum.ADMIN]), diff --git a/src/services/meetings/meetings-schema.ts b/src/services/meetings/meetings-schema.ts index 950a0fed..0f1da2a3 100644 --- a/src/services/meetings/meetings-schema.ts +++ b/src/services/meetings/meetings-schema.ts @@ -2,22 +2,39 @@ import { Schema } from "mongoose"; import { Database } from "../../database.types"; import { z } from "zod"; import { v4 as uuidv4 } from "uuid"; +import { registry } from "../../middleware/openapi-registry"; -export const CommitteeNames = z.enum([ - "CONTENT", - "CORPORATE", - "DESIGN", - "DEV", - "FULL TEAM", - "MARKETING", - "OPERATIONS", -]); // would it be better to import the committee names from Supabase itself (similar to RoleTypes) +export const CommitteeNames = registry.register( + "CommitteeNames", + z + .enum([ + "CONTENT", + "CORPORATE", + "DESIGN", + "DEV", + "FULL TEAM", + "MARKETING", + "OPERATIONS", + ]) + .openapi("CommitteeNames", { description: "R|P committee name" }) +); // would it be better to import the committee names from Supabase itself (similar to RoleTypes) -export const meetingView = z.object({ - meetingId: z.coerce.string().default(() => uuidv4()), - committeeType: CommitteeNames, - startTime: z.coerce.date(), -}); +export const meetingView = registry.register( + "MeetingView", + z + .object({ + meetingId: z.coerce.string().default(() => uuidv4()), + committeeType: CommitteeNames, + startTime: z.coerce.date().openapi({ format: "date-time" }), + }) + .openapi("MeetingView", { + example: { + meetingId: "3a72d491-c2f9-4baf-af5a-55713621d978", + committeeType: "DEV", + startTime: new Date("2025-04-01T18:00:00Z"), + }, + }) +); export type Meeting = z.infer; // TODO: phase out meeting schema @@ -41,9 +58,26 @@ export const MeetingSchema = new Schema({ export type MeetingType = Database["public"]["Tables"]["meetings"]["Row"]; -export const createMeetingValidator = z.object({ - committeeType: CommitteeNames, - startTime: z.coerce.date(), -}); +export const createMeetingValidator = registry.register( + "CreateMeetingValidator", + z + .object({ + committeeType: CommitteeNames, + startTime: z.coerce.date().openapi({ format: "date-time" }), + }) + .openapi("CreateMeetingValidator", { + example: { + committeeType: "DEV", + startTime: new Date("2025-04-01T18:00:00Z"), + }, + }) +); -export const updateMeetingValidator = createMeetingValidator.partial(); +export const updateMeetingValidator = registry.register( + "UpdateMeetingValidator", + createMeetingValidator + .partial() + .openapi("UpdateMeetingValidator", { + example: { startTime: new Date("2025-04-02T18:00:00Z") }, + }) +); diff --git a/src/services/notifications/notifications-router.ts b/src/services/notifications/notifications-router.ts index 11dcf98c..e0a64ec8 100644 --- a/src/services/notifications/notifications-router.ts +++ b/src/services/notifications/notifications-router.ts @@ -13,6 +13,34 @@ import { getCurrentDay } from "../checkin/checkin-utils"; const notificationsRouter = Router(); +/** + * @swagger + * /notifications/register: + * post: + * summary: Register a device for push notifications + * description: | + * Registers the caller’s FCM device token under their userId and + * subscribes them to the `allUsers` topic plus any tag-based topics + * derived from their attendee profile. + * + * **Required roles: USER** + * tags: [Notifications] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: ‘#/components/schemas/RegisterDeviceValidator’ + * responses: + * 201: + * description: Device registered; returns the validated registration data + * content: + * application/json: + * schema: + * $ref: ‘#/components/schemas/RegisterDeviceValidator’ + * security: + * - bearerAuth: [] + */ // Register user’s device identifier under their userId // Request body: deviceId: The FCM device token from the client app. notificationsRouter.post( @@ -59,6 +87,39 @@ notificationsRouter.post( } ); +/** + * @swagger + * /notifications/topics/{topicName}: + * post: + * summary: Send a push notification to a topic + * description: | + * Sends an FCM notification to all devices subscribed to the given topic. + * + * **Required roles: SUPER_ADMIN** + * tags: [Notifications] + * parameters: + * - name: topicName + * in: path + * required: true + * schema: + * type: string + * example: allUsers + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SendToTopicValidator' + * responses: + * 200: + * description: Notification successfully sent + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/NotificationSuccessResponse' + * security: + * - bearerAuth: [] + */ // Super admins can send notifications to a specific topic // parameter: the topicName that the admin is sending to // ^ Can get this from dropdown (will have a route to get all topics) @@ -89,6 +150,33 @@ notificationsRouter.post( } ); +/** + * @swagger + * /notifications/custom-topic: + * post: + * summary: Create a custom notification topic + * description: | + * Persists a custom topic name to the database so it appears in + * the topics list and can be targeted by future notifications. + * + * **Required roles: ADMIN** + * tags: [Notifications] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CustomTopicValidator' + * responses: + * 201: + * description: Custom topic created + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/NotificationSuccessResponse' + * security: + * - bearerAuth: [] + */ // Admins can create a custom topic // Request body: topicName notificationsRouter.post( @@ -107,6 +195,33 @@ notificationsRouter.post( } ); +/** + * @swagger + * /notifications/manual-users-topic: + * post: + * summary: Manually subscribe a user to a topic + * description: | + * Looks up the user's registered device token and subscribes it to + * the specified FCM topic. + * + * **Required roles: ADMIN** + * tags: [Notifications] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ManualTopicValidator' + * responses: + * 200: + * description: User successfully subscribed to topic + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/NotificationSuccessResponse' + * security: + * - bearerAuth: [] + */ // Admins can manually subscribe a user to a topic // Request body: userId, topicName notificationsRouter.post( @@ -134,6 +249,33 @@ notificationsRouter.post( } ); +/** + * @swagger + * /notifications/manual-users-topic: + * delete: + * summary: Manually unsubscribe a user from a topic + * description: | + * Looks up the user's registered device token and unsubscribes it from + * the specified FCM topic. + * + * **Required roles: ADMIN** + * tags: [Notifications] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ManualTopicValidator' + * responses: + * 200: + * description: User successfully unsubscribed from topic + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/NotificationSuccessResponse' + * security: + * - bearerAuth: [] + */ // Admins can manually unsubscribe a user from a topic // Request body: userId, topicName notificationsRouter.delete( @@ -161,6 +303,28 @@ notificationsRouter.delete( } ); +/** + * @swagger + * /notifications/topics: + * get: + * summary: Get all available notification topics + * description: | + * Returns a sorted, deduplicated list of all subscribable FCM topics, + * including static topics (`allUsers`, daily food-wave), event-derived + * topics, custom topics from the database, and tag-based topics. + * + * **Required roles: ADMIN** + * tags: [Notifications] + * responses: + * 200: + * description: All available notification topics + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TopicsListResponse' + * security: + * - bearerAuth: [] + */ // Get all available notification topics // Firebase doesn't have an actual way to get this. // one topic is allUsers, defined earlier in this file diff --git a/src/services/notifications/notifications-schema.ts b/src/services/notifications/notifications-schema.ts index a0fa5a01..1165aa54 100644 --- a/src/services/notifications/notifications-schema.ts +++ b/src/services/notifications/notifications-schema.ts @@ -1,17 +1,67 @@ import { z } from "zod"; +import { registry } from "../../middleware/openapi-registry"; -const registerDeviceSchema = z.object({ - deviceId: z.string(), -}); +const registerDeviceSchema = registry.register( + "RegisterDeviceValidator", + z + .object({ deviceId: z.string() }) + .openapi("RegisterDeviceValidator", { + example: { deviceId: "fCm_token_abc123" }, + }) +); -const sendToTopicSchema = z.object({ - title: z.string().min(1, { message: "Title cannot be empty" }), - body: z.string().min(1, { message: "Body cannot be empty" }), -}); +const sendToTopicSchema = registry.register( + "SendToTopicValidator", + z + .object({ + title: z.string().min(1, { message: "Title cannot be empty" }), + body: z.string().min(1, { message: "Body cannot be empty" }), + }) + .openapi("SendToTopicValidator", { + example: { title: "R|P Update", body: "Check-in opens at 9am!" }, + }) +); -const manualTopicSchema = z.object({ - userId: z.string(), - topicName: z.string().min(1), -}); +const manualTopicSchema = registry.register( + "ManualTopicValidator", + z + .object({ + userId: z.string(), + topicName: z.string().min(1), + }) + .openapi("ManualTopicValidator", { + example: { + userId: "user_abc123", + topicName: "tag_Career_Readiness", + }, + }) +); + +export const customTopicSchema = registry.register( + "CustomTopicValidator", + z + .object({ topicName: z.string().min(1) }) + .openapi("CustomTopicValidator", { + example: { topicName: "my-custom-topic" }, + }) +); + +export const notificationSuccessResponse = registry.register( + "NotificationSuccessResponse", + z + .object({ status: z.string(), message: z.string() }) + .openapi("NotificationSuccessResponse", { + example: { status: "success", message: "Notification sent to topic: allUsers" }, + }) +); + +export const topicsListResponse = registry.register( + "TopicsListResponse", + z + .object({ topics: z.array(z.string()) }) + .openapi("TopicsListResponse", { + example: { topics: ["allUsers", "tag_AI", "tag_Career_Readiness"] }, + }) +); export { registerDeviceSchema, sendToTopicSchema, manualTopicSchema }; diff --git a/src/services/puzzlebang/puzzlebang-router.ts b/src/services/puzzlebang/puzzlebang-router.ts index 59cb9bcc..da0af744 100644 --- a/src/services/puzzlebang/puzzlebang-router.ts +++ b/src/services/puzzlebang/puzzlebang-router.ts @@ -10,6 +10,52 @@ const puzzlebangRouter = Router(); puzzlebangRouter.use(PuzzlebangChecker); +/** + * @swagger + * /puzzlebang/: + * post: + * summary: Record a completed puzzle for a user + * description: | + * Marks a puzzle as completed for the attendee identified by email, + * awards the appropriate points, and returns the user's updated puzzle + * completion list. + * + * Authentication is via a shared API key passed in the `Authorization` + * header (not a bearer JWT). + * + * **Required roles: none (API key required)** + * tags: [Puzzlebang] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PuzzlebangCompleteRequestValidator' + * responses: + * 200: + * description: Puzzle recorded and points awarded + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PuzzlebangCompleteResponse' + * 404: + * description: No registration or attendee found for the given email + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "NotFound" + * 409: + * description: Puzzle already completed by this user + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "AlreadyCompleted" + * security: [] + */ puzzlebangRouter.post("/", async (req, res) => { const { email, puzzleId } = PuzzlebangCompleteRequestValidator.parse( req.body diff --git a/src/services/puzzlebang/puzzlebang-validators.ts b/src/services/puzzlebang/puzzlebang-validators.ts index 3b862b96..8378769b 100644 --- a/src/services/puzzlebang/puzzlebang-validators.ts +++ b/src/services/puzzlebang/puzzlebang-validators.ts @@ -1,7 +1,30 @@ import { z } from "zod"; +import { registry } from "../../middleware/openapi-registry"; -export const PuzzlebangCompleteRequestValidator = z.object({ - // userId: z.string(), - email: z.string().email(), - puzzleId: z.string(), -}); +export const PuzzlebangCompleteRequestValidator = registry.register( + "PuzzlebangCompleteRequestValidator", + z + .object({ + // userId: z.string(), + email: z.string().email(), + puzzleId: z.string(), + }) + .openapi("PuzzlebangCompleteRequestValidator", { + example: { email: "hacker@example.com", puzzleId: "puzzle_01" }, + }) +); + +export const PuzzlebangCompleteResponse = registry.register( + "PuzzlebangCompleteResponse", + z + .object({ + email: z.string().email(), + puzzlesCompleted: z.array(z.string()), + }) + .openapi("PuzzlebangCompleteResponse", { + example: { + email: "hacker@example.com", + puzzlesCompleted: ["puzzle_01", "puzzle_02"], + }, + }) +); diff --git a/src/services/registration/registration-router.ts b/src/services/registration/registration-router.ts index db0cb9ff..35893364 100644 --- a/src/services/registration/registration-router.ts +++ b/src/services/registration/registration-router.ts @@ -16,6 +16,41 @@ import { Role } from "../auth/auth-models"; const registrationRouter = Router(); registrationRouter.use(cors()); +/** + * @swagger + * /registration/draft: + * post: + * summary: Save a registration draft + * description: | + * Upserts an in-progress registration draft for the authenticated user. + * Allows partial form data to be saved before final submission. + * + * **Required roles: (any authenticated user)** + * tags: [Registration] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegistrationDraftValidator' + * responses: + * 200: + * description: The saved draft (with userId attached) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegistrationDraftValidator' + * 400: + * description: Validation error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: {} + * security: + * - bearerAuth: [] + */ registrationRouter.post("/draft", RoleChecker([]), async (req, res) => { const payload = res.locals.payload; @@ -38,6 +73,34 @@ registrationRouter.post("/draft", RoleChecker([]), async (req, res) => { return res.status(StatusCodes.OK).json(registrationDraft); }); +/** + * @swagger + * /registration/draft: + * get: + * summary: Get the current user's registration draft + * description: | + * Returns the saved draft for the authenticated user, if one exists. + * + * **Required roles: (any authenticated user)** + * tags: [Registration] + * responses: + * 200: + * description: The user's saved draft + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegistrationDraftValidator' + * 404: + * description: No draft found for this user + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "DoesNotExist" + * security: + * - bearerAuth: [] + */ registrationRouter.get("/draft", RoleChecker([]), async (req, res) => { const { data: draftRegistration } = await SupabaseDB.DRAFT_REGISTRATIONS.select("*") @@ -53,6 +116,42 @@ registrationRouter.get("/draft", RoleChecker([]), async (req, res) => { return res.status(StatusCodes.OK).json(draftRegistration); }); +/** + * @swagger + * /registration/submit: + * post: + * summary: Submit a completed registration + * description: | + * Finalises the user's registration: upserts the registration record, + * assigns the USER role, creates/updates the attendee profile, subscribes + * the user to the attendees mailing list, and sends a confirmation email. + * + * **Required roles: (any authenticated user)** + * tags: [Registration] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegistrationValidator' + * responses: + * 200: + * description: The submitted registration (with userId attached) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegistrationValidator' + * 400: + * description: Validation error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: {} + * security: + * - bearerAuth: [] + */ registrationRouter.post("/submit", RoleChecker([]), async (req, res) => { const payload = res.locals.payload; @@ -162,6 +261,29 @@ registrationRouter.post("/submit", RoleChecker([]), async (req, res) => { return res.status(StatusCodes.OK).json(registration); }); +/** + * @swagger + * /registration/all: + * get: + * summary: Get all registrations with resumes + * description: | + * Returns a summary of all registrations where the attendee has indicated + * they have a resume. Used by corporate partners and admins for recruiting. + * + * **Required roles: ADMIN | CORPORATE** + * tags: [Registration] + * responses: + * 200: + * description: List of registration summaries + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/RegistrationSummaryView' + * security: + * - bearerAuth: [] + */ registrationRouter.get( "/all", RoleChecker([Role.Enum.ADMIN, Role.Enum.CORPORATE]), diff --git a/src/services/registration/registration-schema.ts b/src/services/registration/registration-schema.ts index a071582b..da622c15 100644 --- a/src/services/registration/registration-schema.ts +++ b/src/services/registration/registration-schema.ts @@ -1,54 +1,142 @@ import { z } from "zod"; import { Database } from "../../database.types"; +import { registry } from "../../middleware/openapi-registry"; // Zod schema for registration drafts -const RegistrationDraftValidator = z.object({ - allergies: z.array(z.string().max(50)).max(10), - allergiesOther: z.string().max(50), - dietaryRestrictions: z.array(z.string().max(50)).max(10), - dietaryOther: z.string().max(50), - educationLevel: z.string().max(50), - educationOther: z.string().max(50), - email: z.string().max(256), - ethnicity: z.array(z.string().max(50)).max(10), - ethnicityOther: z.string().max(50), - gender: z.string().max(50), - genderOther: z.string().max(50), - graduationYear: z.string().max(50), - howDidYouHear: z.array(z.string().max(50)).max(10), - majors: z.array(z.string().max(50)).max(5), - minors: z.array(z.string().max(50)).max(5), - name: z.string().max(50), - opportunities: z.array(z.string().max(50)).max(10), - personalLinks: z.array(z.string().max(50)).max(3), - resume: z.string().max(50).optional(), - school: z.string().max(50), - isInterestedMechMania: z.boolean(), - isInterestedPuzzleBang: z.boolean(), - tags: z.array(z.string().max(50)).max(15), -}); +const RegistrationDraftValidator = registry.register( + "RegistrationDraftValidator", + z + .object({ + allergies: z.array(z.string().max(50)).max(10), + allergiesOther: z.string().max(50), + dietaryRestrictions: z.array(z.string().max(50)).max(10), + dietaryOther: z.string().max(50), + educationLevel: z.string().max(50), + educationOther: z.string().max(50), + email: z.string().max(256), + ethnicity: z.array(z.string().max(50)).max(10), + ethnicityOther: z.string().max(50), + gender: z.string().max(50), + genderOther: z.string().max(50), + graduationYear: z.string().max(50), + howDidYouHear: z.array(z.string().max(50)).max(10), + majors: z.array(z.string().max(50)).max(5), + minors: z.array(z.string().max(50)).max(5), + name: z.string().max(50), + opportunities: z.array(z.string().max(50)).max(10), + personalLinks: z.array(z.string().max(50)).max(3), + resume: z.string().max(50).optional(), + school: z.string().max(50), + isInterestedMechMania: z.boolean(), + isInterestedPuzzleBang: z.boolean(), + tags: z.array(z.string().max(50)).max(15), + }) + .openapi("RegistrationDraftValidator", { + example: { + allergies: [], + allergiesOther: "", + dietaryRestrictions: [], + dietaryOther: "", + educationLevel: "Undergraduate", + educationOther: "", + email: "hacker@example.com", + ethnicity: [], + ethnicityOther: "", + gender: "Prefer not to say", + genderOther: "", + graduationYear: "2026", + howDidYouHear: ["Social media"], + majors: ["Computer Science"], + minors: [], + name: "Jane Doe", + opportunities: [], + personalLinks: [], + school: "University of Illinois Urbana-Champaign", + isInterestedMechMania: false, + isInterestedPuzzleBang: true, + tags: ["AI"], + }, + }) +); // Zod schema for registration -const RegistrationValidator = z.object({ - allergies: z.array(z.string().max(50)).max(10), - dietaryRestrictions: z.array(z.string().max(50)).max(10), - educationLevel: z.string().max(50), - email: z.string().email().max(256), - ethnicity: z.array(z.string().max(50)).max(10), - gender: z.string().max(50), - graduationYear: z.string().max(50), - howDidYouHear: z.array(z.string().max(50)).max(10), - majors: z.array(z.string().max(50)).max(5), - minors: z.array(z.string().max(50)).max(5), - name: z.string().max(50), - opportunities: z.array(z.string().max(50)).max(10), - personalLinks: z.array(z.string().max(50)).max(3), - hasResume: z.boolean(), - school: z.string().max(50), - isInterestedMechMania: z.boolean(), - isInterestedPuzzleBang: z.boolean(), - tags: z.array(z.string().max(50)).max(15), -}); +const RegistrationValidator = registry.register( + "RegistrationValidator", + z + .object({ + allergies: z.array(z.string().max(50)).max(10), + dietaryRestrictions: z.array(z.string().max(50)).max(10), + educationLevel: z.string().max(50), + email: z.string().email().max(256), + ethnicity: z.array(z.string().max(50)).max(10), + gender: z.string().max(50), + graduationYear: z.string().max(50), + howDidYouHear: z.array(z.string().max(50)).max(10), + majors: z.array(z.string().max(50)).max(5), + minors: z.array(z.string().max(50)).max(5), + name: z.string().max(50), + opportunities: z.array(z.string().max(50)).max(10), + personalLinks: z.array(z.string().max(50)).max(3), + hasResume: z.boolean(), + school: z.string().max(50), + isInterestedMechMania: z.boolean(), + isInterestedPuzzleBang: z.boolean(), + tags: z.array(z.string().max(50)).max(15), + }) + .openapi("RegistrationValidator", { + example: { + allergies: [], + dietaryRestrictions: [], + educationLevel: "Undergraduate", + email: "hacker@example.com", + ethnicity: [], + gender: "Prefer not to say", + graduationYear: "2026", + howDidYouHear: ["Social media"], + majors: ["Computer Science"], + minors: [], + name: "Jane Doe", + opportunities: [], + personalLinks: [], + hasResume: true, + school: "University of Illinois Urbana-Champaign", + isInterestedMechMania: false, + isInterestedPuzzleBang: true, + tags: ["AI"], + }, + }) +); + +export const RegistrationSummaryView = registry.register( + "RegistrationSummaryView", + z + .object({ + userId: z.string(), + name: z.string(), + majors: z.array(z.string()), + minors: z.array(z.string()), + school: z.string(), + educationLevel: z.string(), + graduationYear: z.string(), + opportunities: z.array(z.string()), + personalLinks: z.array(z.string()), + }) + .openapi("RegistrationSummaryView", { + description: + "Subset of registration fields returned to corporate/admin for resume review.", + example: { + userId: "user_abc123", + name: "Jane Doe", + majors: ["Computer Science"], + minors: [], + school: "University of Illinois Urbana-Champaign", + educationLevel: "Undergraduate", + graduationYear: "2026", + opportunities: ["Internship"], + personalLinks: ["github.com/janedoe"], + }, + }) +); export type Registration = Database["public"]["Tables"]["registrations"]["Row"]; diff --git a/src/services/s3/s3-router.ts b/src/services/s3/s3-router.ts index 2ed31f89..b200cdf3 100644 --- a/src/services/s3/s3-router.ts +++ b/src/services/s3/s3-router.ts @@ -10,6 +10,28 @@ import BatchResumeDownloadValidator from "./s3-schema"; const s3Router: Router = Router(); +/** + * @swagger + * /s3/upload/: + * get: + * summary: Get a presigned URL for resume upload + * description: | + * Returns an S3 presigned POST URL and the required form fields so the + * client can upload a PDF resume directly to S3 without routing through + * the API server. + * + * **Required roles: (any authenticated user)** + * tags: [S3] + * responses: + * 200: + * description: Presigned POST URL and form fields + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/S3UploadUrlResponse' + * security: + * - bearerAuth: [] + */ s3Router.get( "/upload/", RoleChecker([], false), @@ -25,6 +47,26 @@ s3Router.get( } ); +/** + * @swagger + * /s3/download/: + * get: + * summary: Get a presigned URL to download own resume + * description: | + * Returns a presigned GET URL for the authenticated user's own resume. + * + * **Required roles: USER** + * tags: [S3] + * responses: + * 200: + * description: Presigned download URL + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/S3DownloadUrlResponse' + * security: + * - bearerAuth: [] + */ s3Router.get( "/download/", RoleChecker([Role.Enum.USER], false), @@ -40,6 +82,32 @@ s3Router.get( } ); +/** + * @swagger + * /s3/download/user/{USERID}: + * get: + * summary: Get a presigned URL to download another user's resume + * description: | + * Returns a presigned GET URL for the specified user's resume. + * + * **Required roles: STAFF | CORPORATE** + * tags: [S3] + * parameters: + * - name: USERID + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Presigned download URL + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/S3DownloadUrlResponse' + * security: + * - bearerAuth: [] + */ s3Router.get( "/download/user/:USERID", RoleChecker([Role.Enum.STAFF, Role.Enum.CORPORATE], false), @@ -53,6 +121,33 @@ s3Router.get( } ); +/** + * @swagger + * /s3/download/batch/: + * post: + * summary: Get presigned download URLs for multiple users + * description: | + * Returns presigned GET URLs for a list of user IDs. Entries are `null` + * when no resume exists for a given user. + * + * **Required roles: STAFF | CORPORATE** + * tags: [S3] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BatchResumeDownloadValidator' + * responses: + * 200: + * description: Batch of presigned download URLs + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/S3BatchDownloadResponse' + * security: + * - bearerAuth: [] + */ s3Router.post( "/download/batch/", RoleChecker([Role.Enum.STAFF, Role.Enum.CORPORATE], false), diff --git a/src/services/s3/s3-schema.ts b/src/services/s3/s3-schema.ts index 62ffa53f..078c9d97 100644 --- a/src/services/s3/s3-schema.ts +++ b/src/services/s3/s3-schema.ts @@ -1,7 +1,69 @@ import { z } from "zod"; +import { registry } from "../../middleware/openapi-registry"; -const BatchResumeDownloadValidator = z.object({ - userIds: z.string().array(), -}); +const BatchResumeDownloadValidator = registry.register( + "BatchResumeDownloadValidator", + z + .object({ userIds: z.string().array() }) + .openapi("BatchResumeDownloadValidator", { + example: { userIds: ["user_abc123", "user_def456"] }, + }) +); + +export const S3UploadUrlResponse = registry.register( + "S3UploadUrlResponse", + z + .object({ + url: z.string(), + fields: z.record(z.string()), + }) + .openapi("S3UploadUrlResponse", { + description: + "Presigned POST URL and required form fields for uploading a resume to S3.", + example: { + url: "https://s3.amazonaws.com/bucket-name", + fields: { + "Content-Type": "application/pdf", + success_action_status: "201", + key: "user_abc123.pdf", + AWSAccessKeyId: "AKIA...", + policy: "eyJ...", + signature: "abc123==", + }, + }, + }) +); + +export const S3DownloadUrlResponse = registry.register( + "S3DownloadUrlResponse", + z + .object({ url: z.string() }) + .openapi("S3DownloadUrlResponse", { + description: "Presigned GET URL for downloading a resume from S3.", + example: { + url: "https://s3.amazonaws.com/bucket-name/user_abc123.pdf?X-Amz-Signature=...", + }, + }) +); + +export const S3BatchDownloadResponse = registry.register( + "S3BatchDownloadResponse", + z + .object({ + data: z.array(z.string().nullable()), + errorCount: z.number().int().min(0), + }) + .openapi("S3BatchDownloadResponse", { + description: + "Presigned download URLs for a batch of users. Entries are null when no resume exists.", + example: { + data: [ + "https://s3.amazonaws.com/bucket/user_abc123.pdf?X-Amz-Signature=...", + null, + ], + errorCount: 1, + }, + }) +); export default BatchResumeDownloadValidator; diff --git a/src/services/shifts/shifts-router.ts b/src/services/shifts/shifts-router.ts index 019afabd..37ef85ef 100644 --- a/src/services/shifts/shifts-router.ts +++ b/src/services/shifts/shifts-router.ts @@ -12,6 +12,28 @@ import { const shiftsRouter = Router(); +/** + * @swagger + * /shifts/: + * get: + * summary: Get all shifts + * description: | + * Returns all defined shifts, ordered by start time. + * + * **Required roles: STAFF | ADMIN** + * tags: [Shifts] + * responses: + * 200: + * description: List of all shifts + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/ShiftView' + * security: + * - bearerAuth: [] + */ // Get a list of all defined shifts shiftsRouter.get( "/", @@ -25,6 +47,29 @@ shiftsRouter.get( } ); +/** + * @swagger + * /shifts/my-shifts: + * get: + * summary: Get shifts assigned to the current staff member + * description: | + * Returns all shift assignments for the authenticated staff member, + * including the full shift details for each assignment. + * + * **Required roles: STAFF** + * tags: [Shifts] + * responses: + * 200: + * description: List of the caller's shift assignments with embedded shift details + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/ShiftAssignmentView' + * security: + * - bearerAuth: [] + */ // Get all shifts for the logged-in staff member shiftsRouter.get( "/my-shifts", @@ -42,6 +87,32 @@ shiftsRouter.get( } ); +/** + * @swagger + * /shifts/: + * post: + * summary: Create a shift + * description: | + * Creates a new staff shift. + * + * **Required roles: ADMIN** + * tags: [Shifts] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ShiftCreateValidator' + * responses: + * 201: + * description: The newly created shift + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ShiftView' + * security: + * - bearerAuth: [] + */ // Create a new shift // API body: {String} role, {String} startTime {String} endTime, {String} location shiftsRouter.post("/", RoleChecker([Role.Enum.ADMIN]), async (req, res) => { @@ -55,6 +126,39 @@ shiftsRouter.post("/", RoleChecker([Role.Enum.ADMIN]), async (req, res) => { return res.status(StatusCodes.CREATED).json(newShift); }); +/** + * @swagger + * /shifts/{shiftId}: + * patch: + * summary: Update a shift + * description: | + * Updates one or more fields of an existing shift. + * + * **Required roles: ADMIN** + * tags: [Shifts] + * parameters: + * - name: shiftId + * in: path + * required: true + * schema: + * type: string + * format: uuid + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ShiftUpdateValidator' + * responses: + * 200: + * description: The updated shift + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ShiftView' + * security: + * - bearerAuth: [] + */ // Update a shift's details // URL params: shiftId // API body: { role?, startTime?, endTime?, location? } @@ -75,6 +179,30 @@ shiftsRouter.patch( } ); +/** + * @swagger + * /shifts/{shiftId}: + * delete: + * summary: Delete a shift + * description: | + * Deletes a shift and all its assignments (due to foreign key constraint, + * assignments are removed first). + * + * **Required roles: ADMIN** + * tags: [Shifts] + * parameters: + * - name: shiftId + * in: path + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 204: + * description: Shift successfully deleted + * security: + * - bearerAuth: [] + */ // Delete a shift // URL params: shiftId shiftsRouter.delete( @@ -94,6 +222,39 @@ shiftsRouter.delete( } ); +/** + * @swagger + * /shifts/{shiftId}/assignments: + * post: + * summary: Assign a staff member to a shift + * description: | + * Creates an assignment linking a staff member (by email) to the given shift. + * + * **Required roles: ADMIN** + * tags: [Shifts] + * parameters: + * - name: shiftId + * in: path + * required: true + * schema: + * type: string + * format: uuid + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StaffEmailValidator' + * responses: + * 201: + * description: The newly created assignment + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ShiftAssignmentView' + * security: + * - bearerAuth: [] + */ // Assign a staff member to a shift // URL params: shiftId // API body: { staffEmail } @@ -117,6 +278,35 @@ shiftsRouter.post( } ); +/** + * @swagger + * /shifts/{shiftId}/assignments: + * delete: + * summary: Remove a staff member from a shift + * description: | + * Deletes the assignment for the specified staff member and shift. + * + * **Required roles: ADMIN** + * tags: [Shifts] + * parameters: + * - name: shiftId + * in: path + * required: true + * schema: + * type: string + * format: uuid + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StaffEmailValidator' + * responses: + * 204: + * description: Assignment successfully removed + * security: + * - bearerAuth: [] + */ // Remove a staff member from a shift // URL params: shiftId // API body: { staffEmail } @@ -138,6 +328,29 @@ shiftsRouter.delete( } ); +/** + * @swagger + * /shifts/assignments: + * get: + * summary: Get all shift assignments with staff details + * description: | + * Returns all shift assignments, each including the assigned staff + * member's name and email. + * + * **Required roles: STAFF | ADMIN** + * tags: [Shifts] + * responses: + * 200: + * description: List of all shift assignments with embedded staff details + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/ShiftAssignmentView' + * security: + * - bearerAuth: [] + */ // Get a list of all shifts and the staff assigned to them shiftsRouter.get( "/assignments", @@ -152,6 +365,42 @@ shiftsRouter.get( } ); +/** + * @swagger + * /shifts/{shiftId}/acknowledge: + * post: + * summary: Toggle shift acknowledgment + * description: | + * Toggles the acknowledgment status of the caller's own assignment + * for the given shift. + * + * **Required roles: STAFF** + * tags: [Shifts] + * parameters: + * - name: shiftId + * in: path + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: The updated assignment with new acknowledgment status + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ShiftAssignmentView' + * 404: + * description: No assignment found for this staff member and shift + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Shift assignment not found" + * security: + * - bearerAuth: [] + */ // Toggle shift assignment acknowledgment status // URL params: shiftId // Requires STAFF role - staff can only toggle their own shifts diff --git a/src/services/shifts/shifts-validators.ts b/src/services/shifts/shifts-validators.ts index 7b5d16fb..6bd6bf88 100644 --- a/src/services/shifts/shifts-validators.ts +++ b/src/services/shifts/shifts-validators.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { ShiftRoleType } from "../../database"; import { Tables } from "../../database.types"; +import { registry } from "../../middleware/openapi-registry"; const ShiftRoleTypeEnumValues: [ShiftRoleType, ...ShiftRoleType[]] = [ "CLEAN_UP", @@ -12,64 +13,93 @@ const ShiftRoleTypeEnumValues: [ShiftRoleType, ...ShiftRoleType[]] = [ "CHAIR_ON_CALL", ]; -// Shift role type enum values +export const ShiftRoleTypeEnum = registry.register( + "ShiftRoleTypeEnum", + z + .enum(ShiftRoleTypeEnumValues) + .openapi("ShiftRoleTypeEnum", { description: "Staff shift role type" }) +); // Zod schema for creating a new shift -export const ShiftCreateValidator = z - .object({ - name: z - .string() - .min(1, "Shift name is required") - .max(100, "Shift name must be less than 100 characters"), - role: z.enum(ShiftRoleTypeEnumValues, { - errorMap: () => ({ message: "Invalid shift role type" }), - }), - startTime: z.string().datetime("Invalid start time format"), - endTime: z.string().datetime("Invalid end time format"), - location: z - .string() - .min(1, "Location is required") - .max(200, "Location must be less than 200 characters"), - }) - .refine((data) => new Date(data.startTime) < new Date(data.endTime), { - message: "End time must be after start time", - path: ["endTime"], - }); - -// Zod schema for updating a shift -export const ShiftUpdateValidator = z - .object({ - name: z - .string() - .min(1, "Shift name is required") - .max(100, "Shift name must be less than 100 characters") - .optional(), - role: z - .enum(ShiftRoleTypeEnumValues, { +export const ShiftCreateValidator = registry.register( + "ShiftCreateValidator", + z + .object({ + name: z + .string() + .min(1, "Shift name is required") + .max(100, "Shift name must be less than 100 characters"), + role: z.enum(ShiftRoleTypeEnumValues, { errorMap: () => ({ message: "Invalid shift role type" }), - }) - .optional(), - startTime: z.string().datetime("Invalid start time format").optional(), - endTime: z.string().datetime("Invalid end time format").optional(), - location: z - .string() - .min(1, "Location is required") - .max(200, "Location must be less than 200 characters") - .optional(), - }) - .refine( - (data) => { - // Only validate time order if both times are provided - if (data.startTime && data.endTime) { - return new Date(data.startTime) < new Date(data.endTime); - } - return true; - }, - { + }), + startTime: z.string().datetime("Invalid start time format"), + endTime: z.string().datetime("Invalid end time format"), + location: z + .string() + .min(1, "Location is required") + .max(200, "Location must be less than 200 characters"), + }) + .openapi("ShiftCreateValidator", { + example: { + name: "Morning Check-In", + role: "CHECK_IN", + startTime: "2025-04-01T08:00:00Z", + endTime: "2025-04-01T10:00:00Z", + location: "Siebel Center Lobby", + }, + }) + .refine((data) => new Date(data.startTime) < new Date(data.endTime), { message: "End time must be after start time", path: ["endTime"], - } - ); + }) +); + +// Zod schema for updating a shift +export const ShiftUpdateValidator = registry.register( + "ShiftUpdateValidator", + z + .object({ + name: z + .string() + .min(1, "Shift name is required") + .max(100, "Shift name must be less than 100 characters") + .optional(), + role: z + .enum(ShiftRoleTypeEnumValues, { + errorMap: () => ({ message: "Invalid shift role type" }), + }) + .optional(), + startTime: z + .string() + .datetime("Invalid start time format") + .optional(), + endTime: z + .string() + .datetime("Invalid end time format") + .optional(), + location: z + .string() + .min(1, "Location is required") + .max(200, "Location must be less than 200 characters") + .optional(), + }) + .openapi("ShiftUpdateValidator", { + example: { location: "DCL 1320" }, + }) + .refine( + (data) => { + // Only validate time order if both times are provided + if (data.startTime && data.endTime) { + return new Date(data.startTime) < new Date(data.endTime); + } + return true; + }, + { + message: "End time must be after start time", + path: ["endTime"], + } + ) +); // Zod schema for shift ID parameter export const ShiftIdValidator = z.object({ @@ -77,9 +107,54 @@ export const ShiftIdValidator = z.object({ }); // Zod schema for assigning staff to a shift -export const StaffEmailValidator = z.object({ - staffEmail: z.string().email("Invalid email format"), -}); +export const StaffEmailValidator = registry.register( + "StaffEmailValidator", + z + .object({ staffEmail: z.string().email("Invalid email format") }) + .openapi("StaffEmailValidator", { + example: { staffEmail: "volunteer@illinois.edu" }, + }) +); + +export const ShiftView = registry.register( + "ShiftView", + z + .object({ + shiftId: z.string().uuid(), + name: z.string(), + role: ShiftRoleTypeEnum, + startTime: z.string().openapi({ format: "date-time" }), + endTime: z.string().openapi({ format: "date-time" }), + location: z.string(), + }) + .openapi("ShiftView", { + example: { + shiftId: "3a72d491-c2f9-4baf-af5a-55713621d978", + name: "Morning Check-In", + role: "CHECK_IN", + startTime: "2025-04-01T08:00:00Z", + endTime: "2025-04-01T10:00:00Z", + location: "Siebel Center Lobby", + }, + }) +); + +export const ShiftAssignmentView = registry.register( + "ShiftAssignmentView", + z + .object({ + shiftId: z.string().uuid(), + staffEmail: z.string().email(), + acknowledged: z.boolean(), + }) + .openapi("ShiftAssignmentView", { + example: { + shiftId: "3a72d491-c2f9-4baf-af5a-55713621d978", + staffEmail: "volunteer@illinois.edu", + acknowledged: false, + }, + }) +); export type ShiftCreateRequest = z.infer; export type ShiftUpdateRequest = z.infer; diff --git a/src/services/speakers/speakers-router.ts b/src/services/speakers/speakers-router.ts index 9e3f21a8..a144440b 100644 --- a/src/services/speakers/speakers-router.ts +++ b/src/services/speakers/speakers-router.ts @@ -7,7 +7,27 @@ import { SupabaseDB } from "../../database"; const speakersRouter = Router(); -// Get all speakers +/** + * @swagger + * /speakers/: + * get: + * summary: Get all speakers + * description: | + * Returns a list of all speakers. + * + * **Required roles: none** + * tags: [Speakers] + * responses: + * 200: + * description: A list of all speakers + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/SpeakerValidator' + * security: [] + */ speakersRouter.get("/", RoleChecker([], true), async (req, res) => { const { data: speakers } = await SupabaseDB.SPEAKERS.select("*").throwOnError(); @@ -15,7 +35,37 @@ speakersRouter.get("/", RoleChecker([], true), async (req, res) => { return res.status(StatusCodes.OK).json(speakers); }); -// Get a specific speaker +/** + * @swagger + * /speakers/{SPEAKERID}: + * get: + * summary: Get a speaker by id + * description: | + * Returns a single speaker by their id. + * + * **Required roles: none** + * tags: [Speakers] + * parameters: + * - name: SPEAKERID + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The requested speaker + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SpeakerValidator' + * 404: + * description: Couldn't find the requested speaker + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DoesNotExistError' + * security: [] + */ speakersRouter.get("/:SPEAKERID", RoleChecker([], true), async (req, res) => { const speakerId = req.params.SPEAKERID; @@ -33,6 +83,32 @@ speakersRouter.get("/:SPEAKERID", RoleChecker([], true), async (req, res) => { return res.status(StatusCodes.OK).json(speaker); }); +/** + * @swagger + * /speakers/: + * post: + * summary: Create a speaker + * description: | + * Creates a new speaker and adds them to the database. + * + * **Required roles: ADMIN | STAFF** + * tags: [Speakers] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SpeakerValidator' + * responses: + * 201: + * description: The newly created speaker + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SpeakerValidator' + * security: + * - bearerAuth: [] + */ // Create a new speaker speakersRouter.post( "/", @@ -51,6 +127,44 @@ speakersRouter.post( } ); +/** + * @swagger + * /speakers/{SPEAKERID}: + * put: + * summary: Update a speaker + * description: | + * Updates the data for a pre-existing speaker. + * + * **Required roles: ADMIN | STAFF** + * tags: [Speakers] + * parameters: + * - name: SPEAKERID + * in: path + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateSpeakerValidator' + * responses: + * 200: + * description: The updated speaker + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SpeakerValidator' + * 404: + * description: Couldn't find the requested speaker + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DoesNotExistError' + * security: + * - bearerAuth: [] + */ // Update a speaker speakersRouter.put( "/:SPEAKERID", @@ -77,6 +191,34 @@ speakersRouter.put( } ); +/** + * @swagger + * /speakers/{SPEAKERID}: + * delete: + * summary: Delete a speaker + * description: | + * Deletes a speaker entry from the database. + * + * **Required roles: ADMIN | STAFF** + * tags: [Speakers] + * parameters: + * - name: SPEAKERID + * in: path + * required: true + * schema: + * type: string + * responses: + * 204: + * description: The speaker was successfully deleted + * 404: + * description: Couldn't find the requested speaker + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/DoesNotExistError' + * security: + * - bearerAuth: [] + */ // Delete a speaker speakersRouter.delete( "/:SPEAKERID", diff --git a/src/services/speakers/speakers-schema.ts b/src/services/speakers/speakers-schema.ts index af5b78fa..46b10de7 100644 --- a/src/services/speakers/speakers-schema.ts +++ b/src/services/speakers/speakers-schema.ts @@ -1,25 +1,53 @@ import { Schema } from "mongoose"; import { z } from "zod"; import { v4 as uuidv4 } from "uuid"; +import { registry } from "../../middleware/openapi-registry"; export type SpeakerType = z.infer; export type UpdateSpeakerType = z.infer; // Zod schema for speaker -export const SpeakerValidator = z.object({ - speakerId: z.coerce.string().default(() => uuidv4()), - name: z.string(), - title: z.string(), - bio: z.string(), - eventTitle: z.string(), - eventDescription: z.string(), - imgUrl: z.string(), -}); +export const SpeakerValidator = registry.register( + "SpeakerValidator", + z + .object({ + speakerId: z.coerce.string().default(() => uuidv4()), + name: z.string(), + title: z.string(), + bio: z.string(), + eventTitle: z.string(), + eventDescription: z.string(), + imgUrl: z.string(), + }) + .openapi("SpeakerValidator", { + example: { + speakerId: "3a72d491-c2f9-4baf-af5a-55713621d978", + name: "Jane Doe", + title: "Software Engineer", + bio: "Jane is a software engineer at Acme Corp.", + eventTitle: "Building Scalable Systems", + eventDescription: "A talk about distributed system design.", + imgUrl: "example.com/jane.png", + }, + }) +); // Zod schema for updating speaker (omits speakerId) -export const UpdateSpeakerValidator = SpeakerValidator.omit({ - speakerId: true, -}).strict(); +export const UpdateSpeakerValidator = registry.register( + "UpdateSpeakerValidator", + SpeakerValidator.omit({ speakerId: true }) + .strict() + .openapi("UpdateSpeakerValidator", { + example: { + name: "Jane Doe", + title: "Software Engineer", + bio: "Jane is a software engineer at Acme Corp.", + eventTitle: "Building Scalable Systems", + eventDescription: "A talk about distributed system design.", + imgUrl: "example.com/jane.png", + }, + }) +); // Mongoose schema for speaker export const SpeakerSchema = new Schema({ diff --git a/src/services/staff/staff-router.ts b/src/services/staff/staff-router.ts index d91a3f10..52126c08 100644 --- a/src/services/staff/staff-router.ts +++ b/src/services/staff/staff-router.ts @@ -14,6 +14,49 @@ import Config from "../../config"; const staffRouter = Router(); +/** + * @swagger + * /staff/check-in: + * post: + * summary: Check a staff member into a meeting + * description: | + * Records the authenticated staff member as PRESENT for the given meeting. + * Fails if the meeting window has passed or they are already checked in. + * + * **Required roles: STAFF | ADMIN** + * tags: [Staff] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CheckInValidator' + * responses: + * 200: + * description: Updated staff record reflecting the check-in + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StaffView' + * 400: + * description: Already checked in or meeting window has expired + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "AlreadyCheckedIn" + * 404: + * description: Meeting not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "NotFound" + * security: + * - bearerAuth: [] + */ // Check in to a meeting staffRouter.post( "/check-in", @@ -88,6 +131,47 @@ staffRouter.post( } ); +/** + * @swagger + * /staff/{EMAIL}/attendance: + * post: + * summary: Manually update a staff member's meeting attendance + * description: | + * Sets the attendance status for a specific staff member and meeting. + * + * **Required roles: ADMIN** + * tags: [Staff] + * parameters: + * - name: EMAIL + * in: path + * required: true + * schema: + * type: string + * format: email + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpdateStaffAttendanceValidator' + * responses: + * 200: + * description: Updated staff record with new attendance + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StaffView' + * 404: + * description: Meeting or staff member not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "NotFound" + * security: + * - bearerAuth: [] + */ // Update a staff's attendance staffRouter.post( "/:EMAIL/attendance", @@ -145,6 +229,28 @@ staffRouter.post( } ); +/** + * @swagger + * /staff/: + * get: + * summary: Get all staff members + * description: | + * Returns all staff records. + * + * **Required roles: STAFF | ADMIN** + * tags: [Staff] + * responses: + * 200: + * description: List of all staff members + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/StaffView' + * security: + * - bearerAuth: [] + */ // Get all staff staffRouter.get( "/", @@ -157,6 +263,41 @@ staffRouter.get( } ); +/** + * @swagger + * /staff/{EMAIL}: + * get: + * summary: Get a staff member by email + * description: | + * Returns a single staff record identified by email address. + * + * **Required roles: STAFF | ADMIN** + * tags: [Staff] + * parameters: + * - name: EMAIL + * in: path + * required: true + * schema: + * type: string + * format: email + * responses: + * 200: + * description: The requested staff member + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StaffView' + * 404: + * description: Staff member not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ // Get staff member by ID staffRouter.get( "/:EMAIL", @@ -181,6 +322,41 @@ staffRouter.get( } ); +/** + * @swagger + * /staff/: + * post: + * summary: Create a staff member + * description: | + * Creates a new staff record. Fails if a staff member with the same email + * already exists. + * + * **Required roles: ADMIN** + * tags: [Staff] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StaffView' + * responses: + * 201: + * description: The newly created staff record + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StaffView' + * 400: + * description: Validation error or staff member already exists + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserAlreadyExists" + * security: + * - bearerAuth: [] + */ // Create new staff member staffRouter.post("/", RoleChecker([Role.Enum.ADMIN]), async (req, res) => { // validate input using StaffValidator @@ -217,6 +393,37 @@ staffRouter.post("/", RoleChecker([Role.Enum.ADMIN]), async (req, res) => { return res.status(StatusCodes.CREATED).json(savedStaff); }); +/** + * @swagger + * /staff/{EMAIL}: + * delete: + * summary: Delete a staff member + * description: | + * Deletes the staff record for the given email address. + * + * **Required roles: ADMIN** + * tags: [Staff] + * parameters: + * - name: EMAIL + * in: path + * required: true + * schema: + * type: string + * format: email + * responses: + * 204: + * description: Staff member successfully deleted + * 404: + * description: Staff member not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "UserNotFound" + * security: + * - bearerAuth: [] + */ // Delete staff member by ID staffRouter.delete( "/:EMAIL", diff --git a/src/services/staff/staff-schema.ts b/src/services/staff/staff-schema.ts index b0fcb40c..a8838534 100644 --- a/src/services/staff/staff-schema.ts +++ b/src/services/staff/staff-schema.ts @@ -1,41 +1,79 @@ import { z } from "zod"; import { CommitteeTypes } from "../../database"; - -// Zod schema for staff -export const StaffValidator = z.object({ - email: z.coerce.string(), - name: z.string(), - team: z.nativeEnum(CommitteeTypes), - - // add preprocessor to convert a map into a plain javascript object - attendances: z - .preprocess((val) => { - // If the value is an instance of Map, convert it to a plain object - if (val instanceof Map) { - return Object.fromEntries(val); - } - return val; - }, z.record(z.string())) - .default({}), -}); -export type Staff = z.infer; +import { registry } from "../../middleware/openapi-registry"; export enum StaffAttendanceTypeEnum { PRESENT = "PRESENT", EXCUSED = "EXCUSED", ABSENT = "ABSENT", } -export const StaffAttendanceType = z.nativeEnum(StaffAttendanceTypeEnum); +export const StaffAttendanceType = registry.register( + "StaffAttendanceType", + z + .nativeEnum(StaffAttendanceTypeEnum) + .openapi("StaffAttendanceType", { + description: "Attendance status for a staff meeting", + }) +); export type AttendancesMap = Record; -export const CheckInValidator = z.object({ - meetingId: z.string(), -}); +// Zod schema for staff +export const StaffValidator = registry.register( + "StaffView", + z + .object({ + email: z.coerce.string(), + name: z.string(), + team: z.nativeEnum(CommitteeTypes).openapi({ + description: "Committee the staff member belongs to", + }), + // add preprocessor to convert a map into a plain javascript object + attendances: z + .preprocess((val) => { + // If the value is an instance of Map, convert it to a plain object + if (val instanceof Map) { + return Object.fromEntries(val); + } + return val; + }, z.record(z.string())) + .default({}), + }) + .openapi("StaffView", { + example: { + email: "volunteer@illinois.edu", + name: "Jane Doe", + team: "DEV", + attendances: { + "3a72d491-c2f9-4baf-af5a-55713621d978": "PRESENT", + }, + }, + }) +); +export type Staff = z.infer; + +export const CheckInValidator = registry.register( + "CheckInValidator", + z + .object({ meetingId: z.string() }) + .openapi("CheckInValidator", { + example: { meetingId: "3a72d491-c2f9-4baf-af5a-55713621d978" }, + }) +); -export const UpdateStaffAttendanceValidator = z.object({ - meetingId: z.string(), - attendanceType: StaffAttendanceType, -}); +export const UpdateStaffAttendanceValidator = registry.register( + "UpdateStaffAttendanceValidator", + z + .object({ + meetingId: z.string(), + attendanceType: StaffAttendanceType, + }) + .openapi("UpdateStaffAttendanceValidator", { + example: { + meetingId: "3a72d491-c2f9-4baf-af5a-55713621d978", + attendanceType: "PRESENT", + }, + }) +); // // Mongoose schema for staff // export const StaffSchema = new Schema({ diff --git a/src/services/stats/stats-router.ts b/src/services/stats/stats-router.ts index bfc8e434..d9541828 100644 --- a/src/services/stats/stats-router.ts +++ b/src/services/stats/stats-router.ts @@ -5,9 +5,31 @@ import RoleChecker from "../../middleware/role-checker"; import { Role } from "../auth/auth-models"; import { getCurrentDay } from "../checkin/checkin-utils"; import { z } from "zod"; +import "./stats-schema"; // registers OpenAPI schemas const statsRouter = Router(); +/** + * @swagger + * /stats/check-in: + * get: + * summary: Get unique check-in count + * description: | + * Returns the number of unique attendees who have checked in to any + * CHECKIN-type event. + * + * **Required roles: STAFF** + * tags: [Stats] + * responses: + * 200: + * description: Number of unique check-ins + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsCountResponse' + * security: + * - bearerAuth: [] + */ // Get the number of people checked in (staff only) statsRouter.get( "/check-in", @@ -44,6 +66,42 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/merch-item/{PRICE}: + * get: + * summary: Get count of attendees eligible for a merch item + * description: | + * Returns the number of attendees who have accumulated at least PRICE points + * and are therefore eligible to redeem the corresponding merch item. + * + * **Required roles: STAFF** + * tags: [Stats] + * parameters: + * - name: PRICE + * in: path + * required: true + * schema: + * type: integer + * minimum: 0 + * responses: + * 200: + * description: Number of eligible attendees + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsCountResponse' + * 400: + * description: PRICE is negative + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "PRICE must be non-negative" + * security: + * - bearerAuth: [] + */ // Get the number of people eligible for merch item (staff only) statsRouter.get( "/merch-item/:PRICE", @@ -76,6 +134,27 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/priority-attendee: + * get: + * summary: Get count of priority attendees for today + * description: | + * Returns the number of attendees who have priority access on the + * current day of the week. + * + * **Required roles: STAFF** + * tags: [Stats] + * responses: + * 200: + * description: Number of priority attendees for today + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsCountResponse' + * security: + * - bearerAuth: [] + */ // Get the number of priority attendees (staff only) statsRouter.get( "/priority-attendee", @@ -106,6 +185,42 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/attendance/{N}: + * get: + * summary: Get attendance counts for the past N events + * description: | + * Returns the `attendanceCount` for the N most recently ended events, + * ordered newest-first. + * + * **Required roles: STAFF** + * tags: [Stats] + * parameters: + * - name: N + * in: path + * required: true + * schema: + * type: integer + * minimum: 1 + * responses: + * 200: + * description: Attendance counts for the past N events + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsAttendanceCountsResponse' + * 400: + * description: N is not a positive integer + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "N must be greater than 0" + * security: + * - bearerAuth: [] + */ // Get the attendance of the past n events (staff only) statsRouter.get( "/attendance/:N", @@ -144,6 +259,27 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/dietary-restrictions: + * get: + * summary: Get dietary restriction breakdown + * description: | + * Returns counts of registrations grouped by whether they have allergies, + * dietary restrictions, both, or neither, plus per-item breakdowns. + * + * **Required roles: STAFF** + * tags: [Stats] + * responses: + * 200: + * description: Dietary restriction and allergy breakdown + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsDietaryRestrictionsResponse' + * security: + * - bearerAuth: [] + */ // Get the dietary restriction breakdown/counts (staff only) statsRouter.get( "/dietary-restrictions", @@ -221,6 +357,26 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/registrations: + * get: + * summary: Get total registration count + * description: | + * Returns the total number of submitted registrations. + * + * **Required roles: STAFF** + * tags: [Stats] + * responses: + * 200: + * description: Total registration count + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsCountResponse' + * security: + * - bearerAuth: [] + */ // get the number of registrations statsRouter.get( "/registrations", @@ -235,6 +391,49 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/event/{EVENT_ID}/attendance: + * get: + * summary: Get attendance count for a specific event + * description: | + * Returns the `attendanceCount` recorded for the given event. + * + * **Required roles: STAFF** + * tags: [Stats] + * parameters: + * - name: EVENT_ID + * in: path + * required: true + * schema: + * type: string + * format: uuid + * responses: + * 200: + * description: Attendance count for the event + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsEventAttendanceResponse' + * 400: + * description: EVENT_ID is not a valid UUID + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Invalid uuid" + * 404: + * description: Event not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Event not found" + * security: + * - bearerAuth: [] + */ // event attendance at a specific event statsRouter.get( "/event/:EVENT_ID/attendance", @@ -271,6 +470,26 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/tier-counts: + * get: + * summary: Get attendee count per tier + * description: | + * Returns the number of attendees currently at each tier level. + * + * **Required roles: STAFF** + * tags: [Stats] + * responses: + * 200: + * description: Attendee counts broken down by tier + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsTierCountsResponse' + * security: + * - bearerAuth: [] + */ // Number of people at each tier statsRouter.get( "/tier-counts", @@ -295,6 +514,27 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/tag-counts: + * get: + * summary: Get attendee count per interest tag + * description: | + * Returns a map of each interest tag to the number of attendees who + * selected it during registration. + * + * **Required roles: STAFF** + * tags: [Stats] + * responses: + * 200: + * description: Tag selection counts + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsTagCountsResponse' + * security: + * - bearerAuth: [] + */ // Number of people who marked each tag statsRouter.get( "/tag-counts", @@ -315,6 +555,26 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/merch-redemption-counts: + * get: + * summary: Get merch redemption counts per tier + * description: | + * Returns the number of times each tier's merch item has been redeemed. + * + * **Required roles: STAFF** + * tags: [Stats] + * responses: + * 200: + * description: Merch redemption counts broken down by tier item + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsTierCountsResponse' + * security: + * - bearerAuth: [] + */ // Number of people who redeemed each merch item statsRouter.get( "/merch-redemption-counts", @@ -340,6 +600,42 @@ statsRouter.get( } ); +/** + * @swagger + * /stats/attended-at-least/{N}: + * get: + * summary: Get count of attendees who attended at least N events + * description: | + * Returns the number of attendees whose `eventsAttended` list has at + * least N entries. + * + * **Required roles: STAFF** + * tags: [Stats] + * parameters: + * - name: N + * in: path + * required: true + * schema: + * type: integer + * minimum: 0 + * responses: + * 200: + * description: Number of qualifying attendees + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/StatsCountResponse' + * 400: + * description: N is negative + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "N must be greater equal than 0" + * security: + * - bearerAuth: [] + */ // Take in parameter n, return the number of attendees who attended at least n events statsRouter.get( "/attended-at-least/:N", diff --git a/src/services/stats/stats-schema.ts b/src/services/stats/stats-schema.ts new file mode 100644 index 00000000..4b31f283 --- /dev/null +++ b/src/services/stats/stats-schema.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { registry } from "../../middleware/openapi-registry"; + +export const StatsCountResponse = registry.register( + "StatsCountResponse", + z + .object({ count: z.number().int().min(0) }) + .openapi("StatsCountResponse", { example: { count: 42 } }) +); + +export const StatsAttendanceCountsResponse = registry.register( + "StatsAttendanceCountsResponse", + z + .object({ attendanceCounts: z.array(z.number().int().min(0)) }) + .openapi("StatsAttendanceCountsResponse", { + example: { attendanceCounts: [120, 95, 110] }, + }) +); + +export const StatsDietaryRestrictionsResponse = registry.register( + "StatsDietaryRestrictionsResponse", + z + .object({ + none: z.number().int().min(0), + dietaryRestrictions: z.number().int().min(0), + allergies: z.number().int().min(0), + both: z.number().int().min(0), + allergyCounts: z.record(z.number().int().min(0)), + dietaryRestrictionCounts: z.record(z.number().int().min(0)), + }) + .openapi("StatsDietaryRestrictionsResponse", { + example: { + none: 200, + dietaryRestrictions: 30, + allergies: 15, + both: 5, + allergyCounts: { Peanuts: 10, Shellfish: 5 }, + dietaryRestrictionCounts: { Vegetarian: 20, Vegan: 10 }, + }, + }) +); + +export const StatsTierCountsResponse = registry.register( + "StatsTierCountsResponse", + z + .object({ + TIER1: z.number().int().min(0), + TIER2: z.number().int().min(0), + TIER3: z.number().int().min(0), + TIER4: z.number().int().min(0), + }) + .openapi("StatsTierCountsResponse", { + example: { TIER1: 150, TIER2: 80, TIER3: 30, TIER4: 5 }, + }) +); + +export const StatsTagCountsResponse = registry.register( + "StatsTagCountsResponse", + z + .record(z.number().int().min(0)) + .openapi("StatsTagCountsResponse", { + description: + "Map of tag name to the number of attendees who selected it.", + example: { AI: 85, Cybersecurity: 40, Networking: 60 }, + }) +); + +export const StatsEventAttendanceResponse = registry.register( + "StatsEventAttendanceResponse", + z + .object({ attendanceCount: z.number().int().min(0) }) + .openapi("StatsEventAttendanceResponse", { + example: { attendanceCount: 73 }, + }) +); diff --git a/src/services/subscription/subscription-router.ts b/src/services/subscription/subscription-router.ts index e50fb8a5..adccb837 100644 --- a/src/services/subscription/subscription-router.ts +++ b/src/services/subscription/subscription-router.ts @@ -18,6 +18,40 @@ const sesClient = new SESv2Client({ }, }); +/** + * @swagger + * /subscription/: + * post: + * summary: Subscribe to a mailing list + * description: | + * Creates a subscription for the given user and mailing list. + * Idempotent — if the subscription already exists it is not duplicated. + * + * **Required roles: none** + * tags: [Subscription] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SubscriptionValidator' + * responses: + * 201: + * description: Subscription created (or already existed) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SubscriptionValidator' + * 400: + * description: User not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "User not found." + * security: [] + */ // Create a new subscription subscriptionRouter.post("/", cors(), async (req, res) => { // Validate the incoming user subscription @@ -56,6 +90,28 @@ subscriptionRouter.post("/", cors(), async (req, res) => { return res.status(StatusCodes.CREATED).json(subscriptionData); }); +/** + * @swagger + * /subscription/: + * get: + * summary: Get all subscriptions + * description: | + * Returns every subscription record in the database. + * + * **Required roles: ADMIN** + * tags: [Subscription] + * responses: + * 200: + * description: List of all subscriptions + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/SubscriptionValidator' + * security: + * - bearerAuth: [] + */ // Get a list of all subscriptions - envisioning that admins can use this as dropdown to choose who to send emails to subscriptionRouter.get( "/", @@ -68,6 +124,30 @@ subscriptionRouter.get( } ); +/** + * @swagger + * /subscription/lists: + * get: + * summary: Get all unique mailing list names + * description: | + * Returns a deduplicated list of every mailing list that has at least + * one subscriber. + * + * **Required roles: ADMIN** + * tags: [Subscription] + * responses: + * 200: + * description: List of unique mailing list names + * content: + * application/json: + * schema: + * type: array + * items: + * type: string + * example: ["rp_interest", "attendees"] + * security: + * - bearerAuth: [] + */ subscriptionRouter.get( "/lists", RoleChecker([Role.Enum.ADMIN]), @@ -83,6 +163,41 @@ subscriptionRouter.get( } ); +/** + * @swagger + * /subscription/send-email: + * post: + * summary: Send an email to a mailing list + * description: | + * Sends an HTML email to all subscribers of the given mailing list via + * AWS SES. BCC is used so recipients cannot see each other's addresses. + * + * **Required roles: SUPER_ADMIN** + * tags: [Subscription] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SendEmailValidator' + * responses: + * 200: + * description: Email sent successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SubscriptionSuccessResponse' + * 404: + * description: No subscribers found for this mailing list + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "No subscribers found for this mailing list." + * security: + * - bearerAuth: [] + */ // Send an email to a mailing list // API body: {String} mailingList The list to send the email to, {String} subject The subject line of the email, {String} htmlBody The HTML content of the email. subscriptionRouter.post( @@ -142,6 +257,32 @@ subscriptionRouter.post( } ); +/** + * @swagger + * /subscription/send-email/single: + * post: + * summary: Send an email to a single address + * description: | + * Sends an HTML email to one specific email address via AWS SES. + * + * **Required roles: SUPER_ADMIN** + * tags: [Subscription] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SendEmailSingleValidator' + * responses: + * 200: + * description: Email sent successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SubscriptionSuccessResponse' + * security: + * - bearerAuth: [] + */ // Send an email to a specific person // API body: {String} email (the singular email to send to), {String} subject : The subject line of the email, {String} htmlBody : The HTML content of the email. subscriptionRouter.post( @@ -169,6 +310,46 @@ subscriptionRouter.post( } ); +/** + * @swagger + * /subscription/{mailingList}: + * get: + * summary: Get email addresses for a mailing list + * description: | + * Returns the email addresses of all users subscribed to the given + * mailing list. + * + * **Required roles: ADMIN** + * tags: [Subscription] + * parameters: + * - name: mailingList + * in: path + * required: true + * schema: + * type: string + * example: rp_interest + * responses: + * 200: + * description: List of subscriber email addresses + * content: + * application/json: + * schema: + * type: array + * items: + * type: string + * format: email + * example: ["hacker@example.com", "volunteer@illinois.edu"] + * 404: + * description: No subscribers found for this mailing list + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "No subscribers found for this mailing list." + * security: + * - bearerAuth: [] + */ // Get all the emails in a specific mailing list // Param: mailingList - the name of the mailing list to retrieve subscriptionRouter.get( @@ -209,6 +390,44 @@ subscriptionRouter.get( } ); +/** + * @swagger + * /subscription/user/{userId}: + * get: + * summary: Get a user's mailing list subscriptions + * description: | + * Returns the names of all mailing lists the given user is subscribed to. + * Non-admin users may only query their own subscriptions. + * + * **Required roles: USER | ADMIN** + * tags: [Subscription] + * parameters: + * - name: userId + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: List of mailing list names the user is subscribed to + * content: + * application/json: + * schema: + * type: array + * items: + * type: string + * example: ["rp_interest", "attendees"] + * 403: + * description: Non-admin user attempting to query another user's subscriptions + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Access denied." + * security: + * - bearerAuth: [] + */ // Get a user's subscriptions subscriptionRouter.get( "/user/:userId", @@ -239,6 +458,49 @@ subscriptionRouter.get( } ); +/** + * @swagger + * /subscription/: + * delete: + * summary: Unsubscribe from a mailing list + * description: | + * Removes the given user's subscription from the specified mailing list. + * Non-admin users may only unsubscribe themselves. + * + * **Required roles: USER | ADMIN** + * tags: [Subscription] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UnsubscribeValidator' + * responses: + * 200: + * description: Successfully unsubscribed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SubscriptionSuccessResponse' + * 403: + * description: Non-admin user attempting to unsubscribe another user + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Access denied." + * 404: + * description: Subscription not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Subscription not found." + * security: + * - bearerAuth: [] + */ // Unsubscribe from a mailing list subscriptionRouter.delete( "/", diff --git a/src/services/subscription/subscription-schema.ts b/src/services/subscription/subscription-schema.ts index 03a03bfb..0ebfd02e 100644 --- a/src/services/subscription/subscription-schema.ts +++ b/src/services/subscription/subscription-schema.ts @@ -1,14 +1,22 @@ import mongoose from "mongoose"; import { z } from "zod"; import { MailingListName } from "../../config"; +import { registry } from "../../middleware/openapi-registry"; export type IncomingSubscription = z.infer; // Zod schema for incoming user subscriptions -const SubscriptionValidator = z.object({ - userId: z.string(), - mailingList: MailingListName, -}); +const SubscriptionValidator = registry.register( + "SubscriptionValidator", + z + .object({ + userId: z.string(), + mailingList: MailingListName, + }) + .openapi("SubscriptionValidator", { + example: { userId: "user_abc123", mailingList: "rp_interest" }, + }) +); // Zod schema for validating subscription lists const SubscriptionSchemaValidator = z.object({ @@ -16,6 +24,61 @@ const SubscriptionSchemaValidator = z.object({ mailingList: MailingListName, }); +export const SendEmailValidator = registry.register( + "SendEmailValidator", + z + .object({ + mailingList: z.string(), + subject: z.string(), + htmlBody: z.string(), + }) + .openapi("SendEmailValidator", { + example: { + mailingList: "rp_interest", + subject: "R|P 2025 is here!", + htmlBody: "

Welcome to R|P 2025

", + }, + }) +); + +export const SendEmailSingleValidator = registry.register( + "SendEmailSingleValidator", + z + .object({ + email: z.string().email(), + subject: z.string(), + htmlBody: z.string(), + }) + .openapi("SendEmailSingleValidator", { + example: { + email: "hacker@example.com", + subject: "Your R|P registration", + htmlBody: "

Thanks for registering!

", + }, + }) +); + +export const UnsubscribeValidator = registry.register( + "UnsubscribeValidator", + z + .object({ + userId: z.string(), + mailingList: z.string(), + }) + .openapi("UnsubscribeValidator", { + example: { userId: "user_abc123", mailingList: "rp_interest" }, + }) +); + +export const SubscriptionSuccessResponse = registry.register( + "SubscriptionSuccessResponse", + z + .object({ status: z.literal("success") }) + .openapi("SubscriptionSuccessResponse", { + example: { status: "success" }, + }) +); + // Mongoose schema for subscription const SubscriptionSchema = new mongoose.Schema({ userId: { type: String, required: true }, diff --git a/yarn.lock b/yarn.lock index b5c047b2..dbd8cf1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,6 +10,45 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" +"@apidevtools/json-schema-ref-parser@^9.0.6": + version "9.1.2" + resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz#8ff5386b365d4c9faa7c8b566ff16a46a577d9b8" + integrity sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg== + dependencies: + "@jsdevtools/ono" "^7.1.3" + "@types/json-schema" "^7.0.6" + call-me-maybe "^1.0.1" + js-yaml "^4.1.0" + +"@apidevtools/openapi-schemas@^2.0.4": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz#9fa08017fb59d80538812f03fc7cac5992caaa17" + integrity sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ== + +"@apidevtools/swagger-methods@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267" + integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg== + +"@apidevtools/swagger-parser@10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz#32057ae99487872c4dd96b314a1ab4b95d89eaf5" + integrity sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g== + dependencies: + "@apidevtools/json-schema-ref-parser" "^9.0.6" + "@apidevtools/openapi-schemas" "^2.0.4" + "@apidevtools/swagger-methods" "^3.0.2" + "@jsdevtools/ono" "^7.1.3" + call-me-maybe "^1.0.1" + z-schema "^5.0.1" + +"@asteasolutions/zod-to-openapi@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.3.4.tgz#ccf8c6c32f0092df23e9fc90c5e060316826eb15" + integrity sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA== + dependencies: + openapi3-ts "^4.1.2" + "@aws-crypto/crc32@5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz#cfcc22570949c98c6689cfcbd2d693d36cdae2e1" @@ -1602,6 +1641,11 @@ resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== +"@jsdevtools/ono@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" + integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== + "@mongodb-js/saslprep@^1.1.0", "@mongodb-js/saslprep@^1.1.9": version "1.3.0" resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz#75bb770b4b0908047b6c6ac2ec841047660e1c82" @@ -1700,6 +1744,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@scarf/scarf@=1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@scarf/scarf/-/scarf-1.4.0.tgz#3bbb984085dbd6d982494538b523be1ce6562972" + integrity sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ== + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -2431,7 +2480,17 @@ "@types/express-serve-static-core" "^5.0.0" "@types/serve-static" "*" -"@types/express@^4.17.20", "@types/express@^4.17.21": +"@types/express@4.17.21": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/express@^4.17.20": version "4.17.23" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.23.tgz#35af3193c640bfd4d7fe77191cd0ed411a433bef" integrity sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ== @@ -2480,7 +2539,7 @@ expect "^29.0.0" pretty-format "^29.0.0" -"@types/json-schema@^7.0.15": +"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.6": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -2667,6 +2726,19 @@ "@types/methods" "^1.1.4" "@types/superagent" "^8.1.0" +"@types/swagger-jsdoc@^6.0.4": + version "6.0.4" + resolved "https://registry.yarnpkg.com/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz#bb4f60f3a5f103818e022f2e29ff8935113fb83d" + integrity sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ== + +"@types/swagger-ui-express@^4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz#3c0e0bf2543c7efb500eaa081bfde6d92f88096c" + integrity sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g== + dependencies: + "@types/express" "*" + "@types/serve-static" "*" + "@types/tough-cookie@*": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" @@ -3214,6 +3286,11 @@ call-bound@^1.0.2: call-bind-apply-helpers "^1.0.2" get-intrinsic "^1.3.0" +call-me-maybe@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" + integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ== + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -3310,6 +3387,16 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +commander@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75" + integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -3478,6 +3565,13 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +doctrine@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + dotenv@*: version "16.5.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.5.0.tgz#092b49f25f808f020050051d1ff258e404c78692" @@ -4149,6 +4243,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -5075,6 +5181,11 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -5085,6 +5196,11 @@ lodash.isboolean@^3.0.3: resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + lodash.isinteger@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" @@ -5115,6 +5231,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.mergewith@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" + integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== + lodash.once@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" @@ -5519,6 +5640,18 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +openapi-types@^12.1.3: + version "12.1.3" + resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3" + integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== + +openapi3-ts@^4.1.2: + version "4.5.0" + resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-4.5.0.tgz#1241fcdac0a711d654dfa74feebc2ce7a210790a" + integrity sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ== + dependencies: + yaml "^2.8.0" + optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -6232,6 +6365,39 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swagger-jsdoc@^6.2.8: + version "6.2.8" + resolved "https://registry.yarnpkg.com/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz#6d33d9fb07ff4a7c1564379c52c08989ec7d0256" + integrity sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ== + dependencies: + commander "6.2.0" + doctrine "3.0.0" + glob "7.1.6" + lodash.mergewith "^4.6.2" + swagger-parser "^10.0.3" + yaml "2.0.0-1" + +swagger-parser@^10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/swagger-parser/-/swagger-parser-10.0.3.tgz#04cb01c18c3ac192b41161c77f81e79309135d03" + integrity sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg== + dependencies: + "@apidevtools/swagger-parser" "10.0.3" + +swagger-ui-dist@>=5.0.0: + version "5.32.1" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz#21abc8944763901c9fade461c2c669f5621985e3" + integrity sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ== + dependencies: + "@scarf/scarf" "=1.4.0" + +swagger-ui-express@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz#fb8c1b781d2793a6bd2f8a205a3f4bd6fa020dd8" + integrity sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA== + dependencies: + swagger-ui-dist ">=5.0.0" + tar-stream@^3.1.7: version "3.1.7" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" @@ -6490,6 +6656,11 @@ v8-to-istanbul@^9.0.1: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^2.0.0" +validator@^13.7.0: + version "13.15.26" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.26.tgz#36c3deeab30e97806a658728a155c66fcaa5b944" + integrity sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA== + vary@^1, vary@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -6623,6 +6794,16 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml@2.0.0-1: + version "2.0.0-1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.0.0-1.tgz#8c3029b3ee2028306d5bcf396980623115ff8d18" + integrity sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ== + +yaml@^2.8.0: + version "2.8.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5" + integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== + yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" @@ -6659,7 +6840,18 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +z-schema@^5.0.1: + version "5.0.6" + resolved "https://registry.yarnpkg.com/z-schema/-/z-schema-5.0.6.tgz#46d6a687b15e4a4369e18d6cb1c7b8618fc256c5" + integrity sha512-+XR1GhnWklYdfr8YaZv/iu+vY+ux7V5DS5zH1DQf6bO5ufrt/5cgNhVO5qyhsjFXvsqQb/f08DWE9b6uPscyAg== + dependencies: + lodash.get "^4.4.2" + lodash.isequal "^4.5.0" + validator "^13.7.0" + optionalDependencies: + commander "^10.0.0" + zod@^3.22.4: - version "3.25.63" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.63.tgz#5eac66b56aa9f5e1cd3604dd541b819110474efa" - integrity sha512-3ttCkqhtpncYXfP0f6dsyabbYV/nEUW+Xlu89jiXbTBifUfjaSqXOG6JnQPLtqt87n7KAmnMqcjay6c0Wq0Vbw== + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==