From df2c0e728b124747b615a4b55a126c26060595d3 Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Wed, 8 Jul 2026 14:48:11 +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) لحوار الحجز فقط. --- artifacts/api-server/src/routes/protocol.ts | 28 +++++++---- .../tests/protocol-booking-slots.test.mjs | 28 ++++++++--- artifacts/tx-os/src/pages/protocol.tsx | 46 +++++++++++++------ 3 files changed, 75 insertions(+), 27 deletions(-) diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index 81d27e72..e31d1007 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -822,16 +822,28 @@ router.get( // --------------------------------------------------------------------------- // 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. +// Authorization (per Task #704 spec): ALL slot/settings admin endpoints — +// including reads — require protocol.rooms.manage. The booking dialog of +// regular protocol users only needs the ACTIVE slot list, exposed via the +// minimal read-only endpoint /protocol/booking-slots/active below +// (protocol.access), which returns no admin fields. Locked in by the authz +// regression test in tests/protocol-booking-slots.test.mjs. // --------------------------------------------------------------------------- + +// Minimal read for any protocol user: only active slots and only the fields +// the booking UI needs (id, startTime, durationMinutes). +router.get( + "/protocol/booking-slots/active", + requireProtocolAccess, + async (_req: Request, res: Response) => { + const rows = await getActiveSlots(db); + res.json(rows); + }, +); + router.get( "/protocol/booking-slots", - requireProtocolAccess, + requireManageRooms, async (_req: Request, res: Response) => { const rows = await db .select() @@ -962,7 +974,7 @@ router.delete( router.get( "/protocol/settings", - requireProtocolAccess, + requireManageRooms, async (_req: Request, res: Response) => { res.json({ minLeadMinutes: await getMinLeadMinutes(db) }); }, diff --git a/artifacts/api-server/tests/protocol-booking-slots.test.mjs b/artifacts/api-server/tests/protocol-booking-slots.test.mjs index 908f1883..631a7f84 100644 --- a/artifacts/api-server/tests/protocol-booking-slots.test.mjs +++ b/artifacts/api-server/tests/protocol-booking-slots.test.mjs @@ -376,10 +376,10 @@ test("staff bookings must match a slot but ignore the lead time", async () => { 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 () => { +// Authz locked in per Task #704 spec: slot/settings admin endpoints — +// including reads — require protocol.rooms.manage. Regular protocol users +// may only read the minimal active-slot list via /booking-slots/active. +test("protocol viewer can only read active slots; admin reads/writes are 403", 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) @@ -397,10 +397,26 @@ test("protocol viewer can read slots/settings but cannot write them", async () = } const viewerCookie = await login(username, TEST_PASSWORD); + const active = await api( + viewerCookie, + "GET", + "/protocol/booking-slots/active", + ); + assert.equal(active.status, 200, "viewer should read active slots"); + const activeRows = await active.json(); + assert.ok(Array.isArray(activeRows)); + for (const row of activeRows) { + assert.deepEqual( + Object.keys(row).sort(), + ["durationMinutes", "id", "startTime"], + "active endpoint must expose only minimal fields", + ); + } + const getSlots = await api(viewerCookie, "GET", "/protocol/booking-slots"); - assert.equal(getSlots.status, 200, "viewer should read slots"); + assert.equal(getSlots.status, 403, "viewer must not read the admin slot list"); const getSettings = await api(viewerCookie, "GET", "/protocol/settings"); - assert.equal(getSettings.status, 200, "viewer should read settings"); + assert.equal(getSettings.status, 403, "viewer must not read settings"); const post = await api(viewerCookie, "POST", "/protocol/booking-slots", { startTime: "20:20", diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index 65de5c22..b7adeaf5 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -107,6 +107,14 @@ type Slot = { sortOrder: number; }; +// Minimal shape returned by /protocol/booking-slots/active (readable by any +// protocol user; the full Slot list is admin-only). +type ActiveSlotInfo = { + id: number; + startTime: string; + durationMinutes: number; +}; + type BookingStatus = "pending" | "approved" | "rejected" | "cancelled"; type MeetingType = "internal" | "external"; type KeyAttendee = { @@ -609,12 +617,14 @@ export default function ProtocolPage() { queryKey: ["protocol", "rooms"], queryFn: () => apiJson(`${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({ + // Active booking slots (أوقات الحجز) — minimal read available to every + // protocol user; the booking dialog restricts times to these whenever at + // least one is configured. The full admin list lives in SlotsAdminSection + // (requires rooms-manage permission). + const slots = useQuery({ queryKey: ["protocol", "slots"], - queryFn: () => apiJson(`${API}/protocol/booking-slots`), + queryFn: () => + apiJson(`${API}/protocol/booking-slots/active`), }); const [bookingSearch, setBookingSearch] = useState(""); const [debouncedBookingSearch, setDebouncedBookingSearch] = useState(""); @@ -1667,7 +1677,6 @@ export default function ProtocolPage() { {c?.canManageRooms && ( invalidate("slots")} onError={onApiError} @@ -1967,7 +1976,7 @@ export default function ProtocolPage() { {bookingDialog.open && ( s.isActive)} + slots={slots.data ?? []} edit={bookingDialog.edit} initialDay={bookingDialog.day} initialStart={bookingDialog.start} @@ -2391,21 +2400,32 @@ function formatDuration( // 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 qc = useQueryClient(); const [newTime, setNewTime] = useState(""); const [newDuration, setNewDuration] = useState("30"); + // Full slot list (incl. inactive) — admin-only endpoint; this component is + // only rendered for canManageRooms. + const slotsQuery = useQuery({ + queryKey: ["protocol", "slots-admin"], + queryFn: () => apiJson(`${API}/protocol/booking-slots`), + }); + const slots = slotsQuery.data ?? []; + const changed = () => { + qc.invalidateQueries({ queryKey: ["protocol", "slots-admin"] }); + onChanged(); + }; + const settings = useQuery<{ minLeadMinutes: number }>({ queryKey: ["protocol", "settings"], queryFn: () => @@ -2427,7 +2447,7 @@ function SlotsAdminSection({ }), onSuccess: () => { setNewTime(""); - onChanged(); + changed(); }, onError, }); @@ -2437,13 +2457,13 @@ function SlotsAdminSection({ method: "PATCH", body: JSON.stringify({ isActive: !s.isActive }), }), - onSuccess: onChanged, + onSuccess: changed, onError, }); const deleteMut = useMutation({ mutationFn: (id: number) => apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }), - onSuccess: onChanged, + onSuccess: changed, onError, }); const leadMut = useMutation({ @@ -2852,7 +2872,7 @@ function BookingDialog({ // 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[]; + slots: ActiveSlotInfo[]; edit?: Booking; initialDay?: Date; initialStart?: Date;