source: "custom") or mirrors an item from the content library (source: "content"), and it can carry one button.
The Prisma model is
HomeBanner, with studioId marked @unique. The save route is an upsert keyed on studioId, so there is no create or delete route and no banner id in any path.
The save route is
PATCH, but the handler does not merge. The body is parsed with defaults for every field and the whole result is written. A field you leave out is reset to its default. Always send the full banner.Endpoints
GET /v1/web/home-banner
Returns the studio’s banner, or null when none has been saved yet.
Auth: web lane, any role.
No parameters.
Response: data.banner is the HomeBanner row plus a contentItem relation, or null.
contentItemId is set, contentItem carries id, type, title, description, mediaKind, mediaUrl, thumbnailUrl and isPublished from the linked ContentItem.
Errors: UNAUTHORIZED with no studio context.
PATCH /v1/web/home-banner
Creates or replaces the studio’s banner.
Auth: web lane, any role.
boolean
default:"false"
Whether the trainee app shows the banner.
string
default:"custom"
custom or content. With content, the banner’s title, description and media are inherited from the linked content item at read time.string | null
default:"null"
A
ContentItem in the same studio. Required when source is content. Also the button target when ctaKind is content.string | null
default:"null"
Trimmed, maximum 120 characters. Required when
source is custom.string | null
default:"null"
Trimmed, maximum 400 characters.
string | null
default:"null"
One of
image, video, youtube, none.string | null
default:"null"
Trimmed. An uploaded file or a pasted link.
string | null
default:"null"
Trimmed. The poster frame for a video banner.
string
default:"start"
start, center or end.string
default:"none"
none, content, whatsapp or link.string | null
default:"null"
Trimmed, maximum 40 characters.
string | null
default:"null"
Trimmed. Required when
ctaKind is link.string
default:"brand"
brand, white, green, yellow or glass.string
default:"none"
none, play, instagram, whatsapp, facebook, cart, article, recipe, dumbbell or muscle.Cross-field rules
saveBannerBody runs a superRefine after the field checks. Each failure is a VALIDATION error with the listed path and message.
The third rule uses
bannerOwnVideoUrl. A content button with no item is allowed on a custom banner whose own media is a playable video. In that case the button means “play this banner’s video”.
Transform before save
After validation the schema rewrites the value:- Empty strings in
title,description,mediaUrl,mediaThumbnailUrlandctaLabelbecomenull. ctaUrlgetshttps://prefixed when it does not contain://.- When
sourceiscontent, the fieldstitle,description,mediaKind,mediaUrlandmediaThumbnailUrlare forced tonull. The banner stores nothing of its own and reads from the content item.
Service
save checks that contentItemId, when present, belongs to the caller’s studio. If it does not, it throws VALIDATION with the message content item not found (not NOT_FOUND). It then calls repo.save, a Prisma upsert on studioId. There are no other side effects: no notification, no activity record and no job.
Response: data.banner, same shape as the GET response.
contentItem.type value above is illustrative. The set of content types is defined by the content module.
Errors: VALIDATION (422) for schema failures, the cross-field rules, and a content item outside the studio. UNAUTHORIZED with no studio context.
What counts as a playable video
bannerOwnVideoUrl in home-banner.schema.ts decides whether a banner’s own media can be opened by the app’s player. It returns the trimmed mediaUrl only when all of these hold:
sourceis notcontent.mediaKindisvideooryoutube.- The URL matches one of two patterns: a video file ending in
.mp4,.m4v,.mov,.qt,.webm,.mkvor.m3u8(optionally followed by a query string), or a YouTube or Vimeo page that carries a real video id (youtube.com/watch?v=,/shorts/,/embed/,/live/,/v/,youtu.be/,vimeo.com/with at least six digits).
How the trainee app receives the banner
This module has no trainee route.modules/trainee/trainee.service.ts loads the banner record and passes it through resolveTraineeBanner (exported from home-banner.service.ts) when it builds the trainee home payload. The resolver returns null, meaning no banner, in these cases:
- There is no banner, or
isPublishedisfalse. sourceiscontentand the linked item is missing or has no title.sourceiscustomandtitleis empty.
TraineeBanner:
- Content banner.
titleanddescriptioncome from the item. Media isthumbnailUrlwhen present, otherwise the item’smediaUrl. With a thumbnail,mediaKindisimage. Without one, it is the item’s own kind, falling back toimagefor an unknown value.mediaVideoUrlis alwaysnull, because the button already opens the content item screen, which has its own player. - Custom banner with image or YouTube media.
mediaUrlandmediaKindpass through. A storedmediaKindthat is notimage,videooryoutube(includingnone) is read asimage. - Custom banner with video media.
mediaUrlis only ever drawn as a still, so it is replaced bymediaThumbnailUrl. With no thumbnail, bothmediaUrlandmediaKindbecomenulland the card renders as text only.mediaVideoUrlstill carries the playable URL so the button can open the player. - Button.
ctaKind: "none"givescta: null.contentwith an item gives a button that opens that item.contentwith no item but a playable own video gives a button withcontentItemId: nullandurl: null, and the app playsmediaVideoUrl.contentwith neither gives no button.linkneedsctaUrl, otherwise no button.whatsappalways gives a button with no URL.ctaColorandctaIconfall back tobrandandnonewhen the stored value is not in the enum.
contentItemId: null shape for “play this banner’s video” is deliberate. An older app build hides a content button with no item, so it shows the banner without a button instead of a button that does nothing.