diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index caec4d1c..88b87db2 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -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 - .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, + 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({ + startTime, + durationMinutes: body.durationMinutes, + daysOfWeek: body.daysOfWeek, + createdBy: req.session.userId!, + }) + .onConflictDoNothing({ + target: protocolBookingSlotsTable.startTime, + }) + .returning(); + 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; + } + 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); }, ); diff --git a/artifacts/api-server/tests/protocol-booking-slots.test.mjs b/artifacts/api-server/tests/protocol-booking-slots.test.mjs index 173b2f3f..7c760611 100644 --- a/artifacts/api-server/tests/protocol-booking-slots.test.mjs +++ b/artifacts/api-server/tests/protocol-booking-slots.test.mjs @@ -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), + ); }); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 6f854c14..3217e5ba 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1930,10 +1930,10 @@ "intervalFrom": "من", "intervalTo": "إلى", "generate": "توليد الأوقات", - "generated": "تم توليد {{created}} وقتًا (تم تجاهل {{skipped}} مكررًا)", - "generatedNone": "لم يتولد أي وقت — كل الأوقات موجودة مسبقًا.", + "generated": "تم توليد {{created}} وقتًا، ودمج الأيام في {{merged}}، وتجاهل {{skipped}} مكررًا.", + "generatedNone": "لا جديد — كل الأوقات والأيام موجودة مسبقًا.", "pickDay": "اختر يومًا واحدًا على الأقل", "noSlotsForDay": "لا توجد أوقات متاحة في هذا اليوم" } } -} +} \ No newline at end of file diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 6d0d9a92..4dd0f9ba 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1783,10 +1783,10 @@ "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" } } -} +} \ No newline at end of file diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index 08be71a9..fcc1cbdf 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -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, }), });