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:
Replit Agent
2026-07-08 20:16:33 +00:00
parent de407b6a8d
commit 19e6693200
6 changed files with 504 additions and 15 deletions
+114 -3
View File
@@ -286,9 +286,16 @@ function isUniqueViolation(e: unknown): boolean {
}
const slotTimeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
// Days of week: 0=Sunday .. 6=Saturday (Riyadh calendar). Deduped + sorted.
const daysOfWeekSchema = z
.array(z.number().int().min(0).max(6))
.min(1)
.max(7)
.transform((days) => [...new Set(days)].sort((a, b) => a - b));
const slotCreateSchema = z.object({
startTime: z.string().regex(slotTimeRegex, "startTime must be HH:mm"),
durationMinutes: z.number().int().min(5).max(480).optional(),
daysOfWeek: daysOfWeekSchema.optional(),
isActive: z.boolean().optional(),
sortOrder: z.number().int().min(0).max(100000).optional(),
});
@@ -296,6 +303,31 @@ const slotPatchSchema = slotCreateSchema
.partial()
.refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });
// Interval generation ("Calendly-style"): every `durationMinutes` step in
// [startTime, endTime) becomes a slot on the given days. HH:mm arithmetic
// happens on Riyadh wall-clock minutes, no timezone math needed.
const slotIntervalSchema = z
.object({
startTime: z.string().regex(slotTimeRegex, "startTime must be HH:mm"),
endTime: z.string().regex(slotTimeRegex, "endTime must be HH:mm"),
durationMinutes: z.number().int().min(5).max(480),
daysOfWeek: daysOfWeekSchema,
})
.refine((v) => hhmmToMinutes(v.startTime) < hhmmToMinutes(v.endTime), {
message: "startTime must be before endTime",
path: ["endTime"],
});
function hhmmToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m;
}
function minutesToHHmm(total: number): string {
const h = Math.floor(total / 60);
const m = total % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
const settingsPatchSchema = z.object({
// Minimum advance notice for PUBLIC booking requests, in minutes.
// 0 disables the restriction; capped at 7 days.
@@ -492,13 +524,19 @@ async function getMinLeadMinutes(executor: DbExecutor): Promise<number> {
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MIN_LEAD_MINUTES;
}
type ActiveSlot = { id: number; startTime: string; durationMinutes: number };
type ActiveSlot = {
id: number;
startTime: string;
durationMinutes: number;
daysOfWeek: number[];
};
async function getActiveSlots(executor: DbExecutor): Promise<ActiveSlot[]> {
return executor
.select({
id: protocolBookingSlotsTable.id,
startTime: protocolBookingSlotsTable.startTime,
durationMinutes: protocolBookingSlotsTable.durationMinutes,
daysOfWeek: protocolBookingSlotsTable.daysOfWeek,
})
.from(protocolBookingSlotsTable)
.where(eq(protocolBookingSlotsTable.isActive, true))
@@ -522,12 +560,26 @@ function matchesActiveSlot(
return false;
}
const wall = riyadhHHmm(startsAt);
const weekday = riyadhWeekday(startsAt);
const durationMs = endsAt.getTime() - startsAt.getTime();
return slots.some(
(s) => s.startTime === wall && durationMs === s.durationMinutes * 60_000,
(s) =>
s.startTime === wall &&
durationMs === s.durationMinutes * 60_000 &&
s.daysOfWeek.includes(weekday),
);
}
// Day of week (0=Sunday .. 6=Saturday) of the Riyadh calendar day that
// contains `d`. Riyadh is fixed UTC+3, no DST.
function riyadhWeekday(d: Date): number {
return new Date(d.getTime() + 3 * 60 * 60 * 1000).getUTCDay();
}
// Same for a plain "YYYY-MM-DD" Riyadh calendar day.
function ymdWeekday(dateStr: string): number {
return new Date(`${dateStr}T00:00:00Z`).getUTCDay();
}
const INVALID_SLOT_MESSAGE_AR =
"الوقت المحدد غير متاح للحجز، يرجى اختيار أحد الأوقات المتاحة.";
const LEAD_TIME_MESSAGE_AR =
@@ -797,7 +849,10 @@ router.get(
]);
const now = Date.now();
const result = slots.map((s) => {
const weekday = ymdWeekday(dateRaw);
const result = slots
.filter((s) => s.daysOfWeek.includes(weekday))
.map((s) => {
const start = slotStartOn(dateRaw, s.startTime);
const end = new Date(start.getTime() + s.durationMinutes * 60_000);
const booked = busy.some(
@@ -868,6 +923,7 @@ router.post(
.values({
startTime: body.startTime,
durationMinutes: body.durationMinutes ?? 30,
daysOfWeek: body.daysOfWeek ?? [0, 1, 2, 3, 4, 5, 6],
isActive: body.isActive ?? true,
sortOrder: body.sortOrder ?? 0,
createdBy: req.session.userId!,
@@ -895,6 +951,58 @@ router.post(
},
);
// Generate slots from a weekly availability interval (Calendly-style):
// every `durationMinutes` step in [startTime, endTime) whose full duration
// still fits becomes a slot on the given days. Start times that already
// exist are skipped (global start_time uniqueness), never overwritten.
router.post(
"/protocol/booking-slots/generate",
requireManageRooms,
async (req: Request, res: Response) => {
const body = parseBody(res, slotIntervalSchema, req.body);
if (!body) return;
const from = hhmmToMinutes(body.startTime);
const to = hhmmToMinutes(body.endTime);
const candidates: string[] = [];
for (let m = from; m + body.durationMinutes <= to; m += body.durationMinutes) {
candidates.push(minutesToHHmm(m));
}
if (candidates.length === 0) {
res.status(400).json({
error: "الفترة أقصر من مدة الوقت الواحد، لا يمكن توليد أي وقت.",
code: "interval_too_short",
});
return;
}
const inserted = await db
.insert(protocolBookingSlotsTable)
.values(
candidates.map((startTime) => ({
startTime,
durationMinutes: body.durationMinutes,
daysOfWeek: body.daysOfWeek,
createdBy: req.session.userId!,
})),
)
.onConflictDoNothing({ target: protocolBookingSlotsTable.startTime })
.returning();
for (const row of inserted) {
await logAudit(db, {
action: "slot.create",
entityType: "booking_slot",
entityId: row.id,
newValue: row,
performedBy: req.session.userId!,
});
}
res.status(201).json({
created: inserted.length,
skipped: candidates.length - inserted.length,
slots: inserted,
});
},
);
router.patch(
"/protocol/booking-slots/:id",
requireManageRooms,
@@ -918,6 +1026,9 @@ router.patch(
...(body.durationMinutes !== undefined
? { durationMinutes: body.durationMinutes }
: {}),
...(body.daysOfWeek !== undefined
? { daysOfWeek: body.daysOfWeek }
: {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
})
@@ -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");
});