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:
@@ -939,9 +939,39 @@ router.post(
|
||||
res.status(201).json(row);
|
||||
} catch (e) {
|
||||
// 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)) {
|
||||
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({
|
||||
error: "يوجد وقت حجز يبدأ في نفس التوقيت.",
|
||||
error: "يوجد وقت حجز يبدأ في نفس التوقيت في نفس اليوم.",
|
||||
code: "duplicate_slot",
|
||||
});
|
||||
return;
|
||||
@@ -953,8 +983,9 @@ 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.
|
||||
// still fits becomes a slot on the given days. Duplicate rule is per-day:
|
||||
// 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(
|
||||
"/protocol/booking-slots/generate",
|
||||
requireManageRooms,
|
||||
@@ -974,32 +1005,74 @@ router.post(
|
||||
});
|
||||
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)
|
||||
.values(
|
||||
candidates.map((startTime) => ({
|
||||
.values({
|
||||
startTime,
|
||||
durationMinutes: body.durationMinutes,
|
||||
daysOfWeek: body.daysOfWeek,
|
||||
createdBy: req.session.userId!,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({ target: protocolBookingSlotsTable.startTime })
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: protocolBookingSlotsTable.startTime,
|
||||
})
|
||||
.returning();
|
||||
for (const row of inserted) {
|
||||
await logAudit(db, {
|
||||
if (!row) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
created += 1;
|
||||
slots.push(row);
|
||||
await logAudit(tx, {
|
||||
action: "slot.create",
|
||||
entityType: "booking_slot",
|
||||
entityId: row.id,
|
||||
newValue: row,
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
res.status(201).json({
|
||||
created: inserted.length,
|
||||
skipped: candidates.length - inserted.length,
|
||||
slots: inserted,
|
||||
const union = Array.from(
|
||||
new Set([...existing.daysOfWeek, ...body.daysOfWeek]),
|
||||
).sort((a, b) => a - b);
|
||||
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);
|
||||
const b2 = await again.json();
|
||||
assert.equal(b2.created, 0);
|
||||
assert.equal(b2.merged, 0);
|
||||
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.
|
||||
const overlap = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: SLOT_A,
|
||||
@@ -524,7 +547,8 @@ test("interval generation creates slots, skips duplicates, validates input", asy
|
||||
});
|
||||
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.skipped, 1, "SLOT_A already covers all days → skipped");
|
||||
assert.equal(b3.merged, 0);
|
||||
assert.equal(b3.created, 1);
|
||||
|
||||
// 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((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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1930,8 +1930,8 @@
|
||||
"intervalFrom": "من",
|
||||
"intervalTo": "إلى",
|
||||
"generate": "توليد الأوقات",
|
||||
"generated": "تم توليد {{created}} وقتًا (تم تجاهل {{skipped}} مكررًا)",
|
||||
"generatedNone": "لم يتولد أي وقت — كل الأوقات موجودة مسبقًا.",
|
||||
"generated": "تم توليد {{created}} وقتًا، ودمج الأيام في {{merged}}، وتجاهل {{skipped}} مكررًا.",
|
||||
"generatedNone": "لا جديد — كل الأوقات والأيام موجودة مسبقًا.",
|
||||
"pickDay": "اختر يومًا واحدًا على الأقل",
|
||||
"noSlotsForDay": "لا توجد أوقات متاحة في هذا اليوم"
|
||||
}
|
||||
|
||||
@@ -1783,8 +1783,8 @@
|
||||
"intervalFrom": "From",
|
||||
"intervalTo": "To",
|
||||
"generate": "Generate slots",
|
||||
"generated": "{{created}} slot(s) generated ({{skipped}} duplicate(s) skipped)",
|
||||
"generatedNone": "No slots generated — all start times already exist.",
|
||||
"generated": "{{created}} slot(s) created, days merged into {{merged}}, {{skipped}} duplicate(s) skipped.",
|
||||
"generatedNone": "Nothing new — all start times and days already exist.",
|
||||
"pickDay": "Pick at least one day",
|
||||
"noSlotsForDay": "No slots available on this day"
|
||||
}
|
||||
|
||||
@@ -2488,7 +2488,7 @@ function SlotsAdminSection({
|
||||
});
|
||||
const generateMut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiJson<{ created: number; skipped: number }>(
|
||||
apiJson<{ created: number; merged: number; skipped: number }>(
|
||||
`${API}/protocol/booking-slots/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -2503,10 +2503,11 @@ function SlotsAdminSection({
|
||||
onSuccess: (r) => {
|
||||
toast({
|
||||
title:
|
||||
r.created === 0
|
||||
r.created === 0 && r.merged === 0
|
||||
? t("protocol.slots.generatedNone")
|
||||
: t("protocol.slots.generated", {
|
||||
created: r.created,
|
||||
merged: r.merged,
|
||||
skipped: r.skipped,
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user