Skip to content

Data Model

This page documents the core data entities, their relationships, and the Cosmos DB containers that store them. Use this as a reference when working with the API or understanding how data flows through the system.

Core Entities

┌─────────────────┐       ┌─────────────────┐       ┌─────────────────┐
│  Organization   │       │     Event       │       │   TicketType    │
├─────────────────┤       ├─────────────────┤       ├─────────────────┤
│ id              │◄──────│ organizationId  │       │ eventId         │
│ name            │       │ id              │◄──────│ id              │
│ createdAt       │       │ name            │       │ name            │
│ updatedAt       │       │ date            │       │ price           │
└─────────────────┘       │ location        │       │ fee             │
        ▲                 │ venueCapacity   │       │ ticketmasterPrice│
        │                 │ active          │       │ ticketmasterFee │
        │                 │ salesLocked     │       │ sortOrder       │
        │                 │ showtimes[]     │       └─────────────────┘
        │                 │ commitmentDead- │
        │                 │   line          │       ┌─────────────────┐
        │                 │ commitmentStatus│       │   TicketOrder   │
        │                 │ enableQuotas    │       ├─────────────────┤
        │                 │ createdAt       │       │ id              │
        │                 └─────────────────┘       │ organizationId  │
        │                         ▲                 │ eventId         │
        │                         │                 │ showtimeId      │
┌───────┴──────────────┐          │                 │ userId          │
│OrganizationMembership│          │                 │ status (see     │
├──────────────────────┤          │                 │   workflow)     │
│ id                   │          │                 │ tickets[]       │
│ organizationId       │          │                 │ sold/returned   │
│ userId               │          └─────────────────│ quantities      │
│ role (member/        │                            │ totalQuantity   │
│   ticketManager/     │                            │ adminNote       │
│   treasurer/         │                            │ archived        │
│   admin)             │                            │ transferHistory[]│
│ roles[]              │                            │ createdAt       │
│ approved             │                            └─────────────────┘
│ invoiced             │                            ┌─────────────────┐
│ createdAt            │                            │   Allocation    │
└──────────────────────┘                            ├─────────────────┤
        ▲                                           │ id              │
        │                                           │ organizationId  │
┌───────┴─────────┐                                 │ eventId         │
│     User        │                                 │ showtimeId      │
├─────────────────┤                                 │ userId          │
│ id              │                                 │ items[]         │
│ email           │                                 │ createdAt       │
│ name            │                                 └─────────────────┘
│ firstName       │
│ lastName        │                                 ┌─────────────────┐
│ phone           │                                 │  ExternalSale   │
│ isSuperAdmin    │                                 ├─────────────────┤
│ tokenVersion    │                                 │ id              │
│ createdAt       │                                 │ organizationId  │
│ updatedAt       │                                 │ eventId         │
└─────────────────┘                                 │ showtimeId      │
                                                    │ tickets[]       │
        ┌─────────────────┐                         │ totalQuantity   │
        │ImpersonationAudit│                        │ totalAmount     │
        ├─────────────────┤                         │ source          │
        │ id              │                         │ createdAt       │
        │ organizationId  │                         └─────────────────┘
        │ adminUserId     │
        │ targetUserId    │
        │ action          │
        │ timestamp       │
        │ duration        │
        └─────────────────┘

Relationships

  • Organization → Event: One organization has many events (organizationId on Event)
  • Event → TicketType: One event has many ticket types (eventId on TicketType)
  • Event → TicketOrder: One event has many orders (eventId on TicketOrder)
  • Organization → OrganizationMembership: One organization has many memberships (organizationId on OrganizationMembership)
  • User → OrganizationMembership: One user can belong to many organizations (userId on OrganizationMembership)
  • User → TicketOrder: One user can place many orders (userId on TicketOrder)
  • Event → Allocation: Allocations are per user per event/showtime. Allocation limits are only enforced when enableQuotas is true on the event.
  • Event → ExternalSale: External sales tracked per event/showtime
  • User → ImpersonationAudit: Audit logs track admin and target user

Order Workflow

PENDING → APPROVED → DELIVERED → AWAITING_RECONCILIATION → SETTLED
    └────────────────────── cancel or reject ───────────────→ CANCELLED

The optional archived boolean and invoice fields are independent of this status workflow. Reconciliation freezes a sold-only invoice basis but never sets invoice state. invoicedAt means an invoice was issued or sent, not that it was paid.

An order can also reach AWAITING_RECONCILIATION via a transfer, not just via reporting: if a partial order transfer moves an order's last outstanding tickets away, the reduced source order flips straight to awaitingReconciliation — the same state it would reach if those last tickets had been reported sold or returned instead.

