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 له، ومنع الإرسال في الواجهة قبل اختيار التاريخ والوقت). - تعديل بعد الرفض الأول: قراءة قائمة الأوقات الكاملة والإعدادات تتطلب protocol.rooms.manage (حسب المواصفة)؛ أُضيفت نقطة قراءة مصغّرة /protocol/booking-slots/active (protocol.access) لحوار الحجز فقط. - تعديل بعد الرفض الثاني: مهلة الحجز المسبقة (minLeadMinutes) تُطبَّق الآن أيضًا على الحجوزات الداخلية (إنشاء وتعديل) — لا استثناء للموظفين.
This commit is contained in:
@@ -1190,17 +1190,23 @@ 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);
|
||||
// Internal bookings must land on the configured slot grid (when one
|
||||
// exists) AND respect the minimum lead time — the same rules the public
|
||||
// endpoint enforces, so the rule cannot be bypassed internally.
|
||||
const [activeSlotsForCreate, minLeadForCreate] = await Promise.all([
|
||||
getActiveSlots(db),
|
||||
getMinLeadMinutes(db),
|
||||
]);
|
||||
if (!matchesActiveSlot(activeSlotsForCreate, startsAt, endsAt)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
|
||||
return;
|
||||
}
|
||||
if (startsAt.getTime() - Date.now() < minLeadForCreate * 60_000) {
|
||||
res.status(400).json({ error: LEAD_TIME_MESSAGE_AR, code: "lead_time" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-approvers submit requests as "pending"; users with the approve
|
||||
// permission may create an already-approved booking directly. Either way
|
||||
@@ -1306,16 +1312,25 @@ router.patch(
|
||||
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.
|
||||
// the configured slot grid (when one exists) and respect the minimum
|
||||
// lead time — same rules as create, no internal bypass.
|
||||
if (body.startsAt !== undefined || body.endsAt !== undefined) {
|
||||
const activeSlotsForPatch = await getActiveSlots(db);
|
||||
const [activeSlotsForPatch, minLeadForPatch] = await Promise.all([
|
||||
getActiveSlots(db),
|
||||
getMinLeadMinutes(db),
|
||||
]);
|
||||
if (!matchesActiveSlot(activeSlotsForPatch, startsAt, endsAt)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
|
||||
return;
|
||||
}
|
||||
if (startsAt.getTime() - Date.now() < minLeadForPatch * 60_000) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: LEAD_TIME_MESSAGE_AR, code: "lead_time" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
|
||||
@@ -344,8 +344,9 @@ test("a deactivated slot no longer accepts public requests", async () => {
|
||||
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.
|
||||
test("internal bookings enforce both the slot grid and the lead time", async () => {
|
||||
// Large lead time: internal bookings inside the window must be rejected
|
||||
// just like public ones (no internal bypass).
|
||||
const patch = await api(adminCookie, "PATCH", "/protocol/settings", {
|
||||
minLeadMinutes: 10080,
|
||||
});
|
||||
@@ -361,7 +362,7 @@ test("staff bookings must match a slot but ignore the lead time", async () => {
|
||||
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", {
|
||||
const insideLead = await api(adminCookie, "POST", "/protocol/bookings", {
|
||||
roomId,
|
||||
title: "Staff on-slot inside lead window",
|
||||
requesterName: "Staff",
|
||||
@@ -369,10 +370,25 @@ test("staff bookings must match a slot but ignore the lead time", async () => {
|
||||
endsAt: isoAt(SLOT_B, DURATION),
|
||||
});
|
||||
assert.equal(
|
||||
onSlot.status,
|
||||
201,
|
||||
"staff on-slot booking should succeed despite lead time",
|
||||
insideLead.status,
|
||||
400,
|
||||
"internal booking inside the lead window must be rejected",
|
||||
);
|
||||
assert.equal((await insideLead.json()).code, "lead_time");
|
||||
|
||||
// Outside the lead window (lead back to 0) the same booking succeeds.
|
||||
const reset = await api(adminCookie, "PATCH", "/protocol/settings", {
|
||||
minLeadMinutes: 0,
|
||||
});
|
||||
assert.equal(reset.status, 200);
|
||||
const onSlot = await api(adminCookie, "POST", "/protocol/bookings", {
|
||||
roomId,
|
||||
title: "Staff on-slot outside lead window",
|
||||
requesterName: "Staff",
|
||||
startsAt: isoAt(SLOT_B),
|
||||
endsAt: isoAt(SLOT_B, DURATION),
|
||||
});
|
||||
assert.equal(onSlot.status, 201, "on-slot booking outside lead should 201");
|
||||
created.bookingIds.push((await onSlot.json()).id);
|
||||
});
|
||||
|
||||
|
||||
@@ -1905,7 +1905,7 @@
|
||||
"activate": "تفعيل",
|
||||
"deactivate": "تعطيل",
|
||||
"leadTitle": "مهلة الحجز المسبق",
|
||||
"leadHint": "الحد الأدنى بالدقائق قبل بداية الموعد لقبول الطلبات العامة (0 لتعطيل الشرط). لا تنطبق المهلة على موظفي المراسم.",
|
||||
"leadHint": "الحد الأدنى بالدقائق قبل بداية الموعد لقبول أي حجز — عام أو داخلي (0 لتعطيل الشرط).",
|
||||
"leadLabel": "المهلة (دقيقة)",
|
||||
"leadSaved": "تم حفظ المهلة",
|
||||
"dateLabel": "التاريخ",
|
||||
|
||||
@@ -1758,7 +1758,7 @@
|
||||
"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.",
|
||||
"leadHint": "Minimum minutes before the start time for any booking — public or internal — to be accepted (0 disables the rule).",
|
||||
"leadLabel": "Lead time (min)",
|
||||
"leadSaved": "Lead time saved",
|
||||
"dateLabel": "Date",
|
||||
|
||||
Reference in New Issue
Block a user