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:
Replit Agent
2026-07-08 14:51:04 +00:00
parent df2c0e728b
commit a2abc146cd
4 changed files with 47 additions and 16 deletions
@@ -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);
});