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:
@@ -25,6 +25,8 @@ import {
|
|||||||
protocolPhotoAlbumsTable,
|
protocolPhotoAlbumsTable,
|
||||||
protocolPhotosTable,
|
protocolPhotosTable,
|
||||||
protocolAuditLogsTable,
|
protocolAuditLogsTable,
|
||||||
|
protocolBookingSlotsTable,
|
||||||
|
protocolSettingsTable,
|
||||||
rolesTable,
|
rolesTable,
|
||||||
permissionsTable,
|
permissionsTable,
|
||||||
rolePermissionsTable,
|
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({
|
const rejectSchema = z.object({
|
||||||
reason: z.string().trim().max(1000).nullable().optional(),
|
reason: z.string().trim().max(1000).nullable().optional(),
|
||||||
});
|
});
|
||||||
@@ -425,6 +459,80 @@ async function hasBookingConflict(
|
|||||||
return rows.length > 0;
|
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
|
// Capabilities
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -522,6 +630,26 @@ router.post(
|
|||||||
return;
|
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 {
|
try {
|
||||||
const created = await db.transaction(async (tx) => {
|
const created = await db.transaction(async (tx) => {
|
||||||
if (!(await lockRoomRow(tx, body.roomId))) {
|
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
|
// Rooms
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -772,6 +1178,18 @@ router.post(
|
|||||||
return;
|
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
|
// Non-approvers submit requests as "pending"; users with the approve
|
||||||
// permission may create an already-approved booking directly. Either way
|
// permission may create an already-approved booking directly. Either way
|
||||||
// the slot is checked.
|
// the slot is checked.
|
||||||
@@ -875,6 +1293,18 @@ router.patch(
|
|||||||
.json({ error: "startsAt must be before endsAt", code: "validation" });
|
.json({ error: "startsAt must be before endsAt", code: "validation" });
|
||||||
return;
|
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 {
|
try {
|
||||||
const updated = await db.transaction(async (tx) => {
|
const updated = await db.transaction(async (tx) => {
|
||||||
// Lock the (possibly new) room to serialize against concurrent
|
// 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));
|
||||||
|
});
|
||||||
@@ -1894,6 +1894,23 @@
|
|||||||
},
|
},
|
||||||
"audit": {
|
"audit": {
|
||||||
"empty": "لا يوجد نشاط."
|
"empty": "لا يوجد نشاط."
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"title": "أوقات الحجز",
|
||||||
|
"hint": "عند إضافة أوقات، يقتصر الحجز (الداخلي والعام) على هذه الأوقات فقط. اتركها فارغة للسماح بأي وقت.",
|
||||||
|
"startTime": "وقت البداية",
|
||||||
|
"duration": "المدة (دقيقة)",
|
||||||
|
"add": "إضافة",
|
||||||
|
"empty": "لا توجد أوقات محددة — الحجز متاح في أي وقت.",
|
||||||
|
"activate": "تفعيل",
|
||||||
|
"deactivate": "تعطيل",
|
||||||
|
"leadTitle": "مهلة الحجز المسبق",
|
||||||
|
"leadHint": "الحد الأدنى بالدقائق قبل بداية الموعد لقبول الطلبات العامة (0 لتعطيل الشرط). لا تنطبق المهلة على موظفي المراسم.",
|
||||||
|
"leadLabel": "المهلة (دقيقة)",
|
||||||
|
"leadSaved": "تم حفظ المهلة",
|
||||||
|
"dateLabel": "التاريخ",
|
||||||
|
"slotLabel": "الوقت",
|
||||||
|
"pickSlot": "اختر الوقت"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1747,6 +1747,23 @@
|
|||||||
},
|
},
|
||||||
"audit": {
|
"audit": {
|
||||||
"empty": "No activity yet."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,50 @@ const API = `${import.meta.env.BASE_URL}api`;
|
|||||||
|
|
||||||
type PublicRoom = { id: number; nameAr: string; nameEn: string };
|
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 MeetingType = "internal" | "external";
|
||||||
type AttendeeSide = "internal" | "external";
|
type AttendeeSide = "internal" | "external";
|
||||||
type AttendeeRow = { name: string; position: string };
|
type AttendeeRow = { name: string; position: string };
|
||||||
@@ -39,7 +83,9 @@ type FormState = {
|
|||||||
attendeeCount: string;
|
attendeeCount: string;
|
||||||
purpose: string;
|
purpose: string;
|
||||||
roomId: 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;
|
endsAt: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
website: string; // honeypot
|
website: string; // honeypot
|
||||||
@@ -55,6 +101,8 @@ const EMPTY: FormState = {
|
|||||||
attendeeCount: "",
|
attendeeCount: "",
|
||||||
purpose: "",
|
purpose: "",
|
||||||
roomId: "",
|
roomId: "",
|
||||||
|
date: "",
|
||||||
|
slotId: "",
|
||||||
startsAt: "",
|
startsAt: "",
|
||||||
endsAt: "",
|
endsAt: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
@@ -93,6 +141,44 @@ export default function ProtocolRequestPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY);
|
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)
|
// Key attendees ("أبرز الحضور") are two separate lists: من الهيئة (internal)
|
||||||
// and خارج الهيئة (external). Each row is a name + position.
|
// and خارج الهيئة (external). Each row is a name + position.
|
||||||
const [internalAttendees, setInternalAttendees] = useState<AttendeeRow[]>([
|
const [internalAttendees, setInternalAttendees] = useState<AttendeeRow[]>([
|
||||||
@@ -139,17 +225,20 @@ export default function ProtocolRequestPage() {
|
|||||||
const isExternal = form.meetingType === "external";
|
const isExternal = form.meetingType === "external";
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
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 (
|
return (
|
||||||
form.requesterName.trim().length > 0 &&
|
form.requesterName.trim().length > 0 &&
|
||||||
form.title.trim().length > 0 &&
|
form.title.trim().length > 0 &&
|
||||||
form.requesterPhone.trim().length >= 3 &&
|
form.requesterPhone.trim().length >= 3 &&
|
||||||
form.roomId !== "" &&
|
form.roomId !== "" &&
|
||||||
form.startsAt !== "" &&
|
timeOk &&
|
||||||
form.endsAt !== "" &&
|
|
||||||
new Date(form.startsAt) < new Date(form.endsAt) &&
|
|
||||||
(!isExternal || form.requesterOrg.trim().length > 0)
|
(!isExternal || form.requesterOrg.trim().length > 0)
|
||||||
);
|
);
|
||||||
}, [form, isExternal]);
|
}, [form, isExternal, slotMode, selectedSlot]);
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -190,8 +279,18 @@ export default function ProtocolRequestPage() {
|
|||||||
? null
|
? null
|
||||||
: Number(form.attendeeCount),
|
: Number(form.attendeeCount),
|
||||||
keyAttendees,
|
keyAttendees,
|
||||||
startsAt: localToIso(form.startsAt),
|
startsAt:
|
||||||
endsAt: localToIso(form.endsAt),
|
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,
|
notes: form.notes.trim() || null,
|
||||||
website: form.website,
|
website: form.website,
|
||||||
}),
|
}),
|
||||||
@@ -450,6 +549,74 @@ export default function ProtocolRequestPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="startsAt">
|
<Label htmlFor="startsAt">
|
||||||
@@ -482,6 +649,8 @@ export default function ProtocolRequestPage() {
|
|||||||
يجب أن يكون وقت البداية قبل وقت النهاية.
|
يجب أن يكون وقت البداية قبل وقت النهاية.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>أبرز الحضور</Label>
|
<Label>أبرز الحضور</Label>
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ type Room = {
|
|||||||
sortOrder: number;
|
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 BookingStatus = "pending" | "approved" | "rejected" | "cancelled";
|
||||||
type MeetingType = "internal" | "external";
|
type MeetingType = "internal" | "external";
|
||||||
type KeyAttendee = {
|
type KeyAttendee = {
|
||||||
@@ -276,6 +284,45 @@ const BOOKING_BLOCK_STYLE: Record<string, string> = {
|
|||||||
completed: "bg-emerald-100 border-emerald-500 text-emerald-900",
|
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 {
|
function sameYmd(a: Date, b: Date): boolean {
|
||||||
return (
|
return (
|
||||||
a.getFullYear() === b.getFullYear() &&
|
a.getFullYear() === b.getFullYear() &&
|
||||||
@@ -562,6 +609,13 @@ export default function ProtocolPage() {
|
|||||||
queryKey: ["protocol", "rooms"],
|
queryKey: ["protocol", "rooms"],
|
||||||
queryFn: () => apiJson<Room[]>(`${API}/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 [bookingSearch, setBookingSearch] = useState("");
|
||||||
const [debouncedBookingSearch, setDebouncedBookingSearch] = useState("");
|
const [debouncedBookingSearch, setDebouncedBookingSearch] = useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1611,6 +1665,14 @@ export default function ProtocolPage() {
|
|||||||
<EmptyState text={t("protocol.rooms.empty")} />
|
<EmptyState text={t("protocol.rooms.empty")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{c?.canManageRooms && (
|
||||||
|
<SlotsAdminSection
|
||||||
|
slots={slots.data ?? []}
|
||||||
|
isAr={isAr}
|
||||||
|
onChanged={() => invalidate("slots")}
|
||||||
|
onError={onApiError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1905,6 +1967,7 @@ export default function ProtocolPage() {
|
|||||||
{bookingDialog.open && (
|
{bookingDialog.open && (
|
||||||
<BookingDialog
|
<BookingDialog
|
||||||
rooms={rooms.data ?? []}
|
rooms={rooms.data ?? []}
|
||||||
|
slots={(slots.data ?? []).filter((s) => s.isActive)}
|
||||||
edit={bookingDialog.edit}
|
edit={bookingDialog.edit}
|
||||||
initialDay={bookingDialog.day}
|
initialDay={bookingDialog.day}
|
||||||
initialStart={bookingDialog.start}
|
initialStart={bookingDialog.start}
|
||||||
@@ -2325,6 +2388,214 @@ function formatDuration(
|
|||||||
return parts.join(" ");
|
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 }) {
|
function EmptyState({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center text-slate-400 py-10 text-sm">{text}</div>
|
<div className="text-center text-slate-400 py-10 text-sm">{text}</div>
|
||||||
@@ -2568,6 +2839,7 @@ function DialogShell({
|
|||||||
|
|
||||||
function BookingDialog({
|
function BookingDialog({
|
||||||
rooms,
|
rooms,
|
||||||
|
slots,
|
||||||
edit,
|
edit,
|
||||||
initialDay,
|
initialDay,
|
||||||
initialStart,
|
initialStart,
|
||||||
@@ -2577,6 +2849,10 @@ function BookingDialog({
|
|||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
rooms: Room[];
|
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;
|
edit?: Booking;
|
||||||
initialDay?: Date;
|
initialDay?: Date;
|
||||||
initialStart?: Date;
|
initialStart?: Date;
|
||||||
@@ -2585,7 +2861,8 @@ function BookingDialog({
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onError: (e: unknown) => void;
|
onError: (e: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const isAr = i18n.language === "ar";
|
||||||
const dayAt = (hour: number) => {
|
const dayAt = (hour: number) => {
|
||||||
if (!initialDay) return "";
|
if (!initialDay) return "";
|
||||||
const d = new Date(initialDay);
|
const d = new Date(initialDay);
|
||||||
@@ -2610,6 +2887,25 @@ function BookingDialog({
|
|||||||
);
|
);
|
||||||
return dayAt(10);
|
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 [notes, setNotes] = useState(edit?.notes ?? "");
|
||||||
const [requesterPhone, setRequesterPhone] = useState(
|
const [requesterPhone, setRequesterPhone] = useState(
|
||||||
edit?.requesterPhone ?? "",
|
edit?.requesterPhone ?? "",
|
||||||
@@ -2668,8 +2964,18 @@ function BookingDialog({
|
|||||||
attendeeCount.trim() === "" ? null : Number(attendeeCount),
|
attendeeCount.trim() === "" ? null : Number(attendeeCount),
|
||||||
purpose: purpose.trim() || null,
|
purpose: purpose.trim() || null,
|
||||||
keyAttendees,
|
keyAttendees,
|
||||||
startsAt: fromLocalInput(startsAt),
|
startsAt:
|
||||||
endsAt: fromLocalInput(endsAt),
|
slotMode && selectedSlot
|
||||||
|
? slotToIso(slotDate, selectedSlot.startTime)
|
||||||
|
: fromLocalInput(startsAt),
|
||||||
|
endsAt:
|
||||||
|
slotMode && selectedSlot
|
||||||
|
? slotToIso(
|
||||||
|
slotDate,
|
||||||
|
selectedSlot.startTime,
|
||||||
|
selectedSlot.durationMinutes,
|
||||||
|
)
|
||||||
|
: fromLocalInput(endsAt),
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
};
|
};
|
||||||
return edit
|
return edit
|
||||||
@@ -2690,7 +2996,12 @@ function BookingDialog({
|
|||||||
<DialogShell
|
<DialogShell
|
||||||
title={edit ? t("protocol.bookings.edit") : t("protocol.bookings.new")}
|
title={edit ? t("protocol.bookings.edit") : t("protocol.bookings.new")}
|
||||||
onClose={onClose}
|
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")}
|
submitLabel={t("common.save")}
|
||||||
saving={mut.isPending}
|
saving={mut.isPending}
|
||||||
>
|
>
|
||||||
@@ -2785,6 +3096,38 @@ function BookingDialog({
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{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 className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<Label>{t("protocol.bookings.startsAt")}</Label>
|
<Label>{t("protocol.bookings.startsAt")}</Label>
|
||||||
@@ -2805,6 +3148,7 @@ function BookingDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("protocol.bookings.keyAttendees")}</Label>
|
<Label>{t("protocol.bookings.keyAttendees")}</Label>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
|||||||
@@ -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)
|
// 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 ProtocolPhoto = typeof protocolPhotosTable.$inferSelect;
|
||||||
|
|
||||||
export type ProtocolAuditLog = typeof protocolAuditLogsTable.$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;
|
||||||
|
|||||||
Reference in New Issue
Block a user