System summary

  • Single Cloudflare Worker (worker.js) handles all requests for pub.ihnyc-rc.org
  • Static files are served from ./public via Cloudflare’s Assets binding with SPA-style fallback for unknown asset paths
  • /api/events queries 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/tenders returns the active tender directory from D1 (CONTENT_DB)
  • /api/image/:kind/:pageId resolves 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/:pageId and resolved against D1; the legacy /api/sync-tender-qrs Notion write-back route has been retired now that tender data is D1-native
  • /tender/:id is 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 with no-store headers 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:

FlagValueBacking store
CONTENT_SOURCE_EVENTSnotionNotion events DB (NOTION_DATABASE_ID)
CONTENT_SOURCE_ANNOUNCEMENTSd1CONTENT_DB
CONTENT_SOURCE_SHIFTSd1CONTENT_DB
CONTENT_SOURCE_TENDERSd1CONTENT_DB

Specials live entirely in CONTENT_DB and were never Notion-backed.

Notion env vars (events only)

Env VarPurposeRequired
NOTION_DATABASE_IDPub events for todayEvents and weekEventsYes for live events
NOTION_API_KEYNotion API token (Wrangler secret) for the events readYes for live events
  • NOTION_DATABASE_ID is required when live Notion events are enabled
  • If NOTION_API_KEY is 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, and NOTION_PUB_TENDERS_DATABASE_ID vars 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:

  1. Determines “today” using the normal calendar date in America/New_York
  2. Queries the Notion Events DB for todayEvents using a broad date window, then narrows it to rows whose local Eastern start date equals todayISO
  3. Queries the Notion Events DB again for weekEvents covering calendar tomorrow through the next 7 days
  4. Loads TV-included announcements via loadAnnouncements() — from CONTENT_DB in the now-default d1 mode
  5. Rewrites announcement images to short same-origin /api/image/announcement/:pageId URLs
  6. Loads the active shift and current pub-tender footer state — from CONTENT_DB in the now-default d1 mode
  7. Rewrites tender photos and tender QR images to short same-origin /api/image/tender/:pageId and /api/image/tender-qr/:pageId URLs
  8. Synthesizes slides from announcements plus any active tender profiles marked Meet your Tender
  9. Returns lastUpdated, which the TV client uses as the shared server clock anchor
  10. Builds pubHoursWeek from the shift data
  11. 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:

  1. Resolves the tender source via contentSource(env, "tenders")d1 in production
  2. Loads the tender directory from CONTENT_DB through loadTenderDirectory()
  3. Keeps only active tenders
  4. 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 when IMAGE_MIRROR_BUCKET is configured
  • TV display client fetches /api/events with a timestamp query string and no-store request headers, then anchors shared slideshow and featured-event state to the response’s lastUpdated
  • /tv/ HTML is returned with Cache-Control: no-store, no-cache, must-revalidate
  • /tv/ soft-refreshes /api/events every 2 minutes, caches the last successful payload in localStorage, and repaints from that cache immediately after reload
  • /tv/ polls /api/tv-build every 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 against America/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/ingest is gated by the NOW_PLAYING_INGEST_TOKEN secret; see required-secrets.
  • /api/now-playing/current is a Server-Sent Events stream; both TVs and /setlist/ subscribe.
  • /api/now-playing/setlist is the cursor-paginated history used for scope backfill and All time infinite scroll.
  • The Pi’s per-source RMS picker stores program, wallstereo, or wallxlr; 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)