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:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user