Files
TX/artifacts/api-server/src/routes/protocol.ts
T

1844 lines
60 KiB
TypeScript
Raw Normal View History

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<Set<string>> {
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<Set<string>> {
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<void> {
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<T>(
res: Response,
schema: ZodType<T>,
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<Parameters<typeof db.transaction>[0]>[0];
async function logAudit(
executor: DbExecutor,
opts: {
action: string;
entityType: string;
entityId: number | null;
oldValue?: unknown;
newValue?: unknown;
performedBy: number | null;
},
): Promise<void> {
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<Parameters<typeof db.transaction>[0]>[0],
roomId: number,
): Promise<boolean> {
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<string>;
},
): Promise<boolean> {
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<number>`count(*)::int` })
.from(protocolRoomsTable)
.where(eq(protocolRoomsTable.isActive, true));
const [pendingBookings] = await db
.select({ c: sql<number>`count(*)::int` })
.from(protocolRoomBookingsTable)
.where(eq(protocolRoomBookingsTable.status, "pending"));
const [todayBookings] = await db
.select({ c: sql<number>`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<number>`count(*)::int` })
.from(protocolExternalMeetingsTable)
.where(
and(
inArray(protocolExternalMeetingsTable.status, ["pending", "approved"]),
gte(protocolExternalMeetingsTable.startsAt, now),
),
);
const [giftStock] = await db
.select({
kinds: sql<number>`count(*)::int`,
totalStock: sql<number>`coalesce(sum(${protocolGiftsTable.quantityInStock}), 0)::int`,
})
.from(protocolGiftsTable)
.where(eq(protocolGiftsTable.isActive, true));
const [pendingIssues] = await db
.select({ c: sql<number>`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<number>`count(*)::int`,
qty: sql<number>`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<number>`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<number>`count(*)::int`,
approved: sql<number>`count(*) filter (where ${protocolRoomBookingsTable.status} = 'approved')::int`,
pending: sql<number>`count(*) filter (where ${protocolRoomBookingsTable.status} = 'pending')::int`,
rejected: sql<number>`count(*) filter (where ${protocolRoomBookingsTable.status} = 'rejected')::int`,
cancelled: sql<number>`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<number>`count(*)::int`,
issued: sql<number>`count(*) filter (where ${protocolGiftIssuesTable.status} = 'issued')::int`,
issuedQty: sql<number>`coalesce(sum(${protocolGiftIssuesTable.quantity}) filter (where ${protocolGiftIssuesTable.status} = 'issued'), 0)::int`,
pending: sql<number>`count(*) filter (where ${protocolGiftIssuesTable.status} = 'pending')::int`,
rejected: sql<number>`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<number>`count(*)::int`,
pending: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'pending')::int`,
approved: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'approved')::int`,
rejected: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'rejected')::int`,
completed: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'completed')::int`,
cancelled: sql<number>`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;