diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 00f8d2fe..7b246d0d 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -228,18 +228,90 @@ const duplicateSchema = z.object({ targetDate: dateSchema, }); -const requestDetailsSchema = z.record(z.string(), z.unknown()).default({}); +const isoDateOrDatetime = z.union([ + z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + z.string().datetime(), +]); +const trimmedString = (max: number) => z.string().trim().min(1).max(max); -const requestCreateSchema = z.object({ - meetingId: positiveIntSchema.nullable().optional(), - requestType: z.enum(REQUEST_TYPES), - requestDetails: requestDetailsSchema, -}); +const detailsByType = { + create: z.object({ + titleAr: trimmedString(200).optional(), + titleEn: trimmedString(200).optional(), + meetingDate: isoDateOrDatetime.optional(), + notes: z.string().trim().max(2000).optional(), + }), + edit: z.object({ + titleAr: z.string().trim().max(200).optional(), + titleEn: z.string().trim().max(200).optional(), + notes: z.string().trim().max(2000).optional(), + }), + delete: z.object({ + reason: z.string().trim().max(2000).optional(), + }), + reschedule: z.object({ + newDate: isoDateOrDatetime, + newTime: z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/).optional(), + notes: z.string().trim().max(2000).optional(), + }), + add_attendee: z.object({ + nameAr: trimmedString(200), + nameEn: z.string().trim().max(200).optional(), + role: z.string().trim().max(120).optional(), + notes: z.string().trim().max(2000).optional(), + }), + remove_attendee: z.object({ + attendeeId: z.number().int().positive().optional(), + nameAr: z.string().trim().max(200).optional(), + notes: z.string().trim().max(2000).optional(), + }), + change_location: z.object({ + location: trimmedString(200), + notes: z.string().trim().max(2000).optional(), + }), + cancel_meeting: z.object({ + reason: z.string().trim().max(2000).optional(), + }), + note: z + .object({ + text: z.string().trim().max(2000).optional(), + note: z.string().trim().max(2000).optional(), + }) + .catchall(z.unknown()) + .refine( + (v) => Boolean((v.text ?? v.note ?? "").trim()), + { message: "note text required" }, + ), + highlight: z.object({ + notes: z.string().trim().max(2000).optional(), + }), + unhighlight: z.object({ + notes: z.string().trim().max(2000).optional(), + }), + other: z.object({}).catchall(z.unknown()), +} as const; -const requestNestedCreateSchema = z.object({ - requestType: z.enum(REQUEST_TYPES), - requestDetails: requestDetailsSchema, -}); +const requestPayloadSchemas = z.discriminatedUnion("requestType", [ + z.object({ requestType: z.literal("create"), requestDetails: detailsByType.create }), + z.object({ requestType: z.literal("edit"), requestDetails: detailsByType.edit }), + z.object({ requestType: z.literal("delete"), requestDetails: detailsByType.delete }), + z.object({ requestType: z.literal("reschedule"), requestDetails: detailsByType.reschedule }), + z.object({ requestType: z.literal("add_attendee"), requestDetails: detailsByType.add_attendee }), + z.object({ requestType: z.literal("remove_attendee"), requestDetails: detailsByType.remove_attendee }), + z.object({ requestType: z.literal("change_location"), requestDetails: detailsByType.change_location }), + z.object({ requestType: z.literal("cancel_meeting"), requestDetails: detailsByType.cancel_meeting }), + z.object({ requestType: z.literal("note"), requestDetails: detailsByType.note }), + z.object({ requestType: z.literal("highlight"), requestDetails: detailsByType.highlight }), + z.object({ requestType: z.literal("unhighlight"), requestDetails: detailsByType.unhighlight }), + z.object({ requestType: z.literal("other"), requestDetails: detailsByType.other }), +]); + +const requestCreateSchema = z.intersection( + z.object({ meetingId: positiveIntSchema.nullable().optional() }), + requestPayloadSchemas, +); + +const requestNestedCreateSchema = requestPayloadSchemas; const requestReviewSchema = z.union([ z.object({ @@ -933,11 +1005,6 @@ router.get( const requesterRaw = Number(req.query.requester); const userId = req.session.userId!; - // SERVER-SIDE LEAST-PRIVILEGE: only approver/admin/audit roles can see - // requests submitted by other users. Everyone else (CEO, coordinators, - // viewers) is silently scoped to their own submissions regardless of - // what the client asked for. The client may still pass mine=1 to - // narrow further, but it cannot escape the requester filter. const names = await getRoleNamesForUser(userId); const canSeeAllRequests = APPROVE_ROLES.some((r) => names.has(r)) ||