Panic Backstage is a venue operating system for the full inquiry-to-settlement lifecycle: booking intake, event onboarding, contracts, production, ticketing, show execution, and closeout.
The app is intentionally boring to run:
- PHP 8, served from
public/ - no Composer runtime dependencies
- no npm, bundler, or frontend build step
- MySQL with native PDO prepared statements (one database, or one database per tenant in SaaS mode)
- short-lived HS256 session tokens in Secure HttpOnly cookies (Bearer tokens remain available for API clients), with rotating refresh tokens, magic links, and passkey/WebAuthn sign-in
- static HTML/CSS/native Web Components that call JSON endpoints under
/api - LARC/PAN loaded from a pinned CDN module for component coordination
- optional multi-tenant SaaS mode: per-venue database + hostname, resolved per request (off by default — see Multi-Tenant / SaaS Mode)
Panic Backstage is API-first. PHP endpoint classes return JSON only; HTML pages are static and use browser-native Web Components, fetch(), and LARC/PAN topic events to load and mutate data.
The full API surface — routes, request/response schemas, auth requirements, and required capabilities — is documented as an OpenAPI 3.0 spec at docs/openapi.yaml. Paste it into Swagger Editor or Redocly for a browsable reference. scripts/check-openapi-routes.php checks the spec against the real router in CI, including explicit child-route coverage for the Booking Inbox.
The product has one canonical name, Panic Backstage, and one primary business spine:
Inquiry → Qualified booking → Event → Contract/payment → Execution → Settlement
Booking Inbox and Leads own intake and qualification. Onboarding creates the event exactly once. Contracts and payments advance the commercial commitment. Event workspaces own production and show execution. Settlement and closeout finish the record. Contacts, campaigns/Promote, Tasks, Messages, reporting, and automation support that spine; they are not separate systems of record or independent product brands.
Venue names and optional tenant shell labels remain configurable. Older internal Processes\CenterStage namespaces are compatibility implementation details, not a user-facing product name; new code and UI should use “Backstage.” See docs/PRODUCT-BOUNDARIES.md.
The custom kernel in src/Kernel.php resolves constrained API paths to endpoint classes:
GET /api/dashboard -> src/Dashboard.php
GET /api/events -> src/Events.php
POST /api/events -> src/Events.php
GET /api/events/{id} -> src/Events.php
PATCH /api/events/{id} -> src/Events.php
GET /api/events/{id}/tasks -> src/Events/Tasks.php
POST /api/events/{id}/tasks -> src/Events/Tasks.php
PATCH /api/events/{id}/tasks/{taskId} -> src/Events/Tasks.php
DELETE /api/events/{id}/tasks/{taskId} -> src/Events/Tasks.php
GET /api/events/{id}/assets -> src/Events/Assets.php
POST /api/events/{id}/assets -> src/Events/Assets.php
PATCH /api/events/{id}/assets/{id} -> src/Events/Assets.php
GET /api/public/events/{slug} -> src/PublicEvents.php
# Opt-in Firebase web push for the signed-in user (see docs/push-notifications.md):
GET /api/push/config -> src/Push.php
GET /api/push/subscriptions -> src/Push.php
POST /api/push/subscriptions -> src/Push.php
DELETE /api/push/subscriptions/{id} -> src/Push.php
# Public booking-inquiry widget intake (no JWT, CORS-open — powers panic-booking-inquiry):
POST /api/public/inquiries -> src/PublicInquiry.php (creates a lead, source=website)
OPTIONS /api/public/inquiries -> src/PublicInquiry.php (CORS preflight)
GET /api/promote/events/{id} -> src/Promote/CampaignForEvent.php
POST /api/promote/events/{id}/campaign
GET /api/promote/campaigns/{id} -> src/Promote.php
PATCH /api/promote/campaigns/{id}
GET/POST/PATCH/DELETE /api/promote/campaigns/{id}/posts[/{postId}]
POST /api/promote/campaigns/{id}/posts/{postId}/variants/generate
GET /api/promote/campaigns/{id}/broadcasts -> src/Promote/Broadcasts.php
POST /api/promote/campaigns/{id}/broadcasts
GET /api/promote/campaigns/{id}/health -> src/Promote/PromotionHealth.php
GET /api/promote/campaigns/{id}/analytics -> src/Promote/Analytics.php
GET /api/promote/credentials -> src/Promote/Credentials.php
POST/DELETE /api/promote/credentials[/{id}]
# Public contract signing (no JWT — authenticated by one-time token hash):
GET /api/signing/{token} -> src/ContractSigningEndpoint.php
POST /api/signing/{token}/viewed
POST /api/signing/{token}/sign
POST /api/signing/{token}/decline
# Public event syndication feeds (no JWT):
GET /api/feed -> src/Feed.php (discovery index)
GET /api/feed/events.ics -> src/Feed.php (iCalendar)
GET /api/feed/events.rss -> src/Feed.php (RSS 2.0)
GET /api/feed/events.json -> src/Feed.php (structured JSON, CORS-open; powers mab-events-carousel)
# Friendlier alias for the same feeds (calendar-subscription links, marketing
# copy) — routed via root .htaccess / public/.htaccess / public/router.php,
# not under /api:
GET /feeds/public-events.ics -> src/Feed.php
GET /feeds/public-events.rss -> src/Feed.php
GET /feeds/public-events.json -> src/Feed.php
Each endpoint receives a Request, returns a Response, and uses shared services such as Database and Auth. Most endpoints extend BaseEndpoint, which centralises the current-user lookup and the role/capability checks.
The single entrypoint public/api/index.php runs in one of two modes decided purely by environment: single-tenant (default — one DB_* database) or multi-tenant SaaS (active when SUPER_DB_NAME is set — the tenant database is resolved from the request hostname and injected into Database, leaving every endpoint class unchanged). See Multi-Tenant / SaaS Mode.
public/
index.html Main staff UI
login.html Login page
event.html Public event page shell
upcoming.html Public "upcoming shows" listing page (public_visibility events)
invite.html Invite acceptance shell
router.php Local dev router for PHP built-in server
.htaccess Apache rewrite for /api
api/index.php API entrypoint
assets/app.css Venue-ops UI styling
assets/app.js Web Components client (shell, events, dashboard)
assets/promote.js Panic Promote Web Components (15-channel editor, broadcast modal, settings)
assets/push.js Firebase web-push client (lazy, pinned CDN ES module; no build step)
sw.js Push service worker — must sit at the app root to own the right scope
uploads -> ../storage/uploads
src/
bootstrap.php Autoloader and shared function include
Kernel.php API path resolver and request dispatch
BaseEndpoint.php Shared endpoint base: current user + role/capability checks
Request.php HTTP request wrapper
Response.php JSON response wrapper
Database.php PDO wrapper (single-tenant DB_*, or an injected tenant PDO)
Auth.php Short-lived HS256 access tokens — HttpOnly cookie/Bearer, magic links, refresh tokens
Webauthn.php Passkey / WebAuthn registration + login
Identity.php Email → user resolution across primary + verified aliases
Support.php Small helper functions
Dashboard.php
Events.php
Templates.php
PublicEvents.php
Invites.php
Messages.php In-app staff messaging (Inbox / Archive / Outbox)
Push.php Web push device registration for the signed-in user (/api/push/*)
Notifications/ Optional Firebase push: config, canonical message, preferences,
subscriptions, notifier (→ JobQueue), native FCM HTTP v1 client
Outbox.php Admin log of every transactional email sent (manage_users)
StaffMembers.php Venue staff roster
NotificationPreferences.php Per-user email/notification opt-ins
Mailer.php Sendmail/MIME mailer; mirrors staff email into the inbox
Jobs/ Durable database queue + worker dispatch
WizardDefaults.php Admin-configurable event-wizard defaults
Contracts.php / Contract*.php Deal builder, clause library, rendering, e-signature
Promote.php Campaign CRUD top-level endpoint
Tenant/
TenantContext.php Resolve HTTP_HOST → tenant row + tenant PDO (SaaS)
TenantProvisioner.php Create tenant DB, apply tenant migrations, make clients/<slug>/
Database/
Connection.php SUPER/TENANT/PROVISION PDO factory (SaaS credential tiers)
Http/
SuperController.php Super-admin console: tenant fleet + domains (/super, /api/super)
Events/
Tasks.php
Blockers.php
Lineup.php
Schedule.php
Assets.php
Settlement.php
Invites.php
Promote/
Analytics.php Broadcast metrics from DB + null platform placeholders
Broadcasts.php Broadcast creation + adapter dispatch
BroadcastAdapters.php Routes destination_key → platform adapter
CampaignForEvent.php GET/POST campaign for a given event
CopyGenerator.php Deterministic 15-channel variant text generator
Credentials.php Per-venue platform credential store
Destinations.php Destination list endpoint
Posts.php Post CRUD + variant generation
PromotionHealth.php 20-item promotion checklist
Adapters/
EmailAdapter.php Mailchimp v3 + SendGrid v3 Marketing
EventbriteAdapter.php Eventbrite API v3
FacebookAdapter.php Graph API v21.0 page posts
InstagramAdapter.php Graph API v21.0 container → publish
LumaAdapter.php Luma Public API event creation
TikTokAdapter.php TikTok Content Posting API v2
database/
schema.sql App schema baseline — shared by the single-tenant DB
AND every tenant DB (identical code runs against both)
migrations/ Incremental changes on top of schema.sql (NNN_*.sql),
applied to the single-tenant DB and/or any tenant DB
schema-super.sql Super-admin registry baseline (tenants, tenant_domains)
migrations/super/ Incremental changes on top of schema-super.sql
seed.php
scripts/
migrate.php Scope-aware migration runner (single / super / tenant / tenants)
run-background-jobs.php Durable job worker (single / tenant / tenants)
static-analysis.sh PHPStan, syntax, route-contract, and security checks
storage/ Single-tenant data root (uploads, mail, logs)
uploads/events/
clients/ SaaS per-tenant data roots: clients/<slug>/{assets,logs,mail,contracts}
.env.example Environment variable template (single-tenant + SaaS)
- PHP 8.2 or newer
- MySQL 8 or newer
- PHP PDO MySQL extension
No Composer or Node install is required to run the application. Contributors install the locked Composer development dependency to run PHPStan; there are still no Composer runtime packages or frontend build dependencies.
The frontend uses this pinned LARC module directly in the browser:
<script type="module" src="https://cdn.jsdelivr.net/npm/@larcjs/core@3.0.1/pan.mjs"></script>Copy the example environment file:
cp .env.example .envSet the database credentials in .env:
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=panic_backstage
DB_PASSWORD=your-local-password
DB_NAME=panic_backstage
If you need to create the local MySQL user manually:
CREATE DATABASE IF NOT EXISTS panic_backstage CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'panic_backstage'@'localhost' IDENTIFIED BY 'your-local-password';
CREATE USER IF NOT EXISTS 'panic_backstage'@'127.0.0.1' IDENTIFIED BY 'your-local-password';
GRANT ALL PRIVILEGES ON panic_backstage.* TO 'panic_backstage'@'localhost';
GRANT ALL PRIVILEGES ON panic_backstage.* TO 'panic_backstage'@'127.0.0.1';
FLUSH PRIVILEGES;Then seed the database:
php database/seed.phpSeed admin login:
email: admin@venue.local
password: changeme
There is one schema source of truth: database/schema.sql.
It is the complete schema for a fresh database (generated by dumping the live
MySQL structure) and is the baseline that all earlier numbered migrations have
been squashed into. php database/seed.php applies it.
Incremental changes after that baseline live in
database/migrations/ as NNN_description.sql and are
applied with the runner:
php scripts/migrate.php # apply every migration not yet recorded
php scripts/migrate.php status # list applied / pending, apply nothingApplied filenames are recorded in the schema_migrations table, so the runner
is idempotent — a fresh database built from schema.sql starts with zero
pending migrations, and re-running only applies what is new. Write migrations
to be safe to re-run (IF [NOT] EXISTS, guarded ALTERs), since MySQL
auto-commits DDL and a half-failed migration cannot be rolled back.
The command above targets the single-tenant DB_* database, but schema.sql
and database/migrations/ are also what every tenant database is built
from in multi-tenant mode (migrate.php tenant <db> / tenants) — the same
PHP endpoint classes run unchanged against a tenant DB, so it has to stay
structurally identical to the single-tenant one. There used to be a separate,
hand-maintained database/migrations/tenant/ baseline; it was retired because
it silently drifted from the single-tenant migrations (four columns present in
the single-tenant DB were missing from newly-provisioned tenants). Now there's
one baseline and one migrations folder for both.
The super-admin registry is a genuinely separate schema (tenants,
tenant_domains, super_admin_users — not app data) with its own baseline,
database/schema-super.sql, and its own
migrations folder, database/migrations/super/, run with migrate.php super.
See Multi-Tenant / SaaS Mode.
When the migration list grows long, fold it back into the baseline: dump a
fully-migrated database's structure over database/schema.sql and clear
database/migrations/. Regenerate with:
mysqldump --no-data --single-transaction --add-drop-table --routines \
--skip-triggers --set-charset panic_backstage > database/schema.sql--skip-triggers is deliberate: the per-table audit triggers
(trg_<table>_ai/_au/_ad → db_history) are DEFINER-bound and generated
out-of-band by scripts/generate-audit-triggers.php, not part of the
portable baseline — run that script separately after a fresh install if you
want them.
Then trim the mysqldump header/footer boilerplate and per-table
-- Table structure for table comments so the file matches the existing
minimal style (see the top of database/schema.sql for the three SET
lines it starts with), delete the now-folded migration files, and bump
"Next number" in database/migrations/README.md.
The Contacts page (top-level nav, manage_contacts capability) holds the
ticket-buying audience used for event email. Seed it from the ticketing
provider's Fan View CSV export:
php scripts/import-fanview.php [path/to/export.csv] # defaults to database/fanview.csvThe importer keys on the provider's User ID, so re-running UPSERTs instead of
duplicating. The raw export contains real customer PII and is git-ignored
(database/fanview.csv) — keep it local; never commit it.
Panic Backstage runs in one of two modes, chosen entirely by environment
configuration. Single-tenant is the default and original behaviour: one
database configured via the DB_* vars (everything above this section assumes
it). Multi-tenant (SaaS) mode activates the moment SUPER_DB_NAME is set —
each venue ("tenant") gets its own database and its own hostname, resolved per
request. Stand-alone installs are unaffected: with SUPER_DB_NAME blank the
DB_* path is taken and nothing in this section applies.
public/api/index.php is the single entrypoint. When SUPER_DB_NAME is set:
/superand/api/super/*are handled first byPanic\Http\SuperController(the super-admin console) before any tenant is resolved, so they work from any allowed host — or a dedicatedSUPER_HOST— without a tenant DB.GET /healthreturns{"ok":true}without touching a tenant DB.- Every other request is resolved to a tenant by
Panic\Tenant\TenantContext::resolve(): it readsHTTP_HOST(orX-Forwarded-HostwhenTRUST_PROXY=true), validates it againstALLOWED_HOSTS(supports*.example.comwildcard prefixes), and looks it up in the super registry (tenant_domains JOIN tenants WHERE domain = ? AND status = 'active'). An unrecognised host returns a branded "nothing here yet" page (JSON for API clients); a host with no active tenant returns 404. - The resolved tenant's PDO is injected into
Panic\Database, andAPP_URLis rewritten to the matched tenant domain so email links, scanner URLs, and WebAuthn origins all point at the correct host. Every endpoint class then runs unchanged against the tenant's database.
TenantContext::current() exposes the resolved context app-wide, and
TenantContext::clientDir() returns the per-tenant data root —
clients/<slug>/ in SaaS, storage/ single-tenant — used by the mailer, asset
storage, contract PDFs, and logs.
SaaS mode uses three credential sets (src/Database/Connection.php) so the
runtime app user never holds DDL privileges:
| Prefix | Used for | Falls back to |
|---|---|---|
SUPER_DB_* |
super-admin registry (tenants, tenant_domains) |
— |
TENANT_DB_* |
per-tenant runtime queries (SELECT/INSERT/UPDATE/DELETE) | SUPER_DB_* |
PROVISION_DB_* |
elevated DDL (CREATE DATABASE / TABLE) for provisioning + migrations | SUPER_DB_* |
All SaaS variables — plus ALLOWED_HOSTS, TRUST_PROXY, and SUPER_HOST — are
documented in .env.example.
Panic\Http\SuperController serves both a small HTML UI (/super) and a JSON
API (/api/super/*), authenticated by a super-admin session cookie
(separate from the tenant apps' JWT auth). It manages the tenant fleet:
GET /api/super/venues public venue directory (login picker)
POST /api/super/login | /logout super-admin session
GET /api/super/tenants list tenants + domains
POST /api/super/tenants create + provision a tenant
GET /api/super/tenants/{id} fetch one
PATCH /api/super/tenants/{id} update
POST /api/super/tenants/{id}/provision re-provision the tenant DB
POST /api/super/tenants/{id}/domains add a domain alias
DELETE /api/super/tenants/{id}/domains/{domId} remove a domain alias
GET /api/super/me current super admin
POST /api/super/tenants body: { slug, name, database_name?, admin_name?, admin_email? }.
slug and name are required; database_name is derived from slug if
omitted. admin_name/admin_email (optional but recommended) seed that
person's login into the new tenant instead of a generic placeholder — see
below. When provisioning actually seeds demo data (a brand-new, empty
tenant), the response includes a seeded: { admin_email, admin_password, venue_id } object with the one-time generated password; share it with the
venue out-of-band, it is never returned again.
Creating a tenant runs Panic\Tenant\TenantProvisioner: it CREATE DATABASEs
the tenant schema (idempotent), loads database/schema.sql (the same baseline
the single-tenant DB uses, applied in an IF NOT EXISTS form so re-running it
against a live tenant — the "Re-provision" action — never drops anything),
applies + records any database/migrations/*.sql files not yet folded into
that baseline, creates the clients/<slug>/{assets,logs,mail,contracts} data
tree — kept outside public/ so tenant files are never directly
web-accessible — and, only if the tenant has no venues yet, seeds generic
demo data (database/seed_demo_data.php, the same generator seed.php uses
for local dev) personalized with the tenant's name and, when supplied,
admin_name/admin_email. Re-provisioning an already-active tenant skips
seeding entirely so it never inserts a second demo venue on top of real data.
The runner (scripts/migrate.php) is scope-aware; each scope keeps its own
schema_migrations ledger, so every command is idempotent:
php scripts/migrate.php # single-tenant DB (DB_*) — legacy default
php scripts/migrate.php super # super registry (database/schema-super.sql + migrations/super/)
php scripts/migrate.php tenant <database> # one tenant DB (database/schema.sql + migrations/)
php scripts/migrate.php tenants # every tenant in the super registry
php scripts/migrate.php status [super | tenant <database> | tenants]After adding a migration, run php scripts/migrate.php tenants (in addition to
the legacy single-tenant run) to roll it out to every existing venue; newly
provisioned tenants pick it up automatically since TenantProvisioner applies
the same database/migrations/ folder.
Use PHP's built-in server:
php -S localhost:8000 -t public public/router.phpOpen:
http://localhost:8000
The built-in router serves static files normally and dispatches /api/* to public/api/index.php.
To smoke-test the app under a subdirectory path, set APP_BASE_PATH:
APP_BASE_PATH=/backstage php -S localhost:8000 -t public public/router.phpThen open http://localhost:8000/backstage/. Static assets, API calls, invite links, and uploaded media are resolved relative to the app base path instead of the server document root.
The seeded demo is designed for a venue operations walkthrough.
Reset and launch locally:
php database/seed.php
php -S localhost:8000 -t public public/router.phpLogin:
email: admin@venue.local
password: changeme
Suggested flow:
- Open the dashboard and call out the next show, open items, empty holds, flyer needs, and settlement signals.
- Open
Local Band Showcase, edit event details, assign tasks, resolve the open flyer/ticketing items, approve the seeded flyer, and review the run sheet. - Create an invite link from the event workspace and copy it for a collaborator.
- Use the public page button after publishing the event to show the guest-facing event page.
- Open Templates, create a new show from
Three-Band Local Show, then show how tasks and schedule items come preloaded. - Open
Legacy Benefit Night's Closeout tab, add a cost entry with a payee, and show it appear in the Balances panel with a Log Payment action. - Open Calendar and Pipeline to show the same events by date and update operational status.
Example public event URL after seeding:
http://localhost:8000/e/local-band-showcase
Endpoint smoke test against a running local or staging server:
php scripts/endpoint-smoke.php http://localhost:8000The smoke script logs in as admin, loads dashboard data, creates an event from a template, updates an open item, creates and accepts a viewer invite, verifies collaborator event access, verifies unrelated event access and viewer mutation are blocked, saves settlement data, publishes the event, and verifies the public event API. It triggers real invite and magic-link emails through the Mailer (each written to storage/mail/ and piped to the system MTA); it does not exercise multipart asset upload.
The in-app Help pages embed screenshots from public/assets/help/. Regenerate
them after a UI change with:
node scripts/screenshots.mjsThe script is self-contained and has no npm dependencies — it uses Node's
built-in fetch/WebSocket (Node 21+) and the system Chromium/Chrome. It:
- starts a local PHP dev server if one isn't already running,
- mints a non-destructive magic-link token for an admin via
scripts/login-link.php(no password is set or changed), - drives headless Chromium over the DevTools Protocol to log in and capture the dashboard, an event workspace, the ticketing panel, and the contract builder, then
- writes the PNGs to
public/assets/help/and cleans up.
Override the defaults via env vars when your data differs — e.g.:
SHOT_EMAIL=admin@venue.local SHOT_EVENT_ID=641027 SHOT_CONTRACT_ID=10 \
node scripts/screenshots.mjsThe target event must have in-house ticketing enabled for the ticketing panel to
appear, and the contract id must exist. Other knobs: SHOT_PORT,
SHOT_CDP_PORT, SHOT_BASE, SHOT_OUT, SHOT_SCALE (see the file header).
dashboard.png shows date-relative data, so it changes on every run — only
re-commit it when the layout itself has changed.
Use public/ as the web root whenever the host allows it. When this checkout must live below a broader document root, the root .htaccess enforces the same boundary by denying source, scripts, migrations, tests, tenant data, secrets, and internal documents.
For Apache, the included public/.htaccess routes API requests to public/api/index.php.
For Nginx/PHP-FPM, route /api/* to public/api/index.php and serve static files from public/. If the app is mounted under a subdirectory such as /backstage, route that prefix to public/ and set APP_BASE_PATH=/backstage if the server does not expose the prefix through SCRIPT_NAME. Route /files/* and legacy /uploads/* through public/files.php; never serve tenant storage or the public/uploads symlink directly.
Uploaded files are untrusted. Event and inquiry uploads are stored outside the
document root, use a server-generated extension derived from detected MIME
type, and are streamed only through public/files.php, which resolves a
database record and applies event/lead authorization. Legacy /uploads/* URLs
are rewritten to that gateway. Enforce the same no-execution rule at the web
server as defense in depth:
-
Apache: handled automatically by the included upload
.htaccess, which turns the PHP engine off and denies any scriptable file in the uploads tree. This requires the vhost to allow those overrides (AllowOverrideincludingOptionsandFileInfo— the same overrides the app's rewrites already need). -
Nginx/PHP-FPM:
.htaccessis ignored, so add a location block that serves the uploads path statically and never passes it to PHP-FPM, e.g.:location ^~ /uploads/ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME /path/to/app/public/files.php; fastcgi_param QUERY_STRING "_path=$uri"; fastcgi_pass unix:/run/php/php8.2-fpm.sock; }
Keep .env outside version control. It is ignored by .gitignore.
Staging checklist:
- PHP 8.2+, PDO MySQL, and fileinfo enabled.
.envuses staging database credentials andAPP_BASE_PATHwhen mounted below/.- Run
php scripts/migrate.php(andphp scripts/migrate.php tenantsin SaaS mode). storage/assetsor eachclients/<slug>/assetsdirectory is writable by the PHP process.- Install
scripts/cron-background-jobs.shonce per minute so public-inquiry follow-up jobs drain. - The server can reach
https://cdn.jsdelivr.net/npm/@larcjs/core@3.0.1/pan.mjs, or LARC should be vendored in a future offline-demo pass. - Run
php database/seed.phponly when resetting demo data is acceptable.
Events with public_visibility = 1 are exposed as machine-readable syndication
feeds — no authentication required:
GET /api/feed → JSON discovery index (lists ICS + RSS + JSON URLs)
GET /api/feed/events.ics → iCalendar (subscribe in Google / Apple Calendar)
GET /api/feed/events.rss → RSS 2.0 (news aggregators, "what's on" widgets)
GET /api/feed/events.json → structured JSON (embeddable widgets — see below)
Optional query params for all formats: ?venue={slug}, ?days={N}, ?past=1,
?limit={N} (default 500, max 1000). Canceled events are excluded.
events.json additionally sends Access-Control-Allow-Origin: *, since it's
meant to be fetch()ed by browser JS running on a different origin (the
venue's own marketing site) rather than consumed server-side like the other
two formats.
The same three formats are also reachable at /feeds/public-events.{ics,rss,json}
— a friendlier URL (no /api prefix) for calendar-subscription links and
marketing copy. Both paths hit the exact same Feed::fetchEvents() query;
pick whichever reads better in context.
See docs/public-calendar-api.md for the full
reference (response shapes, field-by-field notes, and how this relates to the
single-event /public/events/{idOrSlug} endpoint and to ticket purchasing).
A standalone, publicly-accessible "what's on" page hosted directly on
Backstage itself (/upcoming.html) — distinct from the embeddable
<mab-events-carousel> widget below, which is meant for the venue's other
marketing site. Fetches GET /api/feed/events.json client-side and renders
every public_visibility = 1, non-canceled upcoming event (the same gate as
the feeds above), grouped by month, with room and date-range filters, a
"Details"/"Tickets" link per show, and links to subscribe via the ICS/RSS
feeds. No build step, no dependency on the internal app bundle — self-contained
HTML/CSS/JS so it stays fast and stylistically independent of the staff UI.
public/assets/mab-events-carousel.js is a dependency-free web component that
fetches GET /api/feed/events.json and renders it as a drop-in replacement
for themab.org's hand-authored "Upcoming events" carousel — same markup, same
class names (mab-hero-events, mab-cover-item, mab-date-block,
mab-outline-btn, …), so the site's own theme CSS styles it unchanged. It
renders into light DOM (no shadow root) for exactly that reason. Category and
month filter pills are derived from the fetched data; tickets either link out
(ticketing_mode = external) or open a self-contained modal that iframes the
event's public page (ticketing_mode = internal), the same pattern
themab.org already hand-codes per-event for in-house-ticketed shows.
Zero config to embed — the component reads its own <script src> (the same
trick public/assets/core.js's apiUrl()/appUrl() use) to find the API,
so it works unmodified whether Backstage is mounted at the domain root or
under a path prefix (e.g. this app's own /backstage/):
<section id="events" class="mab-hero-events" aria-label="Upcoming events">
<mab-events-carousel></mab-events-carousel>
</section>
<script src="https://panicbooking.com/backstage/assets/mab-events-carousel.js"></script>Pass a feed="..." attribute only to point a given instance at a different
backend/venue than the one implied by the script's own URL.
See public/mab-events-demo.html for a live, working demo page (also serves
as the component's smoke test).
public/assets/panic-booking-inquiry.js is a dependency-free web component
that renders a full booking-inquiry form and POSTs submissions straight to
POST /api/public/inquiries (src/PublicInquiry.php) — the one write
endpoint in the app built to accept unauthenticated, cross-origin requests
(it answers its own CORS preflight, since Kernel has no global CORS layer).
Unlike <mab-events-carousel>, it renders into a shadow root so it
looks the same regardless of the host page's CSS — the point is "drop it
into any site and it already looks finished," reskinned via CSS custom
properties (--pbi-accent, --pbi-paper, …) rather than by inheriting
theme classes.
<panic-booking-inquiry venue="Mabuhay Gardens"></panic-booking-inquiry>
<script src="https://panicbooking.com/backstage/assets/panic-booking-inquiry.js"></script>Every submission and its follow-up jobs are committed atomically. The inquiry
is a normal leads row (source = website, status = new) in the same
Booking Inbox as every other inquiry. A durable worker then performs
classification, routing, acknowledgment, and venue-admin notification with
bounded retries, so a slow AI or mail provider never holds the public request
open. A honeypot field plus a
per-IP/per-email rate limit (src/RateLimiter.php, the same mechanism the
auth endpoints use) guard against spam.
See public/booking-inquiry-demo.html for a live, working demo (two
reskinned instances, wired to the real endpoint) plus the full attribute/
event/styling reference, and docs/openapi.yaml's /public/inquiries path
for the request/response schema.
Events stay in sync with a Google Sheet in both directions: an inbound cron imports the sheet every 5 minutes, and app edits are pushed back up to the sheet in real time (with a cron-based retry fallback). Outbound writes authenticate as a Google service account.
See docs/google-sheet-sync.md for setup
(service-account key, sharing, permissions), the field/column mapping, and
troubleshooting.
A structured contract system: capture the deal as queryable terms, auto-assemble the document from reusable clause modules via templates with smart (condition-based) clause selection, render to PDF, and send for electronic signature — no DocuSign or third-party account required. Ships a clause library and seven starter templates covering every contract type.
Built-in e-signature workflow (src/ContractSigningEndpoint.php): once a
contract is approved, staff click Send for Signature. Each signer receives a
personalised, time-limited one-time link by email. They review the contract HTML,
type or draw their signature, tick a consent checkbox, and sign. Status
progresses through sent → viewed → partially_signed → signed_by_client → fully_executed. On full execution the system generates a tamper-evident Final
Executed PDF (contract body + signature blocks + audit certificate), stores its
SHA-256 hash, and automatically advances the linked event to Booked. An
optional SIGNATURE_PROVIDER=dropbox_sign stub is wired for future Dropbox Sign
integration (API skeleton present; HTTP calls are TODO).
The contract tables are part of the baseline database/schema.sql; apply
migration 017_contract_signatures.sql for the e-signature tables, then seed the
clause library with php database/seed_contracts.php. See
docs/contracts.md for the data model, signing API,
condition engine, and how to extend it. End-user help is in the app under
Help → Contracts & deal builder and Help → Electronic signatures.
Transactional email is handled by src/Mailer.php. It builds an RFC 5322
message and pipes it to the system /usr/sbin/sendmail interface (the
sendmail-compatible front end to the host MTA, e.g. Exim). Every message is
also written to *.eml for local inspection — storage/mail/ single-tenant, or
clients/<slug>/mail/ in SaaS mode — and any delivery failure is appended to
_delivery-errors.log rather than thrown, so a mail problem never breaks an
auth or invite flow. Every sent message is also recorded in the outbox table
(the admin Outbox / "All Email" view), and emails addressed to a staff user
are mirrored into that user's in-app Inbox.
Email is sent for:
- Collaborator invites (with a per-invite resend action).
- Login / magic links and the admin welcome link.
- Event status changes, private-event inquiries, and Intake-Complete hand-offs.
- Contract send / signed / fully-executed / voided notifications.
- Ticket purchase confirmations and comps.
- Staff messages composed in the in-app Messages center.
Relevant environment variables:
APP_URL=https://panicbooking.com/backstage # base used to build invite/login links
MAIL_FROM_ADDRESS=support@panicbooking.com
MAIL_FROM_NAME=Backstage
MAIL_BCC= # optional, comma/semicolon-separated blind copies
Creating an invite sends the email by default. The Invites form exposes a
Send invitation email checkbox (checked by default); unchecking it generates
the invite link without sending, so an admin can copy and share it manually. The
POST /api/events/{id}/invites endpoint mirrors this with a send_email flag.
Deliverability to external inboxes depends on the host MTA plus SPF/DKIM/DMARC
for the sending domain. Inspect storage/mail/ (or clients/<slug>/mail/) and
the host MTA log to confirm a given message was generated and accepted.
Optional, opt-in Firebase Cloud Messaging web push for signed-in staff, on
desktop and mobile browsers. Full setup walkthrough — Firebase Console steps,
HTTPS and iPhone/iPad requirements, security notes, failure handling — is in
docs/push-notifications.md.
Entirely optional. With no FIREBASE_* configuration, /api/push/config
returns enabled: false, Preferences reports push as unavailable rather than
offering a button that cannot work, nothing is queued, and the rest of the
application is untouched. CI runs unconfigured on purpose.
Architecture, deliberately built on what already exists rather than beside it:
business event → PushNotifier (recipients + preferences) → JobQueue
→ JobWorker (job_type = push_notification)
→ FcmClient (native PHP: RS256 JWT → OAuth2 → FCM HTTP v1)
→ public/sw.js → notification click → Backstage deep link
No Firebase Admin SDK, no Composer runtime package, no npm, no bundler. The
server side is cURL plus OpenSSL (both already CI requirements); the browser
side is a pinned Firebase ES module loaded lazily from Google's CDN, plus a
plain standards-based service worker that handles push itself.
- Enabling is an explicit user action. The browser permission prompt is raised only by clicking Enable notifications in Preferences — never on login or page load. A previously-denied permission is explained ("change it in browser or system settings") rather than re-requested.
- Push preferences are separate from email preferences. The
push_*columns onusersare chosen independently of thenotify_*ones: agreeing to email about a category is not consent to be interrupted on a phone. Both are edited through the existingPOST /api/auth/preferences. - Many devices per user. Registrations are keyed on a SHA-256 of the FCM token, so re-registering a browser is idempotent while a second browser is a second device. Tokens are never returned by the API and never logged.
- A dead registration retires itself. FCM reporting
UNREGISTERED(or a malformed token) disables that one row; transient failures rethrow so the existing queue backoff owns the retry. - iPhone/iPad need a Home Screen install — iOS delivers web push only to an installed web app. Preferences detects this by capability, not user-agent sniffing, and says so.
Wired initially to three high-signal operational events only — a new booking
inquiry reaching the Booking Inbox, an inquiry assigned to you by somebody
else, and a contract signed/declined. Routine saves, status transitions and
marketing activity are deliberately not pushed, and Mailer is untouched:
email and push are separate decisions.
Required environment (all blank by default — see .env.example):
FIREBASE_PUSH_ENABLED= # explicit opt-in switch; blank keeps push off
FIREBASE_PROJECT_ID=
FIREBASE_WEB_API_KEY= # these four are public by design and are
FIREBASE_MESSAGING_SENDER_ID= # returned to authenticated browsers
FIREBASE_APP_ID=
FIREBASE_VAPID_PUBLIC_KEY= # PUBLIC half of the Web Push key pair
FIREBASE_SERVICE_ACCOUNT_FILE= # private key — store OUTSIDE the web root, never commit
Push is delivered by the existing background worker
(scripts/cron-background-jobs.sh); no new process or cron entry is needed.
Run php scripts/migrate.php (and php scripts/migrate.php tenants in SaaS
mode) to apply migration 094_add_push_notifications.sql.
The Messages nav group gives staff an in-app view of the notifications and messages they would otherwise only see by email, reusing the Outbox split-pane interface. It has three boxes:
- Inbox — messages addressed to you: staff-to-staff messages plus system
notifications. Any outgoing email whose recipient matches a staff user account
is mirrored here by
Mailer(pure account-auth templates likemagic-linkandconfirm-emailare excluded). Tracks read/unread state. - Archive — inbox messages you've filed away.
- Outbox — messages you've sent. Composing or replying creates the message and also emails the recipient.
Backed by the messages table (migration 019_messages.sql single-tenant /
tenant/007_messages.sql). A single row serves two views — recipient_user_id
drives the Inbox, sender_user_id drives the Outbox. The frontend lives in
public/assets/messages.js; the shell shows an unread badge on the Inbox link.
GET /api/messages?box=inbox|archive|sent list (?q= &page= &limit=)
GET /api/messages/{id} one message (marks it read)
POST /api/messages compose / reply (+ emails the recipient)
POST /api/messages/{id}/archive | /unarchive file / restore
POST /api/messages/{id}/read | /unread toggle read state
GET /api/messages/recipients addressable users
GET /api/messages/unread-count inbox unread count (nav badge)
The admin Outbox (#outbox, labelled "All Email", gated by manage_users)
remains the global log of every transactional email the system has sent.
- Public forms and booking email create auditable inquiries in Booking Inbox.
- Qualification, assignment, claims, and status changes happen on the lead; onboarding atomically creates one event and applies its task template.
- Contracts, signatures, deposits, and payments move the event from proposal to committed booking.
- The event workspace manages lineup, production tasks, run sheet, assets, ticketing, guest list, staffing, and show execution.
- Settlement, reporting, and closeout complete the same lifecycle record.
- Public event pages are server-rendered (real title/meta/Open-Graph/JSON-LD tags) at the pretty URL
/e/{public_slug}(seesrc/PublicEventPage.phpandSupport::event_public_path()); olderevent.html?id=…/?slug=…links keep resolving indefinitely. - Public event API responses only include events with
public_visibilityenabled. - Web Components publish PAN-compatible topics such as
app.route.changed,events.loaded,event.saved,event.assetUploaded,event.openItemResolved,event.publicationChanged,toast.show, andapi.error.
Server-side authorization is enforced by global user role plus event ownership or event_collaborators rows. Venue admins retain full access to every event, template, invite, asset, settlement, and user list. Non-admin users only see events they own or events where they have a collaborator row.
Event collaborator roles:
event_owner: full access to assigned/collaborating events except global user or template administration.promoter: read event data, edit lineup, tasks, schedule, and open items, and view/copy the public page. Settlement is hidden.band/artist: read the collaborating event, upload assets, and view tasks assigned directly to them.designer: read the collaborating event and upload/manage event assets. Settlement is hidden.staff: read the collaborating event and edit tasks, schedule, and open items.viewer: read-only access to the collaborating event.
Creating an invite emails the recipient an accept link by default, and each pending invite can be re-sent. The link is also shown in the UI to copy and share manually (see Email).
A login account can own more than one email address. Each user has a primary
users.email plus a set of verified secondary addresses (aliases) stored as
JSON in users.alt_emails. This schema is part of the baseline
database/schema.sql: the alt_emails column, its multi-valued uniqueness
index, the email_verification_tokens table, and the user_merges audit table.
-
Identity resolution. All authentication-time email lookups route through
Panic\Identity::resolveUserByEmail(). It tries the exactusers.emailfirst — so signing in with a primary email behaves exactly as before — and only then falls back to a user whosealt_emailscontains the address with a non-nullverified_at. A verified alias is strictly an added sign-in path, never a replacement. Magic-link, password, and magic-link-request flows all resolve through it, and magic-link verify resolves before auto-creating a viewer so an alias never spawns a duplicate account. -
Aliases (add / verify / resend / remove / promote). Admins (or a user acting on their own account) manage addresses from the user Edit modal (
<pb-user-emails>inpublic/assets/user-emails.js). Adding an alias mints a 7-day hashed verification token (email_verification_tokens) and emails a confirmation link of the form{APP_URL}/login.html?verify_email=<token>.login.htmlhandles that parameter by callingPOST /api/auth/verify-email(public, no JWT) and showing an "email confirmed" state; only verified aliases may authenticate. Endpoints:POST /api/users/{id}/emails— add an unverified aliasPOST /api/users/{id}/emails/resend— re-mint and re-send the linkDELETE /api/users/{id}/emails— remove an alias (never the primary)POST /api/users/{id}/emails/primary— promote a verified alias to primary (the old primary is demoted intoalt_emailsas verified)GET|POST /api/auth/verify-email— confirm an alias from its token
Every address stays globally unique across all primaries and aliases via
Identity::emailIsTaken(). -
Duplicate detection + account merge. The Admin ▸ Duplicates tab (
<pb-user-duplicates>) lists likely duplicate account pairs surfaced by three signals: matching normalized name, a shared phone, and a shared gmail-canonical address (dots/+suffixnormalized for detection only — never for login matching). Merging is atomic: everyREFERENCES users(id)row is repointed from the loser to the survivor, passkeys move over, refresh tokens are revoked, the loser's primary and aliases fold into the survivor'salt_emailsas verified entries, the loser is hard-deleted, and auser_mergesaudit row records the per-table counts, folded emails, role decision, and signals. Endpoints (both gated onmanage_users):GET /api/users/duplicates— suggested pairs, strongest match firstPOST /api/users/merge—{survivor_id, loser_id, confirm:true, override_role?}; a cross-role merge returns409unlessoverride_roleis set.
After logging in as the seeded admin:
- Open an event workspace and select the Invites tab.
- Create a viewer, staff, designer, promoter, artist, or band invite. Leave Send invitation email checked to email the link, or uncheck it to just generate a copyable link.
- Use the emailed link, or copy the generated invite link, and open it in a separate browser session or private window.
- Accept the invite with a name and password.
- Confirm the collaborator can open that event from the dashboard or direct event URL.
- Confirm unrelated events are not listed and direct access to unrelated event IDs is rejected.
- For a viewer invite, confirm event detail fields, settlement, invite creation, and destructive asset controls are unavailable.
- For a designer invite, manually verify asset upload/manage controls on that event. The smoke script does not exercise multipart uploads.
Useful checks:
composer install
./scripts/static-analysis.sh
./tests/run-php-tests.sh
php database/seed.php
php -S localhost:8000 -t public public/router.php
php scripts/endpoint-smoke.php http://localhost:8000node --check is optional and only validates the plain JavaScript file. The app does not require Node to run.
Three zero-dependency suites (no npm, no build):
# Hermetic PHP unit tests (no DB, no server) — pure logic, JWT claims, encryption, parsing:
./tests/run-php-tests.sh
# RUN_DB_TESTS=1 ./tests/run-php-tests.sh also runs the MySQL-backed ones
# (point .env at a throwaway DB first)
# API integration tests (curl + php) against a running app + seeded DB:
./run-tests.sh
# TEST_BASE_URL=... TEST_EMAIL=... ./run-tests.sh to target something other
# than the local dev install
# UI tests — headless Chromium over the DevTools Protocol against the live DOM:
node tests/ui/run.mjsThe UI runner starts a local PHP dev server, logs in via a non-destructive
magic-link token, and asserts client-side behaviour (panel reveals, the
ticketing mode toggle, the Doors/Show/End autofill, …) without persisting
changes. It shares its browser/CDP/login machinery with
scripts/screenshots.mjs. See tests/ui/README.md.
.github/workflows/ci.yml runs on every push to main and every pull
request, four jobs: syntax checks plus PHPStan/API-contract/security analysis;
the hermetic PHP suite; the API integration suite; and the headless-Chromium UI suite — the
latter two each against their own MariaDB service container, seeded fresh
every run (php database/seed.php && php scripts/migrate.php,
admin@venue.local / changeme). seed_demo_data() returns
primary_event_id (the richly-populated "Local Band Showcase" demo event —
database/seed.php also prints it as UI_EVENT_ID=<id>), which the UI job
captures and passes to tests/ui/run.mjs so event-scoped tests run for real
instead of skipping. ubuntu-latest ships Chrome preinstalled; the job fails
fast with a clear message if a future runner image ever drops it.
Events can sell tickets directly ("internal" ticketing mode) through a pluggable payment layer with Stripe and Square providers. There is no vendored SDK — both providers talk to their HTTP APIs over raw cURL with zero Composer dependencies.
- Provider configuration (admin): Admin → Payments (
#admin-payments) selects the active provider and currency (thepb-payment-settingspanel, gated bymanage_users). API keys live only in.envand are never returned by the API. - Per-event ticketing (event workspace): the Ticketing tab
(
pb-ticketing-admin, gated by the newmanage_ticketingevent capability — granted tovenue_adminandevent_owneronly) configures tiers, inventory, sales windows, comps, refunds, and door-scanner links. - Public purchase: the public event page mounts
<pb-ticket-purchase>which lists on-sale tiers, reserves a 15-minute inventory hold, and redirects the buyer to the provider's hosted checkout.GET /api/public/tickets/{eventId}— on-sale tiers + live availabilityPOST /api/public/tickets/{eventId}/checkout— create a checkout session
- Physical / pre-printed tickets:
public/register-ticket.html?event={id}is a standalone, no-login form for stock that's already printed with a ticket number and sold outside the app (a table, an external box office). A seller types the printed number + buyer name/email/phone; it's stored on the sametickets/ticket_orderstables (so it shows up in reports, the CRM sync, and door-scanner lookup by that number) viaTicketingService::registerPrintedTicket(). - Webhooks: providers confirm payment via signed webhooks
(
POST /api/webhooks/stripe,POST /api/webhooks/square). These are public routes authenticated by signature verification (HMAC), not JWT; fulfillment is idempotent so retries never double-issue or double-email. - Tickets and QR: each fulfilled unit gets a one-time plaintext token (only
its
sha256hash is stored). The holder's ticket page isGET /t/{token}, and the scannable QR is generated on the fly by a from-scratch encoder atGET /assets/qr.svg?text=<token>(byte mode, ECC level M; verified scannable with OpenCV/ZBar). - Door scanner:
public/scanner.htmlis a mobile camera scanner. It posts the decoded token toPOST /api/scan/redeemusing a scanner-link token (not a JWT); redemption is an atomic single-row flip with a mandatoryticket_scansaudit row. Scanner-link management lives under/api/events/{id}/scanner-links[/{linkId}](JWT +manage_ticketing). - Deposit payment links + QR: the same provider-agnostic checkout layer also
backs a one-off hosted checkout link (and scannable QR) for an event's
deposit — from the event workspace's Payments tab (Payment Link / QR)
or straight out of the Booking Inbox action bar once a lead is onboarded
(Deposit Link / QR). Links are minted idempotently (a re-click reuses the
cached link until it expires) and auto-confirmed to
receivedby the same provider webhooks above — no manual "mark received" step.POST /api/events/{id}/payments/{paymentId}/send-link— mint/reuse a link for an existing payment recordPOST /api/events/{id}/payments/0/deposit-link— find-or-create the event's outstanding deposit record, then the above
Required .env keys (see .env.example): APP_URL, STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET, SQUARE_ACCESS_TOKEN, SQUARE_LOCATION_ID,
SQUARE_WEBHOOK_SIGNATURE_KEY, SQUARE_ENV, SQUARE_WEBHOOK_URL. The ticketing
schema is part of the baseline database/schema.sql.
See docs/ticketing.md for the full data model, API,
payment/fulfillment flow, door-scanner details, and an operating checklist, and
docs/deposit-payments.md for the deposit
link/QR flow specifically (link reuse/expiry, webhook auto-confirmation,
deposit-status lifecycle, operator runbook).
Panic Promote is a campaign command center for event promotion. Each event can have one campaign. A campaign organises marketing posts, channel-specific post variants, broadcast destinations, broadcast history, a 20-item promotion-health checklist, and real broadcast analytics.
- Campaign — one per event; inherits event title, date, times, venue, and ticket URL. Optional
goal_ticketsoverride. - Post — master copy belonging to a campaign; statuses:
draft,approved,scheduled,sent,archived. - Post variant — platform-specific version of a post for one of 15 channels (see Channels below).
- Destination — a place a post can be sent or tracked, grouped into Direct Posts, Event Platforms, Editorial Submissions, and Email (17 destinations; see Destinations).
- Broadcast — records an attempt to send one post to one or more destinations; per-destination results are stored as
promote_broadcast_resultsrows. - Promotion Health — a computed 20-item checklist (
score,complete,total,items[]) derived from event visibility, approved assets, approved variants, broadcast history, and scheduled reminders. - Analytics — real broadcast metrics from the DB (destinations reached, live listings, manual to-dos, failures); platform-specific metrics (ticket sales, email opens, RSVPs) are
nullpending future API read-back integrations.
GET /api/promote/campaigns → list all campaigns
POST /api/promote/campaigns → create a campaign directly
GET /api/promote/campaigns/{campaignId} → fetch one campaign + posts + health + analytics
PATCH /api/promote/campaigns/{campaignId} → update campaign fields
GET /api/promote/events/{eventId} → fetch or describe campaign for an event
POST /api/promote/events/{eventId}/campaign → create campaign for event (idempotent)
GET /api/promote/campaigns/{campaignId}/posts → list posts
POST /api/promote/campaigns/{campaignId}/posts → create post
GET /api/promote/campaigns/{campaignId}/posts/{postId} → fetch one post + variants
PATCH /api/promote/campaigns/{campaignId}/posts/{postId} → update post
DELETE /api/promote/campaigns/{campaignId}/posts/{postId} → delete post
POST /api/promote/campaigns/{campaignId}/posts/{postId}/variants/generate → generate all 15 variants (local, no AI)
PATCH /api/promote/campaigns/{campaignId}/posts/{postId}/variants/{variantId} → update a single variant
GET /api/promote/campaigns/{campaignId}/destinations → list destinations with current status
GET /api/promote/campaigns/{campaignId}/health → promotion-health score + 20-item checklist
GET /api/promote/campaigns/{campaignId}/analytics → real broadcast metrics + null platform placeholders
GET /api/promote/campaigns/{campaignId}/broadcasts → list broadcasts + results
POST /api/promote/campaigns/{campaignId}/broadcasts → create broadcast (fires real adapters)
GET /api/promote/campaigns/{campaignId}/broadcasts/{broadcastId} → fetch one broadcast + results
GET /api/promote/credentials → list per-venue platform credentials
POST /api/promote/credentials → save / update a credential
DELETE /api/promote/credentials/{id} → remove a credential
All routes require an authenticated user. Access is gated the same way as event sub-resources: venue admins have full access; event owners and collaborators can read and create; viewer collaborators get 403 on mutating requests.
The copy generator produces a tailored variant for each of these 15 channels when POST …/variants/generate is called (deterministic local text — no AI or third-party API):
| Channel | Type | Notes |
|---|---|---|
instagram |
Social | Caption ≤ 2,200 chars, hashtags, "link in bio" reminder |
facebook |
Social | Up to 63,206 chars; warn if > 477 (truncated in feed) |
tiktok |
Social | Short punchy caption, portrait-crop note |
email |
Subject + body for Mailchimp / SendGrid list blast | |
email_adhoc |
Same format, intended for manual BCC send to custom recipients | |
eventbrite |
Platform | Event description + metadata |
luma |
Platform | Markdown description + venue address JSON |
funcheap |
Editorial | Calendar listing ≤ 500 chars |
foopee |
Editorial | Concise Bay Area calendar copy |
press |
Editorial | FOR IMMEDIATE RELEASE press blurb |
sf_chronicle |
Editorial | Datebook pitch with media contact line |
sf_station |
Editorial | Listing ≤ 400 chars |
dothebay |
Editorial | Listing ≤ 500 chars, "Music" category note |
songkick |
Platform | Artist-centric show format |
jambase |
Platform | Live music database format |
17 destinations are seeded across four groups. Real API adapters are implemented for the six marked live; the rest produce the appropriate stub status with no external calls.
| Key | Group | Default status | Adapter |
|---|---|---|---|
facebook_page |
Direct Posts | needs_auth |
live — Graph API v21.0, /photos or /feed |
instagram |
Direct Posts | needs_auth |
live — Graph API v21.0, container → publish flow |
tiktok |
Direct Posts | needs_auth |
live — Content Posting API v2, photo PULL_FROM_URL |
eventbrite |
Event Platforms | needs_auth |
live — Eventbrite API v3, creates listing |
luma |
Event Platforms | needs_auth |
live — Luma Public API, creates event |
bandsintown |
Event Platforms | manual_submission |
stub → manual_required |
songkick |
Event Platforms | manual_submission |
stub → manual_required |
jambase |
Event Platforms | manual_submission |
stub → manual_required |
funcheap |
Editorial Submissions | manual_submission |
stub → manual_required |
foopee |
Editorial Submissions | manual_submission |
stub → manual_required |
press_list |
Editorial Submissions | manual_submission |
stub → manual_required |
sf_chronicle |
Editorial Submissions | manual_submission |
stub → manual_required |
sf_station |
Editorial Submissions | manual_submission |
stub → manual_required |
dothebay |
Editorial Submissions | manual_submission |
stub → manual_required |
email_general |
connected |
live — Mailchimp v3 or SendGrid v3 Marketing | |
email_press |
connected |
live — same email adapter, separate list/credentials | |
email_adhoc |
manual_submission |
stub → manual_required (copy-and-BCC workflow) |
| Status | Meaning |
|---|---|
sent |
Real adapter call succeeded; external_url may be set |
queued |
Adapter accepted a scheduled send |
needs_auth |
No credentials configured, or adapter returned an auth error |
manual_required |
Destination is manual_submission; copy was generated for staff to send |
failed |
Adapter attempted a call and received an error |
skipped |
Destination was disabled at broadcast time |
Platform credentials are stored per-venue in the promote_credentials table (never in .env). Connect platforms from the staff app under Panic Promote → Settings (#promote-settings).
Required fields per platform:
| Platform | Required | Optional |
|---|---|---|
| Facebook Page | Page Access Token, Page ID | — |
| User Access Token, IG Business Account ID | — | |
| TikTok | OAuth Access Token | Privacy Level (default PUBLIC_TO_EVERYONE) |
| Eventbrite | Private API Key, Organizer ID | Eventbrite Venue ID |
| Luma | API Key | — |
| Email (general / press) | Provider (mailchimp or sendgrid), API Key, List / Audience ID, From Name, From Email |
Sender ID (SendGrid only) |
Notes:
- Facebook and Instagram share a Graph API app; the access token is a Page token (Facebook) or a User token with
instagram_content_publishscope (Instagram). - TikTok access tokens expire; re-authenticate in Settings if a broadcast fails with
access_token_expired. - Luma cover images cannot be served from external URLs; after a Luma broadcast, upload the flyer manually in the Luma dashboard.
- Mailchimp datacenter is parsed from the API key suffix (e.g.
-us21). - SendGrid uses a single-send (Marketing Campaigns) flow;
sender_idmust match a verified Sender Identity.
Health is computed from 20 deterministic checklist items. Items with severity: 'info' are informational and do not heavily penalise the score when missing.
| # | Key | Checks |
|---|---|---|
| 1 | panic_page_published |
events.public_visibility = 1 |
| 2 | approved_flyer |
Approved flyer asset exists |
| 3 | instagram_approved |
Approved Instagram variant exists |
| 4 | facebook_approved |
Approved Facebook variant exists |
| 5 | eventbrite_listing |
Any Eventbrite broadcast result exists |
| 6 | luma_listing |
Any Luma broadcast result exists |
| 7 | funcheap_submitted |
Any Funcheap broadcast result exists |
| 8 | foopee_submitted |
Any Foopee broadcast result exists |
| 9 | press_email_prepared |
Approved press variant exists |
| 10 | email_blast |
Any email_general broadcast result exists |
| 11 | posts_created |
At least one post exists |
| 12 | goal_set |
goal_tickets is set on the campaign |
| 13 | sf_chronicle_submitted |
Any SF Chronicle broadcast result exists |
| 14 | sf_station_submitted |
Any SF Station broadcast result exists |
| 15 | dothebay_submitted |
Any DoTheBay broadcast result exists |
| 16 | songkick_submitted |
Any SongKick broadcast result exists |
| 17 | jambase_submitted |
Any JamBase broadcast result exists |
| 18 | email_adhoc_sent |
Any email_adhoc broadcast result exists |
| 19 | day_before_reminder |
Any scheduled broadcast within 36 h of the show date |
| 20 | band_assets_collected |
Approved band_photo or logo asset exists |
Apply all migrations with:
php scripts/migrate.phpPromote-specific migrations:
| File | What it adds |
|---|---|
006_panic_promote.sql |
Core tables + seeds 11 original destinations |
007_promote_credentials.sql |
promote_credentials per-venue credential store |
008_add_missing_destinations.sql |
Adds sf_chronicle, sf_station, dothebay, songkick, jambase |
009_add_email_adhoc_destination.sql |
Adds email_adhoc (17th destination) |
Tables:
| Table | Purpose |
|---|---|
promote_campaigns |
One campaign per event |
promote_posts |
Marketing posts belonging to a campaign |
promote_post_variants |
Per-channel variant of a post (15 channels) |
promote_destinations |
Broadcast target registry (seeded, 17 rows) |
promote_broadcasts |
One broadcast attempt per post/send action |
promote_broadcast_results |
Per-destination result row within a broadcast |
promote_credentials |
Platform API keys and config, per venue |
Start the server:
php -S localhost:8000 -t public public/router.phpRun the promote smoke script:
php scripts/promote-smoke.php http://localhost:8000 storage/mailThe script logs in via magic-link, creates an event and campaign, creates a post, generates all 15 variants, fires a broadcast across all four destination groups, verifies per-destination result statuses, fetches health and analytics, asserts all analytics keys are present, verifies a viewer gets 403 on mutations, and asserts 401 for unauthenticated requests.
If you run both
endpoint-smoke.phpandpromote-smoke.phpback-to-back, wait about 2 seconds between them — both scripts mint magic-link tokens and a rapid second request can hit a token-table race.
- Platform analytics read-back — Eventbrite ticket sales, Mailchimp/SendGrid email opens/clicks, and Luma RSVPs are returned as
null. Wiring these up requires scheduled API polling or webhook handlers per platform. - Scheduled broadcast runner — broadcasts created with
send_mode = 'scheduled'are stored asqueuedbut no cron/queue worker exists yet to process them at the scheduled time. - Luma cover image — the Luma API only accepts images hosted on
images.lumacdn.com; upload the event flyer manually in the Luma dashboard after an automated broadcast creates the listing. - TikTok token refresh — TikTok access tokens expire; re-authenticate in Promote Settings when a broadcast fails with
access_token_expired.
The Promote workspace is loaded by public/assets/promote.js (native Web Components, no framework). Navigation routes:
#promote— campaigns list, showing upcoming events with health score and days out.#promote-event-{id}— campaign overview: hero, promotion health, posts, assets, analytics, and broadcast modal.#promote-settings— platform credentials manager; connect and test each destination.
A Panic Promote nav item is added to the staff shell navigation alongside the existing top-level sections.
- Email delivery relies on the host MTA via
/usr/sbin/sendmail; there is no queue, retry, or bounce handling beyond what the MTA provides. - Uploads use local disk storage under
storage/uploads/events/:eventId. - The frontend is intentionally browser-native Web Components, optimized for hackability over framework features.