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));
});
+17
View File
@@ -1894,6 +1894,23 @@
},
"audit": {
"empty": "لا يوجد نشاط."
},
"slots": {
"title": "أوقات الحجز",
"hint": "عند إضافة أوقات، يقتصر الحجز (الداخلي والعام) على هذه الأوقات فقط. اتركها فارغة للسماح بأي وقت.",
"startTime": "وقت البداية",
"duration": "المدة (دقيقة)",
"add": "إضافة",
"empty": "لا توجد أوقات محددة — الحجز متاح في أي وقت.",
"activate": "تفعيل",
"deactivate": "تعطيل",
"leadTitle": "مهلة الحجز المسبق",
"leadHint": "الحد الأدنى بالدقائق قبل بداية الموعد لقبول الطلبات العامة (0 لتعطيل الشرط). لا تنطبق المهلة على موظفي المراسم.",
"leadLabel": "المهلة (دقيقة)",
"leadSaved": "تم حفظ المهلة",
"dateLabel": "التاريخ",
"slotLabel": "الوقت",
"pickSlot": "اختر الوقت"
}
}
}
+17
View File
@@ -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"
}
}
}
+208 -39
View File
@@ -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<FormState>(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<Availability>({
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<Availability>({
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<AttendeeRow[]>([
@@ -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() {
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startsAt">
من <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="startsAt"
value={form.startsAt}
onChange={(v) => set("startsAt", v)}
placeholder="اختر تاريخ ووقت البداية"
/>
</div>
<div className="space-y-2">
<Label htmlFor="endsAt">
إلى <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="endsAt"
value={form.endsAt}
onChange={(v) => set("endsAt", v)}
placeholder="اختر تاريخ ووقت النهاية"
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
/>
</div>
</div>
{form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) >= new Date(form.endsAt) && (
<p className="text-xs text-rose-500 -mt-2">
يجب أن يكون وقت البداية قبل وقت النهاية.
</p>
)}
{slotMode ? (
<>
<div className="space-y-2">
<Label htmlFor="bookingDate">
تاريخ الحجز <span className="text-rose-500">*</span>
</Label>
<Input
id="bookingDate"
type="date"
min={todayRiyadh()}
value={form.date}
onChange={(e) => {
set("date", e.target.value);
set("slotId", "");
}}
/>
</div>
{form.date !== "" && (
<div className="space-y-2">
<Label>
الوقت المتاح <span className="text-rose-500">*</span>
</Label>
{availability.isLoading && (
<p className="text-sm text-slate-500">
جارٍ تحميل الأوقات المتاحة
</p>
)}
{availability.isError && (
<p className="text-xs text-rose-500">
تعذّر تحميل الأوقات، يرجى المحاولة مرة أخرى.
</p>
)}
{availability.data && (
<>
<div className="grid grid-cols-2 gap-2">
{daySlots.map((s) => (
<button
key={s.id}
type="button"
disabled={!s.available}
onClick={() => set("slotId", String(s.id))}
className={`rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors ${
form.slotId === String(s.id)
? "border-sky-500 bg-sky-50 text-sky-700"
: s.available
? "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
: "border-slate-100 bg-slate-50 text-slate-300 cursor-not-allowed line-through"
}`}
dir="ltr"
>
{fmtSlotLabel(s.startTime, s.durationMinutes)}
</button>
))}
</div>
{daySlots.length > 0 &&
daySlots.every((s) => !s.available) && (
<p className="text-xs text-amber-600">
لا توجد أوقات متاحة في هذا اليوم، يرجى اختيار يوم
آخر.
</p>
)}
</>
)}
</div>
)}
</>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startsAt">
من <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="startsAt"
value={form.startsAt}
onChange={(v) => set("startsAt", v)}
placeholder="اختر تاريخ ووقت البداية"
/>
</div>
<div className="space-y-2">
<Label htmlFor="endsAt">
إلى <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="endsAt"
value={form.endsAt}
onChange={(v) => set("endsAt", v)}
placeholder="اختر تاريخ ووقت النهاية"
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
/>
</div>
</div>
{form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) >= new Date(form.endsAt) && (
<p className="text-xs text-rose-500 -mt-2">
يجب أن يكون وقت البداية قبل وقت النهاية.
</p>
)}
</>
)}
<div className="space-y-3">
<Label>أبرز الحضور</Label>
+366 -22
View File
@@ -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<string, string> = {
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<Room[]>(`${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<Slot[]>({
queryKey: ["protocol", "slots"],
queryFn: () => apiJson<Slot[]>(`${API}/protocol/booking-slots`),
});
const [bookingSearch, setBookingSearch] = useState("");
const [debouncedBookingSearch, setDebouncedBookingSearch] = useState("");
useEffect(() => {
@@ -1611,6 +1665,14 @@ export default function ProtocolPage() {
<EmptyState text={t("protocol.rooms.empty")} />
)}
</div>
{c?.canManageRooms && (
<SlotsAdminSection
slots={slots.data ?? []}
isAr={isAr}
onChanged={() => invalidate("slots")}
onError={onApiError}
/>
)}
</section>
)}
@@ -1905,6 +1967,7 @@ export default function ProtocolPage() {
{bookingDialog.open && (
<BookingDialog
rooms={rooms.data ?? []}
slots={(slots.data ?? []).filter((s) => 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<string | null>(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 (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 space-y-3">
<div>
<h3 className="font-semibold text-slate-700">
{t("protocol.slots.title")}
</h3>
<p className="text-xs text-slate-500 mt-0.5">
{t("protocol.slots.hint")}
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<div>
<Label className="text-xs">{t("protocol.slots.startTime")}</Label>
<Input
type="time"
dir="ltr"
className="w-32"
value={newTime}
onChange={(e) => setNewTime(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">{t("protocol.slots.duration")}</Label>
<Input
type="number"
inputMode="numeric"
min={5}
max={480}
step={5}
className="w-24"
value={newDuration}
onChange={(e) => setNewDuration(e.target.value)}
/>
</div>
<Button
size="sm"
disabled={!canAdd || addMut.isPending}
onClick={() => addMut.mutate()}
>
<Plus size={16} className="me-1" />
{t("protocol.slots.add")}
</Button>
</div>
{slots.length === 0 ? (
<p className="text-sm text-slate-400">{t("protocol.slots.empty")}</p>
) : (
<div className="grid gap-1.5 sm:grid-cols-2">
{slots.map((s) => (
<div
key={s.id}
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 px-3 py-2"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-slate-700" dir="ltr">
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
</span>
<span
className={cn(
"text-[11px] px-2 py-0.5 rounded-full shrink-0",
s.isActive
? "bg-emerald-100 text-emerald-700"
: "bg-slate-200 text-slate-500",
)}
>
{s.isActive
? t("protocol.rooms.active")
: t("protocol.rooms.inactive")}
</span>
</div>
<div className="flex gap-1 shrink-0">
<Button
size="sm"
variant="ghost"
title={
s.isActive
? t("protocol.slots.deactivate")
: t("protocol.slots.activate")
}
onClick={() => toggleMut.mutate(s)}
>
{s.isActive ? <Ban size={14} /> : <Check size={14} />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteMut.mutate(s.id)}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4 space-y-2">
<h3 className="font-semibold text-slate-700">
{t("protocol.slots.leadTitle")}
</h3>
<p className="text-xs text-slate-500">{t("protocol.slots.leadHint")}</p>
<div className="flex items-end gap-2">
<div>
<Label className="text-xs">{t("protocol.slots.leadLabel")}</Label>
<Input
type="number"
inputMode="numeric"
min={0}
max={10080}
className="w-28"
value={leadValue}
onChange={(e) => setLeadInput(e.target.value)}
disabled={settings.isLoading}
/>
</div>
<Button
size="sm"
disabled={!leadDirty || leadMut.isPending}
onClick={() => leadMut.mutate()}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="text-center text-slate-400 py-10 text-sm">{text}</div>
@@ -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<string>(() => {
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({
<DialogShell
title={edit ? t("protocol.bookings.edit") : t("protocol.bookings.new")}
onClose={onClose}
onSubmit={() => 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}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.bookings.startsAt")}</Label>
<Input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
required
/>
{slotMode ? (
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.slots.dateLabel")}</Label>
<Input
type="date"
value={slotDate}
onChange={(e) => setSlotDate(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.slots.slotLabel")}</Label>
<Select value={slotId} onValueChange={setSlotId}>
<SelectTrigger>
<SelectValue
placeholder={t("protocol.slots.pickSlot")}
/>
</SelectTrigger>
<SelectContent>
{slots.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
<span dir="ltr">
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>{t("protocol.bookings.endsAt")}</Label>
<Input
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
required
/>
) : (
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.bookings.startsAt")}</Label>
<Input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.bookings.endsAt")}</Label>
<Input
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
required
/>
</div>
</div>
</div>
)}
<div className="space-y-2">
<Label>{t("protocol.bookings.keyAttendees")}</Label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
+70
View File
@@ -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<typeof insertProtocolPhotoSchema>;
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;