diff --git a/lib/events/api.sx b/lib/events/api.sx index 5258fc6d..aa5b2d03 100644 --- a/lib/events/api.sx +++ b/lib/events/api.sx @@ -249,3 +249,29 @@ (fn (b store actor ws we) (> (len (ev/conflicts-p b store actor ws we)) 0))) + +;; ---- conflict-checked booking ---- +;; Capacity is per-event, but an attendee should not be double-booked against +;; THEMSELVES across different events. Would booking `actor` into `occ` overlap +;; an existing booking of theirs elsewhere? (Derived from persist availability; +;; an existing booking into `occ` itself is excluded — that's idempotent.) +(define + ev/would-time-conflict? + (fn + (b store actor occ) + (and + (not (ev-actor-booked? b (ev-occ-key occ) actor)) + (not (ev/free-p? b store actor (get occ :start) (get occ :end)))))) + +;; Book `actor` into `occ` only if it doesn't clash with their other bookings. +;; Re-booking the same occurrence is idempotent (:already); a clash returns +;; :time-conflict; otherwise the normal ev/book-occ! result (:booked / :full). +(define + ev/book-checked! + (fn + (b store actor occ) + (cond + ((ev-actor-booked? b (ev-occ-key occ) actor) (ev/book-occ! b store actor occ)) + ((ev/would-time-conflict? b store actor occ) + {:status :time-conflict :actor actor :occ-key (ev-occ-key occ)}) + (else (ev/book-occ! b store actor occ))))) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 18e86561..7df5fc32 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,13 +1,13 @@ { "lang": "events", - "total_passed": 303, + "total_passed": 311, "total_failed": 0, - "total": 303, + "total": 311, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, {"name":"timezone","passed":17,"failed":0,"total":17}, {"name":"availability","passed":22,"failed":0,"total":22}, - {"name":"api","passed":24,"failed":0,"total":24}, + {"name":"api","passed":32,"failed":0,"total":32}, {"name":"booking","passed":82,"failed":0,"total":82}, {"name":"booking-notify","passed":11,"failed":0,"total":11}, {"name":"ticket","passed":31,"failed":0,"total":31}, @@ -16,5 +16,5 @@ {"name":"federation","passed":29,"failed":0,"total":29}, {"name":"integration","passed":8,"failed":0,"total":8} ], - "generated": "2026-06-07T11:51:43+00:00" + "generated": "2026-06-07T13:59:09+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index 15f6602f..f7eb1a50 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,13 +1,13 @@ # events scoreboard -**303 / 303 passing** (0 failure(s)). +**311 / 311 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 51 | 51 | ok | | timezone | 17 | 17 | ok | | availability | 22 | 22 | ok | -| api | 24 | 24 | ok | +| api | 32 | 32 | ok | | booking | 82 | 82 | ok | | booking-notify | 11 | 11 | ok | | ticket | 31 | 31 | ok | diff --git a/lib/events/tests/api.sx b/lib/events/tests/api.sx index d459a2fb..fb681f8f 100644 --- a/lib/events/tests/api.sx +++ b/lib/events/tests/api.sx @@ -259,6 +259,66 @@ 20)) true)))))))))))) +;; ---- conflict-checked booking ---- +(define + ev-api-cf-run-all! + (fn + () + (let + ((b (persist/open)) + (store + (ev/schedule + (ev/schedule + (ev/schedule (ev/empty) (quote a) (ev-dt 2026 6 1 9 0) 60 nil 10) + (quote bb) + (ev-dt 2026 6 1 9 30) + 60 + nil + 10) + (quote c) + (ev-dt 2026 6 1 11 0) + 60 + nil + 10))) + (let + ((oa (ev-occ (quote a) (ev-dt 2026 6 1 9 0) 60)) + (ob (ev-occ (quote bb) (ev-dt 2026 6 1 9 30) 60)) + (oc (ev-occ (quote c) (ev-dt 2026 6 1 11 0) 60))) + (do + (ev-api-check! + "first checked booking succeeds" + (get (ev/book-checked! b store (quote nia) oa) :status) + :booked) + (ev-api-check! + "overlapping different-event booking is a time conflict" + (get (ev/book-checked! b store (quote nia) ob) :status) + :time-conflict) + (ev-api-check! + "the clashing booking did not land on the roster" + (ev/roster-occ b ob) + (list)) + (ev-api-check! + "a non-overlapping booking is allowed" + (get (ev/book-checked! b store (quote nia) oc) :status) + :booked) + (ev-api-check! + "re-booking the same occurrence is idempotent, not a conflict" + (get (ev/book-checked! b store (quote nia) oa) :status) + :already) + ;; a different actor is unaffected by nia's bookings + (ev-api-check! + "another actor may take the overlapping slot" + (get (ev/book-checked! b store (quote ola) ob) :status) + :booked) + (ev-api-check! + "would-time-conflict? predicate agrees" + (ev/would-time-conflict? b store (quote nia) ob) + true) + (ev-api-check! + "would-time-conflict? false for a free slot" + (ev/would-time-conflict? b store (quote zed) ob) + false)))))) + (define ev-api-tests-run! (fn @@ -268,4 +328,5 @@ (set! ev-api-fail 0) (set! ev-api-failures (list)) (ev-api-run-all!) + (ev-api-cf-run-all!) {:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index 5c62cfd4..0dcf2004 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **303/303** (Phases 1-4 + 9 ext: fed f/b, waitlist, EXDATE/RDATE, overrides, booking/reschedule-notify, fed transport, timezones+DST, e2e delivery pipeline) +`bash lib/events/conformance.sh` → **311/311** (Phases 1-4 + 10 ext: …timezones+DST, e2e delivery pipeline, cross-event conflict-checked booking) ## Ground rules @@ -88,6 +88,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — Cross-event conflict-checked booking (extension). Capacity is + per-event, but `ev/book-checked!` also prevents an attendee double-booking + THEMSELVES across different events: it consults the actor's persist-derived + availability (ev/free-p?) for the occurrence's window and returns + :time-conflict on overlap, else the normal ev/book-occ! result. Re-booking + the same occurrence is idempotent (:already, not a conflict); other actors are + unaffected. `ev/would-time-conflict?` predicate. +8 tests, 311/311 green. - 2026-06-07 — End-to-end delivery pipeline (closes the derivation↔delivery gap). `ev/deliver-messages` bridges SX notification messages to the Scheme notify flow: each (id recipient body) is `serialize`d to s-expression text,