The content module: static pages, and the banner placement/configuration/image model this file focuses on.
docs/catalog.json is the exhaustive list of this module's tables, routes and events. This file
does not repeat it.
- Pages. A slug-addressed content record with a publish gate - draft pages 404 on the public read path exactly like a nonexistent slug, so there is no draft-existence leak.
- Banner placements. A placement is a free-form operator key (a page section, a slot name -
whatever the consuming frontend calls it) with zero or more
bannerConfigurationrows, each a layout plus a set ofbannerImagerows. - Banner image URLs, not banner image bytes. A
bannerImagerow carries a desktop URL, a mobile URL, and an optional link - never a file. This module has no upload endpoint and no storage adapter; see "Expected consumer integration" below.
- A placement is nothing more than the string every
bannerConfigurationfor that section shares. It has no row of its own -listBannerPlacementsderives the placement list from the distinctplacementvalues onbannerConfiguration. - A configuration is a named layout (
carousel|grid|single) plus the images that go with it. An operator can keep several configurations per placement - a seasonal swap, an A/B draft - and only one is ever live. - Exactly one default (= "live") configuration per placement is the invariant. It is enforced
in the database, not in application code:
banner_configuration_default_per_placement_idxis a partial unique index onplacementWHERE is_default = true.setDefaultConfigurationclears any other default for the placement and sets the new one inside the same transaction, so two concurrent "make me the default" calls can never both win - the index rejects the loser. - Each layout has an image-count bound (
BANNER_IMAGE_COUNT_BOUNDSin the contract):singleneeds exactly 1,gridneeds exactly 3,carouselneeds 2-6.setDefaultConfigurationchecks the configuration's slot count (distinctsortOrderamong its default-locale images) against its layout's bounds before promoting it, and refuses withBannerConfigurationImageCountErrorotherwise - a placement can never go live half-built. - Deleting a configuration that is the current default is refused
(
BannerConfigurationIsDefaultError) - unset or replace the default first, then delete.
A non-default configuration can carry at most one bannerSchedule row - a [startsAt, endsAt)
window that makes it the placement's live banner for that window, then automatically reverts to
the standing default, with no admin action at either boundary:
createBannerSchedule(POST /cms/banner-configurations/{id}/schedule) targets a configuration that is not itself the default, in a placement that already has one (a schedule layers onto a default, it doesn't replace the concept of one).startsAtmust be in the future;endsAtmust be afterstartsAt; a configuration that already has a schedule refuses a second one (BannerConfigurationHasScheduleError) - schedule a fresh configuration instead. An overlapping window on the same placement is refused withBannerScheduleOverlapError, which carries the conflicting schedule's ownstartsAt/endsAtso the caller can show the collision without a second lookup; two schedules that only touch at a boundary (new.startsAt === existing.endsAt) do not conflict.updateBannerScheduleEnd(PUT /cms/banner-configurations/{id}/schedule) editsendsAtonly - there is no route to changestartsAtor to cancel a schedule outright. MovingendsAtto now or the past is how a live schedule is ended early. The overlap check reruns, excluding the schedule's own configuration. Once its end time has passed, a schedule is final: it cannot be extended back into the future. Schedule a fresh configuration instead.- The standing default remains in place while a schedule is active or still ahead. Unsetting it is refused until every schedule for the placement has ended, so a scheduled banner always has a configuration to revert to. Expired schedules do not prevent unsetting the default.
listBannerSchedulesByPlacement(GET /cms/banner-placements/{placement}/schedules) lists every schedule for a placement, ordered bystartsAt, each with its configuration's summary.- "What's live" is resolved at read time inside
getPublicBanner, not by a background job: a schedule whose window containsnow()wins over the placement default, otherwise the default renders as before - same auto-expiry approach asrgExclusionin the compliance module. This means a schedule boundary can lag by up to the sameCMS_CACHE_TTL_MSthis module already accepts for any other public-read staleness; nothing needs to invalidate the cache at the boundary itself. - Deleting a configuration with any attached schedule is refused unconditionally - queued,
currently active, or already expired all fail the same way
(
BannerConfigurationHasScheduleError). An expired schedule's configuration simply stays around as an inert row; there is no cleanup job for this ticket's scope.
A bannerImage row's locale defaults to the sentinel DEFAULT_LOCALE ('default'), which is
also the locale every image-count and layout check runs against. A locale override reuses an
existing sortOrder slot rather than adding one: setBannerImage upserts on
(bannerConfigurationId, sortOrder, locale), so calling it twice for the same key updates the row
in place. The public read (getPublicBanner) resolves slots from the default-locale rows, and for
each slot swaps in the requested locale's row when one exists, otherwise falling back to the
default-locale row for that slot - a partially translated configuration still renders completely.
Core never touches image bytes. bannerImage.desktopImageUrl / mobileImageUrl are validated
against PlatformConfig.cms.allowedBannerImageHosts (mirroring the chat-attachment host
allow-list): https: only, and the hostname must equal or be a subdomain of an allowed entry.
A URL that fails validation is rejected with BannerImageHostNotAllowedError before it is ever
written - allowedBannerImageHosts empty means no banner images can be set at all.
This module is intentionally silent about where images come from. A downstream operator wires:
- Its own image upload and storage pipeline, entirely outside this contract - object storage, a CDN, a DAM, whatever it already runs. Core has no upload endpoint and never will; that keeps a storage vendor out of the contract this repository owns.
PlatformConfig.cms.allowedBannerImageHosts, pointed at wherever that pipeline serves images from (its CDN hostname, its bucket's public hostname, etc.) - otherwise everysetBannerImagecall is rejected.- The admin routes (
listBannerPlacements,listBannerConfigurationsByPlacement,createBannerConfiguration,getBannerConfiguration,setBannerImage,deleteBannerImage,setDefaultBannerConfiguration,unsetDefaultBannerConfiguration,deleteBannerConfiguration,createBannerSchedule,updateBannerScheduleEnd,listBannerSchedulesByPlacement) from its backoffice, to manage configurations, images, and schedules per placement. - The public route (
getPublicBanner) from its player-facing app, to render the live configuration for a placement -{ placement, layout, slots },nullwhen nothing is live yet. - A storage cleanup subscriber for the banner mutation events. Their
droppedImageUrlspayload field contains URLs that no remaining banner image row referenced when the mutation committed. Treat them as cleanup candidates and check current CMS references again immediately before deleting an object, because a later mutation can reuse a URL.