Cosmos DB Containers

The following containers are created by api/scripts/setup-database.ts:

ContainerPartition KeyTTLPurpose
users/idUser accounts
organizations/idOrganization entities
organizationMemberships/organizationIdUser–organization relationships with roles
events/organizationIdEvents, showtimes, ticket types
orders/organizationIdTicket orders (approval workflow)
ticketOrders/organizationIdTicket orders (request/approval workflow)
allocations/organizationIdPer-member ticket allocations
registrations/organizationIdDirect-sale registrations
externalSales/organizationIdTicketmaster / box-office sales
freeTickets/organizationIdComplimentary tickets
invitations/organizationIdInvitation codes
rateLimits/idRate-limit counters (auto-expire)
authTokens/idMagic-link tokens (auto-expire)
impersonationAudits/organizationIdImpersonation session logs
notifications/organizationIdIn-app notifications (auto-expire after ~30 days)

Capacity Model

  • venueCapacity is the limit for each showtime.
  • Sold count is calculated per showtime from reported order sales, direct registrations, external sales, and free tickets.
  • Remaining per showtime = max(0, venueCapacity − sold count).
  • Aggregate capacity = venueCapacity × showtime count.
  • Aggregate remaining is the sum of each showtime's remaining seats. Capacity is never borrowed between showtimes.

Notable Field Notes

  • TicketOrder.tickets — The diagram labels this field items[]. The actual TypeScript interface and database documents use tickets: TicketOrderItem[]. Use tickets in all code.
  • TicketOrder.createdAt: Submission timestamp used for the oldest-first Pending manager queue. Equal timestamps use ascending order ID as a stable display tie-breaker.
  • TicketOrder.archived: Optional administrative flag. The UI shows an Archived badge while it is true, but status remains one of the lifecycle values and continues to control reconciliation data and actions.
  • TicketOrder.invoiceBasis: Immutable snapshot created at reconciliation approval. Its lines contain only validated sold quantities multiplied by order-time unit prices. A fully returned order has an empty basis with grandTotal: 0; totalAmount is never a fallback.
  • TicketOrder.invoiceBasisValid: Derived API response field. It validates the frozen basis against sold history and order-time prices and is never persisted.
  • TicketOrder.invoicedAt / invoicedBy / invoicedByName: Optional invoice-issued audit fields. Missing fields always mean not invoiced.
  • TicketOrder.archiveOverride: Persistent evidence for an organization admin override of invoicePrerequisite, invalidInvoiceBasis, or both. The evidence remains after unarchive even though archivedAt and archivedBy represent only the current archive state.
  • TicketOrder.transferHistory — Optional array of TransferHistory entries, one per transfer. Each entry moves only the order's outstanding (unsold, unreturned) quantity; sold/returned evidence always stays on the order it was reported against. A partial transfer that consumes an order's last outstanding tickets flips that order straight to awaitingReconciliation. See TransferHistory for the full field list, and Delegated sales for the delegatedOrigin audit-only provenance field.
  • User.tokenVersion — Integer incremented on logout/token revocation. The JWT payload carries the version at sign-time; every request verifies the versions match (server-side revocation mechanism).
  • Registration.lockedAt — Timestamp marking when the registration edit window closed.
  • Registration.invoicedAt / invoicedById / invoicedByName: Invoice-issued timestamp, stable actor ID, and display name. Legacy invoicedBy values remain display names and are not reinterpreted as IDs.
  • ExternalSale.tickets — Optional array of ExternalSaleTicket items providing per-type breakdown (quantity, unitPrice, unitFee).
  • Event.commitmentDeadline / commitmentStatus — Deadline timestamp and status ("open" | "closed" | "converted") for commitment pledge rounds.
  • Event.enableQuotas — Optional boolean, defaults false. When true, allocation limits are enforced during order creation. Quotas can be managed from the event dashboard.
  • TicketType.ticketmasterPrice / ticketmasterFee — Optional overrides used when tickets are sold through Ticketmaster.

Supporting Types

These interfaces appear in API responses or as nested objects within core entities.

ExternalSaleTicket

Per-type breakdown on an ExternalSale:

FieldTypeDescription
ticketTypeIdstringLinked ticket type
ticketTypeNamestringDisplay name
quantitynumberUnits sold
unitPricenumberPrice per ticket
unitFeenumberFee per ticket

AllocationItem

Per-type allocation within an Allocation:

FieldTypeDescription
ticketTypeIdstringLinked ticket type
ticketTypeNamestringDisplay name
allocatednumberTickets allocated
usednumberTickets used

DashboardStats

Response shape for GET /api/management/dashboard/{eventId}:

