Task #710: Weekly availability intervals (Calendly-style) for protocol booking slots
- DB: protocol_booking_slots.days_of_week integer[] NOT NULL default {0..6} (0=Sun..6=Sat, Riyadh); pushed via drizzle-kit, legacy rows keep all-days behaviour.
- API (routes/protocol.ts): slot create/patch accept daysOfWeek (validated, deduped, sorted); new POST /protocol/booking-slots/generate (requireManageRooms) generates stepped slots in [from,to) skipping existing start times (onConflictDoNothing), returns {created,skipped,slots}, 400 interval_too_short; matchesActiveSlot and public availability now filter by Riyadh weekday.
- UI (protocol.tsx): interval-generation form (from/to/duration + day toggle pills + workdays/all-days presets), slot list shows day names, booking dialog filters slots by chosen date weekday with stale-selection reset and empty-day hint. i18n keys added (ar/en).
- Tests: 13/13 pass incl. generation, duplicate skipping, validation, weekday filtering in availability + booking rejection, viewer 403 on generate.
- Architect review: Pass; added the suggested authz test.
This commit is contained in:
@@ -193,12 +193,13 @@ after(async () => {
|
||||
await pool.query(`DELETE FROM protocol_booking_slots`);
|
||||
for (const s of savedSlots) {
|
||||
await pool.query(
|
||||
`INSERT INTO protocol_booking_slots (id, start_time, duration_minutes, is_active, sort_order, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
`INSERT INTO protocol_booking_slots (id, start_time, duration_minutes, days_of_week, is_active, sort_order, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
s.id,
|
||||
s.start_time,
|
||||
s.duration_minutes,
|
||||
s.days_of_week,
|
||||
s.is_active,
|
||||
s.sort_order,
|
||||
s.created_at,
|
||||
@@ -424,7 +425,7 @@ test("protocol viewer can only read active slots; admin reads/writes are 403", a
|
||||
for (const row of activeRows) {
|
||||
assert.deepEqual(
|
||||
Object.keys(row).sort(),
|
||||
["durationMinutes", "id", "startTime"],
|
||||
["daysOfWeek", "durationMinutes", "id", "startTime"],
|
||||
"active endpoint must expose only minimal fields",
|
||||
);
|
||||
}
|
||||
@@ -438,6 +439,13 @@ test("protocol viewer can only read active slots; admin reads/writes are 403", a
|
||||
startTime: "20:20",
|
||||
});
|
||||
assert.equal(post.status, 403, "viewer must not create slots");
|
||||
const gen = await api(viewerCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "10:00",
|
||||
endTime: "12:00",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [1],
|
||||
});
|
||||
assert.equal(gen.status, 403, "viewer must not generate slots");
|
||||
const patch = await api(viewerCookie, "PATCH", "/protocol/settings", {
|
||||
minLeadMinutes: 5,
|
||||
});
|
||||
@@ -461,3 +469,134 @@ test("deleting a slot removes it from the admin list", async () => {
|
||||
const rows = await list.json();
|
||||
assert.ok(!rows.some((s) => s.id === slotBId));
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #710: weekly availability intervals + per-slot days of week.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const dayWeekday = new Date(`${day}T00:00:00Z`).getUTCDay();
|
||||
const otherWeekday = (dayWeekday + 1) % 7;
|
||||
|
||||
test("slots created without daysOfWeek default to every day (legacy behaviour)", async () => {
|
||||
const list = await api(adminCookie, "GET", "/protocol/booking-slots");
|
||||
assert.equal(list.status, 200);
|
||||
const a = (await list.json()).find((s) => s.id === slotAId);
|
||||
assert.ok(a, "slot A should still exist");
|
||||
assert.deepEqual(a.daysOfWeek, [0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
test("interval generation creates slots, skips duplicates, validates input", async () => {
|
||||
const gen = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "18:07",
|
||||
endTime: "19:37",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [dayWeekday],
|
||||
});
|
||||
assert.equal(gen.status, 201);
|
||||
const body = await gen.json();
|
||||
assert.equal(body.created, 3, "18:07, 18:37, 19:07 should be generated");
|
||||
assert.equal(body.skipped, 0);
|
||||
assert.deepEqual(
|
||||
body.slots.map((s) => s.startTime).sort(),
|
||||
["18:07", "18:37", "19:07"],
|
||||
);
|
||||
for (const s of body.slots) {
|
||||
assert.deepEqual(s.daysOfWeek, [dayWeekday]);
|
||||
}
|
||||
|
||||
// Re-running the exact same interval generates nothing new.
|
||||
const again = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "18:07",
|
||||
endTime: "19:37",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [dayWeekday],
|
||||
});
|
||||
assert.equal(again.status, 201);
|
||||
const b2 = await again.json();
|
||||
assert.equal(b2.created, 0);
|
||||
assert.equal(b2.skipped, 3);
|
||||
|
||||
// Overlapping an existing start time (SLOT_A) only creates the free ones.
|
||||
const overlap = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: SLOT_A,
|
||||
endTime: isoLikeAdd(SLOT_A, 60),
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
|
||||
});
|
||||
assert.equal(overlap.status, 201);
|
||||
const b3 = await overlap.json();
|
||||
assert.equal(b3.skipped, 1, "existing SLOT_A start must be skipped");
|
||||
assert.equal(b3.created, 1);
|
||||
|
||||
// Invalid inputs are rejected.
|
||||
const badRange = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "12:00",
|
||||
endTime: "11:00",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [1],
|
||||
});
|
||||
assert.equal(badRange.status, 400, "from must be before to");
|
||||
const badDay = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "11:00",
|
||||
endTime: "12:00",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [7],
|
||||
});
|
||||
assert.equal(badDay.status, 400, "day 7 is invalid");
|
||||
const tooShort = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "11:00",
|
||||
endTime: "11:10",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [1],
|
||||
});
|
||||
assert.equal(tooShort.status, 400);
|
||||
assert.equal((await tooShort.json()).code, "interval_too_short");
|
||||
});
|
||||
|
||||
// "HH:mm" plus minutes, staying inside the same day (test helper).
|
||||
function isoLikeAdd(hhmm, plusMinutes) {
|
||||
const [h, m] = hhmm.split(":").map(Number);
|
||||
const total = h * 60 + m + plusMinutes;
|
||||
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
|
||||
total % 60,
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
test("availability and booking validation filter slots by weekday", async () => {
|
||||
// A slot only offered on a different weekday than `day`.
|
||||
const res = await api(adminCookie, "POST", "/protocol/booking-slots", {
|
||||
startTime: "21:11",
|
||||
durationMinutes: DURATION,
|
||||
daysOfWeek: [otherWeekday],
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const offDay = await res.json();
|
||||
assert.deepEqual(offDay.daysOfWeek, [otherWeekday]);
|
||||
|
||||
// Public availability for `day` must not list the off-day slot but must
|
||||
// still list slot A (all days).
|
||||
const avail = await fetch(
|
||||
`${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`,
|
||||
);
|
||||
assert.equal(avail.status, 200);
|
||||
const data = await avail.json();
|
||||
assert.ok(
|
||||
!data.slots.some((s) => s.id === offDay.id),
|
||||
"off-day slot must be hidden for this date",
|
||||
);
|
||||
assert.ok(
|
||||
data.slots.some((s) => s.id === slotAId),
|
||||
"all-days slot must still be listed",
|
||||
);
|
||||
|
||||
// Internal booking on the off-day slot's time is rejected: right start
|
||||
// time, wrong weekday.
|
||||
const wrongDay = await api(adminCookie, "POST", "/protocol/bookings", {
|
||||
roomId,
|
||||
title: "Wrong weekday booking",
|
||||
requesterName: "Staff",
|
||||
startsAt: isoAt("21:11"),
|
||||
endsAt: isoAt("21:11", DURATION),
|
||||
});
|
||||
assert.equal(wrongDay.status, 400);
|
||||
assert.equal((await wrongDay.json()).code, "invalid_slot");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user