Dynamic Link Module
Admin-curated link groupings rendered on the storefront landing page. A DynamicLinkGroup has a slug used as the public lookup key; each DynamicLink belongs to exactly one group…
Admin-curated link groupings rendered on the storefront landing page. A DynamicLinkGroup has a slug used as the public lookup key; each DynamicLink belongs to exactly one group and renders as some combination of image / url / text. Typical use: "Top Categories", "Featured Brands", "Promo Tiles", "Footer Quick Links".
Source:
api-modules/dynamic-link(registered viaDynamicLinkModule.forRoot()inapps/api/src/app.module.ts).The module is pluggable: removing the
DynamicLinkModule.forRoot()line fromapp.module.tsdisables the feature entirely without breaking the rest of the app. Schema lives inapi-modules/db/src/schema/dynamic-link.ts(kept central with the other schemas so the existing Drizzle migration pipeline picks it up). Future consumers integrate via theDYNAMIC_LINK_GROUP_PORT/DYNAMIC_LINK_PORTinjection tokens — concrete service classes are intentionally not exported.
Conventions
Authentication
Admin endpoints require a Better-Auth bearer session and a role with the relevant permission (granted to superAdmin and admin by default). Public /store/... endpoints are unauthenticated.
Authorization: Bearer <session-token>| Endpoint group | Required 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 .../links/:linkId/duplicate | dynamicLink: create |
PUT /admin/dynamic-link-groups/:groupId/links/:linkId, PATCH .../links/reorder | dynamicLink: update |
DELETE /admin/dynamic-link-groups/:groupId/links/:linkId | dynamicLink: delete |
GET /store/dynamic-link-groups/slug/:slug | none (public) |
A caller missing the required permission gets 403 Forbidden (FORBIDDEN).
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* pagination, when applicable */ }
}Error envelope
Failures are normalized by HttpExceptionFilter:
{
"data": null,
"message": "Human-readable summary",
"statusCode": 400,
"errorCode": "BAD_REQUEST",
"errors": [ /* zod issues, when validation fails */ ]
}statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT (duplicate slug) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
Hard-delete only. No isActive flag, no deletedAt column. To remove a group or link from the storefront, delete it. Deleting a group cascades at the DB level: all child links are removed in the same statement (ON DELETE CASCADE on the FK).
A deleted slug is immediately reusable. Storefront URLs that previously pointed to a deleted group's slug return 404 once the group is gone.
Slug rules
Slugs are admin-typed (no auto-generation). Validated against:
/^[a-z0-9]+(?:-[a-z0-9]+)*$/- Lowercase only
- Alphanumeric segments separated by single hyphens
- No leading, trailing, or consecutive hyphens
- 1..255 characters
A duplicate slug returns 409 Conflict (CONFLICT). The service does not auto-suffix on collision — the admin must pick a different slug.
Domain types
DynamicLinkGroup
type DynamicLinkGroup = {
id: string; // UUID
title: string; // 1..255
slug: string; // see slug rules
metadata: Record<string, unknown> | null; // free-form JSONB
createdAt: string; // ISO datetime
updatedAt: string; // ISO datetime
};DynamicLink
type DynamicLink = {
id: string; // UUID
groupId: string; // FK → DynamicLinkGroup.id (CASCADE on delete)
image: string | null; // optional URL or storage key, max 2048
url: string | null; // optional click destination, max 2048
text: string | null; // optional label, max 1024
order: number; // int ≥ 0; sort key within the group
metadata: Record<string, unknown> | null;
createdAt: string; // ISO datetime
updatedAt: string; // ISO datetime
};DynamicLinkGroupWithLinks
Returned by admin GET /:id and the public lookup. Group fields with a nested links array sorted by order ASC, createdAt ASC.
type DynamicLinkGroupWithLinks = DynamicLinkGroup & {
links: DynamicLink[];
};Content rules on links
A DynamicLink must have at least one of image, url, or text populated — otherwise there is nothing to render and the request is rejected with 400 VALIDATION_ERROR.
Empty strings ("") and whitespace-only strings (" ") are normalized to null before validation. Trailing/leading whitespace is trimmed on string values. This means:
{ "image": "", "url": "", "text": "Hey" }→ stored asimage=null, url=null, text="Hey"(one field is enough).{ "image": "", "url": "", "text": "" }→ rejected: "At least one of image, url, or text must be provided".{ "metadata": { "icon": "🎁" } }→ rejected (no renderable content).{ "text": " Limited time " }→ stored astext="Limited time".
The same rule re-fires server-side on PUT (update) — the merged record (existing fields + patch) must still satisfy the rule. You can clear individual fields by sending null or "", as long as at least one renderable field remains.
Admin endpoints — Groups
Base path: /admin/dynamic-link-groups
GET / — List groups
Paginated list of groups. Inherits the standard @sc/core/query shape (search, filter, sort, pagination).
Query
| Name | Type | Default | Notes |
|---|---|---|---|
searchValue | string? | — | Search term (use with searchField) |
searchField | "title" | "slug" | — | Column to search |
searchOperator | "contains" | "starts_with" | "ends_with" | "contains" | |
sortBy | "title" | "slug" | "createdAt" | "updatedAt" | — (defaults to createdAt asc) | |
sortDirection | "asc" | "desc" | "desc" | |
limit | int | 100 | 1..500 |
offset | int | 0 | ≥ 0 |
Example
GET /admin/dynamic-link-groups?searchValue=promo&searchField=title&limit=20
Authorization: Bearer <token>Response 200
{
"data": [
{
"id": "0d1a3f8e-...",
"title": "Top Categories",
"slug": "top-categories",
"metadata": null,
"createdAt": "2026-05-02T10:00:00.000Z",
"updatedAt": "2026-05-02T10:00:00.000Z"
}
],
"message": "Success",
"statusCode": 200,
"metadata": { "total": 4, "limit": 20, "offset": 0, "hasMore": false }
}GET /:id — Get a group with its links
Returns the group and its links sorted by order ASC, createdAt ASC. Use this when rendering an admin detail page so you don't need a follow-up call to GET /:groupId/links.
Example
GET /admin/dynamic-link-groups/0d1a3f8e-...
Authorization: Bearer <token>Response 200
{
"data": {
"id": "0d1a3f8e-...",
"title": "Top Categories",
"slug": "top-categories",
"metadata": { "layout": "grid-3" },
"links": [
{
"id": "9c4f2d11-...",
"groupId": "0d1a3f8e-...",
"image": "https://cdn.example.com/cat-skincare.jpg",
"url": "/categories/skincare",
"text": "Skincare",
"order": 0,
"metadata": null,
"createdAt": "2026-05-02T10:05:00.000Z",
"updatedAt": "2026-05-02T10:05:00.000Z"
}
],
"createdAt": "2026-05-02T10:00:00.000Z",
"updatedAt": "2026-05-02T10:00:00.000Z"
},
"message": "Success",
"statusCode": 200
}404 when no group has that ID:
{
"data": null,
"message": "DynamicLinkGroup with id \"0d1a3f8e-...\" not found",
"statusCode": 404,
"errorCode": "NOT_FOUND"
}POST / — Create a group
Body
type CreateDynamicLinkGroupBody = {
title: string; // 1..255
slug: string; // see slug rules
metadata?: Record<string, unknown> | null;
};Example
POST /admin/dynamic-link-groups
Authorization: Bearer <token>
Content-Type: application/json
{
"title": "Top Categories",
"slug": "top-categories",
"metadata": { "layout": "grid-3" }
}Response 201 — the created group (without links; use GET /:id if you want the array).
409 Conflict when the slug is already taken:
{
"data": null,
"message": "DynamicLinkGroup with slug \"top-categories\" already exists",
"statusCode": 409,
"errorCode": "CONFLICT"
}PUT /:id — Update a group
All fields optional. Send only the fields you want to change. If you change slug, uniqueness is re-checked.
Body
type UpdateDynamicLinkGroupBody = Partial<{
title: string;
slug: string;
metadata: Record<string, unknown> | null;
}>;Example
PUT /admin/dynamic-link-groups/0d1a3f8e-...
Authorization: Bearer <token>
Content-Type: application/json
{ "metadata": { "layout": "grid-4" } }Response 200 — the updated group.
DELETE /:id — Delete a group
Hard-delete. Cascades to all child links via DB FK.
Example
DELETE /admin/dynamic-link-groups/0d1a3f8e-...
Authorization: Bearer <token>Response 204 — no body.
POST /:id/duplicate — Duplicate a group
Clones the source group and all its links in a single transaction. The new group gets the title and slug from the request body; child link content (image, url, text, metadata, order) is copied verbatim.
Body
type DuplicateDynamicLinkGroupBody = {
title: string; // required
slug: string; // required, must be unique
};The source group's metadata is cloned into the new group as-is — no override is supported in v1 (modify with PUT /:id afterwards if needed).
Example
POST /admin/dynamic-link-groups/0d1a3f8e-.../duplicate
Authorization: Bearer <token>
Content-Type: application/json
{ "title": "Top Categories (Copy)", "slug": "top-categories-copy" }Response 201 — the cloned group with its cloned links nested.
{
"data": {
"id": "<new-group-id>",
"title": "Top Categories (Copy)",
"slug": "top-categories-copy",
"metadata": { "layout": "grid-3" },
"links": [
{
"id": "<new-link-id>",
"groupId": "<new-group-id>",
"image": "https://cdn.example.com/cat-skincare.jpg",
"url": "/categories/skincare",
"text": "Skincare",
"order": 0,
"metadata": null,
"createdAt": "2026-05-02T10:30:00.000Z",
"updatedAt": "2026-05-02T10:30:00.000Z"
}
],
"createdAt": "2026-05-02T10:30:00.000Z",
"updatedAt": "2026-05-02T10:30:00.000Z"
},
"message": "Created successfully",
"statusCode": 201
}409 Conflict when the new slug is taken. 404 Not Found when the source :id doesn't exist.
Admin endpoints — Links
Base path: /admin/dynamic-link-groups/:groupId/links
Every endpoint validates that any referenced link belongs to :groupId. Cross-group access (e.g. PUT /admin/dynamic-link-groups/<groupB>/links/<linkInGroupA>) returns 404, not 200 — preventing accidental tampering.
GET / — List links in a group
Returns all links for :groupId sorted by order ASC, createdAt ASC. Not paginated in v1 (groups are expected to hold under ~100 links).
Example
GET /admin/dynamic-link-groups/0d1a3f8e-.../links
Authorization: Bearer <token>Response 200
{
"data": [
{ "id": "9c4f2d11-...", "groupId": "0d1a3f8e-...", "image": "...", "url": "/categories/skincare", "text": "Skincare", "order": 0, "metadata": null, "createdAt": "...", "updatedAt": "..." },
{ "id": "73e0a812-...", "groupId": "0d1a3f8e-...", "image": "...", "url": "/categories/hair-care", "text": "Hair Care", "order": 1, "metadata": null, "createdAt": "...", "updatedAt": "..." }
],
"message": "Success",
"statusCode": 200
}404 when the group doesn't exist.
POST / — Create a link
groupId comes from the URL — do not send it in the body.
Body
type CreateDynamicLinkBody = {
image?: string | null; // max 2048; "" → null
url?: string | null; // max 2048; "" → null
text?: string | null; // max 1024; "" → null
order?: number; // int ≥ 0; default 0
metadata?: Record<string, unknown> | null;
};At least one of image, url, or text must be present (after the empty-string-to-null normalization).
Example — image + url + text
POST /admin/dynamic-link-groups/0d1a3f8e-.../links
Authorization: Bearer <token>
Content-Type: application/json
{
"image": "https://cdn.example.com/cat-skincare.jpg",
"url": "/categories/skincare",
"text": "Skincare",
"order": 0
}Example — text-only (image and url empty)
The empty strings normalize to null and the link is accepted because text is present.
POST /admin/dynamic-link-groups/0d1a3f8e-.../links
Authorization: Bearer <token>
Content-Type: application/json
{ "image": "", "url": "", "text": "Limited offer", "order": 5 }Response 201
{
"data": {
"id": "9c4f2d11-...",
"groupId": "0d1a3f8e-...",
"image": null,
"url": null,
"text": "Limited offer",
"order": 5,
"metadata": null,
"createdAt": "2026-05-02T10:05:00.000Z",
"updatedAt": "2026-05-02T10:05:00.000Z"
},
"message": "Created successfully",
"statusCode": 201
}400 Validation Error — sending no renderable content:
POST /admin/dynamic-link-groups/0d1a3f8e-.../links
Authorization: Bearer <token>
Content-Type: application/json
{ "metadata": { "note": "placeholder" } }{
"data": null,
"message": "Validation failed",
"statusCode": 400,
"errorCode": "VALIDATION_ERROR",
"errors": [
{
"code": "custom",
"message": "At least one of image, url, or text must be provided",
"path": ["image"]
}
]
}PATCH /reorder — Bulk reorder links
Accepts an array of { linkId, order } and updates them in a single transaction. All linkIds must belong to :groupId. Duplicate linkIds in the payload are rejected. The response is the full reordered list.
Body
type ReorderDynamicLinksBody = {
items: Array<{
linkId: string; // must belong to :groupId
order: number; // int ≥ 0
}>; // length ≥ 1
};Example
PATCH /admin/dynamic-link-groups/0d1a3f8e-.../links/reorder
Authorization: Bearer <token>
Content-Type: application/json
{
"items": [
{ "linkId": "9c4f2d11-...", "order": 1 },
{ "linkId": "73e0a812-...", "order": 0 }
]
}Response 200 — the links in their new order.
400 Bad Request when:
itemsis empty- A
linkIdis referenced more than once - Any
linkIddoes not belong to:groupId
Route ordering note —
PATCH /reorderis declared in the controller beforePUT /:linkIdso the literal pathreorderis not captured as a UUID parameter. If you ever add another action under this controller, declare it above the:linkIdroutes too.
PUT /:linkId — Update a link
All fields optional. The "at least one of image / url / text" rule is re-validated server-side against the merged record (existing row + patch). You can clear an individual field by sending null or "", as long as the remaining fields keep the rule satisfied.
Body
type UpdateDynamicLinkBody = Partial<{
image: string | null;
url: string | null;
text: string | null;
order: number;
metadata: Record<string, unknown> | null;
}>;Example — clear the image, keep text
PUT /admin/dynamic-link-groups/0d1a3f8e-.../links/9c4f2d11-...
Authorization: Bearer <token>
Content-Type: application/json
{ "image": null }Response 200 — the updated link.
400 Bad Request if the patch would leave the link with no renderable content:
PUT /admin/dynamic-link-groups/0d1a3f8e-.../links/9c4f2d11-...
Authorization: Bearer <token>
Content-Type: application/json
{ "image": "", "url": "", "text": "" }{
"data": null,
"message": "At least one of image, url, or text must be provided",
"statusCode": 400,
"errorCode": "BAD_REQUEST"
}404 when :linkId doesn't belong to :groupId.
DELETE /:linkId — Delete a link
Hard-delete. The parent group is unaffected.
Example
DELETE /admin/dynamic-link-groups/0d1a3f8e-.../links/9c4f2d11-...
Authorization: Bearer <token>Response 204 — no body.
404 when :linkId doesn't belong to :groupId.
POST /:linkId/duplicate — Duplicate a link
Clones a link within the same group. The clone's order is set to max(existing order in group) + 1, so it appears at the end of the list. Cross-group duplication is not supported — open the target group and create the link there if you need it.
Example
POST /admin/dynamic-link-groups/0d1a3f8e-.../links/9c4f2d11-.../duplicate
Authorization: Bearer <token>Response 201 — the cloned link.
{
"data": {
"id": "<new-link-id>",
"groupId": "0d1a3f8e-...",
"image": "https://cdn.example.com/cat-skincare.jpg",
"url": "/categories/skincare",
"text": "Skincare",
"order": 7,
"metadata": null,
"createdAt": "2026-05-02T10:40:00.000Z",
"updatedAt": "2026-05-02T10:40:00.000Z"
},
"message": "Created successfully",
"statusCode": 201
}404 when :linkId doesn't belong to :groupId.
Public storefront endpoint
Base path: /store/dynamic-link-groups
No authentication. No paging. No filtering — all links of the requested group are returned. Storefront should hide groups it doesn't recognize rather than relying on a feature flag.
GET /slug/:slug — Fetch a group by slug
Returns the group + its links sorted by order ASC, createdAt ASC. 404 if no group has that slug (e.g. it was deleted, or the storefront has a stale URL).
Example
GET /store/dynamic-link-groups/slug/top-categoriesResponse 200
{
"data": {
"id": "0d1a3f8e-...",
"title": "Top Categories",
"slug": "top-categories",
"metadata": { "layout": "grid-3" },
"links": [
{
"id": "9c4f2d11-...",
"groupId": "0d1a3f8e-...",
"image": "https://cdn.example.com/cat-skincare.jpg",
"url": "/categories/skincare",
"text": "Skincare",
"order": 0,
"metadata": null,
"createdAt": "2026-05-02T10:05:00.000Z",
"updatedAt": "2026-05-02T10:05:00.000Z"
},
{
"id": "73e0a812-...",
"groupId": "0d1a3f8e-...",
"image": "https://cdn.example.com/cat-haircare.jpg",
"url": "/categories/hair-care",
"text": "Hair Care",
"order": 1,
"metadata": null,
"createdAt": "2026-05-02T10:06:00.000Z",
"updatedAt": "2026-05-02T10:06:00.000Z"
}
],
"createdAt": "2026-05-02T10:00:00.000Z",
"updatedAt": "2026-05-02T10:00:00.000Z"
},
"message": "Success",
"statusCode": 200
}Response 404 — group not found:
{
"data": null,
"message": "DynamicLinkGroup with slug \"top-categories\" not found",
"statusCode": 404,
"errorCode": "NOT_FOUND"
}Events
Emitted via @nestjs/event-emitter (EventEmitter2). Event names use dotted lowercase strings; constants are UPPER_SNAKE_CASE. One event per file under events/dynamic-link-group/ and events/dynamic-link/.
| Constant | Event name | Payload class | Fired when |
|---|---|---|---|
DYNAMIC_LINK_GROUP_CREATED | dynamic-link-group.created | DynamicLinkGroupCreatedEvent { groupId, slug, actorId } | POST /admin/dynamic-link-groups |
DYNAMIC_LINK_GROUP_UPDATED | dynamic-link-group.updated | DynamicLinkGroupUpdatedEvent { groupId, slug, actorId, changedFields } | PUT /admin/dynamic-link-groups/:id |
DYNAMIC_LINK_GROUP_DELETED | dynamic-link-group.deleted | DynamicLinkGroupDeletedEvent { groupId, slug, actorId } | DELETE /admin/dynamic-link-groups/:id |
DYNAMIC_LINK_GROUP_DUPLICATED | dynamic-link-group.duplicated | DynamicLinkGroupDuplicatedEvent { newGroupId, sourceGroupId, newSlug, clonedLinkCount, actorId } | POST /admin/dynamic-link-groups/:id/duplicate |
DYNAMIC_LINK_CREATED | dynamic-link.created | DynamicLinkCreatedEvent { linkId, groupId, actorId } | POST .../links |
DYNAMIC_LINK_UPDATED | dynamic-link.updated | DynamicLinkUpdatedEvent { linkId, groupId, actorId, changedFields } | PUT .../links/:linkId |
DYNAMIC_LINK_DELETED | dynamic-link.deleted | DynamicLinkDeletedEvent { linkId, groupId, actorId } | DELETE .../links/:linkId |
DYNAMIC_LINK_DUPLICATED | dynamic-link.duplicated | DynamicLinkDuplicatedEvent { newLinkId, sourceLinkId, groupId, actorId } | POST .../links/:linkId/duplicate |
DYNAMIC_LINK_REORDERED | dynamic-link.reordered | DynamicLinkReorderedEvent { groupId, count, actorId } | PATCH .../links/reorder |
The per-link clones inside a group-duplicate flow are bundled into the single DYNAMIC_LINK_GROUP_DUPLICATED event (with clonedLinkCount) — no individual DYNAMIC_LINK_CREATED / DYNAMIC_LINK_DUPLICATED events fire for the cloned children, to keep the event stream readable.
The DB-cascade on group delete fires no per-link DYNAMIC_LINK_DELETED events either — listeners that need to react to child deletion should subscribe to DYNAMIC_LINK_GROUP_DELETED and look up affected links themselves before the cascade.
Plugin integration
The module exports two ports as DI tokens. Concrete service classes are not exported.
import {
DYNAMIC_LINK_GROUP_PORT,
DYNAMIC_LINK_PORT,
type DynamicLinkGroupPort,
type DynamicLinkPort,
} from "@sc/dynamic-link";
@Injectable()
class MyConsumer {
constructor(
@Inject(DYNAMIC_LINK_GROUP_PORT) private readonly groupPort: DynamicLinkGroupPort,
@Inject(DYNAMIC_LINK_PORT) private readonly linkPort: DynamicLinkPort,
) {}
}Within the module, DynamicLinkService injects DYNAMIC_LINK_GROUP_PORT (not the concrete DynamicLinkGroupService) for cross-service group lookups. This keeps inter-service coupling at the port level so the implementation can be swapped without touching consumers.
Schema reference
// api-modules/db/src/schema/dynamic-link.ts
dynamicLinkGroup {
id text PK ($defaultFn = crypto.randomUUID)
title text NOT NULL
slug text NOT NULL
metadata jsonb NULL
createdAt timestamp NOT NULL DEFAULT now()
updatedAt timestamp NOT NULL DEFAULT now() ($onUpdate = new Date)
UNIQUE INDEX dynamic_link_group_slug_idx (slug)
}
dynamicLink {
id text PK ($defaultFn = crypto.randomUUID)
groupId text NOT NULL (FK → dynamic_link_group.id, ON DELETE CASCADE)
image text NULL
url text NULL
text text NULL
order integer NOT NULL DEFAULT 0
metadata jsonb NULL
createdAt timestamp NOT NULL DEFAULT now()
updatedAt timestamp NOT NULL DEFAULT now() ($onUpdate = new Date)
INDEX dynamic_link_group_id_idx (group_id)
INDEX dynamic_link_order_idx (group_id, "order")
}
// Drizzle relations
dynamicLinkGroup.links → many(dynamicLink)
dynamicLink.group → one(dynamicLinkGroup, { fields: [groupId], references: [id] })Migration: api-modules/db/drizzle/0012_sharp_freak.sql.
Out of scope (deferred)
- Bulk endpoints beyond reorder — no bulk-create or bulk-delete in v1.
- Link pagination —
GET .../linksreturns all links unpaginated. - Cross-group link duplicate — duplicate is same-group only.
- Group-level reordering — groups are looked up by slug, not ordered as a list.
- Visibility/active flag — hard-delete is the only off-state.
- Soft delete / restore — same.
- Media-table FK for
image— stored as plain text URL. - Conditional/feature-flag plugin loader — module is registered via direct
forRoot()import.
Discount Module
Admin-facing HTTP endpoints for managing platform-wide coupon/discount rules — creation, lifecycle (active → archived → soft-deleted), include/exclude filters across the catalog,…
Free Gift Module
Admin-facing HTTP endpoints for managing platform-wide free-gift rules — three rule types (Automatic, BuyXGetY, Coupon-based), criteria-based eligibility (cart subtotal, order…