Catalogue Domain
Manages all content and scheduling for both the CMS side (playlists, media channels, layout templates, time-blocks) and the ticketing side (ticket groups, variants, tags, banners, rail menus). The two subsystems share the same Go domain package but are registered through separate route groups.
Handles:
- Playlists — CRUD + media item management + reorder
- Playlist Draft / Version / Publish — independent draft editing, versioned rollback, publish-to-live
- Playlist Canvas Templates — multi-zone canvas layouts selectable at playlist-create time
- Media Channels (Feeds) — CRUD + zone assignments + space assignments + time-blocks + fallback config
- Multi-zone Time Blocks — a single time block can drive several zones at once, each with its own playlist
- Background Playout Scheduler — recurring job that resolves the winning time block per channel and dispatches it to devices
- Layout Templates — read + filter by orientation/delivery profile
- Ticket Groups — multilingual event catalogue with gallery, variants, details
- Tags — tagging ticket groups
- Banners — promotional banner carousel management
- Rail Menus — content rails for the customer app, merging ticket groups and advertisements into one placement-ordered feed
- VOD playlists (planned)
- Ticket group search / filtering (planned)
Architecture
graph LR
CMS["CMS Frontend (TellyBoard)"]
CustomerApp["Customer App"]
CoreAPI["CoreAPI"]
CMSDB["CMS DB\n(playlists, media_channels,\nlayout_templates, time_blocks)"]
TicketingDB["Ticketing DB\n(ticket_group, banner, rail_menu, tag)"]
CMS -->|/api/v1/playlists\n/api/v1/media-channels| CoreAPI
CMS -->|/api/ticketGroups\n/api/railMenus\n/api/banners| CoreAPI
CustomerApp -->|/api/ticketGroups\n/api/railMenus/customer| CoreAPI
CoreAPI --> CMSDB
CoreAPI --> TicketingDB
Data Model
erDiagram
Playlist {
string id
string organizationId
string workspaceId
string name
int version
int totalDuration
string status
int mediaCount
string thumbnailUrl
string canvasTemplateId
string aspectRatio
bool multiZone
int actualZoneCount
string readyState
}
PlaylistVersion {
string id
string playlistId
int version
string status
string readyState
string createdBy
string action
string note
string publishedBy
string publishedAt
}
PlaylistCanvasTemplate {
string id
string organizationId
string name
string orientation
string aspectRatio
bool multiZone
int zoneCount
bool isSystem
}
PlaylistItem {
string id
string playlistId
string versionId
string mediaId
string itemType
int sortOrder
int durationSeconds
int zoneIndex
}
MediaChannel {
string id
string organizationId
string name
string orientation
string deliveryMode
string approvalStatus
string status
}
LayoutTemplate {
string id
string name
string orientation
bool isSystem
}
LayoutZone {
string id
string templateId
string name
float widthPercent
float heightPercent
}
TimeBlock {
string id
string channelId
string playlistId
string blockType
string startDate
string endDate
string startTime
string endTime
string recurrence
string[] recurrenceDays
int priority
string color
}
TimeBlockZoneAssignment {
string timeBlockId
string zoneId
string playlistId
}
ChannelFallbackConfig {
string channelId
string standbySlate
string adSlate
string blackoutSlate
string defaultPlaylistId
}
TicketGroup {
uint ticketGroupId
string groupNameBm
string groupNameEn
string groupNameCn
string groupType
bool isActive
string activeStartDate
string activeEndDate
}
TicketVariant {
uint ticketVariantId
uint ticketGroupId
string nameEn
float unitPrice
}
Tag {
uint tagId
string tagName
string tagDesc
}
Banner {
uint bannerId
int placement
bool isActive
string activeStartDate
string activeEndDate
}
RailMenu {
uint railMenuId
string nameEn
int placement
bool isActive
}
Playlist ||--o{ PlaylistItem : "contains"
Playlist ||--o{ PlaylistVersion : "has versions (live/draft/historical)"
PlaylistVersion ||--o{ PlaylistItem : "scopes items via versionId"
PlaylistCanvasTemplate ||--o{ Playlist : "shapes zones for"
MediaChannel ||--o{ TimeBlock : "scheduled by"
TimeBlock ||--o{ TimeBlockZoneAssignment : "drives multiple zones"
LayoutTemplate ||--o{ LayoutZone : "has zones"
TicketGroup ||--o{ TicketVariant : "has variants"
TicketGroup }o--o{ Tag : "tagged with"
RailMenu }o--o{ TicketGroup : "contains (type: ticket)"
Rail Menu items are a discriminated union
A RailMenu's content array isn't purely TicketGroup rows — each entry is { type: "ticket" | "advertisement", placement, detail }, sorted by placement across both types. Advertisement entries come from the advertising domain (read-only here; no CRUD route exists under /railMenus for them). See Rail Menus below.
CMS Endpoints (/api/v1)
Headers: X-Organization-ID, optionally X-Workspace-ID, X-User-ID.
Playlists
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/playlists |
List playlists for org |
POST |
/api/v1/playlists |
Create playlist (accepts canvasTemplateId) |
GET |
/api/v1/playlists/:playlistId |
Get playlist by ID |
PUT |
/api/v1/playlists/:playlistId |
Rename / update playlist (accepts canvasTemplateId) |
DELETE |
/api/v1/playlists/:playlistId |
Delete playlist |
POST |
/api/v1/playlists/:playlistId/media?target=draft\|live |
Replace all media items (full sync) on the live version (default) or the draft version |
GET |
/api/v1/playlists/:playlistId/media?target=draft\|live |
List media items for a specific version (default live) |
DELETE |
/api/v1/playlists/:playlistId/media/:mediaId |
Remove a single media item |
PUT |
/api/v1/playlists/:playlistId/items/reorder |
Reorder items by new sort_order |
PATCH |
/api/v1/playlists/:playlistId/ready |
Mark ready — see Draft, Versioning & Publish |
POST |
/api/v1/playlists/:playlistId/draft |
Start a new draft, copied from the current live version |
POST |
/api/v1/playlists/:playlistId/publish-draft |
Publish the current draft to live |
POST |
/api/v1/playlists/:playlistId/rollback |
Restore an old version directly to live |
POST |
/api/v1/playlists/:playlistId/draft/rollback |
Restore an old version's content into the current draft |
GET |
/api/v1/playlists/:playlistId/versions |
List version history (live/draft/historical) |
GET |
/api/v1/playlist-canvas-templates |
List canvas templates (filter by orientation, multi_zone) |
Media sync is replace-all
POST /playlists/:id/media deletes all existing items for the targeted version and inserts the new list. There is no partial add — always send the full ordered list.
No auth middleware on CMS routes
Unlike the ticketing routes below, none of /api/v1/playlists or /api/v1/media-channels currently run through middleware.Protected/role checks — access control is left to the frontend and the X-Organization-ID/X-User-ID headers being trusted as-is. The PermPlaylistPublish ("ws:playlist:publish") permission constant exists but is not enforced by any handler in this domain; TellyboardAdmin checks it client-side before deciding whether to broadcast directly or route through Approvals (see the Provisioning domain).
Draft, Versioning & Publish
Each playlist has one live version at all times, at most one draft version, and any number of historical versions. Items belong to a specific version (playlist_items.version_id), so editing a draft never touches what's currently live.
stateDiagram-v2
[*] --> live: playlist created
live --> draft: POST /draft (copies live's items)
draft --> draft: POST /media?target=draft (edit items)
draft --> ready: PATCH /ready (readyState="ready")
ready --> live: POST /publish-draft (old live becomes historical)
historical --> live: POST /rollback (restores an old version directly)
historical --> draft: POST /draft/rollback (restores old content into the draft)
| Endpoint | Behavior |
|---|---|
POST /playlists/:id/draft |
409 if a draft already exists. Computes the next version number, inserts a draft row with readyState="test", and copies every item from the current live version into it. Response: { "message": "Draft started", "version": <int> }. |
POST /playlists/:id/media?target=draft |
Edits the draft's item list only (replace-all). If no draft exists yet, one is created from the supplied items directly — note this path does not copy from live the way POST /draft does. |
PATCH /playlists/:id/ready |
If a draft exists, sets its readyState="ready" ({"message": "Draft version marked ready"}). If no draft exists, falls back to the legacy non-versioned path (playlists.status: draft → active), for playlists that haven't adopted versioning yet. |
POST /playlists/:id/publish-draft |
404 if no draft exists. Marks the current live version historical, promotes the draft to live (clears readyState, stamps publishedBy/publishedAt), and updates playlists.version. Response: { "message": "Draft published", "version": <int> }. |
POST /playlists/:id/rollback |
Body: { "version": 3 } or { "version": "v003" }. Directly restores an old version to live (bypasses the draft stage entirely) — archives the current live version to historical first. |
POST /playlists/:id/draft/rollback |
Body: same shape. Copies an old version's items into the current draft (reusing it if one exists, clearing its items first), leaving live untouched. |
GET /playlists/:id/versions |
Returns the full history: { version, status, readyState, note, by, date, liveEndpointCount }[], newest first. |
Two different things both create a draft
POST /playlists/:id/draft copies the live version's items. POST /playlists/:id/media?target=draft (when no draft yet exists) creates one from scratch using only the items in that request body. Pick the one that matches the UI's intent — "start editing from what's live" vs. "replace the draft outright."
Playlist Canvas Templates
GET /api/v1/playlist-canvas-templates lists reusable multi-zone canvas layouts ({ id, organizationId, name, orientation, aspectRatio, multiZone, zoneCount, zones, isSystem }, org-scoped-or-system, is_system DESC, name ASC). Passing canvasTemplateId on POST/PUT /playlists copies aspectRatio, multiZone, and zoneCount onto the playlist (400 "Invalid canvasTemplateId" if the ID doesn't resolve in-scope).
Current User
GET /api/v1/me (requires X-User-ID) returns the caller's { id, displayName, permissions[] }, resolving permissions from the user's org role via organization_members + role_permissions.
Media Channels
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/media-channels |
List all channels for org |
POST |
/api/v1/media-channels |
Create channel |
GET |
/api/v1/media-channels/:channelId |
Get channel by ID |
PUT |
/api/v1/media-channels/:channelId |
Partial update (dynamic fields) |
DELETE |
/api/v1/media-channels/:channelId |
Delete channel |
GET |
/api/v1/media-channels/:channelId/zones |
List zone assignments |
POST |
/api/v1/media-channels/:channelId/zones |
Set zone assignment (upsert by zone) |
GET |
/api/v1/media-channels/:channelId/spaces |
List assigned spaces with device counts |
POST |
/api/v1/media-channels/:channelId/spaces |
Assign channel to a space |
DELETE |
/api/v1/media-channels/:channelId/spaces/:spaceId |
Unassign channel from space |
GET |
/api/v1/media-channels/:channelId/time-blocks |
List time blocks (filtered by date range) |
POST |
/api/v1/media-channels/:channelId/time-blocks |
Create time block |
PUT |
/api/v1/media-channels/:channelId/time-blocks/:blockId |
Update time block |
DELETE |
/api/v1/media-channels/:channelId/time-blocks/:blockId |
Delete time block |
GET |
/api/v1/media-channels/:channelId/fallback-config |
Get fallback config |
PUT |
/api/v1/media-channels/:channelId/fallback-config |
Upsert fallback config |
A time block can drive multiple zones at once
POST/PUT .../time-blocks accept an optional zone_assignments: [{ zone_id, playlist_id, items }] array instead of (or alongside) a single top-level playlistId. If a zone's items array is non-empty, the handler auto-creates a new playlist on the fly (named TB-<blockId prefix>-Zone<N>, populated either by copying an existing playlist or from inline media objects) and assigns it to that zone — this is how the "assign a base playlist per zone" step in a Feed's schedule works. ChannelZoneAssignment.autoGenerated flags zone assignments created this way (or from a channel's basePlaylistId at creation) versus ones set manually via POST .../zones.
Layout Templates
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/layout-templates |
List templates (filter by orientation, delivery_profile) |
Ticketing Endpoints (/api)
Response envelope and side effects
All ticketing responses are wrapped in a standard envelope ({ success, message, data } / { success: false, message } from pkg/models), unlike the bare-JSON CMS endpoints above. Nearly every mutating handler also fires an audit notification (notificationService.CreateNotification(admin, role, category, title, message, timestamp)) as a side effect — not user-visible in the response, but worth knowing if notification volume looks unexpected.
Ticket Groups
Updated 2026-09-16 — admin routes now carry :workspaceId
An in-progress multi-tenancy migration ("Step 1-5 of ticketing
multi-tenancy") moved every admin/mutating route below off a single
hardcoded workspace constant onto a per-request :workspaceId URL
segment, checked against the caller's real workspace membership
(RequireWorkspacePermission, pkg/middleware/auth_middleware.go).
Verified directly against domain/catalogue/routes/routes.go — trust
that file over this table if they ever disagree again.
Public endpoints (no auth required unless stated).
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/ticketGroups |
— | List active ticket groups (customer) |
GET |
/api/ticketGroups/ticketProfile |
— | Full ticket group profile |
GET |
/api/ticketGroups/ticketVariants |
— | Available variants |
GET |
/api/ticketGroups/ticketAvailability |
— | Availability per variant |
GET |
/api/ticketGroups/ticketAvailability/stream |
— | SSE stream, 30s POS poll |
GET |
/api/ticketGroups/ticketServiceTypes |
— | Service type options |
GET |
/api/ticketGroups/attachment/:uniqueExtension |
— | Get cover image |
GET |
/api/ticketGroups/closedDates |
— | Operating-calendar closed dates |
GET |
/api/ticketGroups/stream |
— | SSE ticket group change events |
POST |
/api/ticketGroups/:workspaceId |
workspace: catalogue.manage |
Create ticket group |
PUT |
/api/ticketGroups/:workspaceId/basicInfo |
workspace: catalogue.manage |
Update basic info |
PUT |
/api/ticketGroups/:workspaceId/image |
workspace: catalogue.manage |
Replace cover image |
PUT |
/api/ticketGroups/:workspaceId/placements |
workspace: catalogue.manage |
Update display placements |
PUT |
/api/ticketGroups/:workspaceId/details |
workspace: catalogue.manage |
Update ticket details sections |
PUT |
/api/ticketGroups/:workspaceId/operatingCalendar |
workspace: catalogue.manage |
Update closed days/exceptions |
PUT |
/api/ticketGroups/:workspaceId/variants |
workspace: catalogue.manage |
Update pricing variants |
PUT |
/api/ticketGroups/:workspaceId/variantOverrides |
workspace: catalogue.manage |
Update per-variant overrides |
PUT |
/api/ticketGroups/:workspaceId/organiserInfo |
workspace: catalogue.manage |
Update organiser info |
POST |
/api/ticketGroups/:workspaceId/gallery |
workspace: catalogue.manage |
Upload gallery image |
DELETE |
/api/ticketGroups/:workspaceId/gallery |
workspace: catalogue.manage |
Delete gallery image |
Create is multipart, not JSON
POST /ticketGroups/:workspaceId is multipart/form-data: ~40 scalar form fields plus a required attachment file (.jpg/.jpeg/.png/.pdf, ≤50MB) and optional groupGalleries files (.jpg/.jpeg/.png/.gif, ≤50MB each). ticketDetails, ticketVariants, and ticketTags are passed as JSON-encoded strings inside form fields, not as nested multipart objects. ticketVariants/ticketAvailability/ticketServiceTypes proxy an external scheduling API and require ticketGroupId + date (YYYY-MM-DD) query params (400 if missing/invalid).
Integration Config (Payment / POS profile assignment, per ticket group)
Added 2026-08-27 (70c05d2) as a singleton-replacing per-product config row
holding raw credentials directly; rewritten 2026-09-18 into a pure
association — credentials moved out to org-owned profiles (below), this
table just says which profile a given ticket group uses.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/ticketGroups/:workspaceId/integrations |
workspace: catalogue.manage |
List every product + configured status (no credential values) |
GET |
/api/ticketGroups/:workspaceId/:ticketGroupId/integration |
workspace: catalogue.manage |
Get one product's paymentProfileId/posProfileId |
PUT |
/api/ticketGroups/:workspaceId/:ticketGroupId/integration |
workspace: catalogue.manage |
Assign/unassign a profile — either field may be null |
DELETE |
/api/ticketGroups/:workspaceId/:ticketGroupId/integration |
workspace: catalogue.manage |
Delete the association row |
Known gap — workspace permission ≠ resource ownership (still open)
RequireWorkspacePermission only checks "does the caller have catalogue.manage in :workspaceId" — it never checks "does :ticketGroupId actually belong to :workspaceId." TicketGroup's own CRUD already does this correctly via FindByIDInWorkspace — Integration Config still hasn't had the same fix applied.
New known gap — profile assignment isn't org/workspace-checked either
The PUT above accepts any paymentProfileId/posProfileId and only relies on the database FK to prove the profile exists — it never checks that the profile's organizationId (or workspaceId, if set) matches this ticket group's own scope. A real FK constraint (fk_ticket_group_integration_config_payment_profile, etc.) prevents a dangling reference, but not a cross-org one. Same shape of gap as the one above, just on the new table.
Payment / POS Profiles (org-owned, public schema)
New 2026-09-18 — same shape as EmailProfile: one credential set per org
(optionally narrowed to one workspace), reusable across every ticket group
under it. Native /api/v1 lane, not the flat ticketing root paths above.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/v1/tenancy/organizations/:orgId/payment-profiles |
org: org:payment-profiles:view |
List the org's payment gateway profiles |
POST |
/api/v1/tenancy/organizations/:orgId/payment-profiles |
org: org:payment-profiles:manage |
Create one |
GET |
/api/v1/tenancy/organizations/:orgId/pos-profiles |
org: org:pos-profiles:view |
List the org's POS vendor profiles |
POST |
/api/v1/tenancy/organizations/:orgId/pos-profiles |
org: org:pos-profiles:manage |
Create one |
Granted only to ORG_OWNER/ORG_ADMIN (ebaa1c5, 2026-09-18 — these
permission constants existed for a full week before any role was actually
seeded with them, so every payment/POS request 403'd until this fix landed).
(organizationId, slug) has a unique index — a duplicate name for the
same org now fails create rather than silently shadowing the earlier
profile. A deactivated profile (isActive: false, no API to set this yet)
is rejected at credential-resolve time, not just hidden in list views —
unlike EmailProfile, which still has the opposite bug (IsActive never
checked at send time, see CLAUDE.md §5).
Minimal on purpose — no update/delete yet
Only create + list exist. No UI is wired to these yet either — OrgConsole's Commerce tab (Settings → Integrations) still shows Nucha's original hardcoded mockup (Johor Zoo API, JohorPay Gateway, Micros KLCC, etc.) with zero real API calls behind it; it needs rebuilding against these endpoints, using the working Messaging tab (email profiles) as the template.
The old per-product Email fields are gone, not dead-code-flagged
Ticketing's per-product email config (emailUsername/emailPassword/etc.) has been removed entirely, not just left unused. Ticketing's real email sends should route through the platform's existing EmailProfile/EmailUsageMapping system (already used by org invites) via a ticketing-specific usage type — not rebuilt as a third parallel mechanism.
Tags
Updated 2026-09-16 — admin routes now carry :workspaceId
See the note under Ticket Groups above — same migration, same middleware.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/tags |
— | List all tags |
POST |
/api/tags/:workspaceId |
workspace: catalogue.manage |
Create tag |
PUT |
/api/tags/:workspaceId |
workspace: catalogue.manage |
Update tag |
DELETE |
/api/tags/:workspaceId |
workspace: catalogue.manage |
Delete tag |
Banners
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/banners |
— | List active banners (customer) |
GET |
/api/banners/:workspaceId/all |
workspace: catalogue.manage |
List all banners (admin) |
GET |
/api/banners/attachment/:uniqueExtension |
— | Get banner image |
POST |
/api/banners/:workspaceId |
workspace: catalogue.manage |
Create banner (with image upload) |
PUT |
/api/banners/:workspaceId |
workspace: catalogue.manage |
Update banner |
DELETE |
/api/banners/:workspaceId |
workspace: catalogue.manage |
Delete banner |
PUT |
/api/banners/:workspaceId/placements |
workspace: catalogue.manage |
Reorder banners |
Create/Update are multipart
POST/PUT /banners/:workspaceId are multipart/form-data: redirectUrl, activeStartDate/activeEndDate, isActive, duration (int, ≥1), plus an attachment file. uploadedBy comes from the auth context, not the form body.
Rail Menus
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/railMenus/customer |
— | Customer view: ticket groups + ads, ads filtered to their active date window |
GET |
/api/railMenus/:workspaceId |
workspace: catalogue.manage |
Admin view: ticket groups + all ads, regardless of active window |
POST |
/api/railMenus/:workspaceId |
workspace: catalogue.manage |
Create rail menu |
PUT |
/api/railMenus/:workspaceId |
workspace: catalogue.manage |
Update rail menu |
DELETE |
/api/railMenus/:workspaceId |
workspace: catalogue.manage |
Delete rail menu |
PUT |
/api/railMenus/:workspaceId/placements |
workspace: catalogue.manage |
Reorder all rail menus |
POST |
/api/railMenus/:workspaceId/ticketGroups |
workspace: catalogue.manage |
Assign ticket groups to rail menu |
DELETE |
/api/railMenus/:workspaceId/ticketGroups |
workspace: catalogue.manage |
Remove ticket groups from rail menu |
Rail menu content is ticket groups and advertisements, merged
A rail menu's content array is a discriminated union, sorted by placement across both types: { type: "ticket", placement, detail: TicketGroupDTO } or { type: "advertisement", placement, detail: AdvertisingTicketDTO }. Advertisement entries are read from the advertising domain (RailMenuAdvertisingTicketRepository) — there is no create/update/delete route for ads under /railMenus; they're managed in the advertising domain and only surfaced here on read. AdvertisingTicketDTO fields: advertisementId, advertisementType (brandImage|coupon|editorial|typographic), advertiser, color, title{Bm,En,Cn}, header{Bm,En,Cn}, desc{Bm,En,Cn}, actionButton, redirectUrl, imageUrl, isActive. The customer endpoint filters ads by isActive and the active-date window (server's Malaysia-timezone "now"); the admin endpoint does not filter.
Group Gallery
| Method | Path | Description |
|---|---|---|
GET |
/api/groupGallery/attachment/:uniqueExtension |
Get gallery image |
Time Block Scheduling
Time blocks define what playlist plays on a channel during a given window.
sequenceDiagram
participant Admin
participant CoreAPI
participant DB
Admin->>CoreAPI: POST /media-channels/:id/time-blocks
Note over Admin,CoreAPI: { name, blockType, startDate, endDate,\n startTime, endTime, recurrence,\n recurrenceDays, priority, playlistId }
CoreAPI->>DB: INSERT time_blocks
CoreAPI-->>Admin: 201 { id }
A default block (priority 0) is synthesised from the channel's zone assignment if no scheduled block covers a window (id: "default-"+zoneID, recurrence: "daily", color: "#9ca3af").
Background Playout Scheduler
A recurring goroutine (StartChannelScheduler, started once at boot, re-run every 15s) decides what each channel should actually be showing right now and pushes it to devices — this is what makes scheduled time blocks take effect without any per-request trigger.
flowchart LR
Tick["Every 15s"] --> Resolve["Resolve active time blocks\nper channel (date/time/recurrence)"]
Resolve --> Rank["Rank by type\n(override/event=10, scheduled=5, default=1),\nthen priority, then recency"]
Rank --> Changed{"Winning block\nchanged or 5min elapsed?"}
Changed -->|no| Skip["Skip — already dispatched"]
Changed -->|yes| Dispatch["Dispatch to online devices\nin the channel's assigned spaces"]
Dispatch --> MQTT["Has playlistId?\nMQTT PLAY (retained, QoS 1)"]
Dispatch --> HTTP["No playlistId (zone-driven)?\nInternal HTTP POST /devices/:id/play"]
- "Online" =
last_seen_atwithin the last 5 minutes. - Per-channel in-memory state (
activeStates) avoids redundant re-dispatch; an override block ending with nothing to replace it resumes the channel's default/fallback block. ResyncDeviceOnReconnect(deviceId)(called from the provisioning domain's heartbeat/reconnect path) re-sends just the current active state to a single device that just came back online, without waiting for the next tick.
This is orthogonal to the Provisioning domain's Broadcast feature
The scheduler drives scheduled content per channel/time-block. Broadcast to Space in the Provisioning domain is a separate, ad-hoc "push this now, ignore the schedule" mechanism used for one-off/always-on assignments.
Approval Flow (Media Channels)
stateDiagram-v2
[*] --> draft
draft --> pending_review: submit
pending_review --> approved: admin approves
pending_review --> rejected: admin rejects
rejected --> draft: edit & resubmit
approved --> active: go live
approvalStatus transitions via PATCH on the channel's approvalStatus field in PUT /media-channels/:id.
Key Creation Flows
Create a Playlist and Add Media
POST /api/v1/playlists
Header: X-Organization-ID: <org-uuid>
Body: { "name": "Morning Show" }
→ Returns: { "playlistId": "uuid" }
POST /api/v1/playlists/:playlistId/media
Body: [
{ "mediaId": "...", "position": 0, "duration": 30, "name": "...", "mediaType": "video" },
{ "mediaId": "...", "position": 1, "duration": 15, ... }
]
→ Clears existing items, inserts new ones, recalculates totalDuration + version
Media sync is replace-all
POST /playlists/:id/media deletes all existing items before inserting. Always send the full ordered list.
Thumbnail is derived, not stored on create
There's no separate thumbnail endpoint. GET /playlists computes thumbnailUrl per playlist from its live version's first item (by sort_order): the item's own thumbnailUrl if set, else the item's media URL if it's an image. GET /playlists/:id does not populate this field — only the list endpoint does.
Start, Edit, and Publish a Playlist Draft
POST /api/v1/playlists/:playlistId/draft
→ Copies every item from the live version into a new draft version
→ Returns: { "message": "Draft started", "version": 3 }
POST /api/v1/playlists/:playlistId/media?target=draft
Body: [ { "mediaId": "...", "position": 0, "duration": 30, ... } ]
→ Replaces the draft's items only — live is untouched
→ Returns: { "success": true, "count": 1 }
PATCH /api/v1/playlists/:playlistId/ready
→ Sets the draft's readyState to "ready"
→ Returns: { "message": "Draft version marked ready" }
POST /api/v1/playlists/:playlistId/publish-draft
→ Current live version becomes historical; draft becomes the new live
→ Returns: { "message": "Draft published", "version": 3 }
Not visible anywhere until published
A draft's items never reach a device — the scheduler and /media?target=live (the default) only ever read the live version. Publishing is the only thing that promotes draft content to what's actually broadcast.
Roll Back or Restore a Playlist Version
# Restore an old version directly to live (skips the draft stage)
POST /api/v1/playlists/:playlistId/rollback
Body: { "version": 2 }
→ Archives current live to historical, restores v2 as the new live
→ Returns: { "message": "Rolled back", "version": 2 }
# Restore an old version's content into the current draft instead
POST /api/v1/playlists/:playlistId/draft/rollback
Body: { "version": "v002" }
→ Reuses (or creates) the draft, copies v002's items into it
→ live is untouched
→ Returns: { "message": "Restored content from v002 into current draft (v004)", "version": 4 }
Create a Media Channel with a Base Playlist
POST /api/v1/media-channels
Headers: X-Organization-ID, X-User-ID
Body: {
"name": "Lobby Channel",
"orientation": "landscape",
"deliveryMode": "pre-cached",
"basePlaylistId": "playlist-uuid", ← optional: auto-creates zone assignment
"layoutTemplateId": "template-uuid" ← optional
}
→ If basePlaylistId provided: auto-creates channel_zone_assignment
(uses first zone of layoutTemplate, or falls back to channelId as zoneId)
→ Returns: { "id": "channel-uuid" }
Assign Channel to a Space (Push to Devices)
POST /api/v1/media-channels/:channelId/spaces
Headers: X-User-ID
Body: { "spaceId": "location-uuid" }
→ Inserts channel_space_assignments (ON CONFLICT DO NOTHING)
→ Queries playlist items from channel's zone assignment
→ For each device in the space: publishes MQTT to
topic: "tellyboard/devices/{deviceId}/play"
payload: { id, playlistName, items: [...] }
→ Also dispatches an internal HTTP POST to each device's
{INTERNAL_API_URL}/api/v1/devices/:deviceId/play (in addition to MQTT)
→ Returns: { message, devices, sentCount, playlistId }
Schedule a Time Block
POST /api/v1/media-channels/:channelId/time-blocks
Headers: X-User-ID
Body: {
"name": "Lunch Promo",
"blockType": "scheduled",
"playlistId": "playlist-uuid",
"startDate": "2026-06-01",
"endDate": "2026-08-31",
"startTime": "11:30",
"endTime": "13:30",
"recurrence": "weekly",
"recurrenceDays": ["mon","tue","wed","thu","fri"],
"priority": 20,
"color": "#f59e0b"
}
→ Returns: { "id": "block-uuid" }
Priority
Higher priority wins when blocks overlap. Default blocks (from zone assignments) always have priority: 0.
Multi-zone alternative: zone_assignments
Instead of (or in addition to) a top-level playlistId, the body can carry zone_assignments: [{ zone_id, playlist_id, items }] to drive several zones from one time block. If a zone's items array is supplied inline, the handler auto-creates a playlist for that zone on the fly and assigns it — see Media Channels above.