FieldTypeDescription
organizationIdstringOrganization ID
eventIdstringEvent ID
eventNamestringEvent display name
venueCapacitynumberTotal venue capacity
totalTicketsSoldnumberSum of all tickets sold
totalRevenuenumberSum of all revenue
totalFeenumberSum of all fees
ticketsRemainingnumberRemaining capacity
activeSellersnumberMembers with sales
salesByMemberMemberSales[]Per-member breakdown
salesByTicketTypeTicketTypeSales[]Per-ticket-type breakdown
salesByShowtimeShowtimeSales[]?Per-showtime breakdown

MemberSales

Member breakdown within DashboardStats:

FieldTypeDescription
userIdstringMember user ID
userNamestringDisplay name
ticketsSoldnumberTotal tickets sold by member
totalRevenuenumberRevenue generated
invoicedbooleanLegacy projection of invoiceSummary.allEligibleInvoiced
invoiceSummaryMemberInvoiceSummaryEligibility, invoice amount, blockers, and state
breakdownarrayPer-type { ticketType, quantity, revenue }

MemberInvoiceSummary

FieldTypeDescription
statestringawaitingSettlement, invalidBasis, readyToInvoice, partiallyInvoiced, or invoiced
eligibleCountnumberActive registrations plus settled printed orders with a valid basis
invoicedCountnumberEligible records with invoice-issued state
unresolvedOrderCountnumberActive printed orders not yet settled
invalidBasisOrderCountnumberSettled printed orders whose frozen basis is invalid
invoiceAmountnumberRegistration totals plus valid printed invoiceBasis.grandTotal
allEligibleInvoicedbooleanTrue when at least one record is eligible and every eligible record is invoiced. Unresolved or invalid orders remain separate readiness blockers and can keep state in a warning state.

TicketTypeSales

Ticket type breakdown within DashboardStats:

FieldTypeDescription
ticketTypeIdstringTicket type ID
namestringDisplay name
pricenumberUnit price
soldnumberUnits sold
revenuenumberTotal revenue

ShowtimeSales

Showtime breakdown within DashboardStats:

FieldTypeDescription
showtimeIdstringShowtime ID
dateTimestringISO timestamp
labelstring?Optional display label
soldnumberMEMBER channel only: registrations + owner-direct printed orders. Delegated (partner-sold) orders are never counted here — see partnerSold
partnerSoldnumber?PARTNER channel: delegated (soldByOrganizationId) orders reported sold. Disjoint from sold; absent on pre-fix responses (treat as 0)
externalSoldnumberExternal tickets sold
totalSoldnumbersold + partnerSold + externalSold
remainingnumberRemaining capacity (every channel, plus free tickets, consumes it)
revenuenumberTotal showtime revenue across every channel
partnerRevenuenumber?The delegated (partner) component of revenue
externalBySourcearray{ source, quantity } breakdown

