import { Router, type IRouter } from "express"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import { and, asc, desc, eq, gte, inArray, lt, lte, ne, sql } from "drizzle-orm"; import { z, type ZodType } from "zod"; import { db } from "@workspace/db"; import { protocolRoomsTable, protocolRoomBookingsTable, protocolExternalMeetingsTable, protocolGiftsTable, protocolGiftIssuesTable, protocolAuditLogsTable, rolesTable, permissionsTable, rolePermissionsTable, usersTable, PROTOCOL_GIFT_KINDS, } from "@workspace/db"; import { requireProtocolAccess, getEffectiveRoleIds, userHasPermission, } from "../middlewares/auth"; import type { Request, Response, NextFunction } from "express"; const router: IRouter = Router(); router.param("id", (req, res, next, value) => { if (!/^\d+$/.test(String(value))) { next("route"); return; } next(); }); // --------------------------------------------------------------------------- // Permission helpers // --------------------------------------------------------------------------- // Scoped protocol permissions. Access to protocol routes is guarded by these // permissions (seeded and mapped to the five protocol roles), never by role // names — so an admin can delegate any capability by granting the permission. const PROTOCOL_PERMS = { // Read + tile visibility. Kept in lockstep with the app_permissions grant so // backend usability matches exactly what makes the home-screen tile visible. read: "protocol.access", // Submit booking / issuance requests (created as "pending"). request: "protocol.request", // Create/edit records directly (no approval needed to author). mutate: "protocol.mutate", // Approve/reject bookings and gift issuances. approve: "protocol.approve", // Manage the rooms registry. rooms: "protocol.rooms.manage", // View the audit log. audit: "protocol.audit.read", } as const; async function getRoleNamesForUser(userId: number): Promise> { const ids = await getEffectiveRoleIds(userId); if (ids.length === 0) return new Set(); const rows = await db .select({ name: rolesTable.name }) .from(rolesTable) .where(inArray(rolesTable.id, ids)); return new Set(rows.map((r) => r.name)); } // Effective protocol permission names held by the user (via any effective // role). One query, used to compute the capabilities payload for /me. async function getProtocolPermsForUser(userId: number): Promise> { const ids = await getEffectiveRoleIds(userId); if (ids.length === 0) return new Set(); const rows = await db .select({ name: permissionsTable.name }) .from(rolePermissionsTable) .innerJoin( permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id), ) .where( and( inArray(rolePermissionsTable.roleId, ids), inArray(permissionsTable.name, Object.values(PROTOCOL_PERMS)), ), ); return new Set(rows.map((r) => r.name)); } function makeRequirePerm(perm: string) { return async function ( req: Request, res: Response, next: NextFunction, ): Promise { if (!req.session.userId) { res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); return; } // Mirror requireProtocolAccess: a deactivated user with a still-valid // session must not be able to reach mutating/approval endpoints. const [user] = await db .select({ isActive: usersTable.isActive }) .from(usersTable) .where(eq(usersTable.id, req.session.userId)); if (!user || !user.isActive) { res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); return; } const ok = await userHasPermission(req.session.userId, perm); if (!ok) { res.status(403).json({ error: "Forbidden", code: "forbidden" }); return; } next(); }; } const requireRequest = makeRequirePerm(PROTOCOL_PERMS.request); const requireMutate = makeRequirePerm(PROTOCOL_PERMS.mutate); const requireApprove = makeRequirePerm(PROTOCOL_PERMS.approve); const requireManageRooms = makeRequirePerm(PROTOCOL_PERMS.rooms); const requireAudit = makeRequirePerm(PROTOCOL_PERMS.audit); // --------------------------------------------------------------------------- // Zod schemas // --------------------------------------------------------------------------- const isoDateTime = z.string().datetime({ offset: true }); function parseBody( res: Response, schema: ZodType, body: unknown, ): T | null { const r = schema.safeParse(body ?? {}); if (!r.success) { const issue = r.error.issues[0]; const msg = issue ? `${issue.path.join(".") || "body"}: ${issue.message}` : "validation"; res.status(400).json({ error: msg, code: "validation" }); return null; } return r.data; } const roomCreateSchema = z.object({ nameAr: z.string().trim().min(1).max(200), nameEn: z.string().trim().max(200).optional().default(""), capacity: z.number().int().positive().max(100000).nullable().optional(), location: z.string().trim().max(300).nullable().optional(), isActive: z.boolean().optional(), sortOrder: z.number().int().min(0).max(100000).optional(), }); const roomPatchSchema = roomCreateSchema.partial().refine( (v) => Object.keys(v).length > 0, { message: "no fields to update" }, ); const bookingCreateSchema = z .object({ roomId: z.number().int().positive(), title: z.string().trim().min(1).max(500), requesterName: z.string().trim().max(300).nullable().optional(), startsAt: isoDateTime, endsAt: isoDateTime, notes: z.string().trim().max(5000).nullable().optional(), }) .refine((v) => new Date(v.startsAt) < new Date(v.endsAt), { message: "startsAt must be before endsAt", path: ["endsAt"], }); const bookingPatchSchema = z .object({ roomId: z.number().int().positive().optional(), title: z.string().trim().min(1).max(500).optional(), requesterName: z.string().trim().max(300).nullable().optional(), startsAt: isoDateTime.optional(), endsAt: isoDateTime.optional(), notes: z.string().trim().max(5000).nullable().optional(), }) .refine((v) => Object.keys(v).length > 0, { message: "no fields to update" }); // Public (no-login) booking request. Stricter than the internal schema: // full name, contact phone and title are all REQUIRED, and a hidden // honeypot field (`website`) must stay empty — a filled honeypot is a bot. const publicBookingRequestSchema = z .object({ roomId: z.number().int().positive(), title: z.string().trim().min(1).max(500), requesterName: z.string().trim().min(1).max(300), requesterPhone: z.string().trim().min(3).max(40), requesterOrg: z.string().trim().max(300).nullable().optional(), startsAt: isoDateTime, endsAt: isoDateTime, notes: z.string().trim().max(5000).nullable().optional(), // Honeypot: real users never see or fill this. We accept any value at // the schema level (so a bot gets no "invalid field" signal) and handle // a non-empty value in the route by silently pretending success. website: z.string().max(200).optional(), }) .refine((v) => new Date(v.startsAt) < new Date(v.endsAt), { message: "startsAt must be before endsAt", path: ["endsAt"], }); const rejectSchema = z.object({ reason: z.string().trim().max(1000).nullable().optional(), }); const externalCreateSchema = z .object({ title: z.string().trim().min(1).max(500), partyName: z.string().trim().max(300).nullable().optional(), location: z.string().trim().max(300).nullable().optional(), startsAt: isoDateTime, endsAt: isoDateTime.nullable().optional(), notes: z.string().trim().max(5000).nullable().optional(), }) .refine( (v) => !v.endsAt || new Date(v.startsAt) < new Date(v.endsAt), { message: "startsAt must be before endsAt", path: ["endsAt"] }, ); // Edits may only mark an already-decided meeting completed/cancelled; the // pending -> approved | rejected transition goes through the dedicated // approve/reject endpoints so it stays state-conditional. const externalPatchSchema = z .object({ title: z.string().trim().min(1).max(500).optional(), partyName: z.string().trim().max(300).nullable().optional(), location: z.string().trim().max(300).nullable().optional(), startsAt: isoDateTime.optional(), endsAt: isoDateTime.nullable().optional(), status: z.enum(["completed", "cancelled"]).optional(), notes: z.string().trim().max(5000).nullable().optional(), }) .refine((v) => Object.keys(v).length > 0, { message: "no fields to update" }); const giftCreateSchema = z.object({ nameAr: z.string().trim().min(1).max(300), nameEn: z.string().trim().max(300).optional().default(""), kind: z.enum(PROTOCOL_GIFT_KINDS).optional(), descriptionAr: z.string().trim().max(5000).nullable().optional(), descriptionEn: z.string().trim().max(5000).nullable().optional(), imageUrl: z.string().trim().max(500).nullable().optional(), quantityInStock: z.number().int().min(0).max(1000000).optional(), isActive: z.boolean().optional(), }); const giftPatchSchema = giftCreateSchema.partial().refine( (v) => Object.keys(v).length > 0, { message: "no fields to update" }, ); const issueCreateSchema = z.object({ giftId: z.number().int().positive(), recipientName: z.string().trim().min(1).max(300), occasion: z.string().trim().max(300).nullable().optional(), quantity: z.number().int().positive().max(100000).optional(), notes: z.string().trim().max(5000).nullable().optional(), }); // --------------------------------------------------------------------------- // Audit helper // --------------------------------------------------------------------------- type DbExecutor = typeof db | Parameters[0]>[0]; async function logAudit( executor: DbExecutor, opts: { action: string; entityType: string; entityId: number | null; oldValue?: unknown; newValue?: unknown; performedBy: number | null; }, ): Promise { await executor.insert(protocolAuditLogsTable).values({ action: opts.action, entityType: opts.entityType, entityId: opts.entityId ?? null, oldValue: (opts.oldValue ?? null) as never, newValue: (opts.newValue ?? null) as never, performedBy: opts.performedBy, }); } // Overlap / conflict rule: a booking occupies [startsAt, endsAt). Two // bookings for the same room conflict when // newStart < existingEnd AND newEnd > existingStart // Only "pending" and "approved" rows count as occupying the room. const OCCUPYING_STATUSES = ["pending", "approved"] as const; const CONFLICT_MESSAGE_AR = "لا يمكن إتمام الحجز، توجد حجز آخر لنفس القاعة في نفس الوقت."; // Serialize all booking mutations for a given room by taking a row-level // lock on the room inside the transaction. Any concurrent create / edit / // approve for the same room blocks until this transaction commits, which // makes the check-then-write conflict rule atomic without needing a // btree_gist exclusion constraint (additive-only migration requirement). async function lockRoomRow( tx: Parameters[0]>[0], roomId: number, ): Promise { const rows = await tx .select({ id: protocolRoomsTable.id }) .from(protocolRoomsTable) .where(eq(protocolRoomsTable.id, roomId)) .for("update"); return rows.length > 0; } async function hasBookingConflict( executor: DbExecutor, opts: { roomId: number; startsAt: Date; endsAt: Date; excludeId?: number; statuses?: ReadonlyArray; }, ): Promise { const statuses = opts.statuses ?? OCCUPYING_STATUSES; const conds = [ eq(protocolRoomBookingsTable.roomId, opts.roomId), inArray(protocolRoomBookingsTable.status, statuses as string[]), sql`${protocolRoomBookingsTable.startsAt} < ${opts.endsAt.toISOString()}`, sql`${protocolRoomBookingsTable.endsAt} > ${opts.startsAt.toISOString()}`, ]; if (opts.excludeId !== undefined) { conds.push(ne(protocolRoomBookingsTable.id, opts.excludeId)); } const rows = await executor .select({ id: protocolRoomBookingsTable.id }) .from(protocolRoomBookingsTable) .where(and(...conds)) .limit(1); return rows.length > 0; } // --------------------------------------------------------------------------- // Capabilities // --------------------------------------------------------------------------- router.get( "/protocol/me", requireProtocolAccess, async (req: Request, res: Response) => { const userId = req.session.userId!; const [names, perms] = await Promise.all([ getRoleNamesForUser(userId), getProtocolPermsForUser(userId), ]); res.json({ userId, roles: Array.from(names), canRead: true, canRequest: perms.has(PROTOCOL_PERMS.request), canMutate: perms.has(PROTOCOL_PERMS.mutate), canApprove: perms.has(PROTOCOL_PERMS.approve), canManageRooms: perms.has(PROTOCOL_PERMS.rooms), canViewAudit: perms.has(PROTOCOL_PERMS.audit), }); }, ); // --------------------------------------------------------------------------- // Public (no-login) booking request link // // These two endpoints power the guest-facing room booking request form. They // are intentionally NOT guarded by requireProtocolAccess so anyone with the // link can pick a room and submit a request. Requests are always created as // `pending` + `source = "public"` with no createdBy, and land in the exact // same Protocol staff confirm/reject flow as internal pending bookings. // // Abuse is limited by (a) an IP rate limiter, (b) a hidden honeypot field, // and (c) never exposing the room schedule — a guest only sees the active // room list, never who booked what or when. // --------------------------------------------------------------------------- const publicBookingLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 8, standardHeaders: true, legacyHeaders: false, // Key on the proxy-derived client IP. The app sets `trust proxy = 1`, so // `req.ip` is the address inserted by the single trusted edge proxy and is // NOT client-spoofable via X-Forwarded-For. `ipKeyGenerator` also applies // the correct IPv6 subnet normalization so a single IPv6 client cannot // rotate through addresses within its /64 to defeat the limit. keyGenerator: (req: Request) => ipKeyGenerator(req.ip ?? ""), message: { error: "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.", code: "rate_limited", }, }); router.get( "/protocol/public/rooms", async (_req: Request, res: Response) => { // Only the minimal fields a guest needs to choose a room. No capacity / // location / audit fields, and only active rooms. const rows = await db .select({ id: protocolRoomsTable.id, nameAr: protocolRoomsTable.nameAr, nameEn: protocolRoomsTable.nameEn, }) .from(protocolRoomsTable) .where(eq(protocolRoomsTable.isActive, true)) .orderBy(asc(protocolRoomsTable.sortOrder), asc(protocolRoomsTable.id)); res.json(rows); }, ); router.post( "/protocol/public/booking-requests", publicBookingLimiter, async (req: Request, res: Response) => { const body = parseBody(res, publicBookingRequestSchema, req.body); if (!body) return; // Honeypot tripped — pretend success so bots get no signal, but never // write anything. if (body.website) { res.status(201).json({ ok: true }); return; } const startsAt = new Date(body.startsAt); const endsAt = new Date(body.endsAt); const [room] = await db .select({ id: protocolRoomsTable.id, isActive: protocolRoomsTable.isActive }) .from(protocolRoomsTable) .where(eq(protocolRoomsTable.id, body.roomId)); if (!room || !room.isActive) { res.status(400).json({ error: "room not found", code: "invalid_room" }); return; } try { const created = await db.transaction(async (tx) => { if (!(await lockRoomRow(tx, body.roomId))) { throw new Error("__room_missing__"); } if ( await hasBookingConflict(tx, { roomId: body.roomId, startsAt, endsAt, }) ) { throw new Error("__conflict__"); } const [row] = await tx .insert(protocolRoomBookingsTable) .values({ roomId: body.roomId, title: body.title, requesterName: body.requesterName, requesterPhone: body.requesterPhone, requesterOrg: body.requesterOrg ?? null, startsAt, endsAt, status: "pending", source: "public", notes: body.notes ?? null, createdBy: null, }) .returning(); await logAudit(tx, { action: "booking.public_request", entityType: "booking", entityId: row.id, newValue: row, performedBy: null, }); return row; }); // Deliberately return only an acknowledgement — never the stored row // (which could leak internal ids / other data) to an anonymous caller. res.status(201).json({ ok: true, id: created.id }); } catch (e) { if (e instanceof Error && e.message === "__conflict__") { res .status(409) .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" }); return; } if (e instanceof Error && e.message === "__room_missing__") { res.status(400).json({ error: "room not found", code: "invalid_room" }); return; } throw e; } }, ); // --------------------------------------------------------------------------- // Rooms // --------------------------------------------------------------------------- router.get( "/protocol/rooms", requireProtocolAccess, async (_req: Request, res: Response) => { const rows = await db .select() .from(protocolRoomsTable) .orderBy(asc(protocolRoomsTable.sortOrder), asc(protocolRoomsTable.id)); res.json(rows); }, ); router.post( "/protocol/rooms", requireManageRooms, async (req: Request, res: Response) => { const body = parseBody(res, roomCreateSchema, req.body); if (!body) return; const [row] = await db .insert(protocolRoomsTable) .values({ nameAr: body.nameAr, nameEn: body.nameEn ?? "", capacity: body.capacity ?? null, location: body.location ?? null, isActive: body.isActive ?? true, sortOrder: body.sortOrder ?? 0, createdBy: req.session.userId!, }) .returning(); await logAudit(db, { action: "room.create", entityType: "room", entityId: row.id, newValue: row, performedBy: req.session.userId!, }); res.status(201).json(row); }, ); router.patch( "/protocol/rooms/:id", requireManageRooms, async (req: Request, res: Response) => { const id = Number(req.params.id); const body = parseBody(res, roomPatchSchema, req.body); if (!body) return; const [existing] = await db .select() .from(protocolRoomsTable) .where(eq(protocolRoomsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } const [row] = await db .update(protocolRoomsTable) .set({ ...(body.nameAr !== undefined ? { nameAr: body.nameAr } : {}), ...(body.nameEn !== undefined ? { nameEn: body.nameEn } : {}), ...(body.capacity !== undefined ? { capacity: body.capacity } : {}), ...(body.location !== undefined ? { location: body.location } : {}), ...(body.isActive !== undefined ? { isActive: body.isActive } : {}), ...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}), }) .where(eq(protocolRoomsTable.id, id)) .returning(); await logAudit(db, { action: "room.update", entityType: "room", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); res.json(row); }, ); router.delete( "/protocol/rooms/:id", requireManageRooms, async (req: Request, res: Response) => { const id = Number(req.params.id); const [existing] = await db .select() .from(protocolRoomsTable) .where(eq(protocolRoomsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } await db.delete(protocolRoomsTable).where(eq(protocolRoomsTable.id, id)); await logAudit(db, { action: "room.delete", entityType: "room", entityId: id, oldValue: existing, performedBy: req.session.userId!, }); res.status(204).end(); }, ); // --------------------------------------------------------------------------- // Bookings // --------------------------------------------------------------------------- router.get( "/protocol/bookings", requireProtocolAccess, async (req: Request, res: Response) => { const roomIdRaw = req.query.roomId; const statusRaw = req.query.status; const fromRaw = req.query.from; const toRaw = req.query.to; const conds = []; if (typeof roomIdRaw === "string" && /^\d+$/.test(roomIdRaw)) { conds.push(eq(protocolRoomBookingsTable.roomId, Number(roomIdRaw))); } if ( typeof statusRaw === "string" && ["pending", "approved", "rejected", "cancelled"].includes(statusRaw) ) { conds.push(eq(protocolRoomBookingsTable.status, statusRaw)); } if (typeof fromRaw === "string" && !Number.isNaN(Date.parse(fromRaw))) { conds.push( gte(protocolRoomBookingsTable.startsAt, new Date(fromRaw)), ); } if (typeof toRaw === "string" && !Number.isNaN(Date.parse(toRaw))) { conds.push(lte(protocolRoomBookingsTable.startsAt, new Date(toRaw))); } const rows = await db .select() .from(protocolRoomBookingsTable) .where(conds.length > 0 ? and(...conds) : undefined) .orderBy(desc(protocolRoomBookingsTable.startsAt)); res.json(rows); }, ); router.post( "/protocol/bookings", requireRequest, async (req: Request, res: Response) => { const body = parseBody(res, bookingCreateSchema, req.body); if (!body) return; const startsAt = new Date(body.startsAt); const endsAt = new Date(body.endsAt); const [room] = await db .select({ id: protocolRoomsTable.id, isActive: protocolRoomsTable.isActive }) .from(protocolRoomsTable) .where(eq(protocolRoomsTable.id, body.roomId)); if (!room) { res.status(400).json({ error: "room not found", code: "invalid_room" }); return; } // Non-approvers submit requests as "pending"; users with the approve // permission may create an already-approved booking directly. Either way // the slot is checked. const canApprove = await userHasPermission( req.session.userId!, PROTOCOL_PERMS.approve, ); try { const created = await db.transaction(async (tx) => { // Lock the room to serialize concurrent bookings, then check. if (!(await lockRoomRow(tx, body.roomId))) { throw new Error("__room_missing__"); } if ( await hasBookingConflict(tx, { roomId: body.roomId, startsAt, endsAt, }) ) { throw new Error("__conflict__"); } const [row] = await tx .insert(protocolRoomBookingsTable) .values({ roomId: body.roomId, title: body.title, requesterName: body.requesterName ?? null, startsAt, endsAt, status: canApprove ? "approved" : "pending", notes: body.notes ?? null, createdBy: req.session.userId!, approvedBy: canApprove ? req.session.userId! : null, approvedAt: canApprove ? new Date() : null, }) .returning(); await logAudit(tx, { action: "booking.create", entityType: "booking", entityId: row.id, newValue: row, performedBy: req.session.userId!, }); return row; }); res.status(201).json(created); } catch (e) { if (e instanceof Error && e.message === "__conflict__") { res .status(409) .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" }); return; } if (e instanceof Error && e.message === "__room_missing__") { res.status(400).json({ error: "room not found", code: "invalid_room" }); return; } throw e; } }, ); router.patch( "/protocol/bookings/:id", requireMutate, async (req: Request, res: Response) => { const id = Number(req.params.id); const body = parseBody(res, bookingPatchSchema, req.body); if (!body) return; const [existing] = await db .select() .from(protocolRoomBookingsTable) .where(eq(protocolRoomBookingsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } const roomId = body.roomId ?? existing.roomId; const startsAt = body.startsAt ? new Date(body.startsAt) : existing.startsAt; const endsAt = body.endsAt ? new Date(body.endsAt) : existing.endsAt; if (startsAt >= endsAt) { res .status(400) .json({ error: "startsAt must be before endsAt", code: "validation" }); return; } try { const updated = await db.transaction(async (tx) => { // Lock the (possibly new) room to serialize against concurrent // bookings before the overlap re-check. if (!(await lockRoomRow(tx, roomId))) { throw new Error("__room_missing__"); } // Only re-check the slot if the booking still occupies the room. if ( (existing.status === "pending" || existing.status === "approved") && (await hasBookingConflict(tx, { roomId, startsAt, endsAt, excludeId: id, })) ) { throw new Error("__conflict__"); } const [row] = await tx .update(protocolRoomBookingsTable) .set({ roomId, ...(body.title !== undefined ? { title: body.title } : {}), ...(body.requesterName !== undefined ? { requesterName: body.requesterName } : {}), startsAt, endsAt, ...(body.notes !== undefined ? { notes: body.notes } : {}), }) .where(eq(protocolRoomBookingsTable.id, id)) .returning(); await logAudit(tx, { action: "booking.update", entityType: "booking", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); return row; }); res.json(updated); } catch (e) { if (e instanceof Error && e.message === "__conflict__") { res .status(409) .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" }); return; } if (e instanceof Error && e.message === "__room_missing__") { res.status(400).json({ error: "room not found", code: "invalid_room" }); return; } throw e; } }, ); router.post( "/protocol/bookings/:id/approve", requireApprove, async (req: Request, res: Response) => { const id = Number(req.params.id); try { const updated = await db.transaction(async (tx) => { // Re-read the booking inside the transaction so status checks and // the conflict guard see a consistent snapshot. const [existing] = await tx .select() .from(protocolRoomBookingsTable) .where(eq(protocolRoomBookingsTable.id, id)); if (!existing) throw new Error("__not_found__"); // Lock the room to serialize against concurrent approvals/bookings. await lockRoomRow(tx, existing.roomId); if (existing.status !== "pending") { throw new Error("__invalid_status__"); } // Guard against approving two overlapping pending requests: only // other APPROVED bookings block the approval here. if ( await hasBookingConflict(tx, { roomId: existing.roomId, startsAt: existing.startsAt, endsAt: existing.endsAt, excludeId: id, statuses: ["approved"], }) ) { throw new Error("__conflict__"); } // State-conditional update: only flip a still-pending row. const [row] = await tx .update(protocolRoomBookingsTable) .set({ status: "approved", approvedBy: req.session.userId!, approvedAt: new Date(), rejectionReason: null, }) .where( and( eq(protocolRoomBookingsTable.id, id), eq(protocolRoomBookingsTable.status, "pending"), ), ) .returning(); if (!row) throw new Error("__invalid_status__"); await logAudit(tx, { action: "booking.approve", entityType: "booking", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); return row; }); res.json(updated); } catch (e) { if (e instanceof Error) { if (e.message === "__conflict__") { res .status(409) .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" }); return; } if (e.message === "__not_found__") { res.status(404).json({ error: "Not found", code: "not_found" }); return; } if (e.message === "__invalid_status__") { res .status(409) .json({ error: "booking is not pending", code: "invalid_status" }); return; } } throw e; } }, ); router.post( "/protocol/bookings/:id/reject", requireApprove, async (req: Request, res: Response) => { const id = Number(req.params.id); const body = parseBody(res, rejectSchema, req.body); if (!body) return; const [existing] = await db .select() .from(protocolRoomBookingsTable) .where(eq(protocolRoomBookingsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } // Only a pending booking can be rejected; the state-conditional update // makes this atomic against a concurrent approve/cancel. const [row] = await db .update(protocolRoomBookingsTable) .set({ status: "rejected", approvedBy: req.session.userId!, approvedAt: new Date(), rejectionReason: body.reason ?? null, }) .where( and( eq(protocolRoomBookingsTable.id, id), eq(protocolRoomBookingsTable.status, "pending"), ), ) .returning(); if (!row) { res .status(409) .json({ error: "booking is not pending", code: "invalid_status" }); return; } await logAudit(db, { action: "booking.reject", entityType: "booking", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); res.json(row); }, ); router.post( "/protocol/bookings/:id/cancel", requireMutate, async (req: Request, res: Response) => { const id = Number(req.params.id); const [existing] = await db .select() .from(protocolRoomBookingsTable) .where(eq(protocolRoomBookingsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } const [row] = await db .update(protocolRoomBookingsTable) .set({ status: "cancelled" }) .where(eq(protocolRoomBookingsTable.id, id)) .returning(); await logAudit(db, { action: "booking.cancel", entityType: "booking", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); res.json(row); }, ); router.delete( "/protocol/bookings/:id", requireMutate, async (req: Request, res: Response) => { const id = Number(req.params.id); const [existing] = await db .select() .from(protocolRoomBookingsTable) .where(eq(protocolRoomBookingsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } await db .delete(protocolRoomBookingsTable) .where(eq(protocolRoomBookingsTable.id, id)); await logAudit(db, { action: "booking.delete", entityType: "booking", entityId: id, oldValue: existing, performedBy: req.session.userId!, }); res.status(204).end(); }, ); // --------------------------------------------------------------------------- // External meetings // --------------------------------------------------------------------------- router.get( "/protocol/external-meetings", requireProtocolAccess, async (_req: Request, res: Response) => { const rows = await db .select() .from(protocolExternalMeetingsTable) .orderBy(desc(protocolExternalMeetingsTable.startsAt)); res.json(rows); }, ); router.post( "/protocol/external-meetings", requireMutate, async (req: Request, res: Response) => { const body = parseBody(res, externalCreateSchema, req.body); if (!body) return; const [row] = await db .insert(protocolExternalMeetingsTable) .values({ title: body.title, partyName: body.partyName ?? null, location: body.location ?? null, startsAt: new Date(body.startsAt), endsAt: body.endsAt ? new Date(body.endsAt) : null, status: "pending", notes: body.notes ?? null, createdBy: req.session.userId!, }) .returning(); await logAudit(db, { action: "external_meeting.create", entityType: "external_meeting", entityId: row.id, newValue: row, performedBy: req.session.userId!, }); res.status(201).json(row); }, ); router.patch( "/protocol/external-meetings/:id", requireMutate, async (req: Request, res: Response) => { const id = Number(req.params.id); const body = parseBody(res, externalPatchSchema, req.body); if (!body) return; const [existing] = await db .select() .from(protocolExternalMeetingsTable) .where(eq(protocolExternalMeetingsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } const [row] = await db .update(protocolExternalMeetingsTable) .set({ ...(body.title !== undefined ? { title: body.title } : {}), ...(body.partyName !== undefined ? { partyName: body.partyName } : {}), ...(body.location !== undefined ? { location: body.location } : {}), ...(body.startsAt !== undefined ? { startsAt: new Date(body.startsAt) } : {}), ...(body.endsAt !== undefined ? { endsAt: body.endsAt ? new Date(body.endsAt) : null } : {}), ...(body.status !== undefined ? { status: body.status } : {}), ...(body.notes !== undefined ? { notes: body.notes } : {}), }) .where(eq(protocolExternalMeetingsTable.id, id)) .returning(); await logAudit(db, { action: "external_meeting.update", entityType: "external_meeting", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); res.json(row); }, ); router.delete( "/protocol/external-meetings/:id", requireMutate, async (req: Request, res: Response) => { const id = Number(req.params.id); const [existing] = await db .select() .from(protocolExternalMeetingsTable) .where(eq(protocolExternalMeetingsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } await db .delete(protocolExternalMeetingsTable) .where(eq(protocolExternalMeetingsTable.id, id)); await logAudit(db, { action: "external_meeting.delete", entityType: "external_meeting", entityId: id, oldValue: existing, performedBy: req.session.userId!, }); res.status(204).end(); }, ); router.post( "/protocol/external-meetings/:id/approve", requireApprove, async (req: Request, res: Response) => { const id = Number(req.params.id); const [existing] = await db .select() .from(protocolExternalMeetingsTable) .where(eq(protocolExternalMeetingsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } // State-conditional claim: only a pending meeting flips to approved. const [row] = await db .update(protocolExternalMeetingsTable) .set({ status: "approved", approvedBy: req.session.userId!, approvedAt: new Date(), rejectionReason: null, }) .where( and( eq(protocolExternalMeetingsTable.id, id), eq(protocolExternalMeetingsTable.status, "pending"), ), ) .returning(); if (!row) { res .status(409) .json({ error: "meeting is not pending", code: "invalid_status" }); return; } await logAudit(db, { action: "external_meeting.approve", entityType: "external_meeting", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); res.json(row); }, ); router.post( "/protocol/external-meetings/:id/reject", requireApprove, async (req: Request, res: Response) => { const id = Number(req.params.id); const body = parseBody(res, rejectSchema, req.body); if (!body) return; const [existing] = await db .select() .from(protocolExternalMeetingsTable) .where(eq(protocolExternalMeetingsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } const [row] = await db .update(protocolExternalMeetingsTable) .set({ status: "rejected", approvedBy: req.session.userId!, approvedAt: new Date(), rejectionReason: body.reason ?? null, }) .where( and( eq(protocolExternalMeetingsTable.id, id), eq(protocolExternalMeetingsTable.status, "pending"), ), ) .returning(); if (!row) { res .status(409) .json({ error: "meeting is not pending", code: "invalid_status" }); return; } await logAudit(db, { action: "external_meeting.reject", entityType: "external_meeting", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); res.json(row); }, ); // --------------------------------------------------------------------------- // Gifts & shields catalogue // --------------------------------------------------------------------------- router.get( "/protocol/gifts", requireProtocolAccess, async (req: Request, res: Response) => { const kindRaw = req.query.kind; const conds = []; if ( typeof kindRaw === "string" && (PROTOCOL_GIFT_KINDS as readonly string[]).includes(kindRaw) ) { conds.push(eq(protocolGiftsTable.kind, kindRaw)); } const rows = await db .select() .from(protocolGiftsTable) .where(conds.length > 0 ? and(...conds) : undefined) .orderBy(asc(protocolGiftsTable.nameAr)); res.json(rows); }, ); router.post( "/protocol/gifts", requireMutate, async (req: Request, res: Response) => { const body = parseBody(res, giftCreateSchema, req.body); if (!body) return; const [row] = await db .insert(protocolGiftsTable) .values({ nameAr: body.nameAr, nameEn: body.nameEn ?? "", kind: body.kind ?? "gift", descriptionAr: body.descriptionAr ?? null, descriptionEn: body.descriptionEn ?? null, imageUrl: body.imageUrl ?? null, quantityInStock: body.quantityInStock ?? 0, isActive: body.isActive ?? true, createdBy: req.session.userId!, }) .returning(); await logAudit(db, { action: "gift.create", entityType: "gift", entityId: row.id, newValue: row, performedBy: req.session.userId!, }); res.status(201).json(row); }, ); router.patch( "/protocol/gifts/:id", requireMutate, async (req: Request, res: Response) => { const id = Number(req.params.id); const body = parseBody(res, giftPatchSchema, req.body); if (!body) return; const [existing] = await db .select() .from(protocolGiftsTable) .where(eq(protocolGiftsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } const [row] = await db .update(protocolGiftsTable) .set({ ...(body.nameAr !== undefined ? { nameAr: body.nameAr } : {}), ...(body.nameEn !== undefined ? { nameEn: body.nameEn } : {}), ...(body.kind !== undefined ? { kind: body.kind } : {}), ...(body.descriptionAr !== undefined ? { descriptionAr: body.descriptionAr } : {}), ...(body.descriptionEn !== undefined ? { descriptionEn: body.descriptionEn } : {}), ...(body.imageUrl !== undefined ? { imageUrl: body.imageUrl } : {}), ...(body.quantityInStock !== undefined ? { quantityInStock: body.quantityInStock } : {}), ...(body.isActive !== undefined ? { isActive: body.isActive } : {}), }) .where(eq(protocolGiftsTable.id, id)) .returning(); await logAudit(db, { action: "gift.update", entityType: "gift", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); res.json(row); }, ); router.delete( "/protocol/gifts/:id", requireMutate, async (req: Request, res: Response) => { const id = Number(req.params.id); const [existing] = await db .select() .from(protocolGiftsTable) .where(eq(protocolGiftsTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } // Refuse to delete a gift that has issuance history — the FK is // `on delete restrict`, so return a clean 409 instead of a raw 500. const [used] = await db .select({ id: protocolGiftIssuesTable.id }) .from(protocolGiftIssuesTable) .where(eq(protocolGiftIssuesTable.giftId, id)) .limit(1); if (used) { res.status(409).json({ error: "gift has issuance history", code: "gift_in_use", }); return; } await db.delete(protocolGiftsTable).where(eq(protocolGiftsTable.id, id)); await logAudit(db, { action: "gift.delete", entityType: "gift", entityId: id, oldValue: existing, performedBy: req.session.userId!, }); res.status(204).end(); }, ); // --------------------------------------------------------------------------- // Gift issuances // --------------------------------------------------------------------------- router.get( "/protocol/gift-issues", requireProtocolAccess, async (req: Request, res: Response) => { const statusRaw = req.query.status; const giftIdRaw = req.query.giftId; const conds = []; if ( typeof statusRaw === "string" && ["pending", "approved", "rejected", "issued"].includes(statusRaw) ) { conds.push(eq(protocolGiftIssuesTable.status, statusRaw)); } if (typeof giftIdRaw === "string" && /^\d+$/.test(giftIdRaw)) { conds.push(eq(protocolGiftIssuesTable.giftId, Number(giftIdRaw))); } const rows = await db .select() .from(protocolGiftIssuesTable) .where(conds.length > 0 ? and(...conds) : undefined) .orderBy(desc(protocolGiftIssuesTable.createdAt)); res.json(rows); }, ); router.post( "/protocol/gift-issues", requireRequest, async (req: Request, res: Response) => { const body = parseBody(res, issueCreateSchema, req.body); if (!body) return; const [gift] = await db .select() .from(protocolGiftsTable) .where(eq(protocolGiftsTable.id, body.giftId)); if (!gift) { res.status(400).json({ error: "gift not found", code: "invalid_gift" }); return; } const [row] = await db .insert(protocolGiftIssuesTable) .values({ giftId: body.giftId, recipientName: body.recipientName, occasion: body.occasion ?? null, quantity: body.quantity ?? 1, status: "pending", notes: body.notes ?? null, createdBy: req.session.userId!, }) .returning(); await logAudit(db, { action: "gift_issue.create", entityType: "gift_issue", entityId: row.id, newValue: row, performedBy: req.session.userId!, }); res.status(201).json(row); }, ); router.post( "/protocol/gift-issues/:id/approve", requireApprove, async (req: Request, res: Response) => { const id = Number(req.params.id); try { const updated = await db.transaction(async (tx) => { const [existing] = await tx .select() .from(protocolGiftIssuesTable) .where(eq(protocolGiftIssuesTable.id, id)); if (!existing) throw new Error("__not_found__"); // Authoritatively claim the pending issue exactly once with a // state-conditional update. Two concurrent approvals of the same // issue race here: only one flips pending -> issued and gets a row // back; the loser fails with invalid_status and never decrements. const [row] = await tx .update(protocolGiftIssuesTable) .set({ status: "issued", approvedBy: req.session.userId!, approvedAt: new Date(), issuedAt: new Date(), rejectionReason: null, }) .where( and( eq(protocolGiftIssuesTable.id, id), eq(protocolGiftIssuesTable.status, "pending"), ), ) .returning(); if (!row) throw new Error("__invalid_status__"); const [gift] = await tx .select() .from(protocolGiftsTable) .where(eq(protocolGiftsTable.id, existing.giftId)); if (!gift) throw new Error("__invalid_gift__"); // Conditional decrement: matches only when enough stock remains. // A failure here throws and rolls back the status claim above, so // an issue never ends up "issued" without stock being consumed. const decremented = await tx .update(protocolGiftsTable) .set({ quantityInStock: sql`${protocolGiftsTable.quantityInStock} - ${existing.quantity}`, }) .where( and( eq(protocolGiftsTable.id, gift.id), gte(protocolGiftsTable.quantityInStock, existing.quantity), ), ) .returning({ id: protocolGiftsTable.id }); if (decremented.length === 0) { throw new Error("__insufficient_stock__"); } await logAudit(tx, { action: "gift_issue.approve", entityType: "gift_issue", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); return row; }); res.json(updated); } catch (e) { if (e instanceof Error) { if (e.message === "__not_found__") { res.status(404).json({ error: "Not found", code: "not_found" }); return; } if (e.message === "__invalid_status__") { res .status(409) .json({ error: "issue is not pending", code: "invalid_status" }); return; } if (e.message === "__invalid_gift__") { res.status(400).json({ error: "gift not found", code: "invalid_gift" }); return; } if (e.message === "__insufficient_stock__") { res.status(409).json({ error: "الكمية المتوفرة غير كافية", code: "insufficient_stock", }); return; } } throw e; } }, ); router.post( "/protocol/gift-issues/:id/reject", requireApprove, async (req: Request, res: Response) => { const id = Number(req.params.id); const body = parseBody(res, rejectSchema, req.body); if (!body) return; const [existing] = await db .select() .from(protocolGiftIssuesTable) .where(eq(protocolGiftIssuesTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } // Only a pending issue can be rejected; the state-conditional update // makes this atomic against a concurrent approve. const [row] = await db .update(protocolGiftIssuesTable) .set({ status: "rejected", approvedBy: req.session.userId!, approvedAt: new Date(), rejectionReason: body.reason ?? null, }) .where( and( eq(protocolGiftIssuesTable.id, id), eq(protocolGiftIssuesTable.status, "pending"), ), ) .returning(); if (!row) { res .status(409) .json({ error: "issue is not pending", code: "invalid_status" }); return; } await logAudit(db, { action: "gift_issue.reject", entityType: "gift_issue", entityId: id, oldValue: existing, newValue: row, performedBy: req.session.userId!, }); res.json(row); }, ); router.delete( "/protocol/gift-issues/:id", requireMutate, async (req: Request, res: Response) => { const id = Number(req.params.id); const [existing] = await db .select() .from(protocolGiftIssuesTable) .where(eq(protocolGiftIssuesTable.id, id)); if (!existing) { res.status(404).json({ error: "Not found", code: "not_found" }); return; } await db .delete(protocolGiftIssuesTable) .where(eq(protocolGiftIssuesTable.id, id)); await logAudit(db, { action: "gift_issue.delete", entityType: "gift_issue", entityId: id, oldValue: existing, performedBy: req.session.userId!, }); res.status(204).end(); }, ); // --------------------------------------------------------------------------- // Dashboard // --------------------------------------------------------------------------- router.get( "/protocol/dashboard", requireProtocolAccess, async (_req: Request, res: Response) => { const now = new Date(); const startOfDay = new Date( now.getFullYear(), now.getMonth(), now.getDate(), ); const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); const [roomCount] = await db .select({ c: sql`count(*)::int` }) .from(protocolRoomsTable) .where(eq(protocolRoomsTable.isActive, true)); const [pendingBookings] = await db .select({ c: sql`count(*)::int` }) .from(protocolRoomBookingsTable) .where(eq(protocolRoomBookingsTable.status, "pending")); const [todayBookings] = await db .select({ c: sql`count(*)::int` }) .from(protocolRoomBookingsTable) .where( and( inArray(protocolRoomBookingsTable.status, ["pending", "approved"]), gte(protocolRoomBookingsTable.startsAt, startOfDay), lte(protocolRoomBookingsTable.startsAt, endOfDay), ), ); const [upcomingExternal] = await db .select({ c: sql`count(*)::int` }) .from(protocolExternalMeetingsTable) .where( and( inArray(protocolExternalMeetingsTable.status, ["pending", "approved"]), gte(protocolExternalMeetingsTable.startsAt, now), ), ); const [giftStock] = await db .select({ kinds: sql`count(*)::int`, totalStock: sql`coalesce(sum(${protocolGiftsTable.quantityInStock}), 0)::int`, }) .from(protocolGiftsTable) .where(eq(protocolGiftsTable.isActive, true)); const [pendingIssues] = await db .select({ c: sql`count(*)::int` }) .from(protocolGiftIssuesTable) .where(eq(protocolGiftIssuesTable.status, "pending")); // Gifts/shields actually issued during the current calendar month. const [issuedThisMonth] = await db .select({ count: sql`count(*)::int`, qty: sql`coalesce(sum(${protocolGiftIssuesTable.quantity}), 0)::int`, }) .from(protocolGiftIssuesTable) .where( and( eq(protocolGiftIssuesTable.status, "issued"), gte(protocolGiftIssuesTable.issuedAt, startOfMonth), lt(protocolGiftIssuesTable.issuedAt, startOfNextMonth), ), ); // Rooms free right now = active rooms with no pending/approved booking // currently spanning [startsAt, endsAt). const [availableNow] = await db .select({ c: sql`count(*)::int` }) .from(protocolRoomsTable) .where( and( eq(protocolRoomsTable.isActive, true), sql`not exists ( select 1 from ${protocolRoomBookingsTable} b where b.room_id = ${protocolRoomsTable.id} and b.status in ('pending', 'approved') and b.starts_at <= ${now.toISOString()} and b.ends_at > ${now.toISOString()} )`, ), ); res.json({ activeRooms: roomCount?.c ?? 0, roomsAvailableNow: availableNow?.c ?? 0, pendingBookings: pendingBookings?.c ?? 0, todayBookings: todayBookings?.c ?? 0, upcomingExternalMeetings: upcomingExternal?.c ?? 0, giftCatalogCount: giftStock?.kinds ?? 0, totalGiftStock: giftStock?.totalStock ?? 0, pendingGiftIssues: pendingIssues?.c ?? 0, giftsIssuedThisMonth: issuedThisMonth?.count ?? 0, giftsIssuedThisMonthQty: issuedThisMonth?.qty ?? 0, }); }, ); // --------------------------------------------------------------------------- // Reports // --------------------------------------------------------------------------- router.get( "/protocol/reports", requireProtocolAccess, async (req: Request, res: Response) => { const fromRaw = req.query.from; const toRaw = req.query.to; const from = typeof fromRaw === "string" && !Number.isNaN(Date.parse(fromRaw)) ? new Date(fromRaw) : null; const to = typeof toRaw === "string" && !Number.isNaN(Date.parse(toRaw)) ? new Date(toRaw) : null; const bookingRange = []; if (from) bookingRange.push(gte(protocolRoomBookingsTable.startsAt, from)); if (to) bookingRange.push(lte(protocolRoomBookingsTable.startsAt, to)); const bookingsByRoom = await db .select({ roomId: protocolRoomBookingsTable.roomId, roomNameAr: protocolRoomsTable.nameAr, roomNameEn: protocolRoomsTable.nameEn, total: sql`count(*)::int`, approved: sql`count(*) filter (where ${protocolRoomBookingsTable.status} = 'approved')::int`, pending: sql`count(*) filter (where ${protocolRoomBookingsTable.status} = 'pending')::int`, rejected: sql`count(*) filter (where ${protocolRoomBookingsTable.status} = 'rejected')::int`, cancelled: sql`count(*) filter (where ${protocolRoomBookingsTable.status} = 'cancelled')::int`, }) .from(protocolRoomBookingsTable) .innerJoin( protocolRoomsTable, eq(protocolRoomsTable.id, protocolRoomBookingsTable.roomId), ) .where(bookingRange.length > 0 ? and(...bookingRange) : undefined) .groupBy( protocolRoomBookingsTable.roomId, protocolRoomsTable.nameAr, protocolRoomsTable.nameEn, ) .orderBy(desc(sql`count(*)`)); const issueRange = []; if (from) issueRange.push(gte(protocolGiftIssuesTable.createdAt, from)); if (to) issueRange.push(lte(protocolGiftIssuesTable.createdAt, to)); const issuesByGift = await db .select({ giftId: protocolGiftIssuesTable.giftId, giftNameAr: protocolGiftsTable.nameAr, giftNameEn: protocolGiftsTable.nameEn, kind: protocolGiftsTable.kind, totalRequests: sql`count(*)::int`, issued: sql`count(*) filter (where ${protocolGiftIssuesTable.status} = 'issued')::int`, issuedQty: sql`coalesce(sum(${protocolGiftIssuesTable.quantity}) filter (where ${protocolGiftIssuesTable.status} = 'issued'), 0)::int`, pending: sql`count(*) filter (where ${protocolGiftIssuesTable.status} = 'pending')::int`, rejected: sql`count(*) filter (where ${protocolGiftIssuesTable.status} = 'rejected')::int`, }) .from(protocolGiftIssuesTable) .innerJoin( protocolGiftsTable, eq(protocolGiftsTable.id, protocolGiftIssuesTable.giftId), ) .where(issueRange.length > 0 ? and(...issueRange) : undefined) .groupBy( protocolGiftIssuesTable.giftId, protocolGiftsTable.nameAr, protocolGiftsTable.nameEn, protocolGiftsTable.kind, ) .orderBy(desc(sql`count(*)`)); const externalRange = []; if (from) externalRange.push(gte(protocolExternalMeetingsTable.startsAt, from)); if (to) externalRange.push(lte(protocolExternalMeetingsTable.startsAt, to)); const [externalMeetings] = await db .select({ total: sql`count(*)::int`, pending: sql`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'pending')::int`, approved: sql`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'approved')::int`, rejected: sql`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'rejected')::int`, completed: sql`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'completed')::int`, cancelled: sql`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'cancelled')::int`, }) .from(protocolExternalMeetingsTable) .where(externalRange.length > 0 ? and(...externalRange) : undefined); res.json({ bookingsByRoom, issuesByGift, externalMeetings: externalMeetings ?? { total: 0, pending: 0, approved: 0, rejected: 0, completed: 0, cancelled: 0, }, }); }, ); // --------------------------------------------------------------------------- // Audit log // --------------------------------------------------------------------------- router.get( "/protocol/audit", requireAudit, async (req: Request, res: Response) => { const limitRaw = req.query.limit; const limit = typeof limitRaw === "string" && /^\d+$/.test(limitRaw) ? Math.min(Number(limitRaw), 500) : 200; const rows = await db .select({ id: protocolAuditLogsTable.id, action: protocolAuditLogsTable.action, entityType: protocolAuditLogsTable.entityType, entityId: protocolAuditLogsTable.entityId, oldValue: protocolAuditLogsTable.oldValue, newValue: protocolAuditLogsTable.newValue, performedBy: protocolAuditLogsTable.performedBy, performedAt: protocolAuditLogsTable.performedAt, actorUsername: usersTable.username, actorNameAr: usersTable.displayNameAr, actorNameEn: usersTable.displayNameEn, }) .from(protocolAuditLogsTable) .leftJoin( usersTable, eq(usersTable.id, protocolAuditLogsTable.performedBy), ) .orderBy(desc(protocolAuditLogsTable.performedAt)) .limit(limit); res.json(rows); }, ); export default router;