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/id—User accounts
organizations/id—Organization entities
organizationMemberships/organizationId—User–organization relationships with roles
events/organizationId—Events, showtimes, ticket types
orders/organizationId—Ticket orders (approval workflow)
ticketOrders/organizationId—Ticket orders (request/approval workflow)
allocations/organizationId—Per-member ticket allocations
registrations/organizationId—Direct-sale registrations
externalSales/organizationId—Ticketmaster / box-office sales
freeTickets/organizationId—Complimentary tickets
invitations/organizationId—Invitation codes
rateLimits/id✅Rate-limit counters (auto-expire)
authTokens/id✅Magic-link tokens (auto-expire)
impersonationAudits/organizationId—Impersonation session logs
notifications/organizationId✅In-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.
  • Capacity consumed uses the same sources except that external lines use their explicit capacity contribution (legacy fallback: full external quantity).
  • Remaining per showtime = max(0, venueCapacity − capacity consumed).
  • 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) and an immutable Ticketmaster-only category snapshot when applicable.
  • ExternalSale.capacityQuantity — Explicit capacity contribution for modern rows. Legacy rows without it continue to contribute totalQuantity. Reported quantity (totalQuantity) and capacity contribution remain separate aggregates.
  • Event.ticketmasterCustomCategories — Participant-local, event-scoped reusable external categories. They are embedded configuration only and never become public TicketType documents. Deletion hard-removes the selectable configuration and reclaims the 50-item limit; immutable sale snapshots preserve history. Shared Event projection replacement preserves the local array and never copies it between organizations.
  • Event.ticketmasterCatalogMigration — Versioned, Event-local completion marker for the one-time conversion of legacy Ticketmaster pricing into independent categories. It records the selected source generation and bounded opaque SHA-256 source aliases. The marker, categories, and any historical pricing locks are written atomically under the Event ETag.
  • 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 — Deprecated legacy Ticketmaster overrides. They are consulted only by the one-time catalog initializer when no mapped Event category generation exists.

Supporting Types ​

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

ExternalSaleTicket ​

Per-type breakdown on an ExternalSale:

FieldTypeDescription
ticketTypeIdstringNormal ticket type ID or stable external-category line identity
ticketTypeNamestringOriginal display label
quantitynumberUnits sold
unitPricenumberPrice per ticket
unitFeenumberFee per ticket
externalCategoryobject?Original custom/ad-hoc label, reusable category ID/version, optional mapped ticket type snapshot, and countsTowardCapacity. Reusable sale requests must submit the exact selected version; persisted snapshots are immutable.

Optional mapping is report attribution, not inventory. It contributes the line quantity once to the mapped Ticketmaster column for the normal ticket type, while event sales use totalQuantity once and capacity uses capacityQuantity once. Aggregate-only corrections mark line allocation unavailable rather than rewriting or fabricating snapshot quantities.

TicketmasterCustomCategory ​

Reusable Ticketmaster-only configuration embedded on an Event:

FieldTypeDescription
id / versionstring / numberStable identity and monotonic configuration revision
labelstringValidated external label; canonical duplicate checks are case/whitespace insensitive
ticketmasterPrice / ticketmasterFeenumberExternal-channel values; exact zero is valid
countsTowardCapacitybooleanExplicit contribution setting; defaults to true
mappedTicketTypeIdstring?Deprecated legacy linkage. The catalog initializer removes it while preserving the category ID and all Event-local settings.
audit fieldsstringCreator/updater IDs, names, and timestamps

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/sharedEventId—Shared 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.


Organization capability mode ​

Organization.organizationMode is optional:

Stored valueEffective modeMeaning
AbsentfullComplete product capabilities; this is the canonical representation of full
"sellingPartner"sellingPartnerLimited organization that operates delegated sales but owns no events and does not participate in Shared Events

The mode reuses the existing Organization document, memberships, roles, settings, notifications, and delegatedEventSales[] reverse index. It does not introduce a separate organization type or container.

Mode-change coordination also lives on the organization document:

FieldTypeDescription
organizationModeTransitionOrganizationModeTransition?One in-progress downgrade marker with id, targetMode, startedAt, expiresAt, and requestedBy
ownerStateLeasesOrganizationOwnerStateLease[]?Short-lived writer leases with id, owner capability, startedAt, and expiresAt
_etagstringRequired condition for exact stale-operation recovery

A full → sellingPartner downgrade first creates the transition marker, refuses new owner-state leases, verifies owned events, Shared Event participation, and outbound active or invited delegations, then completes through ETag-conditional replacement. Owner-state writers acquire a lease before creating incompatible state. Expiry marks an operation as eligible for operator review; it does not remove the operation automatically.

Explicit recovery removes only an exact stale transition or lease whose kind, ID, and ETag still match. Recovery does not change the mode and does not skip dependency checks. The operator reruns the downgrade after recovery.

Cross-document coordination

Mode transitions and delegated reverse-index synchronization use authoritative reads, ETag conditions, leases, retry, and repair across existing documents. They are not cross-container transactions and do not claim cross-container atomicity.

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 through retryable two-phase synchronization and repair. 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.

Partner sales summary projection ​

DelegatedPartnerSalesSummary is a response projection, not a stored document. The server builds it from authoritative orders in the owner's ticketOrders partition after resolving the current active delegation:

FieldDescription
totalsOrder count, ordered, sold, returned, outstanding, grossSoldRevenue, and currency
byTicketType / byShowtimeAggregate quantities and gross revenue using order-time price evidence
byMemberCurrent-approved-member rows with operational order drill-down; unproven identities collapse into an unattributed row
lifecycleCounts for pending, approved, delivered, awaiting reconciliation, settled, and cancelled
scopeMachine-readable statement of the partner audience, current-delegation order scope, and excluded data
settlementStates partnerOrganizationOffPlatform and netSettlementAvailable: false

The projection excludes owner-direct sales, other partners, external sales, owner capacity, contact details, notes, invoice state, organizer controls, Cosmos metadata, and continuation tokens. Cancelled orders remain in lifecycle counts but contribute no sold, returned, outstanding, or gross-revenue totals.

Reporting channel and settlement responsibility ​

A delegated order is never an individual owner member's sale. The owner remains responsible for any settlement with the partner organization outside the platform in v1 (#307 Decision 4; #357 D1 keeps reconciliation and settlement authority with the owner). The platform stores no compensation terms, computes no net settlement, issues no partner invoice, and tracks no settlement payment. Every owner reporting surface therefore places the order 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