Each studio has at most one home banner. It appears on the home screen of the trainee app when it is published. A banner is either written by hand (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.
When 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, mediaThumbnailUrl and ctaLabel become null.
  • ctaUrl gets https:// prefixed when it does not contain ://.
  • When source is content, the fields title, description, mediaKind, mediaUrl and mediaThumbnailUrl are forced to null. 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.
The 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:
  • source is not content.
  • mediaKind is video or youtube.
  • The URL matches one of two patterns: a video file ending in .mp4, .m4v, .mov, .qt, .webm, .mkv or .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).
The kind alone is not trusted because the web panel infers it from a pasted link. A share page such as an Instagram reel, a Drive file or a YouTube playlist is neither an image nor a playable video, and handing it to the app would open an empty full-screen player. The file extension list is wider than the panel’s own check on purpose, because uploads keep the extension of the coach’s original file name.

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 isPublished is false.
  • source is content and the linked item is missing or has no title.
  • source is custom and title is empty.
Otherwise it returns a TraineeBanner:
Resolution rules:
  • Content banner. title and description come from the item. Media is thumbnailUrl when present, otherwise the item’s mediaUrl. With a thumbnail, mediaKind is image. Without one, it is the item’s own kind, falling back to image for an unknown value. mediaVideoUrl is always null, because the button already opens the content item screen, which has its own player.
  • Custom banner with image or YouTube media. mediaUrl and mediaKind pass through. A stored mediaKind that is not image, video or youtube (including none) is read as image.
  • Custom banner with video media. mediaUrl is only ever drawn as a still, so it is replaced by mediaThumbnailUrl. With no thumbnail, both mediaUrl and mediaKind become null and the card renders as text only. mediaVideoUrl still carries the playable URL so the button can open the player.
  • Button. ctaKind: "none" gives cta: null. content with an item gives a button that opens that item. content with no item but a playable own video gives a button with contentItemId: null and url: null, and the app plays mediaVideoUrl. content with neither gives no button. link needs ctaUrl, otherwise no button. whatsapp always gives a button with no URL. ctaColor and ctaIcon fall back to brand and none when the stored value is not in the enum.
The 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.