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 (
organizationIdon Event) - Event → TicketType: One event has many ticket types (
eventIdon TicketType) - Event → TicketOrder: One event has many orders (
eventIdon TicketOrder) - Organization → OrganizationMembership: One organization has many memberships (
organizationIdon OrganizationMembership) - User → OrganizationMembership: One user can belong to many organizations (
userIdon OrganizationMembership) - User → TicketOrder: One user can place many orders (
userIdon TicketOrder) - Event → Allocation: Allocations are per user per event/showtime. Allocation limits are only enforced when
enableQuotasistrueon 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 ───────────────→ CANCELLEDThe 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:
| Container | Partition Key | TTL | Purpose |
|---|---|---|---|
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
venueCapacityis 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 fielditems[]. The actual TypeScript interface and database documents usetickets: TicketOrderItem[]. Useticketsin 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, butstatusremains 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 withgrandTotal: 0;totalAmountis 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 ofinvoicePrerequisite,invalidInvoiceBasis, or both. The evidence remains after unarchive even thougharchivedAtandarchivedByrepresent only the current archive state.TicketOrder.transferHistory— Optional array ofTransferHistoryentries, 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 toawaitingReconciliation. See TransferHistory for the full field list, and Delegated sales for thedelegatedOriginaudit-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. LegacyinvoicedByvalues remain display names and are not reinterpreted as IDs.ExternalSale.tickets— Optional array ofExternalSaleTicketitems 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 contributetotalQuantity. 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 publicTicketTypedocuments. 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, defaultsfalse. Whentrue, 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:
| Field | Type | Description |
|---|---|---|
ticketTypeId | string | Normal ticket type ID or stable external-category line identity |
ticketTypeName | string | Original display label |
quantity | number | Units sold |
unitPrice | number | Price per ticket |
unitFee | number | Fee per ticket |
externalCategory | object? | 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:
| Field | Type | Description |
|---|---|---|
id / version | string / number | Stable identity and monotonic configuration revision |
label | string | Validated external label; canonical duplicate checks are case/whitespace insensitive |
ticketmasterPrice / ticketmasterFee | number | External-channel values; exact zero is valid |
countsTowardCapacity | boolean | Explicit contribution setting; defaults to true |
mappedTicketTypeId | string? | Deprecated legacy linkage. The catalog initializer removes it while preserving the category ID and all Event-local settings. |
| audit fields | string | Creator/updater IDs, names, and timestamps |
AllocationItem
Per-type allocation within an Allocation:
| Field | Type | Description |
|---|---|---|
ticketTypeId | string | Linked ticket type |
ticketTypeName | string | Display name |
allocated | number | Tickets allocated |
used | number | Tickets used |
DashboardStats
Response shape for GET /api/management/dashboard/{eventId}:
| Field | Type | Description |
|---|---|---|
organizationId | string | Organization ID |
eventId | string | Event ID |
eventName | string | Event display name |
venueCapacity | number | Total venue capacity |
totalTicketsSold | number | Sum of all tickets sold |
totalRevenue | number | Sum of all revenue |
totalFee | number | Sum of all fees |
ticketsRemaining | number | Remaining capacity |
activeSellers | number | Members with sales |
salesByMember | MemberSales[] | Per-member breakdown |
salesByTicketType | TicketTypeSales[] | Per-ticket-type breakdown |
salesByShowtime | ShowtimeSales[]? | Per-showtime breakdown |
MemberSales
Member breakdown within DashboardStats:
| Field | Type | Description |
|---|---|---|
userId | string | Member user ID |
userName | string | Display name |
ticketsSold | number | Total tickets sold by member |
totalRevenue | number | Revenue generated |
invoiced | boolean | Legacy projection of invoiceSummary.allEligibleInvoiced |
invoiceSummary | MemberInvoiceSummary | Eligibility, invoice amount, blockers, and state |
breakdown | array | Per-type { ticketType, quantity, revenue } |
MemberInvoiceSummary
| Field | Type | Description |
|---|---|---|
state | string | awaitingSettlement, invalidBasis, readyToInvoice, partiallyInvoiced, or invoiced |
eligibleCount | number | Active registrations plus settled printed orders with a valid basis |
invoicedCount | number | Eligible records with invoice-issued state |
unresolvedOrderCount | number | Active printed orders not yet settled |
invalidBasisOrderCount | number | Settled printed orders whose frozen basis is invalid |
invoiceAmount | number | Registration totals plus valid printed invoiceBasis.grandTotal |
allEligibleInvoiced | boolean | True 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:
| Field | Type | Description |
|---|---|---|
ticketTypeId | string | Ticket type ID |
name | string | Display name |
price | number | Unit price |
sold | number | Units sold |
revenue | number | Total revenue |
ShowtimeSales
Showtime breakdown within DashboardStats:
| Field | Type | Description |
|---|---|---|
showtimeId | string | Showtime ID |
dateTime | string | ISO timestamp |
label | string? | Optional display label |
sold | number | MEMBER channel only: registrations + owner-direct printed orders. Delegated (partner-sold) orders are never counted here — see partnerSold |
partnerSold | number? | PARTNER channel: delegated (soldByOrganizationId) orders reported sold. Disjoint from sold; absent on pre-fix responses (treat as 0) |
externalSold | number | External tickets sold |
totalSold | number | sold + partnerSold + externalSold |
remaining | number | Remaining capacity (every channel, plus free tickets, consumes it) |
revenue | number | Total showtime revenue across every channel |
partnerRevenue | number? | The delegated (partner) component of revenue |
externalBySource | array | { 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 thesold(member) channel above. It is reported inpartnerSoldand insalesByPartnerOrganization.
MyStats
Response shape for GET /api/me/stats:
| Field | Type | Description |
|---|---|---|
totalTicketsSold | number | Total tickets sold by current user |
totalRevenue | number | Total revenue generated |
byShowtime | array | Per-showtime sales breakdown |
byTicketType | array | { ticketTypeId, ticketTypeName, quantity, revenue } |
eventBreakdown | array | { eventId, eventName, ticketsSold, revenue } |
Pledge
Commitment round pledge submitted by a member:
| Field | Type | Description |
|---|---|---|
id | string | Pledge ID |
organizationId | string | Organization ID |
eventId | string | Event ID |
eventName | string | Event display name |
userId | string | Member user ID |
userName | string | Member display name |
userEmail | string | Member email |
sureCount | number | Tickets member is sure to sell |
possibleCount | number | Tickets member might sell |
createdAt | string | ISO timestamp |
updatedAt | string | ISO timestamp |
TransferHistory
Transfer history entry on TicketOrder, appended by order transfers:
| Field | Type | Description |
|---|---|---|
fromUserId | string | Previous owner (or the partner buyer-of-record, if delegatedOrigin is set) |
fromUserName | string | Previous owner display name |
toUserId | string | New owner |
toUserName | string | New owner name |
transferredBy | string | Manager who performed the transfer |
transferredByName | string | Manager display name |
transferredAt | string | ISO timestamp |
reason | string? | Optional reason |
quantity | number | Tickets transferred (always ≤ the outstanding balance at transfer time) |
delegatedOrigin | DelegatedOriginAudit? | Present only when the transferred inventory originated from a partner delegated sale. Audit-only — see DelegatedOriginAudit |
transferRequestId | string | Client-generated UUID identifying one logical transfer intent; doubles as the idempotency key for replay detection |
requestPayloadKey | string | Canonical 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 |
sourceOrderId | string | The order the tickets were transferred out of |
sourceOrderStatusAtTransfer | TicketOrder["status"] | Source order's status snapshot at the moment of transfer (captures whether it flipped to awaitingReconciliation) |
sourceRemainingQuantityAtTransfer | number | Outstanding quantity left on the source order right after this transfer (0 for a full transfer) |
resultOrderId | string | Destination order ID — equals sourceOrderId for a full transfer (reassigned in place); a deterministic ID derived from the transfer request for a partial transfer |
resultOrderStatusAtTransfer | TicketOrder["status"] | Destination order's status snapshot at the moment of transfer |
resultQuantityAtTransfer | number | Quantity 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.
| Field | Type | Description |
|---|---|---|
originSoldByOrganizationId | string | Partner organization that originally made the sale. Audit label only. |
originSoldByUserId | string? | Partner staff member who acted on the original sale |
originSoldByUserName | string? | Denormalized display name |
originDelegationId | string? | The delegation the original sale was made under. Never an authority comparand |
originOrderId | string | The delegated order the inventory was transferred out of |
originBuyerUserId | string? | Immutable original buyer-of-record (owner-audit only) |
originBuyerUserName | string? | Immutable original buyer-of-record display name (owner-audit only) |
originBuyerUserEmail | string? | Immutable original buyer-of-record email (owner-audit only) |
transferredAt | string | ISO timestamp of the transfer that moved this inventory to the owner org |
transferRequestId | string | The 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:
| Field | Type | Description |
|---|---|---|
organizationId | string | Organization ID |
organizationName | string | Organization display name |
role | OrganizationRole | Member's role in the organization |
approved | boolean | Whether 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
| Container | Partition key | TTL | Purpose |
|---|---|---|---|
sharedEvents | /sharedEventId | — | Shared event root documents, participant roster, audit entries, projection sync jobs |
sharedEventCapacityLedger | /sharedEventId | defaultTtl: -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.
Invitation link entity
┌──────────────────────────────────────────────┐
│ 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:trueif 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 invitationusedbut 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):
| Field | Value / shape | Notes |
|---|---|---|
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.sharedEventId | string | Shared event identifier |
meta.sharedEventName | string | Display name of the shared event |
meta.organizerOrganizationName | string | Display 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:
| Method | Route | Auth | Description |
|---|---|---|---|
GET | /api/shared-event-invitations/pending | Required (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/accept | Required (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:
| Field | Type | Description |
|---|---|---|
invitationId | string | Invitation document ID (from the pending-invitations list) |
sharedEventId | string | Shared event ID (partition key for point read) |
organizationId | string | Organization 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.
Local event projection link
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:
| Field | Type | Description |
|---|---|---|
sharedEventId | string | The shared event this projection belongs to |
role | organizer | participant | This organization's role |
capacityPolicy | quota | pool | Inherited from shared event |
participantStatus | ParticipantStatus | Current roster status |
A standalone event (no shared-event link) is unchanged and continues to behave as before.
Capacity counter key scheme
| Mode | Counter key format | Enforcement scope |
|---|---|---|
| Pool | pool:{showtimeId} | All organizations share one counter per showtime |
| Quota | quota:{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 value | Effective mode | Meaning |
|---|---|---|
| Absent | full | Complete product capabilities; this is the canonical representation of full |
"sellingPartner" | sellingPartner | Limited 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:
| Field | Type | Description |
|---|---|---|
organizationModeTransition | OrganizationModeTransition? | One in-progress downgrade marker with id, targetMode, startedAt, expiresAt, and requestedBy |
ownerStateLeases | OrganizationOwnerStateLease[]? | Short-lived writer leases with id, owner capability, startedAt, and expiresAt |
_etag | string | Required 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:
| Field | Type | Description |
|---|---|---|
id | string | UUID identifying this delegation |
partnerOrganizationId | string | The invited partner organization |
partnerOrganizationName | string | Hydrated snapshot; not re-synced on partner rename |
status | EventSalesDelegationStatus | "invited" | "active" | "declined" | "revoked" |
invitedBy / invitedByName | string | Owner admin who sent the invitation |
invitedAt | string | ISO timestamp |
invitationTokenHash | string? | SHA-256 hash of the one-time invitation token; present only while status === "invited". The raw token itself is never persisted. |
invitationExpiresAt | string? | ISO timestamp; 14-day TTL, mirrors shared-event invitation links |
respondedBy / respondedByName / respondedAt | string? | Partner admin who accepted or declined |
revokedBy / revokedByName / revokedAt | string? | Owner admin who revoked, and when |
revokedReason | string? | Mandatory free-text reason captured when revoking |
permissions | EventSalesDelegationPermissions | Fixed, non-configurable permission bundle (see below) |
assignmentVersion | number | Optimistic-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 |
reverseIndexOperationId | string | UUID minted once per transition (invite/accept/revoke), reused across retries of the same transition |
reverseIndexTransitionVersion | number? | Monotonic counter preventing the reverse-index projection from ever moving backwards to an older transition |
reverseIndexSyncedAt | string? | ISO timestamp; set once sync succeeds |
reverseIndexSyncAttempts | number? | 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:
| Field | Type | Description |
|---|---|---|
canCreateSales | true | Partner can create delegated orders for its own approved members |
canReportSoldReturned | true | Partner can report sold/returned quantities on its own delegated orders |
canViewOwnAttributedReports | true | Partner can see its own attributed sales, never the owner's full reports |
canApproveOwnMemberOrders | true? | 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:
| Field | Type | Description |
|---|---|---|
delegationId | string | Matches the owner's EventSalesDelegation.id |
eventId | string | The delegated event |
ownerOrganizationId | string | Load-bearing: the only server-side path a partner route uses to resolve the owner's Cosmos partition key without a cross-partition query |
ownerOrganizationName | string | Snapshot for display |
status | EventSalesDelegationStatus | Denormalized copy, kept in sync on every transition |
transitionVersion | number? | 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:
| Field | Type | Description |
|---|---|---|
soldByOrganizationId | string? | 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. |
soldByOrganizationName | string? | Server-authoritative display name, stamped immutably at order creation. Names an organization, not a person — never redacted from ordinary owner members. |
soldByUserId / soldByUserName | string? | The partner staff member who acted, and a denormalized display name. Names a person at the partner — redacted from ordinary owner members. |
delegationId | string? | Back-reference to the EventSalesDelegation.id this order was created under |
buyerUserId / buyerUserName / buyerUserEmail | string? | The partner-org member the ticket is for. Redacted from ordinary owner members; visible to owner ticketManager/treasurer/admin roles. |
delegatedOrigin | DelegatedOriginAudit? | 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:
| Field | Description |
|---|---|
totals | Order count, ordered, sold, returned, outstanding, grossSoldRevenue, and currency |
byTicketType / byShowtime | Aggregate quantities and gross revenue using order-time price evidence |
byMember | Current-approved-member rows with operational order drill-down; unproven identities collapse into an unattributed row |
lifecycle | Counts for pending, approved, delivered, awaiting reconciliation, settled, and cancelled |
scope | Machine-readable statement of the partner audience, current-delegation order scope, and excluded data |
settlement | States 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:
| Surface | Delegated order |
|---|---|
DashboardStats.salesByMember | Excluded |
DashboardStats.salesByPartnerOrganization | Grouped by soldByOrganizationId |
ShowtimeSales.sold (member channel) | Excluded — counted in ShowtimeSales.partnerSold |
ShowtimeSales.totalSold / remaining | Included (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 prerequisite | No member-invoice prerequisite (getSettledArchiveBlockers omits invoicePrerequisite); the invalid-basis blocker still applies |
| CSV/Excel export | Own 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