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.
- Remaining per showtime =
max(0, venueCapacity − sold count). - Aggregate capacity =
venueCapacity × showtime count. - Aggregate remaining is the sum of each showtime's remaining seats. Capacity is never borrowed between showtimes.
Notable Field Notes
TicketOrder.tickets— The diagram labels this 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).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— Optional overrides used when tickets are sold through Ticketmaster.
Supporting Types
These interfaces appear in API responses or as nested objects within core entities.
ExternalSaleTicket
Per-type breakdown on an ExternalSale:
| Field | Type | Description |
|---|---|---|
ticketTypeId | string | Linked ticket type |
ticketTypeName | string | Display name |
quantity | number | Units sold |
unitPrice | number | Price per ticket |
unitFee | number | Fee per ticket |
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.
Delegated sales (selling partners)
The delegated-sales module lets an event owner grant a scoped, self-service selling capability to another organization for a normal (non-Shared) event, without any co-ownership or shared-management authority. EventSalesDelegation is embedded on the owner's Event document and is the sole authorization source of truth; DelegatedEventSalesRef is a denormalized, eventually-consistent reverse-index projection embedded on the partner's own Organization document, kept in sync via a two-phase commit protocol. No new Cosmos containers are introduced — both types live on existing events and organizations documents.
EventSalesDelegation
Embedded in Event.salesDelegations[] on the owner's event document:
| 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.
Reporting channel and settlement responsibility
A delegated order is never an individual owner member's sale. The owner settles it with the partner organization, outside the platform in v1 (#307 Decision 4; #357 D1 keeps reconciliation, invoicing and settlement authority exclusively with the owner). Every owner reporting surface therefore places it in the partner channel:
| 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