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); duplicate rule is day-scoped: same start time on a different day merges the days into the existing slot (existing duration kept), fully covered times skipped; returns {created,merged,skipped,slots}; manual POST create also merges disjoint days instead of 409; matchesActiveSlot and public availability filter by Riyadh weekday.
- UI (protocol.tsx): interval-generation form (from/to/duration + day toggle pills + workdays/all-days presets), toast shows created/merged/skipped, 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, day-scoped merge (same time different day is NOT a duplicate), duplicate skipping, validation, weekday filtering in availability + booking rejection, viewer 403 on generate.
This commit is contained in:
Replit Agent
2026-07-08 20:20:30 +00:00
parent 19e6693200
commit b56f1682a9
5 changed files with 157 additions and 37 deletions
+88 -15
View File
@@ -939,9 +939,39 @@ router.post(
res.status(201).json(row); res.status(201).json(row);
} catch (e) { } catch (e) {
// Unique start_time violation → a slot already starts at this time. // Unique start_time violation → a slot already starts at this time.
// Duplicate rule is per-day: if the requested days don't overlap the
// existing slot's days, merge them instead of rejecting.
if (isUniqueViolation(e)) { if (isUniqueViolation(e)) {
const requestedDays = body.daysOfWeek ?? [0, 1, 2, 3, 4, 5, 6];
const [existing] = await db
.select()
.from(protocolBookingSlotsTable)
.where(eq(protocolBookingSlotsTable.startTime, body.startTime));
if (
existing &&
!existing.daysOfWeek.some((d) => requestedDays.includes(d))
) {
const union = Array.from(
new Set([...existing.daysOfWeek, ...requestedDays]),
).sort((a, b) => a - b);
const [row] = await db
.update(protocolBookingSlotsTable)
.set({ daysOfWeek: union })
.where(eq(protocolBookingSlotsTable.id, existing.id))
.returning();
await logAudit(db, {
action: "slot.update",
entityType: "booking_slot",
entityId: existing.id,
oldValue: existing,
newValue: row,
performedBy: req.session.userId!,
});
res.status(201).json(row);
return;
}
res.status(409).json({ res.status(409).json({
error: "يوجد وقت حجز يبدأ في نفس التوقيت.", error: "يوجد وقت حجز يبدأ في نفس التوقيت في نفس اليوم.",
code: "duplicate_slot", code: "duplicate_slot",
}); });
return; return;
@@ -953,8 +983,9 @@ router.post(
// Generate slots from a weekly availability interval (Calendly-style): // Generate slots from a weekly availability interval (Calendly-style):
// every `durationMinutes` step in [startTime, endTime) whose full duration // every `durationMinutes` step in [startTime, endTime) whose full duration
// still fits becomes a slot on the given days. Start times that already // still fits becomes a slot on the given days. Duplicate rule is per-day:
// exist are skipped (global start_time uniqueness), never overwritten. // if a slot already starts at the same time, the requested days are merged
// into it (its duration is kept); only fully-covered times are skipped.
router.post( router.post(
"/protocol/booking-slots/generate", "/protocol/booking-slots/generate",
requireManageRooms, requireManageRooms,
@@ -974,32 +1005,74 @@ router.post(
}); });
return; return;
} }
const inserted = await db const result = await db.transaction(async (tx) => {
const existingRows = await tx
.select()
.from(protocolBookingSlotsTable)
.where(inArray(protocolBookingSlotsTable.startTime, candidates))
.for("update");
const byStart = new Map(existingRows.map((r) => [r.startTime, r]));
const slots: (typeof existingRows)[number][] = [];
let created = 0;
let merged = 0;
let skipped = 0;
for (const startTime of candidates) {
const existing = byStart.get(startTime);
if (!existing) {
const [row] = await tx
.insert(protocolBookingSlotsTable) .insert(protocolBookingSlotsTable)
.values( .values({
candidates.map((startTime) => ({
startTime, startTime,
durationMinutes: body.durationMinutes, durationMinutes: body.durationMinutes,
daysOfWeek: body.daysOfWeek, daysOfWeek: body.daysOfWeek,
createdBy: req.session.userId!, createdBy: req.session.userId!,
})), })
) .onConflictDoNothing({
.onConflictDoNothing({ target: protocolBookingSlotsTable.startTime }) target: protocolBookingSlotsTable.startTime,
})
.returning(); .returning();
for (const row of inserted) { if (!row) {
await logAudit(db, { skipped += 1;
continue;
}
created += 1;
slots.push(row);
await logAudit(tx, {
action: "slot.create", action: "slot.create",
entityType: "booking_slot", entityType: "booking_slot",
entityId: row.id, entityId: row.id,
newValue: row, newValue: row,
performedBy: req.session.userId!, performedBy: req.session.userId!,
}); });
continue;
} }
res.status(201).json({ const union = Array.from(
created: inserted.length, new Set([...existing.daysOfWeek, ...body.daysOfWeek]),
skipped: candidates.length - inserted.length, ).sort((a, b) => a - b);
slots: inserted, if (union.length === existing.daysOfWeek.length) {
// All requested days already covered at this start time.
skipped += 1;
continue;
}
const [row] = await tx
.update(protocolBookingSlotsTable)
.set({ daysOfWeek: union })
.where(eq(protocolBookingSlotsTable.id, existing.id))
.returning();
merged += 1;
slots.push(row);
await logAudit(tx, {
action: "slot.update",
entityType: "booking_slot",
entityId: existing.id,
oldValue: existing,
newValue: row,
performedBy: req.session.userId!,
}); });
}
return { created, merged, skipped, slots };
});
res.status(201).json(result);
}, },
); );
@@ -513,8 +513,31 @@ test("interval generation creates slots, skips duplicates, validates input", asy
assert.equal(again.status, 201); assert.equal(again.status, 201);
const b2 = await again.json(); const b2 = await again.json();
assert.equal(b2.created, 0); assert.equal(b2.created, 0);
assert.equal(b2.merged, 0);
assert.equal(b2.skipped, 3); assert.equal(b2.skipped, 3);
// Same start times but a *different* day: not a duplicate — the day is
// merged into the existing slots instead of being skipped.
const otherDayGen = await api(
adminCookie,
"POST",
"/protocol/booking-slots/generate",
{
startTime: "18:07",
endTime: "19:37",
durationMinutes: 30,
daysOfWeek: [otherWeekday],
},
);
assert.equal(otherDayGen.status, 201);
const b2b = await otherDayGen.json();
assert.equal(b2b.created, 0);
assert.equal(b2b.merged, 3, "different day must merge, not skip");
assert.equal(b2b.skipped, 0);
for (const s of b2b.slots) {
assert.deepEqual(s.daysOfWeek, [dayWeekday, otherWeekday].sort((a, b) => a - b));
}
// Overlapping an existing start time (SLOT_A) only creates the free ones. // Overlapping an existing start time (SLOT_A) only creates the free ones.
const overlap = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { const overlap = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
startTime: SLOT_A, startTime: SLOT_A,
@@ -524,7 +547,8 @@ test("interval generation creates slots, skips duplicates, validates input", asy
}); });
assert.equal(overlap.status, 201); assert.equal(overlap.status, 201);
const b3 = await overlap.json(); const b3 = await overlap.json();
assert.equal(b3.skipped, 1, "existing SLOT_A start must be skipped"); assert.equal(b3.skipped, 1, "SLOT_A already covers all days → skipped");
assert.equal(b3.merged, 0);
assert.equal(b3.created, 1); assert.equal(b3.created, 1);
// Invalid inputs are rejected. // Invalid inputs are rejected.
@@ -599,4 +623,26 @@ test("availability and booking validation filter slots by weekday", async () =>
}); });
assert.equal(wrongDay.status, 400); assert.equal(wrongDay.status, 400);
assert.equal((await wrongDay.json()).code, "invalid_slot"); assert.equal((await wrongDay.json()).code, "invalid_slot");
// Manual create at the same start time: overlapping day → 409; a
// disjoint day is merged into the existing slot instead.
const dup = await api(adminCookie, "POST", "/protocol/booking-slots", {
startTime: "21:11",
durationMinutes: DURATION,
daysOfWeek: [otherWeekday],
});
assert.equal(dup.status, 409, "same time + same day is a duplicate");
const mergeDay = (otherWeekday + 1) % 7;
const mergedRes = await api(adminCookie, "POST", "/protocol/booking-slots", {
startTime: "21:11",
durationMinutes: DURATION,
daysOfWeek: [mergeDay],
});
assert.equal(mergedRes.status, 201, "same time + new day merges");
const mergedRow = await mergedRes.json();
assert.equal(mergedRow.id, offDay.id, "must merge into the existing slot");
assert.deepEqual(
mergedRow.daysOfWeek,
[otherWeekday, mergeDay].sort((a, b) => a - b),
);
}); });
+2 -2
View File
@@ -1930,8 +1930,8 @@
"intervalFrom": "من", "intervalFrom": "من",
"intervalTo": "إلى", "intervalTo": "إلى",
"generate": "توليد الأوقات", "generate": "توليد الأوقات",
"generated": "تم توليد {{created}} وقتًا (تم تجاهل {{skipped}} مكررًا)", "generated": "تم توليد {{created}} وقتًا، ودمج الأيام في {{merged}}، وتجاهل {{skipped}} مكررًا.",
"generatedNone": م يتولد أي وقت — كل الأوقات موجودة مسبقًا.", "generatedNone": ا جديد — كل الأوقات والأيام موجودة مسبقًا.",
"pickDay": "اختر يومًا واحدًا على الأقل", "pickDay": "اختر يومًا واحدًا على الأقل",
"noSlotsForDay": "لا توجد أوقات متاحة في هذا اليوم" "noSlotsForDay": "لا توجد أوقات متاحة في هذا اليوم"
} }
+2 -2
View File
@@ -1783,8 +1783,8 @@
"intervalFrom": "From", "intervalFrom": "From",
"intervalTo": "To", "intervalTo": "To",
"generate": "Generate slots", "generate": "Generate slots",
"generated": "{{created}} slot(s) generated ({{skipped}} duplicate(s) skipped)", "generated": "{{created}} slot(s) created, days merged into {{merged}}, {{skipped}} duplicate(s) skipped.",
"generatedNone": "No slots generated — all start times already exist.", "generatedNone": "Nothing new — all start times and days already exist.",
"pickDay": "Pick at least one day", "pickDay": "Pick at least one day",
"noSlotsForDay": "No slots available on this day" "noSlotsForDay": "No slots available on this day"
} }
+3 -2
View File
@@ -2488,7 +2488,7 @@ function SlotsAdminSection({
}); });
const generateMut = useMutation({ const generateMut = useMutation({
mutationFn: () => mutationFn: () =>
apiJson<{ created: number; skipped: number }>( apiJson<{ created: number; merged: number; skipped: number }>(
`${API}/protocol/booking-slots/generate`, `${API}/protocol/booking-slots/generate`,
{ {
method: "POST", method: "POST",
@@ -2503,10 +2503,11 @@ function SlotsAdminSection({
onSuccess: (r) => { onSuccess: (r) => {
toast({ toast({
title: title:
r.created === 0 r.created === 0 && r.merged === 0
? t("protocol.slots.generatedNone") ? t("protocol.slots.generatedNone")
: t("protocol.slots.generated", { : t("protocol.slots.generated", {
created: r.created, created: r.created,
merged: r.merged,
skipped: r.skipped, skipped: r.skipped,
}), }),
}); });