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:
| Event | Notification workflow |
|---|---|
patient.created | patient-welcome |
appointment.created | appointment-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.