Channel rule (#345). A delegated order is settled with the partner organization, not with a member, so it is excluded from salesByMember, from owner member invoicing, and from the sold (member) channel above. It is reported in partnerSold and in salesByPartnerOrganization.

MyStats

Response shape for GET /api/me/stats:

FieldTypeDescription
totalTicketsSoldnumberTotal tickets sold by current user
totalRevenuenumberTotal revenue generated
byShowtimearrayPer-showtime sales breakdown
byTicketTypearray{ ticketTypeId, ticketTypeName, quantity, revenue }
eventBreakdownarray{ eventId, eventName, ticketsSold, revenue }

Pledge

Commitment round pledge submitted by a member:

FieldTypeDescription
idstringPledge ID
organizationIdstringOrganization ID
eventIdstringEvent ID
eventNamestringEvent display name
userIdstringMember user ID
userNamestringMember display name
userEmailstringMember email
sureCountnumberTickets member is sure to sell
possibleCountnumberTickets member might sell
createdAtstringISO timestamp
updatedAtstringISO timestamp

TransferHistory

Transfer history entry on TicketOrder, appended by order transfers:

FieldTypeDescription
fromUserIdstringPrevious owner (or the partner buyer-of-record, if delegatedOrigin is set)
fromUserNamestringPrevious owner display name
toUserIdstringNew owner
toUserNamestringNew owner name
transferredBystringManager who performed the transfer
transferredByNamestringManager display name
transferredAtstringISO timestamp
reasonstring?Optional reason
quantitynumberTickets transferred (always ≤ the outstanding balance at transfer time)
delegatedOriginDelegatedOriginAudit?Present only when the transferred inventory originated from a partner delegated sale. Audit-only — see DelegatedOriginAudit
transferRequestIdstringClient-generated UUID identifying one logical transfer intent; doubles as the idempotency key for replay detection
requestPayloadKeystringCanonical key of recipient + exact per-type quantities/mode, used to detect a replay with materially different contents
transferType"full" | "partial"Whether the whole order was reassigned or only part of its outstanding balance moved
sourceOrderIdstringThe order the tickets were transferred out of
sourceOrderStatusAtTransferTicketOrder["status"]Source order's status snapshot at the moment of transfer (captures whether it flipped to awaitingReconciliation)
sourceRemainingQuantityAtTransfernumberOutstanding quantity left on the source order right after this transfer (0 for a full transfer)
resultOrderIdstringDestination order ID — equals sourceOrderId for a full transfer (reassigned in place); a deterministic ID derived from the transfer request for a partial transfer
resultOrderStatusAtTransferTicketOrder["status"]Destination order's status snapshot at the moment of transfer
resultQuantityAtTransfernumberQuantity on the destination order at the moment of transfer

Full vs. partial transfer

A full transfer is only possible when the entire order is still outstanding (nothing sold or returned yet) — it reassigns the same order in place, so sourceOrderId === resultOrderId. A partial transfer always creates a new destination order and leaves the reduced source order behind; if that partial transfer consumes the source order's last outstanding tickets, sourceOrderStatusAtTransfer records the flip to "awaitingReconciliation".

DelegatedOriginAudit

Historical, audit-only provenance recorded when outstanding inventory that a selling partner originally created is transferred into an owner-organization member's order. It carries no authority — it's never read by any authorization check, Cosmos discovery filter, or sales/reporting classification (those all use the live soldByOrganizationId/soldByUserId/soldByUserName/delegationId fields on TicketOrder, described in Delegated sales). It exists purely so the transfer trail can still show where the inventory came from.

FieldTypeDescription
originSoldByOrganizationIdstringPartner organization that originally made the sale. Audit label only.
originSoldByUserIdstring?Partner staff member who acted on the original sale
originSoldByUserNamestring?Denormalized display name
originDelegationIdstring?The delegation the original sale was made under. Never an authority comparand
originOrderIdstringThe delegated order the inventory was transferred out of
originBuyerUserIdstring?Immutable original buyer-of-record (owner-audit only)
originBuyerUserNamestring?Immutable original buyer-of-record display name (owner-audit only)
originBuyerUserEmailstring?Immutable original buyer-of-record email (owner-audit only)
transferredAtstringISO timestamp of the transfer that moved this inventory to the owner org
transferRequestIdstringThe transfer request this provenance was captured by

Privacy boundary

originBuyerUser* and originSoldByUser* identify a person at the partner organization. order-read-projection.ts strips these fields — along with the rest of delegatedOrigin — from every response an ordinary owner-organization member receives. Only ticketManager, treasurer, and admin roles at the owner organization ever see them, and the partner organization never sees them at all.

UserOrganizationMembership

Organization entry in the GET /api/auth/me response:

FieldTypeDescription
organizationIdstringOrganization ID
organizationNamestringOrganization display name
roleOrganizationRoleMember's role in the organization
approvedbooleanWhether membership is approved

Shared events

The shared events module adds a coordination layer above the per-organization event model. It introduces two new Cosmos DB containers and several new entity types. All existing entities and sales flows are unchanged; the module is strictly additive.

Containers

ContainerPartition keyTTLPurpose
sharedEvents/sharedEventIdShared event root documents, participant roster, audit entries, projection sync jobs
sharedEventCapacityLedger/sharedEventIddefaultTtl: -1 (terminal docs expire)Capacity counters, reservations, adjustments, CapacityGuardDoc, CapacityFenceDoc

Both containers use all-path indexing with targeted composite indexes.

Entity overview

┌────────────────────┐      ┌───────────────────────────┐
│    SharedEvent     │      │   SharedEventParticipant  │
├────────────────────┤      ├───────────────────────────┤
│ id (sharedEventId) │◄─────│ sharedEventId             │
│ name               │      │ organizationId            │
│ showtimes[]        │      │ status (invited/accepted/ │
│ ticketTypeTemplates│      │   active/declined/left/   │
│ capacityPolicy     │      │   removed)                │
│ sharedCapacity     │      │ role (organizer/          │
│ organizerOrgId?    │      │   participant)            │
│ status (draft/open/│      │ projectionLink?           │
│  locked/cancelling/│      │ quotaByShowtime?          │
│  cancelled/        │      └───────────────────────────┘
│  completed)        │
│ owners[]           │      ┌───────────────────────────┐
│ grants[]           │      │   SharedEventOwner        │
│ externalChannels[] │      ├───────────────────────────┤
│ schemaVersion: 1   │      │ userId                    │
└────────────────────┘      │ organizationId            │
                            │ addedAt                   │
                            └───────────────────────────┘

┌───────────────────────────┐      ┌────────────────────────────┐
│     SharedEventGrant      │      │   ExternalSalesChannel     │
├───────────────────────────┤      ├────────────────────────────┤
│ userId                    │      │ source (billetto/          │
│ organizationId            │      │   ticketmaster)            │
│ capabilities[]            │      │ mode (automation/manual)   │
│ roleTemplate?             │      │ responsibleOrgId           │
│ issuedAt                  │      │ connectionRef?             │
│ issuedBy                  │      │ status (active/disabled)   │
└───────────────────────────┘      │ assignedBy                 │
                                   │ effectiveFrom              │
                                   └────────────────────────────┘

Capacity entities

┌────────────────────────────┐      ┌────────────────────────────┐
│      CapacityCounter       │      │     CapacityReservation    │
├────────────────────────────┤      ├────────────────────────────┤
│ id: "pool:{showtimeId}"    │      │ id: "{reservationId}"      │
│   or                       │      │ sharedEventId              │
│   "quota:{orgId}:{stId}"   │      │ showtimeId                 │
│ sharedEventId              │      │ organizationId             │
│ capacity                   │      │ quantity                   │
│ reserved                   │      │ state (held/committed/     │
│ committed                  │      │   released)                │
│ health (ok/reconciling/    │      │ expiresAt                  │
│   overbooked/retired)      │      │ operationId                │
└────────────────────────────┘      │ schemaVersion: 1           │
                                    └────────────────────────────┘

┌────────────────────────────┐      ┌────────────────────────────┐
│    CapacityAdjustment      │      │      CapacityFenceDoc      │
├────────────────────────────┤      ├────────────────────────────┤
│ id: "adjustment:           │      │ id: "fence:{sharedEventId}"│
│   {operationId}"           │      │ state (open/cancelling/    │
│ operationId                │      │   cancelled)               │
│ showtimeId                 │      │ (no TTL — permanent)       │
│ organizationId             │      └────────────────────────────┘
│ kind:                      │
│   wholeEventCancellation   │
│   administrativeCorrection │
│   externalObservation      │
│   reconciliation           │
│ signedDelta                │
│ originalQty?               │
│ correctedQty?              │
│ actor / authority / reason │
└────────────────────────────┘

