Skip to Content

Events

Every domain event uses the same versioned envelope:

type DomainEvent = { id: EventId; type: DomainEventType; version: 1; workspaceId: WorkspaceId; actor: { kind: "member"; id: MemberId } | { kind: "patient"; id: PatientId }; occurredAt: string; resourceType: "appointment" | "patient"; resourceId: string; };

The envelope contains identifiers only. It does not copy names, contact details, appointment reasons, or other health data into Workflow storage. Listeners for non-deletion events can load the current resource when they need more context.

Persistence

events is the append-only history. Every row stores the event ID, workspace, type, version, member, occurrence time, and the primary polymorphic resource_type / resource_id pair. recorded_at is the database receipt time; it is kept separate from the domain-owned occurred_at.

event_resources links an event to every timeline where it is relevant. The primary resource is always linked. Appointment events also link to their patient and practitioner, so those timelines do not depend on mutable joins back to the current appointment row. Polymorphic resource IDs deliberately have no resource foreign key, allowing history to survive resource deletion.

Event catalog

The generated repository inventory lists the current event types from domainEventTypes. Add a contract only when its mutation path exists.

Delivery

Repositories write the resource mutation, canonical event, timeline links, and event-outbox row in one PostgreSQL transaction. The outbox references the canonical event by ID rather than storing a second payload. The scheduled /v1/system/events dispatcher claims pending rows with SKIP LOCKED, starts dispatchDomainEvent through Vercel Workflow, and marks accepted rows dispatched. A failed start releases the claim for retry. Delivery is at least once.

All listener lists are explicit in apps/api/src/workflows/events/listeners.ts. The mapped type requires a key for every event in the public catalog, so adding a new event fails typechecking until its listener seam exists.

The current creation listeners start transactional notification workflows:

EventNotification workflow
patient.createdpatient-welcome
appointment.createdappointment-confirmation

The notification step reloads the resource through its repository. Domain event payloads remain identifier-only.

Listener constraints

Add a workflow with the exact event type to apps/api/src/workflows/events/listeners.ts. Its exhaustive map fails typechecking when a registered event lacks a listener entry. Listener workflows for one event run concurrently. External effects use event.id or the Workflow step ID as an idempotency key because steps can retry.

Last updated on