Skip to content

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.

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_at within 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.