Audit entity

┌────────────────────────────────────┐
│        SharedEventAuditEntry       │
├────────────────────────────────────┤
│ id: deterministic per action+target│
│ sharedEventId                      │
│ action (SharedEventAuditAction)    │
│ actorUserId / actorOrganizationId  │
│ targetOrganizationId?              │
│ targetUserId?                      │
│ timestamp                          │
│ metadata (never exposed in API)    │
│ schemaVersion: 1                   │
└────────────────────────────────────┘

The audit trail is append-only. No entry is ever edited or deleted. There are 32 defined SharedEventAuditAction values covering: roster transitions, owner changes, grant issue/revoke, capacity-policy changes, capacity edits, cancellation events, external channel changes, reconciliation repairs, and invitation-link generation, revocation, and acceptance.

┌──────────────────────────────────────────────┐
│       SharedEventInvitationLink              │
├──────────────────────────────────────────────┤
│ id: "invite:{first-16-hex-of-token-hash}"    │
│ documentType: "sharedEventInvitationLink"    │
│ sharedEventId                                │
│ tokenHash (SHA-256 of raw token — raw token  │
│   never stored)                              │
│ status ("active" | "used" | "revoked")       │
│ createdBy / createdByOrganizationId          │
│ createdAt / expiresAt                        │
│ usedBy? / usedByOrganizationId? / usedAt?    │
│ revokedAt? / revokedBy?                      │
│ emailDeliveryAttempted (boolean)             │
│ ttl (auto-expire after 14 days + 24h buffer) │
│ schemaVersion: 1                             │
└──────────────────────────────────────────────┘

Invitation link documents are stored in the sharedEvents container under the same partition as their shared event (/sharedEventId). This reuses the existing container and partitioning; no new container is required.

Key properties:

  • tokenHash: SHA-256 of the raw token. The raw token is returned to the organizer exactly once at creation and is never stored, logged, or returned again. The document ID is deterministically derived from the hash prefix.
  • emailDeliveryAttempted: true if an email send was attempted. The email address itself is never stored.
  • ttl: Cosmos TTL causes the document to expire automatically (14-day link lifetime plus a 24-hour buffer for in-flight operations).
  • usedByOrganizationId: Set atomically on acceptance. If the acceptance saga is interrupted after marking the invitation used but before the roster is updated, this field acts as a durable recovery marker for idempotent retry.

