From a2abc146cd424685903b2852156f07a38295b158 Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Wed, 8 Jul 2026 14:51:04 +0000 Subject: [PATCH] =?UTF-8?q?Task=20#704:=20=D8=A3=D9=88=D9=82=D8=A7=D8=AA?= =?UTF-8?q?=20=D8=AD=D8=AC=D8=B2=20=D9=85=D8=AD=D8=AF=D8=AF=D8=A9=20=D9=8A?= =?UTF-8?q?=D8=AF=D9=8A=D8=B1=D9=87=D8=A7=20=D8=A7=D9=84=D8=A3=D8=AF=D9=85?= =?UTF-8?q?=D9=86=20+=20=D9=85=D9=87=D9=84=D8=A9=20=D8=AD=D8=AC=D8=B2=20?= =?UTF-8?q?=D9=85=D8=B3=D8=A8=D9=82=D8=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) تُطبَّق الآن أيضًا على الحجوزات الداخلية (إنشاء وتعديل) — لا استثناء للموظفين. --- artifacts/api-server/src/routes/protocol.ts | 31 ++++++++++++++----- .../tests/protocol-booking-slots.test.mjs | 28 +++++++++++++---- artifacts/tx-os/src/locales/ar.json | 2 +- artifacts/tx-os/src/locales/en.json | 2 +- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index e31d1007..6911f763 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -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) => { diff --git a/artifacts/api-server/tests/protocol-booking-slots.test.mjs b/artifacts/api-server/tests/protocol-booking-slots.test.mjs index 631a7f84..c1c9fc9d 100644 --- a/artifacts/api-server/tests/protocol-booking-slots.test.mjs +++ b/artifacts/api-server/tests/protocol-booking-slots.test.mjs @@ -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); }); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 732d3939..bab4b984 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1905,7 +1905,7 @@ "activate": "تفعيل", "deactivate": "تعطيل", "leadTitle": "مهلة الحجز المسبق", - "leadHint": "الحد الأدنى بالدقائق قبل بداية الموعد لقبول الطلبات العامة (0 لتعطيل الشرط). لا تنطبق المهلة على موظفي المراسم.", + "leadHint": "الحد الأدنى بالدقائق قبل بداية الموعد لقبول أي حجز — عام أو داخلي (0 لتعطيل الشرط).", "leadLabel": "المهلة (دقيقة)", "leadSaved": "تم حفظ المهلة", "dateLabel": "التاريخ", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 949a1b34..e2b53f04 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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",