diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index 65b08b00..81d27e72 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -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 { + 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 { + 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 diff --git a/artifacts/api-server/tests/protocol-booking-slots.test.mjs b/artifacts/api-server/tests/protocol-booking-slots.test.mjs new file mode 100644 index 00000000..908f1883 --- /dev/null +++ b/artifacts/api-server/tests/protocol-booking-slots.test.mjs @@ -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)); +}); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 83071f27..732d3939 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1894,6 +1894,23 @@ }, "audit": { "empty": "لا يوجد نشاط." + }, + "slots": { + "title": "أوقات الحجز", + "hint": "عند إضافة أوقات، يقتصر الحجز (الداخلي والعام) على هذه الأوقات فقط. اتركها فارغة للسماح بأي وقت.", + "startTime": "وقت البداية", + "duration": "المدة (دقيقة)", + "add": "إضافة", + "empty": "لا توجد أوقات محددة — الحجز متاح في أي وقت.", + "activate": "تفعيل", + "deactivate": "تعطيل", + "leadTitle": "مهلة الحجز المسبق", + "leadHint": "الحد الأدنى بالدقائق قبل بداية الموعد لقبول الطلبات العامة (0 لتعطيل الشرط). لا تنطبق المهلة على موظفي المراسم.", + "leadLabel": "المهلة (دقيقة)", + "leadSaved": "تم حفظ المهلة", + "dateLabel": "التاريخ", + "slotLabel": "الوقت", + "pickSlot": "اختر الوقت" } } } diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 150f91d0..949a1b34 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1747,6 +1747,23 @@ }, "audit": { "empty": "No activity yet." + }, + "slots": { + "title": "Booking time slots", + "hint": "When slots are added, bookings (internal and public) are limited to these times only. Leave empty to allow any time.", + "startTime": "Start time", + "duration": "Duration (min)", + "add": "Add", + "empty": "No slots defined — booking is allowed at any time.", + "activate": "Activate", + "deactivate": "Deactivate", + "leadTitle": "Advance booking lead time", + "leadHint": "Minimum minutes before the start time for public requests to be accepted (0 disables the rule). Does not apply to protocol staff.", + "leadLabel": "Lead time (min)", + "leadSaved": "Lead time saved", + "dateLabel": "Date", + "slotLabel": "Time", + "pickSlot": "Pick a time" } } } diff --git a/artifacts/tx-os/src/pages/protocol-request.tsx b/artifacts/tx-os/src/pages/protocol-request.tsx index 49d5dce9..f8f174d7 100644 --- a/artifacts/tx-os/src/pages/protocol-request.tsx +++ b/artifacts/tx-os/src/pages/protocol-request.tsx @@ -25,6 +25,50 @@ const API = `${import.meta.env.BASE_URL}api`; type PublicRoom = { id: number; nameAr: string; nameEn: string }; +// Availability for one room + Riyadh calendar day: the admin-configured slot +// grid with per-slot booked / too-soon flags. An empty `slots` array means +// the admin hasn't configured slots — the form falls back to free date-time +// pickers (legacy mode). +type AvailabilitySlot = { + id: number; + startTime: string; // "HH:mm" Riyadh wall clock + durationMinutes: number; + booked: boolean; + tooSoon: boolean; + available: boolean; +}; +type Availability = { minLeadMinutes: number; slots: AvailabilitySlot[] }; + +function fmtSlotLabel(startTime: string, durationMinutes: number): string { + const [h, m] = startTime.split(":").map(Number); + const endTotal = h * 60 + m + durationMinutes; + const eh = Math.floor(endTotal / 60) % 24; + const em = endTotal % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + const to12 = (hh: number, mm: number) => { + const period = hh < 12 ? "ص" : "م"; + let h12 = hh % 12; + if (h12 === 0) h12 = 12; + return `${h12}:${pad(mm)} ${period}`; + }; + return `${to12(h, m)} – ${to12(eh, em)}`; +} + +// Slot start/end as absolute instants. Riyadh has no DST so the fixed +// +03:00 offset is always correct. +function slotIso(date: string, startTime: string, durationMinutes?: number) { + const start = new Date(`${date}T${startTime}:00+03:00`); + if (durationMinutes === undefined) return start.toISOString(); + return new Date(start.getTime() + durationMinutes * 60_000).toISOString(); +} + +function todayRiyadh(): string { + // en-CA gives YYYY-MM-DD directly. + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Riyadh", + }).format(new Date()); +} + type MeetingType = "internal" | "external"; type AttendeeSide = "internal" | "external"; type AttendeeRow = { name: string; position: string }; @@ -39,7 +83,9 @@ type FormState = { attendeeCount: string; purpose: string; roomId: string; - startsAt: string; + date: string; // YYYY-MM-DD (Riyadh calendar day) — slot mode + slotId: string; // selected slot id — slot mode + startsAt: string; // legacy free mode (no slots configured) endsAt: string; notes: string; website: string; // honeypot @@ -55,6 +101,8 @@ const EMPTY: FormState = { attendeeCount: "", purpose: "", roomId: "", + date: "", + slotId: "", startsAt: "", endsAt: "", notes: "", @@ -93,6 +141,44 @@ export default function ProtocolRequestPage() { }); const [form, setForm] = useState(EMPTY); + + // Availability (slot grid) for the chosen room + day. When the admin has + // configured slots, guests pick a day then one of the returned slots; the + // legacy free start/end pickers only render when no slots exist at all. + const availability = useQuery({ + queryKey: ["protocol-public", "availability", form.roomId, form.date], + enabled: form.roomId !== "" && form.date !== "", + queryFn: async () => { + const r = await fetch( + `${API}/protocol/public/availability?roomId=${encodeURIComponent( + form.roomId, + )}&date=${encodeURIComponent(form.date)}`, + ); + if (!r.ok) throw new Error("availability"); + return r.json(); + }, + }); + // Probe with today's date before a day is picked, so we know whether slot + // mode is on (the slot list itself is per-day, but its existence is global). + const slotProbe = useQuery({ + queryKey: ["protocol-public", "availability-probe", form.roomId], + enabled: form.roomId !== "", + queryFn: async () => { + const r = await fetch( + `${API}/protocol/public/availability?roomId=${encodeURIComponent( + form.roomId, + )}&date=${todayRiyadh()}`, + ); + if (!r.ok) throw new Error("availability"); + return r.json(); + }, + }); + const slotMode = + form.roomId !== "" && + slotProbe.data !== undefined && + slotProbe.data.slots.length > 0; + const daySlots = availability.data?.slots ?? []; + const selectedSlot = daySlots.find((s) => String(s.id) === form.slotId); // Key attendees ("أبرز الحضور") are two separate lists: من الهيئة (internal) // and خارج الهيئة (external). Each row is a name + position. const [internalAttendees, setInternalAttendees] = useState([ @@ -139,17 +225,20 @@ export default function ProtocolRequestPage() { const isExternal = form.meetingType === "external"; const canSubmit = useMemo(() => { + const timeOk = slotMode + ? form.date !== "" && selectedSlot !== undefined && selectedSlot.available + : form.startsAt !== "" && + form.endsAt !== "" && + new Date(form.startsAt) < new Date(form.endsAt); return ( form.requesterName.trim().length > 0 && form.title.trim().length > 0 && form.requesterPhone.trim().length >= 3 && form.roomId !== "" && - form.startsAt !== "" && - form.endsAt !== "" && - new Date(form.startsAt) < new Date(form.endsAt) && + timeOk && (!isExternal || form.requesterOrg.trim().length > 0) ); - }, [form, isExternal]); + }, [form, isExternal, slotMode, selectedSlot]); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -190,8 +279,18 @@ export default function ProtocolRequestPage() { ? null : Number(form.attendeeCount), keyAttendees, - startsAt: localToIso(form.startsAt), - endsAt: localToIso(form.endsAt), + startsAt: + slotMode && selectedSlot + ? slotIso(form.date, selectedSlot.startTime) + : localToIso(form.startsAt), + endsAt: + slotMode && selectedSlot + ? slotIso( + form.date, + selectedSlot.startTime, + selectedSlot.durationMinutes, + ) + : localToIso(form.endsAt), notes: form.notes.trim() || null, website: form.website, }), @@ -450,38 +549,108 @@ export default function ProtocolRequestPage() { /> -
-
- - set("startsAt", v)} - placeholder="اختر تاريخ ووقت البداية" - /> -
-
- - set("endsAt", v)} - placeholder="اختر تاريخ ووقت النهاية" - minDate={form.startsAt ? new Date(form.startsAt) : undefined} - /> -
-
- {form.startsAt !== "" && - form.endsAt !== "" && - new Date(form.startsAt) >= new Date(form.endsAt) && ( -

- يجب أن يكون وقت البداية قبل وقت النهاية. -

- )} + {slotMode ? ( + <> +
+ + { + set("date", e.target.value); + set("slotId", ""); + }} + /> +
+ {form.date !== "" && ( +
+ + {availability.isLoading && ( +

+ جارٍ تحميل الأوقات المتاحة… +

+ )} + {availability.isError && ( +

+ تعذّر تحميل الأوقات، يرجى المحاولة مرة أخرى. +

+ )} + {availability.data && ( + <> +
+ {daySlots.map((s) => ( + + ))} +
+ {daySlots.length > 0 && + daySlots.every((s) => !s.available) && ( +

+ لا توجد أوقات متاحة في هذا اليوم، يرجى اختيار يوم + آخر. +

+ )} + + )} +
+ )} + + ) : ( + <> +
+
+ + set("startsAt", v)} + placeholder="اختر تاريخ ووقت البداية" + /> +
+
+ + set("endsAt", v)} + placeholder="اختر تاريخ ووقت النهاية" + minDate={form.startsAt ? new Date(form.startsAt) : undefined} + /> +
+
+ {form.startsAt !== "" && + form.endsAt !== "" && + new Date(form.startsAt) >= new Date(form.endsAt) && ( +

+ يجب أن يكون وقت البداية قبل وقت النهاية. +

+ )} + + )}
diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index f5d565a0..65de5c22 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -99,6 +99,14 @@ type Room = { sortOrder: number; }; +type Slot = { + id: number; + startTime: string; // "HH:mm" Riyadh wall clock + durationMinutes: number; + isActive: boolean; + sortOrder: number; +}; + type BookingStatus = "pending" | "approved" | "rejected" | "cancelled"; type MeetingType = "internal" | "external"; type KeyAttendee = { @@ -276,6 +284,45 @@ const BOOKING_BLOCK_STYLE: Record = { completed: "bg-emerald-100 border-emerald-500 text-emerald-900", }; +// --- Booking slots (أوقات الحجز) helpers. Slot times are Riyadh wall-clock +// "HH:mm"; Riyadh has no DST so a fixed +03:00 offset is always correct. +const riyadhTimeFmt = new Intl.DateTimeFormat("en-GB", { + timeZone: "Asia/Riyadh", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}); +function riyadhHHmm(iso: string): string { + return riyadhTimeFmt.format(new Date(iso)); +} +function riyadhYmd(iso: string): string { + return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Riyadh" }).format( + new Date(iso), + ); +} +function slotToIso(date: string, startTime: string, plusMinutes = 0): string { + const start = new Date(`${date}T${startTime}:00+03:00`); + return new Date(start.getTime() + plusMinutes * 60_000).toISOString(); +} +function fmtSlotRange( + startTime: string, + durationMinutes: number, + isAr: boolean, +): string { + const [h, m] = startTime.split(":").map(Number); + const endTotal = h * 60 + m + durationMinutes; + const eh = Math.floor(endTotal / 60) % 24; + const em = endTotal % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + const to12 = (hh: number, mm: number) => { + const period = hh < 12 ? (isAr ? "ص" : "AM") : isAr ? "م" : "PM"; + let h12 = hh % 12; + if (h12 === 0) h12 = 12; + return `${h12}:${pad(mm)} ${period}`; + }; + return `${to12(h, m)} – ${to12(eh, em)}`; +} + function sameYmd(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && @@ -562,6 +609,13 @@ export default function ProtocolPage() { queryKey: ["protocol", "rooms"], queryFn: () => apiJson(`${API}/protocol/rooms`), }); + // Admin-managed booking slots (أوقات الحجز). Used by the rooms tab admin + // section and by the booking dialog (which restricts times to active slots + // whenever at least one is configured). + const slots = useQuery({ + queryKey: ["protocol", "slots"], + queryFn: () => apiJson(`${API}/protocol/booking-slots`), + }); const [bookingSearch, setBookingSearch] = useState(""); const [debouncedBookingSearch, setDebouncedBookingSearch] = useState(""); useEffect(() => { @@ -1611,6 +1665,14 @@ export default function ProtocolPage() { )}
+ {c?.canManageRooms && ( + invalidate("slots")} + onError={onApiError} + /> + )} )} @@ -1905,6 +1967,7 @@ export default function ProtocolPage() { {bookingDialog.open && ( s.isActive)} edit={bookingDialog.edit} initialDay={bookingDialog.day} initialStart={bookingDialog.start} @@ -2325,6 +2388,214 @@ function formatDuration( return parts.join(" "); } +// Admin management of booking slots (أوقات الحجز) + the minimum lead-time +// setting for public requests. Shown in the rooms tab for canManageRooms. +function SlotsAdminSection({ + slots, + isAr, + onChanged, + onError, +}: { + slots: Slot[]; + isAr: boolean; + onChanged: () => void; + onError: (e: unknown) => void; +}) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [newTime, setNewTime] = useState(""); + const [newDuration, setNewDuration] = useState("30"); + + const settings = useQuery<{ minLeadMinutes: number }>({ + queryKey: ["protocol", "settings"], + queryFn: () => + apiJson<{ minLeadMinutes: number }>(`${API}/protocol/settings`), + }); + const [leadInput, setLeadInput] = useState(null); + const leadValue = + leadInput ?? + (settings.data ? String(settings.data.minLeadMinutes) : ""); + + const addMut = useMutation({ + mutationFn: () => + apiJson(`${API}/protocol/booking-slots`, { + method: "POST", + body: JSON.stringify({ + startTime: newTime, + durationMinutes: Number(newDuration), + }), + }), + onSuccess: () => { + setNewTime(""); + onChanged(); + }, + onError, + }); + const toggleMut = useMutation({ + mutationFn: (s: Slot) => + apiJson(`${API}/protocol/booking-slots/${s.id}`, { + method: "PATCH", + body: JSON.stringify({ isActive: !s.isActive }), + }), + onSuccess: onChanged, + onError, + }); + const deleteMut = useMutation({ + mutationFn: (id: number) => + apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }), + onSuccess: onChanged, + onError, + }); + const leadMut = useMutation({ + mutationFn: () => + apiJson(`${API}/protocol/settings`, { + method: "PATCH", + body: JSON.stringify({ minLeadMinutes: Number(leadValue) }), + }), + onSuccess: () => { + setLeadInput(null); + settings.refetch(); + toast({ title: t("protocol.slots.leadSaved") }); + }, + onError, + }); + + const canAdd = /^\d{2}:\d{2}$/.test(newTime) && Number(newDuration) >= 5; + const leadDirty = + leadInput !== null && + settings.data !== undefined && + leadInput !== String(settings.data.minLeadMinutes) && + /^\d+$/.test(leadInput); + + return ( +
+
+
+

+ {t("protocol.slots.title")} +

+

+ {t("protocol.slots.hint")} +

+
+
+
+ + setNewTime(e.target.value)} + /> +
+
+ + setNewDuration(e.target.value)} + /> +
+ +
+ {slots.length === 0 ? ( +

{t("protocol.slots.empty")}

+ ) : ( +
+ {slots.map((s) => ( +
+
+ + {fmtSlotRange(s.startTime, s.durationMinutes, isAr)} + + + {s.isActive + ? t("protocol.rooms.active") + : t("protocol.rooms.inactive")} + +
+
+ + +
+
+ ))} +
+ )} +
+ +
+