Acceptance uses an ETag-conditional replace so that exactly one concurrent acceptor wins; all others receive a generic "link unavailable" response.

In-app invitation notifications (D16)

When an invitation link is created with an optional email address, the platform performs a best-effort asynchronous side-effect lookup. If the email matches a known, approved organization admin, the system creates one in-app notification per organization context that admin currently administers. Notifications are stored in the existing notifications container partitioned by /organizationId — no new container is required.

Notification document fields relevant to this type (full notification document follows the standard schema):

FieldValue / shapeNotes
type"shared_event_invitation_received"Added to NotificationType union in D16
title"Shared Event Invitation"Localized in the client
message"You have been invited to participate in {sharedEventName}."Display string
actionUrl"/shared-events/pending-invitations"Token-free, session-authenticated route
meta.sharedEventIdstringShared event identifier
meta.sharedEventNamestringDisplay name of the shared event
meta.organizerOrganizationNamestringDisplay name of the organizer organization
incidentKey"se:invite-notif:{invitationDocId}:{recipientUserId}:{orgId}"Dedup key — not surfaced in any API response or UI
ttl≤ 15 days (bounded by invitation remaining lifetime)Dynamic TTL specific to this notification type; computed from the invitation's remaining lifetime, not the general notifications-container TTL

Privacy invariants:

  • The notification document never contains the recipient's email address, the raw invitation token, the token hash, or the invitation document ID.
  • The organizer cannot determine from any API response, UI state, or accessible endpoint whether a match was found, a notification was created, read, or acted upon.

Dedup and fan-out rules:

  • Dedup is per {invitationDocId}:{recipientUserId}:{organizationId}. A concurrent or repeated fan-out attempt for the same triple is idempotent (Cosmos 409 treated as success).
  • Fan-out is capped at 10 organization partitions per invitation.
  • The lookup and fan-out are fire-and-forget: any failure does not affect the invitation link creation response.

TTL alignment:

  • Invitation TTL: 14 days (plus a 24-hour buffer). Notifications use a dynamic TTL capped at 15 days, computed from the invitation's remaining lifetime.
  • A notification may outlive the invitation's expiry. Stale notifications lead to an empty pending-invitations page, not an error.

Pending-invitations API routes:

MethodRouteAuthDescription
GET/api/shared-event-invitations/pendingRequired (session)Returns active invitation links for shared events where the authenticated user is an admin of at least one eligible organization. No invitation token required. Organization-independent (no ?organizationId= query parameter); eligible admin organizations are resolved from the authenticated session. Returns a bounded set of up to 50 results (unordered).
POST/api/shared-event-invitations/pending/acceptRequired (session)Accepts an invitation via the in-app path. Uses the same ETag-conditional linearization point as token-based acceptance — exactly one concurrent acceptor wins.

Request body for POST /api/shared-event-invitations/pending/accept:

FieldTypeDescription
invitationIdstringInvitation document ID (from the pending-invitations list)
sharedEventIdstringShared event ID (partition key for point read)
organizationIdstringOrganization ID to accept for (user-selected)

The backend verifies that the invitation's stored recipientUserId matches the authenticated session user before performing any domain checks. A mismatch returns the same generic "link unavailable" response as all other error paths.

When a participant accepts, a local event projection is created from the shared templates and linked to the shared event via a SharedEventProjectionLink embedded in the event document:

FieldTypeDescription
sharedEventIdstringThe shared event this projection belongs to
roleorganizer | participantThis organization's role
capacityPolicyquota | poolInherited from shared event
participantStatusParticipantStatusCurrent roster status

A standalone event (no shared-event link) is unchanged and continues to behave as before.

Capacity counter key scheme

ModeCounter key formatEnforcement scope
Poolpool:{showtimeId}All organizations share one counter per showtime
Quotaquota:{organizationId}:{showtimeId}One counter per org per showtime

Counter mutations use Cosmos PatchOperation.incr() with server-side condition filters inside transactional batches, not ETag read-modify-write. This guarantees atomicity under concurrent load.

Relationships

  • SharedEvent → SharedEventParticipant: One shared event has 2..N participants (one per organization).
  • SharedEvent → SharedEventOwner: One shared event has 1..N named owners.
  • SharedEvent → SharedEventGrant: One shared event has 0..N named-user grants.
  • SharedEvent → ExternalSalesChannel: One shared event has 0..N channel assignments (one per source).
  • SharedEvent → SharedEventAuditEntry: Append-only log; unbounded, paginated.
  • SharedEventParticipant → Event (projection): One participant has at most one local event projection per shared event.
  • sharedEventCapacityLedger → CapacityCounter: One counter per (mode, org, showtime) combination.
  • sharedEventCapacityLedger → CapacityReservation: One reservation per in-flight checkout.
  • sharedEventCapacityLedger → CapacityFenceDoc: One permanent fence per shared event (created on first reserve or cancellation).

