Task #704: أوقات حجز محددة يديرها الأدمن + مهلة حجز مسبقة

- DB: protocol_booking_slots (وقت "HH:mm" بتوقيت الرياض، مدة، تفعيل، ترتيب)
  + protocol_settings (booking.minLeadMinutes، الافتراضي 60 دقيقة).
- API: CRUD ‎/protocol/booking-slots (الكتابة تتطلب protocol.rooms.manage،
  تكرار الوقت → 409 duplicate_slot)، GET/PATCH ‎/protocol/settings (upsert + سجل
  تدقيق)، ونقطة عامة ‎/protocol/public/availability (محدّد معدل خاص، تعيد
  booked/tooSoon/available لكل وقت).
- الفرض في الخادم: الطلب العام يجب أن يطابق وقتاً مفعّلاً + يحترم المهلة
  (400 invalid_slot / lead_time برسائل عربية)؛ الموظفون ملزمون بالأوقات لكن
  معفيون من المهلة. بدون أوقات مفعّلة يعمل الوضع الحر القديم.
- الواجهة: نموذج الطلب العام يعرض شبكة أوقات حسب التوفر (مع fallback قديم)؛
  تبويب القاعات يعرض قسم إدارة الأوقات + المهلة لمن يملك canManageRooms؛
  حوار الحجز الداخلي يتحول لتاريخ + قائمة أوقات عند وجود أوقات مفعّلة.
- i18n: مفاتيح protocol.slots.* بالعربية والإنجليزية.
- اختبارات: tests/protocol-booking-slots.test.mjs — ‏10 اختبارات ناجحة
  (CRUD/تكرار، الإعدادات، التوفر، invalid_slot، lead_time، تعارض، وقت معطّل،
  إعفاء الموظفين، صلاحيات القراءة/الكتابة، الحذف).
- مراجعة المعماري: PASS؛ عولجت الملاحظات (توثيق قرار الصلاحيات + اختبار
  regression له، ومنع الإرسال في الواجهة قبل اختيار التاريخ والوقت).
