Repository navigation
Persist invitation-related booking webhook events atomically #4
Description
Booking state can commit successfully while its corresponding booking webhook is never persisted. This is a follow-up for the scheduling-invitations work on feat/scheduling-invitations-complete in this fork.
Invitation lifecycle transitions already use a transactional outbox. However, booking.created, booking.rescheduled, and booking.cancelled are enqueued from asynchronous post-commit side-effect handlers. A process exit between commit and enqueue, an expired side-effect context, or an enqueue error can permanently lose the event. Existing delivery retries only recover events that reached the delivery/jobs tables.
Affected paths:
internal/handler/booking_handler.go: confirmation and cancellation side effects enqueue booking events after database changes.internal/handler/manage_handler.go: rescheduling side effects enqueue the event after calendar/email work; an earlier error can return before enqueue.internal/webhook/invitation.go: durable invitation lifecycle outbox provides an existing pattern to reuse.
For an external ticketing integration, a lost rescheduling or cancellation event can leave the external ticket with stale appointment information even though Calnode committed the change. A durable invitation-booked event does not cover later booking changes.
Expected change:
- Persist invitation-related booking event intent atomically with successful booking creation, rescheduling, and cancellation.
- Dispatch through the existing signed webhook delivery/retry infrastructure, without network operations inside the booking transaction or a separate signing/envelope system.
- Capture transition-time intervals, status, assigned hosts, invitation ID, and external correlation; define how meeting/location enrichment becomes available.
- Use stable event IDs across recovery/retry and prevent duplicate publication from current side-effect handlers.
- Preserve ordinary booking behavior and never include raw scheduling tokens or URLs.
Acceptance coverage:
- Stop/restart after commit but before dispatch and recover each booking event.
- A rolled-back change emits no event.
- Repeated polling and failed delivery retries retain stable IDs and correlation.
- Slow/failing calendar or email operations cannot prevent event intent from being persisted.
- Rescheduling/cancellation preserve the invitation's external reference in recovered payloads.
This issue tracks the reliability gap; the current branch documents its best-effort booking-event enqueue boundary. No live M365 credentials should be required for these regression tests.
Activity
KitKat31337 commentedon Oct 2, 2026
I propose a transactional booking-event outbox for invitation-related bookings, reusing the existing webhook dispatcher and signing/retry infrastructure.
Moving
Enqueueearlier in the post-commit goroutine would reduce the failure window but would not close it. The event intent must commit with the booking change.Persist one record per booking transition. Add a normal additive Goose migration for
booking_event_outboxwith a stable event ID, booking ID, monotonically increasing booking revision, event name, occurrence timestamp, immutable payload snapshot, recipient-owner IDs, and dispatch timestamp. Enforce uniqueness on(booking_id, revision). Do not reuse the invitation outbox'sUNIQUE(invitation_id, event): multiple reschedules are legitimate. Keep the invitation lifecycle outbox and its event names intact.Capture events inside the booking transaction. Add a small local persistence helper in the booking service, called only for bookings with
scheduling_invitation_id:Commit.Commit, retaining the previous interval as well as the new one.CancelandCancelByIDin a transaction and capture the event with the successful status transition. This covers customer, staff, and archive/admin cancellation paths.An outbox insert failure rolls back the booking change. Repeated cancellation/no-op requests must not create another transition event. All queries use the same transaction and drain cursors before subsequent queries, respecting SQLite's single-connection architecture. No calendar, email, or HTTP calls occur in this transaction.
Snapshot what the event actually describes. Store transition-time status, start/end, effective duration, assigned host IDs/primary marker, invitation/event-type IDs, external
{system, reference, url}, and optional attendee/intake fields needed by the existing field-selection logic. Snapshot the primary-host and invitation-creator owner IDs for routing. Do not reconstruct an old event's interval/status/host assignment from the current booking when the worker eventually runs. Use the existing envelope, with its stableid; include the booking revision in correlateddataso receivers can recognize out-of-order changes. Exclude bearer tokens, token hashes, scheduling URLs, and manage credentials.Drain atomically into existing delivery jobs. Extend the worker with a booking-outbox drain following
ProcessSchedulingInvitations: materialize pending rows, resolve active subscriptions/field selections for the captured owner IDs, build each subscriber's payload from the snapshot, insertwebhook_deliveriesandwebhook.deliverjobs, and mark the event dispatched in one local transaction. Preserve mandatory invitation correlation despite optional field selection. Document subscription resolution at dispatch, matching the existing lifecycle approach. Add a nullable event-ID link and a uniqueness invariant on(event_id, webhook_id)for delivery rows as a second duplicate guard. Once queued, the stored body, event ID, delivery ID, and signature remain unchanged through retries.Keep meeting generation independent. Do not wait indefinitely for calendar/email work before dispatching a booking event. At first dispatch, enrich meeting/location information only if it is available and the current booking revision still matches the captured revision; otherwise retain the transition snapshot. This prevents a later reschedule's meeting URL from being attached to an earlier event. Freeze enrichment when delivery rows are created. A missing generated URL is an honest pending/unavailable value; consumers can reconcile current booking details through the existing read APIs. A guaranteed later meeting-ready notification would be a separate follow-up, not an implicit promise of this fix.
Remove the duplicate publication path. For invitation bookings, stop the current asynchronous side-effect handlers from also calling
Enqueuefor these three events. Continue running calendar/meeting/confirmation/reminder work after commit. Ordinary bookings keep their current publication behavior in this focused change; broader adoption can follow separately.The guarantee is durable event capture and crash-safe queueing, not exactly-once HTTP delivery or indefinite retries. Existing delivery attempt limits still apply. A crash after the receiver accepts an event but before local success recording can cause redelivery with the same IDs; receivers must deduplicate. Ordering remains unguaranteed, with booking revisions allowing stale events to be recognized.
Focused regression tests should cover:
These tests can use disposable SQLite databases, existing provider/mailer fakes, and a local webhook receiver; no live M365 credentials are needed.