Dynamic Link Module — Admin
HTTP surface for managing dynamic link groups (CMS-style tile collections — e.g. homepage banner slots) and the dynamic links inside each group. Groups are addressable by slug and…
HTTP surface for managing dynamic link groups (CMS-style tile collections — e.g. homepage banner slots) and the dynamic links inside each group. Groups are addressable by slug and read by the public storefront; this admin surface is the only writer.
Source:
api-modules/dynamic-link/src/controllers/admin-dynamic-link-group.controller.ts,api-modules/dynamic-link/src/controllers/admin-dynamic-link.controller.ts.A group is a named collection of links rendered as an ordered list (carousel, tile grid, feature row). A link is one renderable item with optional image / url / text and an
orderfor sorting within its parent group.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission.
| Endpoint group | Permission |
|---|---|
GET /admin/dynamic-link-groups, GET /admin/dynamic-link-groups/:id | dynamicLinkGroup: read |
POST /admin/dynamic-link-groups, POST /admin/dynamic-link-groups/:id/duplicate | dynamicLinkGroup: create |
PUT /admin/dynamic-link-groups/:id | dynamicLinkGroup: update |
DELETE /admin/dynamic-link-groups/:id | dynamicLinkGroup: delete |
GET /admin/dynamic-link-groups/:groupId/links | dynamicLink: read |
POST /admin/dynamic-link-groups/:groupId/links, POST /admin/dynamic-link-groups/:groupId/links/:linkId/duplicate | dynamicLink: create |
PUT /admin/dynamic-link-groups/:groupId/links/:linkId, PATCH /admin/dynamic-link-groups/:groupId/links/reorder | dynamicLink: update |
DELETE /admin/dynamic-link-groups/:groupId/links/:linkId | dynamicLink: 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 | UNIQUE_VIOLATION (slug collision) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
Groups and links are hard-deleted (no soft-delete). Deleting a group cascades to its links via the database foreign key.
Links additionally carry an isActive toggle (default true). Setting it to false hides the link from the public storefront read (GET /store/dynamic-link-groups/slug/:slug) without deleting it. Admin reads (GET .../links, GET /admin/dynamic-link-groups/:id) always return every link regardless of isActive, so the toggle can be flipped back later.
Links also carry a platform enum (APP | WEB | BOTH, default BOTH) reused from the platform enum shared with banner/discount/free-gift. The public storefront read can filter on it via ?platform= or the x-platform header.
Domain types
DynamicLinkGroupResponse
type DynamicLinkGroupResponse = {
id: string;
title: string; // 1..255
slug: string; // lowercase alnum + hyphens, 1..255
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
updatedAt: string;
};
type DynamicLinkGroupWithLinksResponse = DynamicLinkGroupResponse & {
links: DynamicLinkResponse[]; // sorted by order ASC
};DynamicLinkResponse
A link must have at least one of image, url, or text — the rest can be null.
type DynamicLinkResponse = {
id: string;
groupId: string;
image: string | null; // max 2048 chars
url: string | null; // max 2048 chars
text: string | null; // max 1024 chars
order: number; // ASC sort within group, >= 0
isActive: boolean; // default true; false hides it from the store read
platform: "APP" | "WEB" | "BOTH"; // default BOTH
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
};Dynamic link groups
Base path: /admin/dynamic-link-groups.
GET /admin/dynamic-link-groups — List groups
Required permission: dynamicLinkGroup: read. Standard QueryDto (page / limit / search / filters[]).
Response 200 — paginated envelope of DynamicLinkGroupResponse[].
GET /admin/dynamic-link-groups/:id — Get a group with its links
Required permission: dynamicLinkGroup: read. Returns the group with nested links[] sorted by order ASC, including inactive links — unlike the public storefront read, this admin endpoint does not filter on isActive.
Response 200 — DynamicLinkGroupWithLinksResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/dynamic-link-groups — Create a group
Required permission: dynamicLinkGroup: create.
Body
{
"title": "Homepage Hero Tiles",
"slug": "homepage-hero-tiles",
"metadata": { "platform": "WEB" }
}| Field | Type | Constraints |
|---|---|---|
title | string | 1..255 |
slug | string | 1..255, lowercase alnum + hyphens |
metadata | Record<string, unknown> | null? | Free-form |
Response 201 — DynamicLinkGroupResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 409 | UNIQUE_VIOLATION | slug already in use |
PUT /admin/dynamic-link-groups/:id — Update a group
Required permission: dynamicLinkGroup: update. Partial body.
Response 200 — DynamicLinkGroupResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 409 | UNIQUE_VIOLATION | slug taken by another group |
DELETE /admin/dynamic-link-groups/:id — Delete a group
Required permission: dynamicLinkGroup: delete. Hard delete; cascades to child links via DB FK.
Response 204 No Content.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/dynamic-link-groups/:id/duplicate — Duplicate a group
Required permission: dynamicLinkGroup: create. Clones the source group with a fresh title + slug. Source metadata is copied as-is; every child link is cloned with its order, image, url, text, isActive, platform, and metadata.
Body
{ "title": "Homepage Hero Tiles (v2)", "slug": "homepage-hero-tiles-v2" }Response 201 — DynamicLinkGroupWithLinksResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Source group not found |
| 409 | UNIQUE_VIOLATION | New slug taken |
Dynamic links
Base path: /admin/dynamic-link-groups/:groupId/links.
GET .../links — List links in a group
Required permission: dynamicLink: read. Returns the full ordered list (no pagination — link counts per group are small by design), including inactive links (isActive: false) — the admin table needs to see and toggle them.
Response 200 — DynamicLinkResponse[] sorted by order ASC.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Group not found |
POST .../links — Create a link
Required permission: dynamicLink: create. At least one of image, url, text must be present (whitespace-only inputs are normalized to null before the check).
Body
{
"image": "tiles/promo-summer.png",
"url": "/sale/summer",
"text": "Summer Sale",
"order": 0,
"metadata": { "campaign": "Q2" }
}| Field | Type | Constraints |
|---|---|---|
image | string | null? | max 2048; trimmed; empty → null |
url | string | null? | max 2048 |
text | string | null? | max 1024 |
order | int | >= 0. Default 0 |
isActive | boolean? | Default true |
platform | "APP" / "WEB" / "BOTH", optional | Default BOTH |
metadata | Record<string, unknown> | null? | Free-form |
Response 201 — DynamicLinkResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | All three of image/url/text are empty, or body otherwise invalid |
| 404 | NOT_FOUND | Group not found |
PUT .../links/:linkId — Update a link
Required permission: dynamicLink: update. Partial; the "at least one renderable" rule re-runs against the merged state. This is also how the isActive toggle is flipped — send { "isActive": false } to hide the link from the store read, or { "isActive": true } to restore it. platform can be changed the same way (e.g. { "platform": "APP" }).
Response 200 — DynamicLinkResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Link not in this group |
DELETE .../links/:linkId — Delete a link
Required permission: dynamicLink: delete. Hard delete.
Response 204 No Content.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Link not in this group |
POST .../links/:linkId/duplicate — Duplicate a link
Required permission: dynamicLink: create. Clones the link within the same group with order = max(order) + 1; isActive and platform are copied verbatim from the source link.
Response 201 — cloned DynamicLinkResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Link not in this group |
PATCH .../links/reorder — Bulk reorder
Required permission: dynamicLink: update. Reapplies order for every supplied id. The literal route segment reorder is declared before :linkId so it cannot collide with a UUID.
Body
{
"items": [
{ "linkId": "01J9aaaa...", "order": 0 },
{ "linkId": "01J9bbbb...", "order": 1 },
{ "linkId": "01J9cccc...", "order": 2 }
]
}| Field | Type | Constraints |
|---|---|---|
items[].linkId | string | Required |
items[].order | int | >= 0 |
items | array | Min 1 entry |
Response 200 — reordered DynamicLinkResponse[] in their new order.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Empty items, missing linkId, etc. |
| 404 | NOT_FOUND | Group not found (links not in group are silently ignored at the service layer) |
Related modules
admin-rbac— gates every endpoint viadynamicLinkGroup:*/dynamicLink:*. Seeadmin-rbac.md.store/dynamic-link— unauthenticated storefront read of a group by slug.
Discount Module — Admin
HTTP surface for managing platform-wide discount coupons: percentage or fixed-amount discounts, with rich targeting (variants / categories / brands / tags / ingredients / vendors…
Dynamic Product Listing Module — Admin
HTTP surface for managing dynamic product listing groups (named containers) and dynamic product listings (saved filter specs). Groups and listings are addressable by slug; the storefront…