System summary
- Single Cloudflare Worker (
worker.js) handles all requests for pub.ihnyc-rc.org - Static files are served from
./publicvia Cloudflare’s Assets binding with SPA-style fallback for unknown asset paths /api/eventsqueries the events, shifts, and announcements data sources and assembles a unified payload for the landing page, announcements page, and TV board. Shifts, announcements, and tenders now read from D1 (CONTENT_DB); only events still come from Notion/api/tendersreturns the active tender directory from D1 (CONTENT_DB)/api/image/:kind/:pageIdresolves short same-origin image URLs and can mirror upstream-hosted images into R2 so TV/announcement surfaces do not depend on long-lived access to signed file URLs- Tender QR codes are served same-origin from
/api/image/tender-qr/:pageIdand resolved against D1; the legacy/api/sync-tender-qrsNotion write-back route has been retired now that tender data is D1-native /tender/:idis a compatibility redirect into the modal-backed/tenders/experience- API responses are cached at the edge for 2 minutes (
CACHE_TTL_SECONDS = 120) /tv/itself is served withno-storeheaders so screens are less likely to stay on stale frontend assets- No build step; plain HTML, CSS, and JavaScript for all static pages
Sources: worker.js, wrangler.toml
Request routing
flowchart TD REQ["Incoming Request"] --> WORKER["Cloudflare Worker (worker.js)"] WORKER --> ROUTE{Route type?} ROUTE -- "/api/events" --> EVENTS_CACHE{Edge Cache Hit?} EVENTS_CACHE -- Yes --> EVENTS_CACHED["Return Cached /api/events"] EVENTS_CACHE -- No --> EVENTS_NOTION["Query Notion APIs"] EVENTS_NOTION --> EVENTS_ASSEMBLE["Assemble event payload"] EVENTS_ASSEMBLE --> EVENTS_STORE["Store in edge cache (2 min TTL)"] EVENTS_STORE --> EVENTS_RETURN["Return JSON"] ROUTE -- "/api/tenders" --> TENDERS_CACHE{Edge Cache Hit?} TENDERS_CACHE -- Yes --> TENDERS_CACHED["Return Cached /api/tenders"] TENDERS_CACHE -- No --> TENDERS_D1["Query tender directory from D1 (CONTENT_DB)"] TENDERS_D1 --> TENDERS_ASSEMBLE["Assemble tender directory"] TENDERS_ASSEMBLE --> TENDERS_STORE["Store in edge cache (2 min TTL)"] TENDERS_STORE --> TENDERS_RETURN["Return JSON"] ROUTE -- "/api/image/:kind/:pageId" --> IMAGE_R2{"R2 mirror hit?"} IMAGE_R2 -- Yes --> IMAGE_R2_RETURN["Return mirrored image from R2"] IMAGE_R2 -- No --> IMAGE_RESOLVE["Resolve upstream file URL"] IMAGE_RESOLVE --> IMAGE_FETCH["Fetch upstream image"] IMAGE_FETCH --> IMAGE_STORE["Store bytes in R2 mirror"] IMAGE_STORE --> IMAGE_RETURN["Return proxied image"] ROUTE -- "/tender/:id" --> REDIRECT["302 redirect to /tenders/?tender=:id"] ROUTE -- "Static asset or page" --> ASSETS["Cloudflare Assets (./public)"] ASSETS --> FOUND{Asset found?} FOUND -- Yes --> SERVE["Serve static file"] FOUND -- No --> DIRCHECK{Slashless directory page?} DIRCHECK -- Yes --> DIRREDIRECT["302 redirect to trailing-slash path"] DIRCHECK -- No --> FALLBACK["Serve index.html (SPA fallback)"]
Sources: worker.js (fetch handler, CACHING section)
Data sources
The pub started Notion-backed, but the scheduler, tenders, shifts, announcements, and specials have since been migrated to Cloudflare D1 (CONTENT_DB), which is now the source of truth for those entities. Notion remains only for events — the /api/events read of the events database. A per-entity CONTENT_SOURCE_* flag, read through the contentSource(env, entity) helper in worker/lib/content-source.js, selects the backing store (notion | d1 | snapshot) for each migrated entity:
| Flag | Value | Backing store |
|---|---|---|
CONTENT_SOURCE_EVENTS | notion | Notion events DB (NOTION_DATABASE_ID) |
CONTENT_SOURCE_ANNOUNCEMENTS | d1 | CONTENT_DB |
CONTENT_SOURCE_SHIFTS | d1 | CONTENT_DB |
CONTENT_SOURCE_TENDERS | d1 | CONTENT_DB |
Specials live entirely in CONTENT_DB and were never Notion-backed.
Notion env vars (events only)
| Env Var | Purpose | Required |
|---|---|---|
NOTION_DATABASE_ID | Pub events for todayEvents and weekEvents | Yes for live events |
NOTION_API_KEY | Notion API token (Wrangler secret) for the events read | Yes for live events |
NOTION_DATABASE_IDis required when live Notion events are enabled- If
NOTION_API_KEYis absent, the worker skips the live Notion events query and returns placeholder/empty event data; the D1-backed surfaces are unaffected - The legacy
NOTION_SHIFTS_DATABASE_ID,NOTION_ANNOUNCEMENTS_DATABASE_ID, andNOTION_PUB_TENDERS_DATABASE_IDvars are vestigial — those entities now read from D1 regardless
Sources: worker.js (CONFIG section, contentSource), worker/lib/content-source.js, wrangler.toml
/api/events payload assembly
When /api/events is called, the worker:
- Determines “today” using the normal calendar date in
America/New_York - Queries the Notion Events DB for
todayEventsusing a broad date window, then narrows it to rows whose local Eastern start date equalstodayISO - Queries the Notion Events DB again for
weekEventscovering calendar tomorrow through the next 7 days - Loads TV-included announcements via
loadAnnouncements()— fromCONTENT_DBin the now-defaultd1mode - Rewrites announcement images to short same-origin
/api/image/announcement/:pageIdURLs - Loads the active shift and current pub-tender footer state — from
CONTENT_DBin the now-defaultd1mode - Rewrites tender photos and tender QR images to short same-origin
/api/image/tender/:pageIdand/api/image/tender-qr/:pageIdURLs - Synthesizes
slidesfrom announcements plus any active tender profiles markedMeet your Tender - Returns
lastUpdated, which the TV client uses as the shared server clock anchor - Builds
pubHoursWeekfrom the shift data - Returns a unified JSON object used by
/,/announcements/, and/tv/
Only steps 2–3 (events) reach Notion; the announcement, shift, and tender reads resolve through contentSource(env, entity) against CONTENT_DB.
When an image route is requested, the worker can now use an R2 mirror bucket bound as IMAGE_MIRROR_BUCKET:
- first request: resolve the current upstream file URL, fetch the bytes, store them in R2, then serve the image
- later requests: serve the mirrored copy from R2 when it exists
- stale mirrored objects are refreshed from the upstream source after
IMAGE_R2_REFRESH_TTL_SECONDS - if the bucket binding is absent, the worker falls back to direct proxying plus edge caching only
Sources: worker.js (fetchFromNotion, loadAnnouncements, fetchPubTenderFromNotion)
/api/tenders payload assembly
When /api/tenders is called, the worker:
- Resolves the tender source via
contentSource(env, "tenders")—d1in production - Loads the tender directory from
CONTENT_DBthroughloadTenderDirectory() - Keeps only active tenders
- Maps each row into a lightweight directory card object for
/tenders/
The Notion fetch path (fetchTenderDirectoryFromNotion) is retained only as a fallback for non-d1 modes; in production the directory is served entirely from D1.
Sources: worker.js (handleTendersApi, contentSource), worker/lib/content-tenders.js (loadTenderDirectory)
Caching
- Cache TTL: 2 minutes (
CACHE_TTL_SECONDS = 120) - Cache key: full request URL
- Cache stored in Cloudflare’s edge cache, not KV or D1
/api/image/*responses use browser and edge cache headers, and can use R2 as a durable origin mirror whenIMAGE_MIRROR_BUCKETis configured- TV display client fetches
/api/eventswith a timestamp query string andno-storerequest headers, then anchors shared slideshow and featured-event state to the response’slastUpdated /tv/HTML is returned withCache-Control: no-store, no-cache, must-revalidate/tv/soft-refreshes/api/eventsevery 2 minutes, caches the last successful payload inlocalStorage, and repaints from that cache immediately after reload/tv/polls/api/tv-buildevery minute and reloads only when the deployed TV build fingerprint changes, preferably at a slide boundary- Hard
<meta http-equiv="refresh" content="3600">fallback every hour in case JavaScript hangs completely - Overnight serving-hours state on
/and/tv/is computed againstAmerica/New_York, not the device timezone
Sources: worker.js (CACHING section), public/tv/index.html, public/tv/script.js
Song-ID pipeline (Now Playing and Setlist)
A separate pipeline runs alongside the Notion-backed routes, identifying audio playing through the pub PA system and surfacing it on the TVs and /setlist/:
flowchart LR QSYS["Q-SYS Core 110f<br/>(AES67-TX-1 multicast)"] -->|"L24/48000/6ch<br/>233.254.6.0:5004"| PI["Pub Raspberry Pi<br/>(aes67-linux-daemon + RAVENNA<br/>+ pi-capture Python service)"] PI -->|POST identified track<br/>Bearer token| WORKER["Cloudflare Worker<br/>/api/now-playing/ingest"] WORKER --> D1[("Cloudflare D1<br/>SETLIST_DB")] WORKER -->|SSE /api/now-playing/current| TVS["TVs (/tv/, /bar-tv-left/, /bar-tv-right/)"] WORKER -->|SSE| SETLIST["/setlist/ page"]
Key properties:
- The Worker NEVER receives raw audio. The Pi (or a future Mac-side ShazamKit service) does the identification and posts the result (
{artist, title, source, isrc, ...}). - D1 binding is
SETLIST_DB; the current tables and later migrations are summarized in Storage model. /api/now-playing/ingestis gated by theNOW_PLAYING_INGEST_TOKENsecret; see required-secrets./api/now-playing/currentis a Server-Sent Events stream; both TVs and/setlist/subscribe./api/now-playing/setlistis the cursor-paginated history used for scope backfill and All time infinite scroll.- The Pi’s per-source RMS picker stores
program,wallstereo, orwallxlr; the setlist displays HDMI, Wall Stereo, or Wall XLR.
The full chain, channel mapping, design rationale, and known operational issues are documented in now-playing-system. The Pi-side runbook is in pi-capture-ops.
Sources: worker.js (Pub "Now Playing" section banner), migrations/0001_create_setlist.sql, pi/pi_capture/, qsys/IH-Pub-1_updated-master-gain-up_20260517.qsys
Static site structure
public/
|-- index.html # Landing page with serving hours + nav
|-- styles.css # Shared styles used by several interior pages
|-- assets/ # Logos and static imagery
|-- tv/
| |-- index.html # TV board shell
| |-- styles.css # TV-specific layout and motion
| `-- script.js # TV rendering and refresh logic
|-- announcements/ # Live announcements page
|-- tenders/ # Modal-backed tender directory
|-- pub-tenders/ # Tender-facing static guide
|-- tutorials/
| |-- index.html # Tutorial index card grid
| `-- bar-left-controls/ # Interactive bar-left A/V, lights, and HDMI help page
|-- menu/ # Menu page
|-- specials/ # Specials archive (live; current + past specials)
`-- events/ # Events page (live; upcoming events from /api/events)Sources: public/ directory, wrangler.toml ([assets] binding)