events: booking lifecycle notifications + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s
booking-notify.sx walks the booking stream into ordered notifications by kind (booked/promoted/held/confirmed/released/cancelled/waitlisted). Promotion detected by folding the waitlist (a booking for a waitlisted actor is a promotion). id=occ-key/seq -> idempotent re-derivation, no double-ping. Connects ticketing to the delivery layer. 265/265 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
102
lib/events/booking-notify.sx
Normal file
102
lib/events/booking-notify.sx
Normal file
@@ -0,0 +1,102 @@
|
||||
;; lib/events/booking-notify.sx — derive lifecycle notifications from the
|
||||
;; booking stream, for delivery via notify.sx.
|
||||
;;
|
||||
;; Walking the append-only booking stream yields one notification per state
|
||||
;; change, in order, classified by kind:
|
||||
;;
|
||||
;; :booked a confirmed booking
|
||||
;; :promoted a booking for an actor who was on the waitlist (auto-promote)
|
||||
;; :held a provisional hold (pending payment)
|
||||
;; :confirmed a held seat became confirmed (payment succeeded)
|
||||
;; :released a held seat was released (payment failed/expired)
|
||||
;; :cancelled a seat was given up
|
||||
;; :waitlisted an actor joined the waitlist
|
||||
;;
|
||||
;; Promotion is detected by folding the waitlist as we walk: a :booking for an
|
||||
;; actor currently on the waitlist is a promotion, not a fresh booking.
|
||||
;;
|
||||
;; Each notification's id is occ-key/seq (the stream seq is unique and stable),
|
||||
;; so re-deriving and re-delivering is idempotent — the notify transport dedups
|
||||
;; on this id and never double-pings.
|
||||
|
||||
(define
|
||||
ev-bn-kind
|
||||
(fn
|
||||
(typ promoted?)
|
||||
(cond
|
||||
((= typ :hold) :held)
|
||||
((= typ :booking) (if promoted? :promoted :booked))
|
||||
((= typ :confirm) :confirmed)
|
||||
((= typ :cancel) :cancelled)
|
||||
((= typ :release) :released)
|
||||
((= typ :waitlist) :waitlisted)
|
||||
(else nil))))
|
||||
|
||||
(define
|
||||
ev-bn-update-waiting
|
||||
(fn
|
||||
(typ actor waiting)
|
||||
(cond
|
||||
((= typ :waitlist)
|
||||
(if
|
||||
(ev-bk-member? actor waiting)
|
||||
waiting
|
||||
(ev-bk-append waiting actor)))
|
||||
((= typ :unwaitlist) (ev-bk-remove waiting actor))
|
||||
((= typ :booking) (ev-bk-remove waiting actor))
|
||||
((= typ :hold) (ev-bk-remove waiting actor))
|
||||
(else waiting))))
|
||||
|
||||
(define ev-bn-mk (fn (occ-key label actor kind seq) {:id (str occ-key "/" seq) :event label :kind kind :recipient actor :seq seq}))
|
||||
|
||||
(define
|
||||
ev-bn-step
|
||||
(fn
|
||||
(occ-key label events waiting)
|
||||
(if
|
||||
(empty? events)
|
||||
(list)
|
||||
(let
|
||||
((e (first events)))
|
||||
(let
|
||||
((typ (persist/event-type e))
|
||||
(actor (get (persist/event-data e) :actor))
|
||||
(seq (persist/event-seq e)))
|
||||
(let
|
||||
((promoted? (and (= typ :booking) (ev-bk-member? actor waiting))))
|
||||
(let
|
||||
((kind (ev-bn-kind typ promoted?))
|
||||
(waiting2 (ev-bn-update-waiting typ actor waiting)))
|
||||
(if
|
||||
(nil? kind)
|
||||
(ev-bn-step occ-key label (rest events) waiting2)
|
||||
(cons
|
||||
(ev-bn-mk occ-key label actor kind seq)
|
||||
(ev-bn-step occ-key label (rest events) waiting2))))))))))
|
||||
|
||||
;; The ordered lifecycle notifications for an occurrence's bookings. `label` is
|
||||
;; a human-facing event id carried on each notification.
|
||||
(define
|
||||
ev/booking-notifications
|
||||
(fn
|
||||
(b occ-key label)
|
||||
(ev-bn-step
|
||||
occ-key
|
||||
label
|
||||
(persist/read b (ev-booking-stream occ-key))
|
||||
(list))))
|
||||
|
||||
;; Filter notifications to a single kind.
|
||||
(define
|
||||
ev/notify-of-kind
|
||||
(fn (notifs kind) (filter (fn (n) (= (get n :kind) kind)) notifs)))
|
||||
|
||||
;; Project a notification to notify.sx's (id recipient body) wire shape.
|
||||
(define
|
||||
ev/booking-notify->msg
|
||||
(fn
|
||||
(n)
|
||||
(list
|
||||
(get n :id)
|
||||
(get n :recipient)
|
||||
(list :booking-event (get n :kind) (get n :event)))))
|
||||
@@ -26,6 +26,7 @@ PRELOADS=(
|
||||
lib/persist/concurrency.sx
|
||||
lib/persist/api.sx
|
||||
lib/events/booking.sx
|
||||
lib/events/booking-notify.sx
|
||||
lib/events/ticket.sx
|
||||
lib/guest/lex.sx
|
||||
lib/guest/reflective/env.sx
|
||||
@@ -49,6 +50,7 @@ SUITES=(
|
||||
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
|
||||
"api:lib/events/tests/api.sx:(ev-api-tests-run!)"
|
||||
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
|
||||
"booking-notify:lib/events/tests/booking-notify.sx:(ev-booking-notify-tests-run!)"
|
||||
"ticket:lib/events/tests/ticket.sx:(ev-ticket-tests-run!)"
|
||||
"notify:lib/events/tests/notify.sx:(ev-notify-tests-run!)"
|
||||
"reminders:lib/events/tests/reminders.sx:(ev-reminders-tests-run!)"
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"lang": "events",
|
||||
"total_passed": 254,
|
||||
"total_passed": 265,
|
||||
"total_failed": 0,
|
||||
"total": 254,
|
||||
"total": 265,
|
||||
"suites": [
|
||||
{"name":"calendar","passed":51,"failed":0,"total":51},
|
||||
{"name":"availability","passed":22,"failed":0,"total":22},
|
||||
{"name":"api","passed":24,"failed":0,"total":24},
|
||||
{"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},
|
||||
{"name":"notify","passed":7,"failed":0,"total":7},
|
||||
{"name":"reminders","passed":14,"failed":0,"total":14},
|
||||
{"name":"federation","passed":23,"failed":0,"total":23}
|
||||
],
|
||||
"generated": "2026-06-07T06:51:44+00:00"
|
||||
"generated": "2026-06-07T07:20:13+00:00"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# events scoreboard
|
||||
|
||||
**254 / 254 passing** (0 failure(s)).
|
||||
**265 / 265 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
@@ -8,6 +8,7 @@
|
||||
| availability | 22 | 22 | ok |
|
||||
| api | 24 | 24 | ok |
|
||||
| booking | 82 | 82 | ok |
|
||||
| booking-notify | 11 | 11 | ok |
|
||||
| ticket | 31 | 31 | ok |
|
||||
| notify | 7 | 7 | ok |
|
||||
| reminders | 14 | 14 | ok |
|
||||
|
||||
137
lib/events/tests/booking-notify.sx
Normal file
137
lib/events/tests/booking-notify.sx
Normal file
@@ -0,0 +1,137 @@
|
||||
;; lib/events/tests/booking-notify.sx — lifecycle notifications from the stream.
|
||||
|
||||
(define ev-bn-pass 0)
|
||||
(define ev-bn-fail 0)
|
||||
(define ev-bn-failures (list))
|
||||
|
||||
(define
|
||||
ev-bn-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-bn-pass (+ ev-bn-pass 1))
|
||||
(do
|
||||
(set! ev-bn-fail (+ ev-bn-fail 1))
|
||||
(append!
|
||||
ev-bn-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define
|
||||
ev-bn-kinds
|
||||
(fn
|
||||
(notifs)
|
||||
(map (fn (n) (list (get n :recipient) (get n :kind))) notifs)))
|
||||
|
||||
(define
|
||||
ev-bn-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "o" 1 (quote a))
|
||||
(ev/waitlist! b "o" 1 (quote x))
|
||||
(ev/cancel-promote! b "o" 1 (quote a))
|
||||
(let
|
||||
((ns (ev/booking-notifications b "o" (quote yoga))))
|
||||
(do
|
||||
(ev-bn-check!
|
||||
"lifecycle notifications in order"
|
||||
(ev-bn-kinds ns)
|
||||
(list
|
||||
(list (quote a) :booked)
|
||||
(list (quote x) :waitlisted)
|
||||
(list (quote a) :cancelled)
|
||||
(list (quote x) :promoted)))
|
||||
(ev-bn-check!
|
||||
"promotion targets the waitlisted actor"
|
||||
(map
|
||||
(fn (n) (get n :recipient))
|
||||
(ev/notify-of-kind ns :promoted))
|
||||
(list (quote x)))
|
||||
(ev-bn-check!
|
||||
"a fresh booking is not flagged as a promotion"
|
||||
(len (ev/notify-of-kind ns :booked))
|
||||
1)
|
||||
(ev-bn-check!
|
||||
"every notification carries the event label"
|
||||
(get (first ns) :event)
|
||||
(quote yoga))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/hold! b "p" 3 (quote q))
|
||||
(ev/confirm! b "p" (quote q))
|
||||
(ev-bn-check!
|
||||
"hold then confirm notifications"
|
||||
(ev-bn-kinds (ev/booking-notifications b "p" (quote gig)))
|
||||
(list (list (quote q) :held) (list (quote q) :confirmed)))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/hold! b "r" 1 (quote q))
|
||||
(ev/release! b "r" (quote q))
|
||||
(ev-bn-check!
|
||||
"hold then release notifications"
|
||||
(ev-bn-kinds (ev/booking-notifications b "r" (quote gig)))
|
||||
(list (list (quote q) :held) (list (quote q) :released)))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "k" 5 (quote a))
|
||||
(ev/book! b "k" 5 (quote c))
|
||||
(let
|
||||
((ns (ev/booking-notifications b "k" (quote talk))))
|
||||
(do
|
||||
(ev-bn-check!
|
||||
"notification ids are occ-key/seq"
|
||||
(map (fn (n) (get n :id)) ns)
|
||||
(list "k/1" "k/2"))
|
||||
(ev-bn-check!
|
||||
"re-deriving yields identical ids (idempotent)"
|
||||
(map
|
||||
(fn (n) (get n :id))
|
||||
(ev/booking-notifications b "k" (quote talk)))
|
||||
(list "k/1" "k/2"))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "w" 5 (quote a))
|
||||
(ev-bn-check!
|
||||
"notification projects to (id recipient body)"
|
||||
(ev/booking-notify->msg
|
||||
(first (ev/booking-notifications b "w" (quote talk))))
|
||||
(list
|
||||
"w/1"
|
||||
(quote a)
|
||||
(list :booking-event :booked (quote talk))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "u" 1 (quote a))
|
||||
(ev/waitlist! b "u" 1 (quote x))
|
||||
(ev/leave-waitlist! b "u" (quote x))
|
||||
(ev-bn-check!
|
||||
"leaving the waitlist emits no notification"
|
||||
(len
|
||||
(ev/notify-of-kind
|
||||
(ev/booking-notifications b "u" (quote e))
|
||||
:left-waitlist))
|
||||
0)
|
||||
(ev-bn-check!
|
||||
"unbooked occurrence has no notifications"
|
||||
(ev/booking-notifications b "empty" (quote e))
|
||||
(list)))))))
|
||||
|
||||
(define
|
||||
ev-booking-notify-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-bn-pass 0)
|
||||
(set! ev-bn-fail 0)
|
||||
(set! ev-bn-failures (list))
|
||||
(ev-bn-run-all!)
|
||||
{:failures ev-bn-failures :total (+ ev-bn-pass ev-bn-fail) :passed ev-bn-pass :failed ev-bn-fail})))
|
||||
@@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/events/conformance.sh` → **254/254** (Phases 1-4 + ext: fed free/busy, waitlist, EXDATE/RDATE, overrides)
|
||||
`bash lib/events/conformance.sh` → **265/265** (Phases 1-4 + ext: fed free/busy, waitlist, EXDATE/RDATE, overrides, booking-notify)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -86,6 +86,15 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-06-07 — Booking lifecycle notifications (extension). `booking-notify.sx`
|
||||
walks the booking stream into ordered notifications classified by kind:
|
||||
:booked / :promoted / :held / :confirmed / :released / :cancelled /
|
||||
:waitlisted. Promotion is detected by folding the waitlist as we walk (a
|
||||
:booking for a currently-waitlisted actor is a promotion, not a fresh
|
||||
booking). id = occ-key/seq (stable stream seq → idempotent re-derivation, no
|
||||
double-ping). `ev/booking-notifications`, `ev/notify-of-kind`,
|
||||
`ev/booking-notify->msg` (notify wire shape). Connects ticketing to the
|
||||
delivery layer. +11 tests, 265/265 green.
|
||||
- 2026-06-07 — Per-occurrence overrides / reschedule (RFC 5545 RECURRENCE-ID).
|
||||
`ev-with-override event orig-start new-start new-duration` adds an :overrides
|
||||
entry keyed by the occurrence's original start. `ev-expand` applies overrides
|
||||
|
||||
Reference in New Issue
Block a user