+ {t("protocol.slots.leadTitle")} +

+

{t("protocol.slots.leadHint")}

+
+
+ + setLeadInput(e.target.value)} + disabled={settings.isLoading} + /> +
+ +
+
+
+ ); +} + function EmptyState({ text }: { text: string }) { return (
{text}
@@ -2568,6 +2839,7 @@ function DialogShell({ function BookingDialog({ rooms, + slots, edit, initialDay, initialStart, @@ -2577,6 +2849,10 @@ function BookingDialog({ onError, }: { rooms: Room[]; + // Active booking slots. When non-empty the dialog restricts times to the + // slot grid (same rule the server enforces); when empty it keeps the + // legacy free start/end inputs. + slots: Slot[]; edit?: Booking; initialDay?: Date; initialStart?: Date; @@ -2585,7 +2861,8 @@ function BookingDialog({ onSaved: () => void; onError: (e: unknown) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const isAr = i18n.language === "ar"; const dayAt = (hour: number) => { if (!initialDay) return ""; const d = new Date(initialDay); @@ -2610,6 +2887,25 @@ function BookingDialog({ ); return dayAt(10); }); + const slotMode = slots.length > 0; + // Slot-mode state: a Riyadh calendar day + one of the active slots. + const [slotDate, setSlotDate] = useState(() => { + if (edit) return riyadhYmd(edit.startsAt); + if (initialStart) return riyadhYmd(initialStart.toISOString()); + if (initialDay) return riyadhYmd(initialDay.toISOString()); + return ""; + }); + const [slotId, setSlotId] = useState(() => { + if (!slotMode) return ""; + const wanted = edit + ? riyadhHHmm(edit.startsAt) + : initialStart + ? riyadhHHmm(initialStart.toISOString()) + : ""; + const match = slots.find((s) => s.startTime === wanted); + return match ? String(match.id) : ""; + }); + const selectedSlot = slots.find((s) => String(s.id) === slotId); const [notes, setNotes] = useState(edit?.notes ?? ""); const [requesterPhone, setRequesterPhone] = useState( edit?.requesterPhone ?? "", @@ -2668,8 +2964,18 @@ function BookingDialog({ attendeeCount.trim() === "" ? null : Number(attendeeCount), purpose: purpose.trim() || null, keyAttendees, - startsAt: fromLocalInput(startsAt), - endsAt: fromLocalInput(endsAt), + startsAt: + slotMode && selectedSlot + ? slotToIso(slotDate, selectedSlot.startTime) + : fromLocalInput(startsAt), + endsAt: + slotMode && selectedSlot + ? slotToIso( + slotDate, + selectedSlot.startTime, + selectedSlot.durationMinutes, + ) + : fromLocalInput(endsAt), notes: notes || null, }; return edit @@ -2690,7 +2996,12 @@ function BookingDialog({ mut.mutate()} + onSubmit={() => { + // Slot mode: both a date and a slot are required; block the submit + // client-side instead of round-tripping to a server invalid_slot. + if (slotMode && (!slotDate || !selectedSlot)) return; + mut.mutate(); + }} submitLabel={t("common.save")} saving={mut.isPending} > @@ -2785,26 +3096,59 @@ function BookingDialog({ rows={2} /> -
-
- - setStartsAt(e.target.value)} - required - /> + {slotMode ? ( +
+
+ + setSlotDate(e.target.value)} + required + /> +
+
+ + +
-
- - setEndsAt(e.target.value)} - required - /> + ) : ( +
+
+ + setStartsAt(e.target.value)} + required + /> +
+
+ + setEndsAt(e.target.value)} + required + /> +
-
+ )}
diff --git a/lib/db/src/schema/protocol.ts b/lib/db/src/schema/protocol.ts index 643694dc..cd5e9a1a 100644 --- a/lib/db/src/schema/protocol.ts +++ b/lib/db/src/schema/protocol.ts @@ -344,6 +344,66 @@ export const protocolPhotosTable = pgTable( }), ); +// --------------------------------------------------------------------------- +// Bookable time slots (أوقات الحجز). +// +// Slots are GLOBAL — they define the daily time grid available for booking in +// every room (per-room availability still comes from the conflict rule). +// Keeping them global was a deliberate simplicity choice: the admin manages +// one list of times instead of one list per room, matching how the team +// actually schedules. `startTime` is a wall-clock "HH:mm" in Asia/Riyadh. +// --------------------------------------------------------------------------- +export const protocolBookingSlotsTable = pgTable( + "protocol_booking_slots", + { + id: serial("id").primaryKey(), + // Wall-clock start time in Asia/Riyadh, canonical "HH:mm" (24h, zero + // padded). Uniqueness is enforced so two slots can't start together. + startTime: varchar("start_time", { length: 5 }).notNull(), + durationMinutes: integer("duration_minutes").notNull().default(30), + isActive: boolean("is_active").notNull().default(true), + sortOrder: integer("sort_order").notNull().default(0), + createdBy: integer("created_by").references(() => usersTable.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => ({ + activeIdx: index("protocol_slots_active_idx").on(t.isActive), + startUnique: uniqueIndex("protocol_slots_start_unique").on(t.startTime), + }), +); + +// --------------------------------------------------------------------------- +// Module settings (key/value). Currently only `booking.minLeadMinutes` — the +// minimum advance notice (in minutes) required before a PUBLIC booking's +// start time. Stored as a string value to keep the table generic. +// --------------------------------------------------------------------------- +export const protocolSettingsTable = pgTable( + "protocol_settings", + { + id: serial("id").primaryKey(), + key: varchar("key", { length: 100 }).notNull(), + value: text("value").notNull(), + updatedBy: integer("updated_by").references(() => usersTable.id, { + onDelete: "set null", + }), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => ({ + keyUnique: uniqueIndex("protocol_settings_key_unique").on(t.key), + }), +); + // --------------------------------------------------------------------------- // Audit log (scoped to the protocol module only) // --------------------------------------------------------------------------- @@ -424,3 +484,13 @@ export type InsertProtocolPhoto = z.infer; export type ProtocolPhoto = typeof protocolPhotosTable.$inferSelect; export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect; + +export const insertProtocolBookingSlotSchema = createInsertSchema( + protocolBookingSlotsTable, +).omit({ id: true, createdAt: true, updatedAt: true }); +export type InsertProtocolBookingSlot = z.infer< + typeof insertProtocolBookingSlotSchema +>; +export type ProtocolBookingSlot = typeof protocolBookingSlotsTable.$inferSelect; + +export type ProtocolSetting = typeof protocolSettingsTable.$inferSelect;