Supercommerce API Docs
Full Module Docs

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 via DynamicLinkModule.forRoot() in apps/api/src/app.module.ts).

The module is pluggable: removing the DynamicLinkModule.forRoot() line from app.module.ts disables the feature entirely without breaking the rest of the app. Schema lives in api-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 the DYNAMIC_LINK_GROUP_PORT / DYNAMIC_LINK_PORT injection 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 groupRequired permission
GET /admin/dynamic-link-groups, GET /admin/dynamic-link-groups/:iddynamicLinkGroup: read
POST /admin/dynamic-link-groups, POST /admin/dynamic-link-groups/:id/duplicatedynamicLinkGroup: create
PUT /admin/dynamic-link-groups/:iddynamicLinkGroup: update
DELETE /admin/dynamic-link-groups/:iddynamicLinkGroup: delete
GET /admin/dynamic-link-groups/:groupId/linksdynamicLink: read
POST /admin/dynamic-link-groups/:groupId/links, POST .../links/:linkId/duplicatedynamicLink: create
PUT /admin/dynamic-link-groups/:groupId/links/:linkId, PATCH .../links/reorderdynamicLink: update
DELETE /admin/dynamic-link-groups/:groupId/links/:linkIddynamicLink: delete
GET /store/dynamic-link-groups/slug/:slugnone (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 */ ]
}
statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT (duplicate slug)
500INTERNAL_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
};
type DynamicLink = {
  id: string;                                    // UUID
  groupId: string;                               // FKDynamicLinkGroup.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;                                 // int0; 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
};

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.

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 as image=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 as text="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

NameTypeDefaultNotes
searchValuestring?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"
limitint1001..500
offsetint0≥ 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 }
}

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.


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.

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;                         // int0; 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"]
    }
  ]
}

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;        // int0
  }>;                     // length1
};

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:

  • items is empty
  • A linkId is referenced more than once
  • Any linkId does not belong to :groupId

Route ordering notePATCH /reorder is declared in the controller before PUT /:linkId so the literal path reorder is not captured as a UUID parameter. If you ever add another action under this controller, declare it above the :linkId routes too.


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.


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-categories

Or, to only get links targeting the app:

GET /store/dynamic-link-groups/slug/top-categories?platform=APP

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,
        "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/.

ConstantEvent namePayload classFired when
DYNAMIC_LINK_GROUP_CREATEDdynamic-link-group.createdDynamicLinkGroupCreatedEvent { groupId, slug, actorId }POST /admin/dynamic-link-groups
DYNAMIC_LINK_GROUP_UPDATEDdynamic-link-group.updatedDynamicLinkGroupUpdatedEvent { groupId, slug, actorId, changedFields }PUT /admin/dynamic-link-groups/:id
DYNAMIC_LINK_GROUP_DELETEDdynamic-link-group.deletedDynamicLinkGroupDeletedEvent { groupId, slug, actorId }DELETE /admin/dynamic-link-groups/:id
DYNAMIC_LINK_GROUP_DUPLICATEDdynamic-link-group.duplicatedDynamicLinkGroupDuplicatedEvent { newGroupId, sourceGroupId, newSlug, clonedLinkCount, actorId }POST /admin/dynamic-link-groups/:id/duplicate
DYNAMIC_LINK_CREATEDdynamic-link.createdDynamicLinkCreatedEvent { linkId, groupId, actorId }POST .../links
DYNAMIC_LINK_UPDATEDdynamic-link.updatedDynamicLinkUpdatedEvent { linkId, groupId, actorId, changedFields }PUT .../links/:linkId
DYNAMIC_LINK_DELETEDdynamic-link.deletedDynamicLinkDeletedEvent { linkId, groupId, actorId }DELETE .../links/:linkId
DYNAMIC_LINK_DUPLICATEDdynamic-link.duplicatedDynamicLinkDuplicatedEvent { newLinkId, sourceLinkId, groupId, actorId }POST .../links/:linkId/duplicate
DYNAMIC_LINK_REORDEREDdynamic-link.reorderedDynamicLinkReorderedEvent { 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 paginationGET .../links returns 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 isActive toggle; 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.

On this page