diff --git a/api/middleware/errorHandler.ts b/api/middleware/errorHandler.ts new file mode 100644 index 0000000..3badb31 --- /dev/null +++ b/api/middleware/errorHandler.ts @@ -0,0 +1,11 @@ +import { NextFunction, Request, Response } from "express"; +import { Error as MongooseError } from "mongoose"; + +export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) { + if (err instanceof MongooseError.CastError) { + res.status(400).json({ error: `Invalid ID: ${err.value}` }); + } + else { + res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/api/routes/Alumni.ts b/api/routes/Alumni.ts index 3bc5d72..3dd2bc6 100644 --- a/api/routes/Alumni.ts +++ b/api/routes/Alumni.ts @@ -1,5 +1,6 @@ import { Router, Request, Response } from 'express'; import Alumni from '../models/Alumni'; +import { validate, createAlumniSchema, updateAlumniSchema, CreateAlumniInput, UpdateAlumniInput } from '../schema/AlumniSchema'; const router = Router(); @@ -25,9 +26,9 @@ router.get('/:id', async (req: Request, res: Response) => { }); // Create a new alumni profile -router.post('/', async (req: Request, res: Response) => { +router.post('/', validate(createAlumniSchema), async (req: Request, res: Response) => { try { - const alumni = await new Alumni(req.body).save(); + const alumni = await new Alumni(req.body as CreateAlumniInput).save(); res.status(201).json(alumni); } catch (err) { res.status(500).json({ error: (err as Error).message }); @@ -35,9 +36,9 @@ router.post('/', async (req: Request, res: Response) => { }); // Update an existing alumni profile -router.put('/:id', async (req: Request, res: Response) => { +router.put('/:id', validate(updateAlumniSchema), async (req: Request, res: Response) => { try { - const alumni = await Alumni.findByIdAndUpdate(req.params.id, req.body, { + const alumni = await Alumni.findByIdAndUpdate(req.params.id, req.body as UpdateAlumniInput, { new: true, runValidators: true, }); diff --git a/api/schema/AlumniSchema.ts b/api/schema/AlumniSchema.ts new file mode 100644 index 0000000..8d97e2d --- /dev/null +++ b/api/schema/AlumniSchema.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { Request, Response } from "express"; + +export const createAlumniSchema = z.object({ + bio: z.string().max(2600).optional(), + headline: z.string().max(220).optional(), + profilePhotoUrl: z.url("Must be a valid URL").optional(), + linkedInUrl: z.url("Must be a valid URL").regex(/linkedin\.com\/in/, "Must be a valid LinkedIn URL").optional(), + startYear: z.number().refine((y) => y.toString().length === 4, "Must be a 4-digit year"), + graduationYear: z.number().refine((y) => y.toString().length === 4, "Must be a 4-digit year"), + major: z.string().max(50).optional(), + experiences: z.array( + z.object({ + company: z.string().min(1).max(100), + title: z.string().min(1).max(100), + startDate: z.date().optional(), + endDate: z.date().optional(), + isCurrent: z.boolean(), + description: z.string().max(2000) + }) + ).optional() +}); + +export type CreateAlumniInput = z.infer; + +export const updateAlumniSchema = z.object({ + bio: z.string().max(2600).optional(), + headline: z.string().max(220).optional(), + profilePhotoUrl: z.url("Must be a valid URL").optional(), + linkedInUrl: z.url("Must be a valid URL").regex(/linkedin\.com\/in/, "Must be a valid LinkedIn URL").optional(), + startYear: z.number().refine((y) => y.toString().length === 4, "Must be a 4-digit year").optional(), + graduationYear: z.number().refine((y) => y.toString().length === 4, "Must be a 4-digit year").optional(), + major: z.string().max(50).optional(), + experiences: z.array( + z.object({ + company: z.string().min(1).max(100).optional(), + title: z.string().min(1).max(100).optional(), + startDate: z.date().optional().optional(), + endDate: z.date().optional().optional(), + isCurrent: z.boolean().optional(), + description: z.string().max(2000).optional() + }) + ).optional() +}) +.refine(data => Object.keys(data).length > 0, { + message: "At least one field must be provided for update", +}); + +export type UpdateAlumniInput = z.infer; + +export const validate = (schema: z.ZodObject) => (req: Request, res: Response, next: Function) => { + try { + schema.parse(req.body); + next(); + } catch (err) { + res.status(400).json({ error: (err as Error).message }); + } +}; diff --git a/api/server.ts b/api/server.ts index 74c269f..0c27171 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,12 +1,14 @@ import express from "express"; import mongoose from "mongoose"; import alumniRouter from "./routes/Alumni"; +import { errorHandler } from "./middleware/errorHandler"; const app = express(); const PORT = 8081; app.use(express.json()); app.use("/api", alumniRouter); +app.use(errorHandler); const dbHost = process.env.DATABASE_HOST || "127.0.0.1"; mongoose diff --git a/package-lock.json b/package-lock.json index cbc1bf7..113376d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "ISC", "dependencies": { "express": "^5.2.1", - "mongoose": "^9.3.1" + "mongoose": "^9.3.1", + "zod": "^4.4.2" }, "devDependencies": { "@types/express": "^5.0.6", @@ -1689,6 +1690,15 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 28f0111..49a2711 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "homepage": "https://github.com/SCE-Development/sce-linkedin#readme", "dependencies": { "express": "^5.2.1", - "mongoose": "^9.3.1" + "mongoose": "^9.3.1", + "zod": "^4.4.2" }, "devDependencies": { "@types/express": "^5.0.6",