What is deferred

The following external-channel integration work is not implemented in the current module and is deferred to #242:

  • Billetto OAuth credential resolver and token lifecycle.
  • Billetto webhook ingestion and attendee/refund/cancellation sync.
  • Provider runtime operations (cancellation events, refund records, attendee lists).

The connectionRef field on ExternalSalesChannel stores an Azure Key Vault secret URI reference only. No raw credentials are stored in Cosmos.


Delegated sales (selling partners)

The delegated-sales module lets an event owner grant a scoped, self-service selling capability to another organization for a normal (non-Shared) event, without any co-ownership or shared-management authority. EventSalesDelegation is embedded on the owner's Event document and is the sole authorization source of truth; DelegatedEventSalesRef is a denormalized, eventually-consistent reverse-index projection embedded on the partner's own Organization document, kept in sync via a two-phase commit protocol. No new Cosmos containers are introduced — both types live on existing events and organizations documents.

EventSalesDelegation

Embedded in Event.salesDelegations[] on the owner's event document:

FieldTypeDescription
idstringUUID identifying this delegation
partnerOrganizationIdstringThe invited partner organization
partnerOrganizationNamestringHydrated snapshot; not re-synced on partner rename
statusEventSalesDelegationStatus"invited" | "active" | "declined" | "revoked"
invitedBy / invitedByNamestringOwner admin who sent the invitation
invitedAtstringISO timestamp
invitationTokenHashstring?SHA-256 hash of the one-time invitation token; present only while status === "invited". The raw token itself is never persisted.
invitationExpiresAtstring?ISO timestamp; 14-day TTL, mirrors shared-event invitation links
respondedBy / respondedByName / respondedAtstring?Partner admin who accepted or declined
revokedBy / revokedByName / revokedAtstring?Owner admin who revoked, and when
revokedReasonstring?Mandatory free-text reason captured when revoking
permissionsEventSalesDelegationPermissionsFixed, non-configurable permission bundle (see below)
assignmentVersionnumberOptimistic-concurrency counter
orderSubmissionMode"directDelivered" | "ownerApproval"Reserved schema slot only — not implemented; absence always means "directDelivered". This field does not drive the v1.2.0 member self-service pending/approval flow described below — that flow is controlled entirely by canApproveOwnMemberOrders and the ordinary TicketOrder.status lifecycle, not by this reserved slot.
reverseIndexSyncStatus"pending" | "synced" | "failed"Tracks whether the partner-side reverse index reflects this delegation
reverseIndexOperationIdstringUUID minted once per transition (invite/accept/revoke), reused across retries of the same transition
reverseIndexTransitionVersionnumber?Monotonic counter preventing the reverse-index projection from ever moving backwards to an older transition
reverseIndexSyncedAtstring?ISO timestamp; set once sync succeeds
reverseIndexSyncAttemptsnumber?Cumulative sync attempts, including later repair passes

Eligibility, not approval

Any organization that exists and is not soft-deleted (and is not the inviting organization itself) is eligible to be invited — there's no separate verification or approval workflow. How the target organization is identified is role-gated (an administrator's email for ordinary admins, an organization-name search for super admins only) — see API reference → Delegated sales for the request contract. This eligibility rule itself is unchanged; only the discovery input differs.

EventSalesDelegationPermissions

Fixed for v1 — every active delegation grants exactly this bundle, and nothing else. It cannot be configured per-delegation or per-organization:

