Architecture
System Overview
┌─────────────────────────────────────────────────────────────┐
│ Users (Members/Admins) │
└────────────┬─────────────────────────────────┬──────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Mobile (PWA) │ │ Web Browser │
│ iOS/Android │ │ Desktop/Mobile │
└────────┬────────┘ └────────┬────────┘
│ │
└────────────┬────────────────────┘
▼
┌─────────────────────────┐
│ Azure Static Web App │
│ (Frontend: Vue 3 PWA) │
└────────────┬────────────┘
│ HTTP/REST
▼
┌─────────────────────────┐
│ Azure Functions │
│ (API: TypeScript) │
│ - Authentication │
│ - Business Logic │
│ - Rate Limiting │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Azure Cosmos DB │
│ (NoSQL Database) │
│ - Users & Organizations│
│ - Events & Orders │
│ - Allocations & Sales │
└─────────────────────────┘
External Services:
┌──────────────┐
│ Resend │ ← Magic Link Emails (Optional email service)
└──────────────┘Production observability
Azure Functions sends host-native backend telemetry to workspace-based Application Insights and Log Analytics. The frontend doesn't load a separate telemetry SDK. This keeps the beta footprint small and focuses monitoring on API failures, authentication, invitations, order transitions, and capacity invariants.
The production baseline includes:
- Sampling for routine host telemetry, limited to five items per second per function instance
- Warning and error events with stable names, severity, and request-scoped correlation
- 31-day effective retention for Application Insights telemetry in the linked Log Analytics workspace
- A 100 MB/day Application Insights ingestion cap with an early warning at 80 MB
- Portal-only alerts for failed requests, exception spikes, failed dependencies, and high ingestion
- Best-effort telemetry that never interrupts ticket or authentication workflows
The Application Insights resource sets RetentionInDays to 30 because the component API accepts fixed values. This setting does not create a separate effective retention period for workspace-based telemetry. The linked workspace's 31-day setting governs stored data.
Privacy boundaries are part of the telemetry contract. Don't log raw magic links, codes, email addresses, authorization headers, request bodies, full stable identifiers, or customer-level data from another organization. Structured failure events mask personal data and shorten identifiers used for diagnosis. Application Insights masks the platform-collected client IP field, but that masking does not redact values written into custom trace messages. Current custom traces can therefore contain client IP data and require the same access and retention controls as other personal data. The platform also disables snapshot collection, SQL command text, and extended HTTP trigger details.
Tech Stack
| Layer | Technology |
|---|---|
| Frontend | Vue 3 + Vite + Tailwind CSS + PWA + vue-i18n |
| Backend | Azure Functions (Node.js / TypeScript) |
| Database | Azure Cosmos DB (Free tier) |
| Hosting | Azure Static Web Apps (Free tier) |
| Infrastructure | Bicep (Infrastructure as Code) |
| Monitoring | Application Insights + Log Analytics |
Project Structure
choir-tickets/
├── api/ # Azure Functions backend
│ ├── src/
│ │ ├── functions/ # Function endpoints
│ │ │ ├── auth.ts # Authentication (magic link)
│ │ │ ├── choirs.ts # Organization CRUD (super admin)
│ │ │ ├── users.ts # User management (super admin)
│ │ │ ├── events.ts # Event CRUD + showtimes
│ │ │ ├── orders.ts # Ticket order workflow
│ │ │ ├── allocations.ts # Ticket allocations per member
│ │ │ ├── registrations.ts# Legacy registrations + history
│ │ │ ├── external-sales.ts # Ticketmaster/external sales
│ │ │ ├── reports.ts # Reporting endpoints
│ │ │ ├── impersonation.ts# User impersonation with audit
│ │ │ └── admin-dashboard.ts # Dashboard + exports
│ │ └── shared/ # Shared utilities
│ │ ├── auth.ts # Auth helpers + impersonation
│ │ ├── database.ts # Cosmos DB client
│ │ ├── email.ts # Email sending (Resend)
│ │ └── types.ts # TypeScript types
│ ├── scripts/
│ │ ├── seed-admin.ts # Create initial super admin
│ │ └── setup-database.ts # Create containers with TTL
│ ├── tests/ # Vitest API tests
│ ├── host.json
│ ├── local.settings.json
│ └── package.json
│
├── frontend/ # Vue 3 PWA
│ ├── src/
│ │ ├── assets/ # CSS, images
│ │ ├── components/ # Vue components
│ │ │ ├── Accordion.vue # Collapsible sections container
│ │ │ ├── AccordionItem.vue # Individual collapsible section
│ │ │ ├── AppLayout.vue # Main navigation layout
│ │ │ ├── BottomSheet.vue # Mobile-friendly modal (drag-to-close)
│ │ │ ├── OrganizationSelector.vue # Multi-organization selector
│ │ │ ├── ConfirmDialog.vue # Confirmation dialog
│ │ │ ├── DataTable.vue # Responsive table with card layout
│ │ │ ├── ErrorBoundary.vue # Error boundary wrapper
│ │ │ ├── ImpersonationBanner.vue # Impersonation indicator
│ │ │ ├── Tabs.vue # Tab container (lazy, ARIA, keyboard)
│ │ │ └── ToastContainer.vue # Toast notifications
│ │ ├── composables/ # Vue composables
│ │ │ ├── useApi.ts # API client
│ │ │ └── useTheme.ts # Theme management
│ │ ├── i18n/ # Internationalization
│ │ │ ├── en.ts # English translations
│ │ │ ├── no.ts # Norwegian translations
│ │ │ └── index.ts # i18n setup
│ │ ├── stores/ # Pinia stores
│ │ │ └── auth.ts # Auth state + impersonation
│ │ ├── views/ # Page components
│ │ │ ├── admin/ # Admin/Manager pages
│ │ │ │ ├── DashboardView.vue
│ │ │ │ ├── EventsView.vue
│ │ │ │ ├── EventFormView.vue
│ │ │ │ ├── MembersView.vue
│ │ │ │ ├── MemberSalesView.vue # Individual member sales
│ │ │ │ ├── OrdersView.vue # Order management
│ │ │ │ ├── AllocationsView.vue # Allocation management
│ │ │ │ ├── TicketmasterView.vue # External sales
│ │ │ │ └── ReportsView.vue
│ │ │ ├── super-admin/ # Super admin pages
│ │ │ │ ├── OrganizationsView.vue
│ │ │ │ ├── OrganizationDetailView.vue
│ │ │ │ └── UsersView.vue
│ │ │ ├── HomeView.vue
│ │ │ ├── OrderTicketsView.vue # Member ticket ordering
│ │ │ ├── HistoryView.vue
│ │ │ ├── JoinView.vue # Invitation acceptance
│ │ │ ├── LoginView.vue
│ │ │ └── VerifyView.vue
│ │ ├── router/ # Vue Router
│ │ ├── App.vue
│ │ └── main.ts
│ ├── e2e/ # Playwright e2e tests
│ ├── index.html
│ ├── vite.config.ts
│ └── package.json
│
├── infra/ # Infrastructure as Code
│ ├── main.bicep # Main Bicep template
│ └── main.bicepparam # Parameters
│
├── staticwebapp.config.json # SWA configuration
└── README.mdShared Component Library
The frontend uses a custom shared component library built with pure Tailwind CSS (no external component library). Dark mode is supported via the class strategy. All components are in frontend/src/components/.
| Component | Description |
|---|---|
| Tabs.vue | Reusable tab container with lazy slot rendering, ARIA roles (tablist, tab, tabpanel), keyboard navigation (arrow keys), and optional badge counts per tab |
| Accordion.vue | Collapsible sections container with smooth CSS transitions. Pairs with AccordionItem.vue |
| AccordionItem.vue | Individual collapsible section with title, error badge support for validation feedback, and animated expand/collapse |
| DataTable.vue | Responsive table with built-in search, sorting, pagination, and configurable columns. Switches to card layout on mobile |
| BottomSheet.vue | Mobile-friendly modal with drag-to-close gesture support. Used for forms and detail views on small screens |
| ConfirmDialog.vue | Reusable confirmation dialog replacing native confirm() |
| ToastContainer.vue | Toast notification system replacing native alert() |
| OrganizationSelector.vue | Multi-organization selector dropdown for users belonging to multiple organizations |
| ErrorBoundary.vue | Error boundary wrapper that catches and displays component-level errors gracefully |
| ImpersonationBanner.vue | Red banner indicating active user impersonation session |
Design pattern: Components use pure Tailwind utility classes with no third-party UI framework. Dark mode is implemented via the Tailwind class strategy (dark: variants), toggled at the document root.
PWA Update Strategy
The service worker is configured with registerType: 'prompt' (not autoUpdate). This means:
- The app polls for a new service worker every 60 seconds using
useRegisterSW({ onRegisteredSW }) - When a new version is available, the user sees a "New version available" toast notification
- The user clicks the toast to trigger the update — no silent background reload
- iOS Safari fallback: After accepting the update, if
controllerchangedoesn't fire within 1 second (a known iOS Safari limitation),location.reload()is called to force the page to pick up the new service worker
This approach prevents users from being interrupted mid-workflow by unexpected reloads.
Project Governance & Orchestration
The repository includes two framework directories used for project governance and coordination:
.specify/— Project governance framework defining architectural principles (constitution) and feature specification templates. Specs live inspecs/and follow templates defined here..squad/— Multi-agent work orchestration system with specialized agents for frontend, backend, infra, QA, and documentation. Defines roles, workflows, and sprint coordination.
These are optional for contributors but are referenced in PR review checklists and help maintain consistency across the project.
Key Concepts
Two Ticketing Workflows
The app supports two distinct workflows for different use cases:
Order Workflow (Member-Initiated, Approval Required):
- Members request tickets → Ticket Manager approves → Marks as delivered
- Status flow: PENDING → APPROVED → DELIVERED (or CANCELLED)
- Use case: Fair distribution with oversight
- Features: Order history, admin notes, transfer between showtimes
Registration Workflow (Admin-Initiated, Direct Sale):
- Admin/Ticket Manager creates registration directly (no approval needed)
- 1-hour grace period for edits/deletions
- Use case: Walk-up sales, box office transactions
- Simpler and faster for point-of-sale scenarios
When to use which workflow?
| Scenario | Recommended Workflow |
|---|---|
| Members requesting tickets for themselves | Orders (approval required) |
| Walk-up sales at event | Registrations (direct sale) |
| Box office transactions | Registrations (direct sale) |
| Pre-event ticket allocation | Orders (with allocations) |
| VIP/Sponsor complimentary tickets | Free Tickets |
| External platform sales (Ticketmaster) | External Sales tracking |
Capacity Tracking
The system tracks venue capacity across multiple dimensions:
- Member Sales: Orders (approved/delivered) + Registrations
- External Sales: Tickets sold via Ticketmaster, box office, etc.
- Free Tickets: Complimentary tickets for VIPs, sponsors
- Total Sold: Member Sales + External Sales + Free Tickets
- Remaining: Venue Capacity - Total Sold
All tracked per showtime with real-time updates.
Invitation System
Grow your organization membership through shareable invitation codes:
Admin creates invitation with optional:
- Usage limit (e.g., 50 uses)
- Expiry date
- Default role for new members
Share link:
https://your-app.com/join/{code}New members:
- Click link → Enter details → Accept invitation
- Account auto-created and linked to organization
- Login email sent automatically
Admin can revoke invitations anytime
Grace Period Protection
To prevent accidental data loss while allowing quick corrections:
- Registrations: 1-hour grace period for edits/deletions
- After grace period: Locked to protect historical data
- Orders: No grace period (tracked through status changes)
This ensures financial records remain accurate while providing flexibility for mistakes.
Application Flows
Authentication Flow
Magic Link Flow:
- User enters email at
/login - System sends email with magic link + 6-digit verification code
- User can either:
- Click magic link → Auto-verify → Redirect to app
- Enter 6-digit code manually → Verify → Redirect to app
- JWT session token issued (stored in localStorage)
- Token expires based on
JWT_EXPIRYsetting
- User enters email at
Security Features:
- Magic links expire after 30 minutes
- Verification codes expire after 30 minutes
- Links and codes are one-time credentials
- User lookup completes before the credential is consumed, and an ETag-guarded write prevents concurrent double use
- Rate limiting: 5 attempts per IP/email per 15 minutes
- Generic responses (no email enumeration)
Member Onboarding Flow
Via Invitation Code:
- Admin creates invitation code with optional usage limit and expiry
- Share invitation link:
https://app.com/join/{code} - New user clicks link → Enters email and name → Accepts invitation
- Account created and linked to organization with specified role
- Login email sent automatically
Via Admin Import:
- Admin uploads CSV with email, name, role
- Accounts created in batch
- Optional: Invitation emails sent to new members
Ticket Ordering Flow (Approval Workflow)
Member Creates Order:
- Browse active events on home page
- Select event and showtime
- Choose ticket types and quantities
- Add optional personal note
- Continue to the review summary and add the selection to the cart
- See a persistent confirmation, then open the cart or add more tickets
- Submit the cart (status: PENDING, one order per event)
Ticket Manager Approves:
- View pending orders oldest first, with localized submission date and time
- Filter active operational queues by event and partial member name
- Review order details and member history
- Approve or reject with optional admin note
- Status changes to APPROVED or CANCELLED
Delivery:
- Ticket Manager marks order as DELIVERED when tickets given to member
- Member sees DELIVERED status in their history
Edge Cases:
- Members can cancel Pending orders; manager actions follow the lifecycle guards
- Approved/delivered orders can be transferred between showtimes
- Archiving is an admin-only presentation flag and doesn't change lifecycle status, reconciliation data, or actions
- See Direct Sales Flow below for registration-specific behavior
Direct Sales Flow (Registrations)
Admin/Ticket Manager:
- Navigate to event dashboard
- Create registration directly (bypasses approval)
- Select member, showtime, ticket types
- Registration created immediately
Grace Period:
- 1-hour window to edit or delete registration
- After 1 hour, registration is locked (prevents accidental deletion)
External Sales Tracking
Ticket Manager logs external sales:
- Navigate to Ticketmaster/External Sales view
- Select event and showtime
- Enter source (e.g., "Ticketmaster", "Box Office")
- Add ticket types and quantities
- Optional: Add fees (billettgebyr)
Capacity Tracking:
- Dashboard shows: Member Sales + External Sales + Free Tickets = Total Sold
- Remaining = Venue Capacity - Total Sold
Allocation Management
Admin sets allocations:
- Navigate to Allocations view
- Select member, event, showtime
- Set ticket limits per ticket type
- Member sees allocation limits when ordering
Member views allocations:
- Personal dashboard shows allocated tickets
- Allocation limits displayed during ordering
Free Tickets Management
Admin creates free tickets:
- Navigate to event management
- Add complimentary tickets for VIPs, sponsors, etc.
- Select showtime and ticket types
- Free tickets count toward capacity
Capacity aware:
- Free tickets reduce available capacity
- Dashboard shows free ticket breakdown
Impersonation Flow (Super Admin)
Super Admin impersonates user:
- Navigate to Users view
- Click "Impersonate" on target user
- System logs: adminUserId, targetUserId, timestamp, action: 'start'
- Red banner appears: "Viewing as [User Name]"
- All actions logged to impersonation audit trail
End impersonation:
- Click "Stop Impersonating" in banner
- System logs: action: 'stop', duration
- Return to super admin view
Audit trail:
- All impersonation sessions logged with duration
- Admins can review impersonation history
Multi-Organization Management
Super Admin creates organization:
- Navigate to Organizations view → Create Organization
- Enter organization name and details
- System creates organization entity
Assign members to organization:
- Import via CSV or add individually
- Set role per member (member, ticketManager, treasurer, admin)
Member in multiple organizations:
- Organization selector appears in navigation
- Switch between organizations
- All data (events, orders, reports) scoped to selected organization
Reporting and Export
View Reports:
- Navigate to Reports view (Treasurer+)
- Select event and date range
- Filter by specific member using the member dropdown
- View financial summary and breakdowns
Export Data:
- Dashboard → Export Excel (multi-language support)
- Reports → Export CSV
- Data includes: member sales, external sales, free tickets, revenue
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. An oversold showtime doesn't borrow capacity from another showtime.
User Roles
| Role | Permissions |
|---|---|
member | Order tickets, view own history and allocations |
ticketManager | All member permissions + manage orders, allocations, external sales |
treasurer | All ticket manager permissions + access reports and exports |
admin | All treasurer permissions + manage events, members, dashboard |
super admin | All admin permissions + create/manage organizations, manage all users, impersonate users |
Role Matrix
| Capability | Member | Ticket Manager | Treasurer | Admin | Super Admin |
|---|---|---|---|---|---|
| Order tickets | ✅ | ✅ | ✅ | ✅ | ✅ |
| View own history | ✅ | ✅ | ✅ | ✅ | ✅ |
| Manage orders | ❌ | ✅ | ✅ | ✅ | ✅ |
| Manage allocations | ❌ | ✅ | ✅ | ✅ | ✅ |
| Log external sales | ❌ | ✅ | ✅ | ✅ | ✅ |
| View reports | ❌ | ❌ | ✅ | ✅ | ✅ |
| Export reports | ❌ | ❌ | ✅ | ✅ | ✅ |
| View dashboard | ❌ | ❌ | ❌ | ✅ | ✅ |
| Manage events | ❌ | ❌ | ❌ | ✅ | ✅ |
| Manage members | ❌ | ❌ | ❌ | ✅ | ✅ |
| Manage all organizations | ❌ | ❌ | ❌ | ❌ | ✅ |
| Impersonate users | ❌ | ❌ | ❌ | ❌ | ✅ |
Navigation Structure
| Section | Role Required | Pages (role-filtered) |
|---|---|---|
| Member | All users | Home, Order Tickets, My History |
| Management | ticketManager+ | Orders, Commitments, Ticketmaster, Allocations, Reports, Dashboard, Events, Members |
| Super Admin | super admin | Organizations, All Users |
Note: The Management section consolidates the former Ticket Manager, Treasurer, and Admin sections. Items are role-filtered — each user sees only the pages their role permits (e.g., a ticketManager sees Orders, Commitments, Ticketmaster, and Allocations but not Reports or Dashboard).
Security
Authentication Security
- Magic Link + Verification Code — No passwords stored; login via email link or 6-digit code
- Dual Authentication Methods — Users can click the magic link in email OR enter the verification code for easier mobile access
- Generic Responses — Login flow returns same message whether email exists or not (prevents email enumeration)
- Token expiry: Magic links and verification codes expire after 30 minutes
- JWT Sessions — Stateless session tokens with configurable expiry
Rate Limiting
Rate limiting is implemented using Cosmos DB with TTL for automatic cleanup:
| Limit Type | Threshold | Window | Behavior |
|---|---|---|---|
| Per IP | 5 | 15 minutes | HTTP 429 with Retry-After |
| Per Email | 5 | 15 minutes | Silent (returns generic success) |
Why Cosmos DB for rate limiting?
- Azure Static Web Apps / Functions are serverless — in-memory rate limiting doesn't work across instances
- TTL auto-deletes expired entries (no RU cost for cleanup)
- Cost: ~2-3 RUs per rate-limited request
- Rate-limited spam requests don't query the users container — saves RUs
Shared events
The shared events module adds a coordination layer above the single-organization event model. It is strictly additive: all existing entities, containers, sales flows, reports, and dashboards are unchanged when no shared event is involved.
Design: coordinator plus projections
The architecture uses a coordinator-plus-projections model with a centralized capacity ledger service:
┌─────────────────────────────────────────────────────────────┐
│ SharedEvent (coordinator) │
│ One per concert — stores identity, capacity policy, roster, │
│ permissions, audit trail, and external channel assignments │
└──────────┬──────────────────────────────────┬───────────────┘
│ │
┌─────▼──────┐ ┌──────▼─────┐
│ Event │ │ Event │ (per-org local projections)
│ (org A) │ │ (org B) │
│ ← existing │ │ ← existing │
│ pipeline │ │ pipeline │
└─────┬──────┘ └──────┬─────┘
│ │
└──────────────┬───────────────────┘
│
┌───────────────▼────────────────────┐
│ sharedEventCapacityLedger │
│ Centralized capacity counters and │
│ reservations — enforced per │
│ showtime in both quota and pool │
│ modes via transactional batches │
└────────────────────────────────────┘Two new Cosmos DB containers
| Container | Purpose |
|---|---|
sharedEvents (partition /sharedEventId) | Coordinator root, participant roster, audit log, projection sync jobs, capacity guards, capacity fence |
sharedEventCapacityLedger (partition /sharedEventId, defaultTtl -1) | Capacity counters, reservations (with expiry TTL), adjustments, capacity fence document |
The total container count after adding these two is 21 of 25 (4 headroom within the free-tier limit).
Capacity service
All capacity-affecting operations in both quota and pool modes go through the centralized shared-event-capacity.ts service. It uses Cosmos PatchOperation.incr() with server-side condition filters inside transactional batches — not ETag read-modify-write — for atomicity under concurrent load.
Reserve flow (pool mode):
reserve batch (atomic):
ops[0] — CapacityFenceDoc: Create ifNoneMatch:* (init) or Replace ifMatch:_etag (validate state)
ops[1] — CapacityCounter: patch-incr reserved with condition reserved+committed+qty <= capacity
ops[2] — CapacityReservation: Create ifNoneMatch:* (idempotency guard)Commit / release flow:
- Commit: patch-incr
−reserved,+committed; patch reservationheld → committed. - Release: patch-incr
−reserved; patch reservationheld → releasedwith terminal TTL.
Counter key scheme:
- Pool mode:
pool:{showtimeId}— one shared counter per showtime. - Quota mode:
quota:{organizationId}:{showtimeId}— one counter per org per showtime.
Cancellation saga
The cancellation saga transitions open|locked → cancelling → cancelled with guaranteed linearization via the permanent CapacityFenceDoc:
acquireCancellationFence— fence transitions to"cancelling"(linearization point; concurrent reserve batches fail their fence check).- Root document transitions to
cancelling(ETag-guarded;revertCancellationFencerecovers a failed CAS). - Capacity guard acquired (TTL-bounded exclusive write lock).
- Participant projections locked.
- All held reservations drained (page from
nullcontinuation each time to avoid mutation-skip). - Fence transitions to
"cancelled"(permanent; never expires). - Root document transitions to
cancelled+ audit entry.
Cancellation does not refund committed sales. Committed sales are preserved exactly as recorded. The app has no customer refund workflow in v1. See guide/shared-event-organizer.md for the no-refund semantics.
Capacity-policy transitions
Transitions between quota and pool mode use a freeze-then-apply guard (CapacityGuardDoc with TTL) that serializes the transition with concurrent purchases:
- While the guard is held,
reserveCapacitycalls return a retryable 503. - The guard expires automatically if the process crashes, preventing permanent lockout.
- Two concurrent transitions cannot both proceed (ETag conflict).
Deterministic projection IDs
When a participant accepts, the projection sync job creates a local Event document with a deterministic ID: Event.id == sharedEventId (org-partitioned). This ensures that interrupted acceptance sagas are idempotent on retry.
Absolute reconciliation
The capacity service can reconcile the shared pool counter against authoritative per-organization sales records using runAbsoluteReconciliation. Reconciliation always favors the per-org sales records (the source of truth for money taken) and corrects the ledger toward them. It never uses Change Feed; it queries sales directly.
External sales channels
Channel assignments are stored on the SharedEvent root. The connectionRef field, when present, is an Azure Key Vault secret URI reference — raw credentials are never stored in Cosmos. Channel assignment changes are audited.
Billetto runtime deferred to #242. OAuth credential resolver, webhook ingestion, attendee/refund/cancellation sync, and provider runtime operations are not yet implemented. Manual Ticketmaster entries are fully operational.
New API routes (shared events module)
The shared events module adds the following Azure Function routes under api/src/functions/:
| Module | Routes |
|---|---|
shared-events.ts | GET/POST /api/shared-events; GET/PUT/DELETE /api/shared-events/{id}; roster lifecycle, capacity policy, transitions, reporting; organization search (super-admin only) |
shared-events.ts | POST /api/shared-events/{id}/invitation-links — generate link (owner or manageParticipants; rate-limited) |
shared-events.ts | GET /api/shared-events/{id}/invitation-links — list links; returns metadata only, never the token or its hash |
shared-events.ts | DELETE /api/shared-events/{id}/invitation-links/{linkId} — revoke link (owner or manageParticipants) |
shared-events.ts | POST /api/shared-event-invitations/accept — accept link (authenticated admin; token in request body; rate-limited) |
shared-events.ts | GET /api/shared-event-invitations/preview — preview event and organizer name for a link (?token=...) |
shared-events.ts | GET /api/shared-event-invitations/pending — list active invitations for the authenticated user's admin orgs (no token required; D16) |
shared-events.ts | POST /api/shared-event-invitations/pending/accept — accept via in-app path using invitation document ID (token-free; same ETag linearization; D16) |
shared-event-channels.ts | GET/PUT/DELETE /api/shared-events/{id}/external-sales-channels/{source} |
shared-event-maintenance.ts | POST /api/management/shared-event-maintenance (superAdmin) — full maintenance pipeline; opportunistic expiry from capacity mutations |
Invitation links
Invitation links provide a consent-based path for adding organizations to a shared event without requiring the organizer to search the full platform organization registry. Organization search is restricted to platform super admins.
- Token generation:
crypto.randomBytes(32)→ base64url raw token (43 chars). The document stores only the SHA-256 hash; the raw token is returned once and never retained. - Storage: invitation link documents are stored in the
sharedEventscontainer under the same partition key (/sharedEventId) as the shared event root. No new container is required. - Rate limiting: link generation and acceptance are rate-limited per the
invitationaction class (10 operations per 15-minute window per shared event). - Acceptance atomicity: ETag-conditional replace marks the invitation
usedand adds the organization to the roster. If two callers attempt simultaneous acceptance, exactly one wins; the other receives a generic 404. - Acceptance recovery:
usedByOrganizationIdon the invitation document is a durable recovery marker. If the acceptance saga is interrupted after markingusedbut before the roster update, a retry detects the marker and completes the roster step idempotently. - Generic responses: all error paths (expired, used, revoked, invalid token, wrong event state) return the same
404 { success: false, error: "Invitation link is not available" }with 50–200 ms random jitter to prevent timing-based enumeration. - No email persistence: if the organizer provides an email address, the platform attempts delivery and records only
emailDeliveryAttempted: booleanon the invitation document. The email address itself is never stored or logged. - Audit redaction: token hash, raw token, and email address are never included in audit entries or server logs; only the document ID (derived from the hash prefix) is recorded.
In-app invitation notifications (D16)
When a shared-event invitation link is created with an optional email address, the system performs a best-effort asynchronous side-effect to check whether that email belongs to a known, approved organization admin (via isKnownOrgAdmin). If a match is found, the system fans out one in-app notification to every organization partition the matched user currently administers.
Key design points:
- Fire-and-forget: fan-out is started after the invitation link creation response is composed. Any failure (Cosmos error, timeout, partial fan-out) does not affect the invitation link or the response returned to the organizer.
- Anti-oracle: the organizer receives an identical response whether or not a match was found, and whether or not a notification was created, read, or acted upon.
- Token-free action URL: the notification's
actionUrlis/shared-events/pending-invitations— a session-authenticated page that queries live invitation state. No invitation token or hash is embedded in the notification document. - Privacy: the notification document never contains the recipient's email address, raw token, token hash, or invitation document ID. Only bounded system identifiers are stored:
sharedEventId,sharedEventName,organizerOrganizationName. - Dedup: each notification uses a deterministic document ID derived from
{invitationDocId}:{recipientUserId}:{orgId}. Concurrent or repeated fan-out attempts for the same triple are silently idempotent (Cosmos 409 = success). - No new containers: notifications use the existing
notificationscontainer partitioned by/organizationId. - TTL: bounded by the invitation's remaining lifetime (up to 15 days). Stale notifications (invitation used, revoked, or expired) lead to an empty pending-invitations page — not an error.
- Recipient binding: the
GET /api/shared-event-invitations/pendingendpoint queries invitations whoserecipientUserIdmatches the authenticated session user. ThePOST .../acceptendpoint verifies the same binding before any domain checks, returning the same generic "link unavailable" response on mismatch.
Backward compatibility
The module is deployed behind a feature flag. With the flag off, all existing event, order, registration, allocation, free-ticket, external-sale, report, and dashboard flows are completely unchanged. A standalone event (no sharedEventLink) behaves exactly as before.
Next: Data Model · See also: API Reference · Environment Variables