This commit is contained in:
Replit Agent
2026-07-08 14:43:44 +00:00
parent d48e434400
commit f3fe741314
7 changed files with 1539 additions and 61 deletions
+430
View File
@@ -25,6 +25,8 @@ import {
protocolPhotoAlbumsTable,
protocolPhotosTable,
protocolAuditLogsTable,
protocolBookingSlotsTable,
protocolSettingsTable,
rolesTable,
permissionsTable,
rolePermissionsTable,
@@ -268,6 +270,38 @@ const publicBookingRequestSchema = z
},
);
// Bookable time slot management (أوقات الحجز). startTime is a canonical
// zero-padded 24h "HH:mm" wall-clock time in Asia/Riyadh.
// Postgres unique violation (23505). Drizzle may wrap the pg error in a
// DrizzleQueryError, so check the error itself and its `cause`.
function isUniqueViolation(e: unknown): boolean {
const codeOf = (x: unknown) =>
x instanceof Error && "code" in x
? (x as { code?: string }).code
: undefined;
return (
codeOf(e) === "23505" ||
(e instanceof Error && codeOf((e as { cause?: unknown }).cause) === "23505")
);
}
const slotTimeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
const slotCreateSchema = z.object({
startTime: z.string().regex(slotTimeRegex, "startTime must be HH:mm"),
durationMinutes: z.number().int().min(5).max(480).optional(),
isActive: z.boolean().optional(),
sortOrder: z.number().int().min(0).max(100000).optional(),
});
const slotPatchSchema = slotCreateSchema
.partial()
.refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });
const settingsPatchSchema = z.object({
// Minimum advance notice for PUBLIC booking requests, in minutes.
// 0 disables the restriction; capped at 7 days.
minLeadMinutes: z.number().int().min(0).max(10080),
});
const rejectSchema = z.object({
reason: z.string().trim().max(1000).nullable().optional(),
});
@@ -425,6 +459,80 @@ async function hasBookingConflict(
return rows.length > 0;
}
// ---------------------------------------------------------------------------
// Booking slots + lead-time helpers
// ---------------------------------------------------------------------------
const MIN_LEAD_SETTING_KEY = "booking.minLeadMinutes";
const DEFAULT_MIN_LEAD_MINUTES = 60;
// Riyadh has no DST, so a fixed +03:00 offset is always correct — but we
// still derive the wall-clock time via Intl so the mapping stays exact.
const RIYADH_TZ = "Asia/Riyadh";
const riyadhTimeFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: RIYADH_TZ,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
function riyadhHHmm(d: Date): string {
return riyadhTimeFmt.format(d);
}
// Build the absolute Date at which slot `startTime` ("HH:mm" Riyadh wall
// clock) begins on `dateStr` ("YYYY-MM-DD", a Riyadh calendar day).
function slotStartOn(dateStr: string, startTime: string): Date {
return new Date(`${dateStr}T${startTime}:00+03:00`);
}
async function getMinLeadMinutes(executor: DbExecutor): Promise<number> {
const [row] = await executor
.select({ value: protocolSettingsTable.value })
.from(protocolSettingsTable)
.where(eq(protocolSettingsTable.key, MIN_LEAD_SETTING_KEY));
if (!row) return DEFAULT_MIN_LEAD_MINUTES;
const n = Number(row.value);
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MIN_LEAD_MINUTES;
}
type ActiveSlot = { id: number; startTime: string; durationMinutes: number };
async function getActiveSlots(executor: DbExecutor): Promise<ActiveSlot[]> {
return executor
.select({
id: protocolBookingSlotsTable.id,
startTime: protocolBookingSlotsTable.startTime,
durationMinutes: protocolBookingSlotsTable.durationMinutes,
})
.from(protocolBookingSlotsTable)
.where(eq(protocolBookingSlotsTable.isActive, true))
.orderBy(
asc(protocolBookingSlotsTable.sortOrder),
asc(protocolBookingSlotsTable.startTime),
);
}
// Does [startsAt, endsAt) exactly match one of the configured slots?
// Backwards-compat rule: when NO active slots are configured the module
// behaves as before (free time input) — enforcement only kicks in once the
// admin has defined at least one slot.
function matchesActiveSlot(
slots: ActiveSlot[],
startsAt: Date,
endsAt: Date,
): boolean {
if (slots.length === 0) return true;
if (startsAt.getUTCSeconds() !== 0 || startsAt.getUTCMilliseconds() !== 0) {
return false;
}
const wall = riyadhHHmm(startsAt);
const durationMs = endsAt.getTime() - startsAt.getTime();
return slots.some(
(s) => s.startTime === wall && durationMs === s.durationMinutes * 60_000,
);
}
const INVALID_SLOT_MESSAGE_AR =
"الوقت المحدد غير متاح للحجز، يرجى اختيار أحد الأوقات المتاحة.";
const LEAD_TIME_MESSAGE_AR =
"لا يمكن الحجز في هذا الوقت، يجب الحجز قبل الموعد بمدة كافية.";
// ---------------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------------
@@ -522,6 +630,26 @@ router.post(
return;
}
// Slot + lead-time enforcement for anonymous guests. When the admin has
// configured booking slots, a public request must land exactly on an
// active slot; and it must start at least `minLeadMinutes` from now.
const [activeSlots, minLeadMinutes] = await Promise.all([
getActiveSlots(db),
getMinLeadMinutes(db),
]);
if (!matchesActiveSlot(activeSlots, startsAt, endsAt)) {
res
.status(400)
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
return;
}
if (startsAt.getTime() - Date.now() < minLeadMinutes * 60_000) {
res
.status(400)
.json({ error: LEAD_TIME_MESSAGE_AR, code: "lead_time" });
return;
}
try {
const created = await db.transaction(async (tx) => {
if (!(await lockRoomRow(tx, body.roomId))) {
@@ -596,6 +724,284 @@ router.post(
},
);
// Public availability: which slots can a guest still pick for a room on a
// given Riyadh calendar day. Deliberately reveals only per-slot booleans —
// never who booked, what, or exact busy ranges beyond the slot grid itself.
// Not behind the strict submission limiter (browsing dates must stay cheap),
// but still lightly rate-limited.
const publicAvailabilityLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req: Request) => ipKeyGenerator(req.ip ?? ""),
message: {
error: "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.",
code: "rate_limited",
},
});
router.get(
"/protocol/public/availability",
publicAvailabilityLimiter,
async (req: Request, res: Response) => {
const roomIdRaw = req.query.roomId;
const dateRaw = req.query.date;
if (typeof roomIdRaw !== "string" || !/^\d+$/.test(roomIdRaw)) {
res.status(400).json({ error: "roomId is required", code: "validation" });
return;
}
if (typeof dateRaw !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
res
.status(400)
.json({ error: "date must be YYYY-MM-DD", code: "validation" });
return;
}
const roomId = Number(roomIdRaw);
const dayStart = slotStartOn(dateRaw, "00:00");
if (Number.isNaN(dayStart.getTime())) {
res.status(400).json({ error: "invalid date", code: "validation" });
return;
}
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
const [room] = await db
.select({ id: protocolRoomsTable.id, isActive: protocolRoomsTable.isActive })
.from(protocolRoomsTable)
.where(eq(protocolRoomsTable.id, roomId));
if (!room || !room.isActive) {
res.status(400).json({ error: "room not found", code: "invalid_room" });
return;
}
const [slots, minLeadMinutes, busy] = await Promise.all([
getActiveSlots(db),
getMinLeadMinutes(db),
db
.select({
startsAt: protocolRoomBookingsTable.startsAt,
endsAt: protocolRoomBookingsTable.endsAt,
})
.from(protocolRoomBookingsTable)
.where(
and(
eq(protocolRoomBookingsTable.roomId, roomId),
inArray(
protocolRoomBookingsTable.status,
OCCUPYING_STATUSES as unknown as string[],
),
sql`${protocolRoomBookingsTable.startsAt} < ${dayEnd.toISOString()}`,
sql`${protocolRoomBookingsTable.endsAt} > ${dayStart.toISOString()}`,
),
),
]);
const now = Date.now();
const result = slots.map((s) => {
const start = slotStartOn(dateRaw, s.startTime);
const end = new Date(start.getTime() + s.durationMinutes * 60_000);
const booked = busy.some(
(b) => b.startsAt < end && b.endsAt > start,
);
// A slot is "too soon" for a public guest when its start is closer
// than the configured lead time (staff are not bound by this).
const tooSoon = start.getTime() - now < minLeadMinutes * 60_000;
return {
id: s.id,
startTime: s.startTime,
durationMinutes: s.durationMinutes,
booked,
tooSoon,
available: !booked && !tooSoon,
};
});
res.json({ minLeadMinutes, slots: result });
},
);
// ---------------------------------------------------------------------------
// Booking slots admin (أوقات الحجز) + module settings
//
// Authorization decision: READS of slots/settings are deliberately open to
// any protocol.access holder (not just protocol.rooms.manage) because every
// protocol user's booking dialog needs the active slot list, and viewers
// need the lead-time value for display. WRITES stay behind
// protocol.rooms.manage. Locked in by the authz regression test in
// tests/protocol-booking-slots.test.mjs.
// ---------------------------------------------------------------------------
router.get(
"/protocol/booking-slots",
requireProtocolAccess,
async (_req: Request, res: Response) => {
const rows = await db
.select()
.from(protocolBookingSlotsTable)
.orderBy(
asc(protocolBookingSlotsTable.sortOrder),
asc(protocolBookingSlotsTable.startTime),
);
res.json(rows);
},
);
router.post(
"/protocol/booking-slots",
requireManageRooms,
async (req: Request, res: Response) => {
const body = parseBody(res, slotCreateSchema, req.body);
if (!body) return;
try {
const [row] = await db
.insert(protocolBookingSlotsTable)
.values({
startTime: body.startTime,
durationMinutes: body.durationMinutes ?? 30,
isActive: body.isActive ?? true,
sortOrder: body.sortOrder ?? 0,
createdBy: req.session.userId!,
})
.returning();
await logAudit(db, {
action: "slot.create",
entityType: "booking_slot",
entityId: row.id,
newValue: row,
performedBy: req.session.userId!,
});
res.status(201).json(row);
} catch (e) {
// Unique start_time violation → a slot already starts at this time.
if (isUniqueViolation(e)) {
res.status(409).json({
error: "يوجد وقت حجز يبدأ في نفس التوقيت.",
code: "duplicate_slot",
});
return;
}
throw e;
}
},
);
router.patch(
"/protocol/booking-slots/:id",
requireManageRooms,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
const body = parseBody(res, slotPatchSchema, req.body);
if (!body) return;
const [existing] = await db
.select()
.from(protocolBookingSlotsTable)
.where(eq(protocolBookingSlotsTable.id, id));
if (!existing) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
try {
const [row] = await db
.update(protocolBookingSlotsTable)
.set({
...(body.startTime !== undefined ? { startTime: body.startTime } : {}),
...(body.durationMinutes !== undefined
? { durationMinutes: body.durationMinutes }
: {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
})
.where(eq(protocolBookingSlotsTable.id, id))
.returning();
await logAudit(db, {
action: "slot.update",
entityType: "booking_slot",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: req.session.userId!,
});
res.json(row);
} catch (e) {
if (isUniqueViolation(e)) {
res.status(409).json({
error: "يوجد وقت حجز يبدأ في نفس التوقيت.",
code: "duplicate_slot",
});
return;
}
throw e;
}
},
);
router.delete(
"/protocol/booking-slots/:id",
requireManageRooms,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
const [existing] = await db
.select()
.from(protocolBookingSlotsTable)
.where(eq(protocolBookingSlotsTable.id, id));
if (!existing) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
await db
.delete(protocolBookingSlotsTable)
.where(eq(protocolBookingSlotsTable.id, id));
await logAudit(db, {
action: "slot.delete",
entityType: "booking_slot",
entityId: id,
oldValue: existing,
performedBy: req.session.userId!,
});
res.status(204).end();
},
);
router.get(
"/protocol/settings",
requireProtocolAccess,
async (_req: Request, res: Response) => {
res.json({ minLeadMinutes: await getMinLeadMinutes(db) });
},
);
router.patch(
"/protocol/settings",
requireManageRooms,
async (req: Request, res: Response) => {
const body = parseBody(res, settingsPatchSchema, req.body);
if (!body) return;
const oldValue = await getMinLeadMinutes(db);
await db
.insert(protocolSettingsTable)
.values({
key: MIN_LEAD_SETTING_KEY,
value: String(body.minLeadMinutes),
updatedBy: req.session.userId!,
})
.onConflictDoUpdate({
target: protocolSettingsTable.key,
set: {
value: String(body.minLeadMinutes),
updatedBy: req.session.userId!,
updatedAt: new Date(),
},
});
await logAudit(db, {
action: "settings.update",
entityType: "settings",
entityId: null,
oldValue: { minLeadMinutes: oldValue },
newValue: { minLeadMinutes: body.minLeadMinutes },
performedBy: req.session.userId!,
});
res.json({ minLeadMinutes: body.minLeadMinutes });
},
);
// ---------------------------------------------------------------------------
// Rooms
// ---------------------------------------------------------------------------
@@ -772,6 +1178,18 @@ router.post(
return;
}
// Internal bookings must also land on the configured slot grid (when one
// exists), but staff are deliberately NOT bound by the public lead-time
// setting — the lead time exists to give the protocol team room to
// prepare, and the team itself can book at short notice.
const activeSlotsForCreate = await getActiveSlots(db);
if (!matchesActiveSlot(activeSlotsForCreate, startsAt, endsAt)) {
res
.status(400)
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
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.
@@ -875,6 +1293,18 @@ router.patch(
.json({ error: "startsAt must be before endsAt", code: "validation" });
return;
}
// If the edit changes the meeting time, the new time must still land on
// the configured slot grid (when one exists). Same staff exemption from
// the lead-time rule as on create.
if (body.startsAt !== undefined || body.endsAt !== undefined) {
const activeSlotsForPatch = await getActiveSlots(db);
if (!matchesActiveSlot(activeSlotsForPatch, startsAt, endsAt)) {
res
.status(400)
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
return;
}
}
try {
const updated = await db.transaction(async (tx) => {
// Lock the (possibly new) room to serialize against concurrent
@@ -0,0 +1,431 @@
// API contract tests for admin-managed booking slots (أوقات الحجز) and the
// public lead-time setting. Covers:
//
// 1. Admin slot CRUD: create returns 201, duplicate startTime returns 409
// with code "duplicate_slot".
// 2. Settings roundtrip: PATCH minLeadMinutes persists and GET returns it.
// 3. Public availability reflects booked/tooSoon/available per slot.
// 4. Public request off-slot -> 400 invalid_slot.
// 5. Public request on-slot but inside the lead window -> 400 lead_time.
// 6. Public request on-slot outside the lead window -> 201; a second
// request for the same slot conflicts (409).
// 7. A deactivated slot no longer accepts public requests (while another
// slot keeps slot-mode active).
// 8. Staff (internal POST /protocol/bookings) must still match a slot but
// are exempt from the lead time.
//
// The suite snapshots and clears protocol_booking_slots + the
// booking.minLeadMinutes setting up front and restores both in `after`, so
// it is safe against pre-existing data and repeated runs.
//
// NOTE: the public booking endpoint is rate limited at 8 requests / 10 min
// per IP; this file performs at most 6 public POSTs.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const created = {
slotIds: [],
roomIds: [],
bookingIds: [],
userIds: [],
};
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
function uniqueName(prefix) {
return `${prefix}_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
}
// A dedicated protocol_super_admin user (has protocol.rooms.manage +
// protocol.mutate) so the suite doesn't depend on the real admin password.
async function createSuperAdmin() {
const username = uniqueName("slot_admin");
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Slot Test Admin', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
created.userIds.push(rows[0].id);
for (const r of ["user", "protocol_super_admin"]) {
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2
ON CONFLICT DO NOTHING`,
[rows[0].id, r],
);
}
return username;
}
let savedSlots = [];
let savedLeadRow = null;
function extractCookie(res) {
const setCookie = res.headers.get("set-cookie");
if (!setCookie) return null;
return (
setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid=")) ?? null
);
}
async function login(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login should succeed for ${username}`);
const cookie = extractCookie(res);
assert.ok(cookie, "login response should set a session cookie");
return cookie;
}
async function api(cookie, method, path, body) {
const init = {
method,
headers: {
...(cookie ? { Cookie: cookie } : {}),
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
};
if (body !== undefined) init.body = JSON.stringify(body);
return fetch(`${API_BASE}/api${path}`, init);
}
let adminCookie = null;
let roomId = null;
// Tomorrow's calendar day in Riyadh (fixed +03:00, no DST) so every slot we
// book is comfortably in the future.
function riyadhTomorrowYmd() {
const now = new Date(Date.now() + (24 * 60 + 180) * 60 * 1000);
return now.toISOString().slice(0, 10);
}
const day = riyadhTomorrowYmd();
function isoAt(hhmm, plusMinutes = 0) {
const d = new Date(`${day}T${hhmm}:00+03:00`);
return new Date(d.getTime() + plusMinutes * 60_000).toISOString();
}
const SLOT_A = "09:37"; // uncommon times to avoid clashing with real data
const SLOT_B = "13:41";
const DURATION = 45;
function publicBody(startsAt, endsAt, title) {
return {
roomId,
title,
requesterName: "Slot Test Guest",
requesterPhone: "0550000000",
meetingType: "internal",
startsAt,
endsAt,
};
}
before(async () => {
adminCookie = await login(await createSuperAdmin(), TEST_PASSWORD);
// Snapshot + clear pre-existing slots and lead-time setting.
savedSlots = (
await pool.query(`SELECT * FROM protocol_booking_slots ORDER BY id`)
).rows;
await pool.query(`DELETE FROM protocol_booking_slots`);
savedLeadRow = (
await pool.query(
`SELECT value FROM protocol_settings WHERE key = 'booking.minLeadMinutes'`,
)
).rows[0] ?? null;
const roomRes = await api(adminCookie, "POST", "/protocol/rooms", {
nameAr: `قاعة اختبار الأوقات ${Date.now().toString(36)}`,
nameEn: "Slot Test Room",
isActive: true,
});
assert.equal(roomRes.status, 201, "room creation should succeed");
roomId = (await roomRes.json()).id;
created.roomIds.push(roomId);
});
after(async () => {
try {
if (created.bookingIds.length) {
await pool.query(
`DELETE FROM protocol_room_bookings WHERE id = ANY($1::int[])`,
[created.bookingIds],
);
}
// Also remove any bookings created via the public endpoint against our
// test room (ids captured from responses, but belt-and-braces by room).
if (created.roomIds.length) {
await pool.query(
`DELETE FROM protocol_room_bookings WHERE room_id = ANY($1::int[])`,
[created.roomIds],
);
await pool.query(`DELETE FROM protocol_rooms WHERE id = ANY($1::int[])`, [
created.roomIds,
]);
}
if (created.userIds.length) {
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
created.userIds,
]);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
created.userIds,
]);
}
await pool.query(`DELETE FROM protocol_booking_slots`);
for (const s of savedSlots) {
await pool.query(
`INSERT INTO protocol_booking_slots (id, start_time, duration_minutes, is_active, sort_order, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
s.id,
s.start_time,
s.duration_minutes,
s.is_active,
s.sort_order,
s.created_at,
],
);
}
if (savedSlots.length) {
await pool.query(
`SELECT setval(pg_get_serial_sequence('protocol_booking_slots','id'),
(SELECT MAX(id) FROM protocol_booking_slots))`,
);
}
if (savedLeadRow) {
await pool.query(
`INSERT INTO protocol_settings (key, value) VALUES ('booking.minLeadMinutes', $1)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
[savedLeadRow.value],
);
} else {
await pool.query(
`DELETE FROM protocol_settings WHERE key = 'booking.minLeadMinutes'`,
);
}
} finally {
await pool.end();
}
});
let slotAId = null;
let slotBId = null;
test("admin can create slots; duplicate startTime is rejected with 409", async () => {
const resA = await api(adminCookie, "POST", "/protocol/booking-slots", {
startTime: SLOT_A,
durationMinutes: DURATION,
});
assert.equal(resA.status, 201);
slotAId = (await resA.json()).id;
created.slotIds.push(slotAId);
const resB = await api(adminCookie, "POST", "/protocol/booking-slots", {
startTime: SLOT_B,
durationMinutes: DURATION,
});
assert.equal(resB.status, 201);
slotBId = (await resB.json()).id;
created.slotIds.push(slotBId);
const dup = await api(adminCookie, "POST", "/protocol/booking-slots", {
startTime: SLOT_A,
});
assert.equal(dup.status, 409, "duplicate startTime should 409");
assert.equal((await dup.json()).code, "duplicate_slot");
});
test("minLeadMinutes setting persists via PATCH and GET", async () => {
const patch = await api(adminCookie, "PATCH", "/protocol/settings", {
minLeadMinutes: 10080,
});
assert.equal(patch.status, 200);
const get = await api(adminCookie, "GET", "/protocol/settings");
assert.equal(get.status, 200);
assert.equal((await get.json()).minLeadMinutes, 10080);
});
test("public availability marks slots tooSoon inside the lead window", async () => {
// lead is currently 7 days (10080), and our slots are tomorrow -> tooSoon.
const res = await fetch(
`${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`,
);
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.minLeadMinutes, 10080);
const a = data.slots.find((s) => s.id === slotAId);
assert.ok(a, "slot A should appear in availability");
assert.equal(a.booked, false);
assert.equal(a.tooSoon, true);
assert.equal(a.available, false);
});
test("public request off-slot is rejected with invalid_slot", async () => {
const res = await api(null, "POST", "/protocol/public/booking-requests",
publicBody(isoAt("10:15"), isoAt("10:15", DURATION), "Off-slot request"));
assert.equal(res.status, 400);
assert.equal((await res.json()).code, "invalid_slot");
});
test("public request on-slot inside lead window is rejected with lead_time", async () => {
const res = await api(null, "POST", "/protocol/public/booking-requests",
publicBody(isoAt(SLOT_A), isoAt(SLOT_A, DURATION), "Too-soon request"));
assert.equal(res.status, 400);
assert.equal((await res.json()).code, "lead_time");
});
test("public request on-slot outside lead window succeeds; same slot then conflicts", async () => {
const patch = await api(adminCookie, "PATCH", "/protocol/settings", {
minLeadMinutes: 0,
});
assert.equal(patch.status, 200);
const ok = await api(null, "POST", "/protocol/public/booking-requests",
publicBody(isoAt(SLOT_A), isoAt(SLOT_A, DURATION), "Valid slot request"));
assert.equal(ok.status, 201, "on-slot request should be accepted");
const body = await ok.json();
assert.ok(body.id, "response should include an id");
created.bookingIds.push(body.id);
const clash = await api(null, "POST", "/protocol/public/booking-requests",
publicBody(isoAt(SLOT_A), isoAt(SLOT_A, DURATION), "Clashing request"));
assert.equal(clash.status, 409, "same slot should conflict");
// Availability now reports the slot as booked.
const avail = await fetch(
`${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`,
);
const data = await avail.json();
const a = data.slots.find((s) => s.id === slotAId);
assert.equal(a.booked, true);
assert.equal(a.available, false);
});
test("a deactivated slot no longer accepts public requests", async () => {
const patch = await api(
adminCookie,
"PATCH",
`/protocol/booking-slots/${slotBId}`,
{ isActive: false },
);
assert.equal(patch.status, 200);
const res = await api(null, "POST", "/protocol/public/booking-requests",
publicBody(isoAt(SLOT_B), isoAt(SLOT_B, DURATION), "Disabled slot request"));
assert.equal(res.status, 400);
assert.equal((await res.json()).code, "invalid_slot");
// Re-activate for the staff test below.
const undo = await api(
adminCookie,
"PATCH",
`/protocol/booking-slots/${slotBId}`,
{ isActive: true },
);
assert.equal(undo.status, 200);
});
test("staff bookings must match a slot but ignore the lead time", async () => {
// Restore a large lead time; staff must NOT be blocked by it.
const patch = await api(adminCookie, "PATCH", "/protocol/settings", {
minLeadMinutes: 10080,
});
assert.equal(patch.status, 200);
const offSlot = await api(adminCookie, "POST", "/protocol/bookings", {
roomId,
title: "Staff off-slot",
requesterName: "Staff",
startsAt: isoAt("16:03"),
endsAt: isoAt("16:03", DURATION),
});
assert.equal(offSlot.status, 400, "staff off-slot booking should 400");
assert.equal((await offSlot.json()).code, "invalid_slot");
const onSlot = await api(adminCookie, "POST", "/protocol/bookings", {
roomId,
title: "Staff on-slot inside lead window",
requesterName: "Staff",
startsAt: isoAt(SLOT_B),
endsAt: isoAt(SLOT_B, DURATION),
});
assert.equal(
onSlot.status,
201,
"staff on-slot booking should succeed despite lead time",
);
created.bookingIds.push((await onSlot.json()).id);
});
// Authz decision locked in: any protocol.access holder may READ slots and
// settings (the booking dialog needs them), but WRITES require
// protocol.rooms.manage.
test("protocol viewer can read slots/settings but cannot write them", async () => {
const username = uniqueName("slot_viewer");
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Slot Test Viewer', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
created.userIds.push(rows[0].id);
for (const r of ["user", "protocol_viewer"]) {
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2
ON CONFLICT DO NOTHING`,
[rows[0].id, r],
);
}
const viewerCookie = await login(username, TEST_PASSWORD);
const getSlots = await api(viewerCookie, "GET", "/protocol/booking-slots");
assert.equal(getSlots.status, 200, "viewer should read slots");
const getSettings = await api(viewerCookie, "GET", "/protocol/settings");
assert.equal(getSettings.status, 200, "viewer should read settings");
const post = await api(viewerCookie, "POST", "/protocol/booking-slots", {
startTime: "20:20",
});
assert.equal(post.status, 403, "viewer must not create slots");
const patch = await api(viewerCookie, "PATCH", "/protocol/settings", {
minLeadMinutes: 5,
});
assert.equal(patch.status, 403, "viewer must not change settings");
const del = await api(
viewerCookie,
"DELETE",
`/protocol/booking-slots/${slotAId}`,
);
assert.equal(del.status, 403, "viewer must not delete slots");
});
test("deleting a slot removes it from the admin list", async () => {
const del = await api(
adminCookie,
"DELETE",
`/protocol/booking-slots/${slotBId}`,
);
assert.equal(del.status, 204);
const list = await api(adminCookie, "GET", "/protocol/booking-slots");
const rows = await list.json();
assert.ok(!rows.some((s) => s.id === slotBId));
});