FieldTypeDescription
canCreateSalestruePartner can create delegated orders for its own approved members
canReportSoldReturnedtruePartner can report sold/returned quantities on its own delegated orders
canViewOwnAttributedReportstruePartner can see its own attributed sales, never the owner's full reports
canApproveOwnMemberOrderstrue?Added v1.2.0 (#357 Phase 4, D1). The partner organization's Ticket Manager+ can approve, deliver, approve-and-deliver, and cancel-while-pending its own members' self-service orders. Optional in the type only for backward compatibility: a delegation record written before v1.2.0 does not store this field. Its absence on an otherwise active delegation is read as granted — no migration or backfill is required for existing delegations to gain this capability.

DelegatedEventSalesRef

Embedded in Organization.delegatedEventSales[] on the partner's organization document — the reverse index a partner reads to discover events delegated to it, without a cross-partition scan:

FieldTypeDescription
delegationIdstringMatches the owner's EventSalesDelegation.id
eventIdstringThe delegated event
ownerOrganizationIdstringLoad-bearing: the only server-side path a partner route uses to resolve the owner's Cosmos partition key without a cross-partition query
ownerOrganizationNamestringSnapshot for display
statusEventSalesDelegationStatusDenormalized copy, kept in sync on every transition
transitionVersionnumber?The owner-side reverseIndexTransitionVersion this projection was derived from

TicketOrder delegated-sales attribution fields

These optional fields are absent on an owner's own direct-sale orders (the default) and set on an order a partner creates via delegation:

FieldTypeDescription
soldByOrganizationIdstring?Live authority. The acting partner organization. Grants the partner ongoing report-sold/report-returned rights on this order and drives the WHERE c.soldByOrganizationId = @partnerOrganizationId filter partners use to list their own orders.
soldByOrganizationNamestring?Server-authoritative display name, stamped immutably at order creation. Names an organization, not a person — never redacted from ordinary owner members.
soldByUserId / soldByUserNamestring?The partner staff member who acted, and a denormalized display name. Names a person at the partner — redacted from ordinary owner members.
delegationIdstring?Back-reference to the EventSalesDelegation.id this order was created under
buyerUserId / buyerUserName / buyerUserEmailstring?The partner-org member the ticket is for. Redacted from ordinary owner members; visible to owner ticketManager/treasurer/admin roles.
delegatedOriginDelegatedOriginAudit?Set only by a transfer that moves this inventory to an owner-org member — see DelegatedOriginAudit. Audit-only; carries no authority.

Authority vs. audit

soldByOrganizationId, soldByUserId, soldByUserName, and delegationId are live selling/write authority — they must never be stamped onto inventory that has been transferred to an owner-org member, because that would keep granting the partner ongoing access to it. A transfer strips these four fields from the destination order and records the historical fact instead in delegatedOrigin (audit-only, read by nothing that grants access).

Privacy and audit boundary

api/src/shared/order-read-projection.ts enforces a deny-list on every order response:

  • Always redacted for ordinary owner members: buyerUserId, buyerUserName, buyerUserEmail, soldByUserId, soldByUserName, delegatedOrigin — this is the partner's (or the delegated buyer's) personal identity.
  • Never redacted: soldByOrganizationId, soldByOrganizationName, delegationId — these name an organization, not a person, so an ordinary owner member does see "Sold by {organization}" attribution.
  • Full visibility: ticketManager, treasurer, and admin roles at the owner organization always receive the unredacted payload (the audit mandate).
  • Self-exempt: a reader who is the buyer-of-record on their own order keeps their own identity fields regardless of role.

No partner staff member's or partner buyer's personal data (name, email) is ever exposed to the partner organization's counterpart, either — the partner-facing order projection returned by the delegated-sales endpoints is a separate, minimal allow-list shape that never includes the owner's internal identifiers.

Reporting channel and settlement responsibility

A delegated order is never an individual owner member's sale. The owner settles it with the partner organization, outside the platform in v1 (#307 Decision 4; #357 D1 keeps reconciliation, invoicing and settlement authority exclusively with the owner). Every owner reporting surface therefore places it in the partner channel:

SurfaceDelegated order
DashboardStats.salesByMemberExcluded
DashboardStats.salesByPartnerOrganizationGrouped by soldByOrganizationId
ShowtimeSales.sold (member channel)Excluded — counted in ShowtimeSales.partnerSold
ShowtimeSales.totalSold / remainingIncluded (a sold ticket occupies a seat regardless of channel)
GET /management/members/{userId}/sales/{eventId}Excluded
PUT /management/members/{userId}/invoice/{eventId}Refused — an owner member invoice is never issued for partner-sold tickets
Settled-order archive prerequisiteNo member-invoice prerequisite (getSettledArchiveBlockers omits invoicePrerequisite); the invalid-basis blocker still applies
CSV/Excel exportOwn SoldBy column / By Partner Organization block

Because owner member invoicing refuses these orders and no organization-level invoice marking exists yet, invoicedAt stays permanently unset on a settled delegated order. Owner surfaces therefore state that the order is settled with the selling partner rather than claiming a member invoice is outstanding.

For the same reason a settled delegated order carries no member-invoice archive prerequisite: invoicedAt could never be set by any route available to an owner, so requiring it forced every partner-sold order down the organization-admin override path forever and excluded it from bulk archive. Such an order archives normally once its frozen invoice basis is valid. The invalidInvoiceBasis blocker is unchanged for both channels — it concerns the correctness of the recorded sold quantities and amounts, which is independent of who sold the ticket.


Next: API Reference · See also: Architecture

Built with VitePress