Banner Module
HTTP surface for promotional banners attached to catalog entities (categories, brands, tags, ingredients) — admin CRUD + unauthenticated storefront read by entity slug.
HTTP surface for promotional banners attached to catalog entities (categories, brands, tags, ingredients) — admin CRUD + unauthenticated storefront read by entity slug.
Source:
api-modules/banner(registered viaBannerModule.forRoot()inapps/api/src/app.module.ts).Banner attaches to one catalog entity via a polymorphic
(entity_type, entity_id)pair. The catalog module's admin list endpoints surface abannerCountso admins can see how many banners are attached before deleting a brand/category.
Conventions
Authentication
| Endpoint group | Auth | Permission |
|---|---|---|
GET /store/banners/:entityType/slug/:slug | none | — |
GET /admin/banners, GET /admin/banners/:id | required | banner: read |
POST /admin/banners | required | banner: create |
PUT /admin/banners/:id, POST /admin/banners/:id/restore | required | banner: update |
DELETE /admin/banners/:id | required | banner: delete |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT, FOREIGN_KEY_VIOLATION (when entityId doesn't resolve) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
Banners soft-delete via deletedAt. Hidden from default listings and from the storefront read, but GET /admin/banners?includeDeleted=true (via the generic filters field) returns them.
Domain types
BannerResponse
type BannerEntityType = "category" | "brand" | "tag" | "ingredient";
type Platform = "APP" | "WEB" | "BOTH";
type BannerResponse = {
id: string;
entityType: BannerEntityType;
entityId: string;
image: string; // storage key (S3) — resolve to URL client-side
url: string; // click destination
platform: Platform;
isActive: boolean;
sortOrder: number; // ascending; ties broken by createdAt desc
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
updatedAt: string;
deletedAt: string | null;
};URL rules
url is validated to be either an absolute https:// URL or a relative path that begins with /. Plain http://, javascript:, mailto:, etc. are rejected. The storefront uses banner URLs verbatim — keep them sanitized.
Image storage
image is the S3 key returned by the storage module's presigned-upload flow. The banner module does not validate the key format or that the object exists — it's a length-only check (1..1024 chars). The storefront resolves keys to public URLs via the storage CDN.
Admin
Base path: /admin/banners.
GET /admin/banners — List banners
Required permission: banner: read. Standard QueryDto (page / limit / search / filters) plus first-class banner filters.
Query
| Name | Type | Notes |
|---|---|---|
entityType | BannerEntityType? | filter to one of category / brand / tag / ingredient |
entityId | string? | filter to one entity (combine with entityType) |
platform | Platform? | filter to APP / WEB / BOTH |
isActive | boolean? | coerced from true/false |
page, limit, search, filters[] | — | standard QueryDto |
Response 200 — paginated envelope of BannerResponse[].
GET /admin/banners/:id
Required permission: banner: read. 404 on unknown id.
POST /admin/banners — Create
Required permission: banner: create.
Body
{
"entityType": "brand",
"entityId": "01J9...", // must reference a non-deleted entity
"image": "banners/2026-05/festive.jpg",
"url": "/sale", // or "https://example.com/sale"
"platform": "BOTH", // default "BOTH"
"isActive": true, // default true
"sortOrder": 0, // default 0
"metadata": { "campaign": "Q2" } // optional
}| Field | Type | Constraints |
|---|---|---|
entityType | enum | category / brand / tag / ingredient |
entityId | string | Required; non-deleted row of the matching taxonomy table |
image | string | 1..1024 chars; storage key |
url | string | 1..2048 chars; https://... or relative /path |
platform | enum | APP / WEB / BOTH. Default BOTH |
isActive | boolean? | Default true |
sortOrder | int? | >= 0. Default 0 |
metadata | Record<string, unknown> | null? | free-form |
Response 201 — BannerResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod (bad URL, unknown entity type, etc.) |
| 404 | NOT_FOUND | entityId doesn't resolve to a non-deleted row of entityType |
PUT /admin/banners/:id — Update
Required permission: banner: update. entityType and entityId are immutable — to move a banner to a different entity, soft-delete and re-create. Keeps service-layer validation simple and makes the intent explicit.
Body — partial CreateBannerInput minus entityType / entityId.
Response 200 — BannerResponse.
DELETE /admin/banners/:id — Soft delete
Required permission: banner: delete. Stamps deletedAt. Reversible via POST /:id/restore.
POST /admin/banners/:id/restore — Restore
Required permission: banner: update. Clears deletedAt.
Storefront
GET /store/banners/:entityType/slug/:slug — Active banners for an entity
Unauthenticated. Returns active (isActive=true, deletedAt IS NULL) banners attached to the resolved entity, sorted by sortOrder ASC, createdAt DESC.
Path params
| Name | Notes |
|---|---|
entityType | One of category / brand / tag / ingredient. Anything else → 400 BAD_REQUEST |
slug | Slug of the catalog entity |
Query
| Name | Type | Notes |
|---|---|---|
platform | Platform? | When set to APP or WEB, filters out banners targeted at the other platform. Absent or BOTH returns every active banner regardless of platform |
When the entity slug doesn't resolve, the response is an empty array (not 404 — distinguishes "no banners" from "no such category" the same way for the consumer).
Response 200 — array of BannerResponse.
{
"data": [
{
"id": "01J9...",
"entityType": "brand",
"entityId": "01J9...",
"image": "banners/2026-05/festive.jpg",
"url": "/sale",
"platform": "BOTH",
"isActive": true,
"sortOrder": 0,
"metadata": null,
"createdAt": "2026-05-01T08:00:00.000Z",
"updatedAt": "2026-05-01T08:00:00.000Z",
"deletedAt": null
}
],
"message": "Success",
"statusCode": 200
}Domain events
Emitted via EventEmitter2 after writes — consumed by the storefront cache invalidator.
| Event | Fired when |
|---|---|
banner.created | POST /admin/banners |
banner.updated | PUT /admin/banners/:id, POST /admin/banners/:id/restore |
banner.deleted | DELETE /admin/banners/:id |
Related modules
catalog— owns the four entity tables (category,brand,tag,ingredient) that banners reference. Admin lists in catalog surface abannerCountaggregated from this table. Seecatalog.md.storage— provides presigned-upload URLs for banner images and resolves keys to CDN URLs on the storefront.
Admin RBAC Module
HTTP surface for managing platform-admin roles (custom role definitions with a permission map) and reading the catalog of available permissions. Also exports the PermissionsGuard…
Cart Module
HTTP surface for the shopping cart — storefront read/mutate (guest + logged-in), customer-side coupon and free-gift application, guest→customer merge on login, checkout…