2026-07-06 09:25:40 +00:00
|
|
|
import { Router, type IRouter } from "express";
|
2026-07-06 11:18:14 +00:00
|
|
|
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
2026-07-06 09:46:12 +00:00
|
|
|
import { and, asc, desc, eq, gte, inArray, lt, lte, ne, sql } from "drizzle-orm";
|
2026-07-06 09:25:40 +00:00
|
|
|
import { z, type ZodType } from "zod";
|
|
|
|
|
import { db } from "@workspace/db";
|
|
|
|
|
import {
|
|
|
|
|
protocolRoomsTable,
|
|
|
|
|
protocolRoomBookingsTable,
|
|
|
|
|
protocolExternalMeetingsTable,
|
|
|
|
|
protocolGiftsTable,
|
|
|
|
|
protocolGiftIssuesTable,
|
|
|
|
|
protocolAuditLogsTable,
|
|
|
|
|
rolesTable,
|
2026-07-06 09:54:30 +00:00
|
|
|
permissionsTable,
|
|
|
|
|
rolePermissionsTable,
|
2026-07-06 09:25:40 +00:00
|
|
|
usersTable,
|
|
|
|
|
PROTOCOL_GIFT_KINDS,
|
|
|
|
|
} from "@workspace/db";
|
2026-07-06 09:54:30 +00:00
|
|
|
import {
|
|
|
|
|
requireProtocolAccess,
|
|
|
|
|
getEffectiveRoleIds,
|
|
|
|
|
userHasPermission,
|
|
|
|
|
} from "../middlewares/auth";
|
2026-07-06 09:25:40 +00:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-07-06 09:54:30 +00:00
|
|
|
// Permission helpers
|
2026-07-06 09:25:40 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-07-06 09:54:30 +00:00
|
|
|
// 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;
|
2026-07-06 09:25:40 +00:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 09:54:30 +00:00
|
|
|
// 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) {
|
2026-07-06 09:25:40 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-07-06 09:54:30 +00:00
|
|
|
const ok = await userHasPermission(req.session.userId, perm);
|
|
|
|
|
if (!ok) {
|
2026-07-06 09:25:40 +00:00
|
|
|
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
next();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 09:54:30 +00:00
|
|
|
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);
|
2026-07-06 09:25:40 +00:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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" });
|
|
|
|
|
|
2026-07-06 11:18:14 +00:00
|
|
|
// 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"],
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
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"] },
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-06 09:46:12 +00:00
|
|
|
// 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.
|
2026-07-06 09:25:40 +00:00
|
|
|
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(),
|
2026-07-06 09:46:12 +00:00
|
|
|
status: z.enum(["completed", "cancelled"]).optional(),
|
2026-07-06 09:25:40 +00:00
|
|
|
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!;
|
2026-07-06 09:54:30 +00:00
|
|
|
const [names, perms] = await Promise.all([
|
|
|
|
|
getRoleNamesForUser(userId),
|
|
|
|
|
getProtocolPermsForUser(userId),
|
|
|
|
|
]);
|
2026-07-06 09:25:40 +00:00
|
|
|
res.json({
|
|
|
|
|
userId,
|
|
|
|
|
roles: Array.from(names),
|
|
|
|
|
canRead: true,
|
2026-07-06 09:54:30 +00:00
|
|
|
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),
|
2026-07-06 09:25:40 +00:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-06 11:18:14 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 09:54:30 +00:00
|
|
|
// 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,
|
|
|
|
|
);
|
2026-07-06 09:25:40 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-07-06 09:46:12 +00:00
|
|
|
// Only a pending booking can be rejected; the state-conditional update
|
|
|
|
|
// makes this atomic against a concurrent approve/cancel.
|
2026-07-06 09:25:40 +00:00
|
|
|
const [row] = await db
|
|
|
|
|
.update(protocolRoomBookingsTable)
|
|
|
|
|
.set({
|
|
|
|
|
status: "rejected",
|
|
|
|
|
approvedBy: req.session.userId!,
|
|
|
|
|
approvedAt: new Date(),
|
|
|
|
|
rejectionReason: body.reason ?? null,
|
|
|
|
|
})
|
2026-07-06 09:46:12 +00:00
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(protocolRoomBookingsTable.id, id),
|
|
|
|
|
eq(protocolRoomBookingsTable.status, "pending"),
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-07-06 09:25:40 +00:00
|
|
|
.returning();
|
2026-07-06 09:46:12 +00:00
|
|
|
if (!row) {
|
|
|
|
|
res
|
|
|
|
|
.status(409)
|
|
|
|
|
.json({ error: "booking is not pending", code: "invalid_status" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-06 09:25:40 +00:00
|
|
|
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,
|
2026-07-06 09:46:12 +00:00
|
|
|
status: "pending",
|
2026-07-06 09:25:40 +00:00
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-06 09:46:12 +00:00
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2026-07-06 09:46:12 +00:00
|
|
|
// Only a pending issue can be rejected; the state-conditional update
|
|
|
|
|
// makes this atomic against a concurrent approve.
|
2026-07-06 09:25:40 +00:00
|
|
|
const [row] = await db
|
|
|
|
|
.update(protocolGiftIssuesTable)
|
|
|
|
|
.set({
|
|
|
|
|
status: "rejected",
|
|
|
|
|
approvedBy: req.session.userId!,
|
|
|
|
|
approvedAt: new Date(),
|
|
|
|
|
rejectionReason: body.reason ?? null,
|
|
|
|
|
})
|
2026-07-06 09:46:12 +00:00
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(protocolGiftIssuesTable.id, id),
|
|
|
|
|
eq(protocolGiftIssuesTable.status, "pending"),
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-07-06 09:25:40 +00:00
|
|
|
.returning();
|
2026-07-06 09:46:12 +00:00
|
|
|
if (!row) {
|
|
|
|
|
res
|
|
|
|
|
.status(409)
|
|
|
|
|
.json({ error: "issue is not pending", code: "invalid_status" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-06 09:25:40 +00:00
|
|
|
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);
|
2026-07-06 09:46:12 +00:00
|
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
|
|
|
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
2026-07-06 09:25:40 +00:00
|
|
|
|
|
|
|
|
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(
|
2026-07-06 09:46:12 +00:00
|
|
|
inArray(protocolExternalMeetingsTable.status, ["pending", "approved"]),
|
2026-07-06 09:25:40 +00:00
|
|
|
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"));
|
|
|
|
|
|
2026-07-06 09:46:12 +00:00
|
|
|
// 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()}
|
|
|
|
|
)`,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-06 09:25:40 +00:00
|
|
|
res.json({
|
|
|
|
|
activeRooms: roomCount?.c ?? 0,
|
2026-07-06 09:46:12 +00:00
|
|
|
roomsAvailableNow: availableNow?.c ?? 0,
|
2026-07-06 09:25:40 +00:00
|
|
|
pendingBookings: pendingBookings?.c ?? 0,
|
|
|
|
|
todayBookings: todayBookings?.c ?? 0,
|
|
|
|
|
upcomingExternalMeetings: upcomingExternal?.c ?? 0,
|
|
|
|
|
giftCatalogCount: giftStock?.kinds ?? 0,
|
|
|
|
|
totalGiftStock: giftStock?.totalStock ?? 0,
|
|
|
|
|
pendingGiftIssues: pendingIssues?.c ?? 0,
|
2026-07-06 09:46:12 +00:00
|
|
|
giftsIssuedThisMonth: issuedThisMonth?.count ?? 0,
|
|
|
|
|
giftsIssuedThisMonthQty: issuedThisMonth?.qty ?? 0,
|
2026-07-06 09:25:40 +00:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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(*)`));
|
|
|
|
|
|
2026-07-06 09:46:12 +00:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-07-06 09:25:40 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|