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 for groups. No deletedAt column. To remove a group 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).
Links additionally carry an isActive boolean (default true), independent of hard-delete. Setting isActive: false on a link hides it from the public storefront read (GET /store/dynamic-link-groups/slug/:slug) without removing the row — admin reads (GET /admin/dynamic-link-groups/:id, GET .../links) always return every link regardless of isActive so it can be toggled back on.
Links also carry an optional promotional window (startsAt / endsAt) for banners that should only run for certain hours or days. Both ends are nullable and independent, so a link with neither is always live and unscheduled links behave exactly as they did before the feature existed. The window gates the same public read that isActive does, and the two are ANDed: a link renders only when it is active and inside its window. See Scheduling below.
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
isActive: boolean; // default true; false hides it from /store
platform: "APP" | "WEB" | "BOTH"; // default BOTH; filters the public /store read
startsAt: string | null; // ISO; inclusive window start, null = unbounded
endsAt: string | null; // ISO; exclusive window end, null = unbounded
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[];
};The public lookup returns one extra field, nextTransitionAt: string | null —
the earliest schedule boundary still ahead of now across the group's active,
platform-matching links, i.e. when the returned links set next changes. Null
means nothing is scheduled.
Scheduling
startsAt is inclusive and endsAt exclusive: a window of
[10:00, 12:00) shows the link at exactly 10:00:00 and drops it at exactly
12:00:00. Either side may be null for an unbounded end. When both are set,
endsAt must be after startsAt — rejected with 400 VALIDATION_ERROR
otherwise, and re-checked against the merged row on a partial update so
moving one boundary past the other fails too.
A scheduled link changes visibility with no write behind it, so nothing emits a
domain event at the boundary. The public endpoint applies the window per
request and is therefore always correct — including for the mobile app, which
holds no web cache. For the web storefront, a worker-side repeatable sweep
(dynamic-link-schedule-sweep, every 60s) detects a boundary that has just
passed and flushes the dynamic-links cache tag; nextTransitionAt lets an
already-open browser tab refresh itself at the same moment, which a server-side
tag flush cannot do. Full detail in store/dynamic-link.md.
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, isActive, platform, startsAt, endsAt) 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, "isActive": true, "platform": "BOTH", "metadata": null, "createdAt": "...", "updatedAt": "..." },
{ "id": "73e0a812-...", "groupId": "0d1a3f8e-...", "image": "...", "url": "/categories/hair-care", "text": "Hair Care", "order": 1, "isActive": false, "platform": "WEB", "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
isActive?: boolean; // default true
platform?: "APP" | "WEB" | "BOTH"; // default BOTH
startsAt?: string | null; // ISO; inclusive window start, omit/null = unbounded
endsAt?: string | null; // ISO; exclusive window end, omit/null = unbounded
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;
isActive: boolean;
platform: "APP" | "WEB" | "BOTH";
startsAt: string | null;
endsAt: string | null;
metadata: Record<string, unknown> | null;
}>;Send { "isActive": false } to hide the link from the public storefront read without deleting it, or { "isActive": true } to restore it. Send null to clear a schedule boundary — { "endsAt": null } makes a limited-time link run indefinitely; omitting the key leaves the stored boundary alone.
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. isActive and platform are copied verbatim from the source link.
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. Filtered — only links with isActive: true are returned; inactive links are silently omitted. Optionally filter further by platform via ?platform=APP|WEB or the x-platform header (query wins if both are sent) — a link is included when its own platform is BOTH or matches the requested one; absent/BOTH returns links for every platform. 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-categoriesOr, to only get links targeting the app:
GET /store/dynamic-link-groups/slug/top-categories?platform=APPResponse 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,
"isActive": true,
"platform": "BOTH",
"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,
"isActive": true,
"platform": "WEB",
"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
isActive boolean NOT NULL DEFAULT true
platform platform enum('APP'|'WEB'|'BOTH') NOT NULL DEFAULT 'BOTH'
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")
INDEX dynamic_link_platform_idx (platform)
}
// Drizzle relations
dynamicLinkGroup.links → many(dynamicLink)
dynamicLink.group → one(dynamicLinkGroup, { fields: [groupId], references: [id] })Migrations: api-modules/db/drizzle/0012_sharp_freak.sql (initial tables), api-modules/db/drizzle/0074_charming_nekra.sql (is_active column), api-modules/db/drizzle/0075_tiresome_bruce_banner.sql (platform column + index on dynamic_link).
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.
- Soft delete / restore — groups have no equivalent to a link's
isActivetoggle; deleting a group is permanent. - 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…