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) لحوار الحجز فقط.
This commit is contained in:
Replit Agent
2026-07-08 14:48:11 +00:00
parent f3fe741314
commit df2c0e728b
3 changed files with 75 additions and 27 deletions
+20 -8
View File
@@ -822,16 +822,28 @@ router.get(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Booking slots admin (أوقات الحجز) + module settings // Booking slots admin (أوقات الحجز) + module settings
// //
// Authorization decision: READS of slots/settings are deliberately open to // Authorization (per Task #704 spec): ALL slot/settings admin endpoints —
// any protocol.access holder (not just protocol.rooms.manage) because every // including reads — require protocol.rooms.manage. The booking dialog of
// protocol user's booking dialog needs the active slot list, and viewers // regular protocol users only needs the ACTIVE slot list, exposed via the
// need the lead-time value for display. WRITES stay behind // minimal read-only endpoint /protocol/booking-slots/active below
// protocol.rooms.manage. Locked in by the authz regression test in // (protocol.access), which returns no admin fields. Locked in by the authz
// tests/protocol-booking-slots.test.mjs. // 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( router.get(
"/protocol/booking-slots", "/protocol/booking-slots",
requireProtocolAccess, requireManageRooms,
async (_req: Request, res: Response) => { async (_req: Request, res: Response) => {
const rows = await db const rows = await db
.select() .select()
@@ -962,7 +974,7 @@ router.delete(
router.get( router.get(
"/protocol/settings", "/protocol/settings",
requireProtocolAccess, requireManageRooms,
async (_req: Request, res: Response) => { async (_req: Request, res: Response) => {
res.json({ minLeadMinutes: await getMinLeadMinutes(db) }); res.json({ minLeadMinutes: await getMinLeadMinutes(db) });
}, },
@@ -376,10 +376,10 @@ test("staff bookings must match a slot but ignore the lead time", async () => {
created.bookingIds.push((await onSlot.json()).id); created.bookingIds.push((await onSlot.json()).id);
}); });
// Authz decision locked in: any protocol.access holder may READ slots and // Authz locked in per Task #704 spec: slot/settings admin endpoints —
// settings (the booking dialog needs them), but WRITES require // including reads — require protocol.rooms.manage. Regular protocol users
// protocol.rooms.manage. // may only read the minimal active-slot list via /booking-slots/active.
test("protocol viewer can read slots/settings but cannot write them", async () => { test("protocol viewer can only read active slots; admin reads/writes are 403", async () => {
const username = uniqueName("slot_viewer"); const username = uniqueName("slot_viewer");
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) `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 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"); 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"); 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", { const post = await api(viewerCookie, "POST", "/protocol/booking-slots", {
startTime: "20:20", startTime: "20:20",
+33 -13
View File
@@ -107,6 +107,14 @@ type Slot = {
sortOrder: number; 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 BookingStatus = "pending" | "approved" | "rejected" | "cancelled";
type MeetingType = "internal" | "external"; type MeetingType = "internal" | "external";
type KeyAttendee = { type KeyAttendee = {
@@ -609,12 +617,14 @@ 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 // Active booking slots (أوقات الحجز) — minimal read available to every
// section and by the booking dialog (which restricts times to active slots // protocol user; the booking dialog restricts times to these whenever at
// whenever at least one is configured). // least one is configured. The full admin list lives in SlotsAdminSection
const slots = useQuery<Slot[]>({ // (requires rooms-manage permission).
const slots = useQuery<ActiveSlotInfo[]>({
queryKey: ["protocol", "slots"], queryKey: ["protocol", "slots"],
queryFn: () => apiJson<Slot[]>(`${API}/protocol/booking-slots`), queryFn: () =>
apiJson<ActiveSlotInfo[]>(`${API}/protocol/booking-slots/active`),
}); });
const [bookingSearch, setBookingSearch] = useState(""); const [bookingSearch, setBookingSearch] = useState("");
const [debouncedBookingSearch, setDebouncedBookingSearch] = useState(""); const [debouncedBookingSearch, setDebouncedBookingSearch] = useState("");
@@ -1667,7 +1677,6 @@ export default function ProtocolPage() {
</div> </div>
{c?.canManageRooms && ( {c?.canManageRooms && (
<SlotsAdminSection <SlotsAdminSection
slots={slots.data ?? []}
isAr={isAr} isAr={isAr}
onChanged={() => invalidate("slots")} onChanged={() => invalidate("slots")}
onError={onApiError} onError={onApiError}
@@ -1967,7 +1976,7 @@ export default function ProtocolPage() {
{bookingDialog.open && ( {bookingDialog.open && (
<BookingDialog <BookingDialog
rooms={rooms.data ?? []} rooms={rooms.data ?? []}
slots={(slots.data ?? []).filter((s) => s.isActive)} slots={slots.data ?? []}
edit={bookingDialog.edit} edit={bookingDialog.edit}
initialDay={bookingDialog.day} initialDay={bookingDialog.day}
initialStart={bookingDialog.start} initialStart={bookingDialog.start}
@@ -2391,21 +2400,32 @@ function formatDuration(
// Admin management of booking slots (أوقات الحجز) + the minimum lead-time // Admin management of booking slots (أوقات الحجز) + the minimum lead-time
// setting for public requests. Shown in the rooms tab for canManageRooms. // setting for public requests. Shown in the rooms tab for canManageRooms.
function SlotsAdminSection({ function SlotsAdminSection({
slots,
isAr, isAr,
onChanged, onChanged,
onError, onError,
}: { }: {
slots: Slot[];
isAr: boolean; isAr: boolean;
onChanged: () => void; onChanged: () => void;
onError: (e: unknown) => void; onError: (e: unknown) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient();
const [newTime, setNewTime] = useState(""); const [newTime, setNewTime] = useState("");
const [newDuration, setNewDuration] = useState("30"); const [newDuration, setNewDuration] = useState("30");
// Full slot list (incl. inactive) — admin-only endpoint; this component is
// only rendered for canManageRooms.
const slotsQuery = useQuery<Slot[]>({
queryKey: ["protocol", "slots-admin"],
queryFn: () => apiJson<Slot[]>(`${API}/protocol/booking-slots`),
});
const slots = slotsQuery.data ?? [];
const changed = () => {
qc.invalidateQueries({ queryKey: ["protocol", "slots-admin"] });
onChanged();
};
const settings = useQuery<{ minLeadMinutes: number }>({ const settings = useQuery<{ minLeadMinutes: number }>({
queryKey: ["protocol", "settings"], queryKey: ["protocol", "settings"],
queryFn: () => queryFn: () =>
@@ -2427,7 +2447,7 @@ function SlotsAdminSection({
}), }),
onSuccess: () => { onSuccess: () => {
setNewTime(""); setNewTime("");
onChanged(); changed();
}, },
onError, onError,
}); });
@@ -2437,13 +2457,13 @@ function SlotsAdminSection({
method: "PATCH", method: "PATCH",
body: JSON.stringify({ isActive: !s.isActive }), body: JSON.stringify({ isActive: !s.isActive }),
}), }),
onSuccess: onChanged, onSuccess: changed,
onError, onError,
}); });
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: (id: number) => mutationFn: (id: number) =>
apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }), apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }),
onSuccess: onChanged, onSuccess: changed,
onError, onError,
}); });
const leadMut = useMutation({ const leadMut = useMutation({
@@ -2852,7 +2872,7 @@ function BookingDialog({
// Active booking slots. When non-empty the dialog restricts times to the // Active booking slots. When non-empty the dialog restricts times to the
// slot grid (same rule the server enforces); when empty it keeps the // slot grid (same rule the server enforces); when empty it keeps the
// legacy free start/end inputs. // legacy free start/end inputs.
slots: Slot[]; slots: ActiveSlotInfo[];
edit?: Booking; edit?: Booking;
initialDay?: Date; initialDay?: Date;
initialStart?: Date; initialStart?: Date;