Skip to main content

Link templates

Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2 and optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea, 2026-10-07 (B-014 re-verification pass).

This page owns the template CRUD lifecycle: routes, quota, system-template rules, scoping, and delete semantics. The pipeline that attaches templates to links (POST /portal/links, save-as-template, merge-at-read) lives in Link creation pipeline — linked, not repeated here. Curl probes for the lifecycle are in Runtime verification below; token minting is Real-token HTTP.

Routes and guard stack​

src/portal/portal-template.controller.ts stacks @UseGuards(ClerkAuthGuard, RolesGuard, RateLimitGuard) plus @UseInterceptors(AuditInterceptor) on the controller class. Every handler resolves the org via @OrgFromUser(OrgResolutionPipe) — never manually. Rate limiting is RateLimitPresets.portal: 30 requests/min/IP, Redis keyPrefix rl:portal (src/rate-limiting/rate-limit.decorator.ts).

RouteMin roleSuccessErrors
GET /portal/templatesany org role200 LinkTemplate[] (org + system, _count.links per row)401
GET /portal/templates/:idany org role200 LinkTemplate404 (foreign/bogus id)
POST /portal/templatesdeveloper201402 quota, 400 validation / foreign domainId
PATCH /portal/templates/:iddeveloper200403 system, 404, 400
DELETE /portal/templates/:iddeveloper200 { deleted: true }409 attached, 403 system, 404
POST /portal/templates/:id/clonedeveloper201402, 404, 400

GET /portal/entitlements returns quotas.templates { used, limit, exceeded } (portal-entitlements.controller.ts) — the settings tab's quota line reads from it.

Role enforcement​

Writes (POST, PATCH, DELETE, POST :id/clone) carry @Roles('developer') — an inclusive minimum-role check in RolesGuard via hasMinimumRole, so admin and owner pass. Reads are un-decorated: any org role. Ops-org callers bypass RolesGuard, and the bypass fails closed when CLERK_OPS_ORG_ID is unset (src/auth/roles.guard.ts header).

Roles arrive from Clerk session claims: the Clerk JWT must use an org_metadata template that emits the role, or every role-gated template write 403s. Test tokens and e2e set orgRole through the guard override instead (docs/TESTS-NOTES.md). Analyst-role exclusion is code-verified only — no analyst test account exists to exercise it.

Quota: live count, not a ledger​

The template path never touches recordUsage or the UsageLedger. EntitlementsService.countUsage case 'templates' runs prisma.linkTemplate.count({ where: { orgId, isSystem: false } }) (src/entitlements/entitlements.service.ts) — quota is whatever the DB says right now. The controller calls checkQuota(org.id, 'templates') before create and clone, throwing QuotaExceededException (402); delete makes no quota call because the slot frees itself.

Limits mirror src/entitlements/plans.config.ts (lines 86–202): starter 3, solo 5, growth 10, scale 50 (onExhausted: 'block'), enterprise Number.MAX_SAFE_INTEGER with onExhausted: 'overage'. A plan_overrides row wins over config per org (checkQuota). The 402 body is { statusCode: 402, error: "Payment Required", message: "You've reached your templates limit.", code: "QUOTA_EXCEEDED", quota: "templates" } (src/entitlements/quota-exceeded.exception.ts).

Known gap: the check is check-then-write with no transaction or unique constraint, so two concurrent creates can both pass at the limit and overshoot by one. Inferred from code order, not reproduced under load — guard it only if it matters in practice.

System templates​

System rows have orgId: null, isSystem: true, and fixed ids: sys-social-to-app, sys-email-to-app, sys-text-to-app, sys-qr-to-app, sys-referral-to-app, sys-push-to-app, sys-web-to-app (prisma/seed.ts ~130–225). They are listable, cloneable, and directly attachable, but update()/delete() 403 on isSystem, and they never count toward quota. The seed upserts on the fixed ids so re-seeding never detaches referencing links. A demo org template "Summer Campaign" ships with the demo org (1/3 quota used).

docs/TESTS-NOTES.md §Templates lists a sys-custom seed id that does not exist anywhere in backend or portal code — 7 starters, not 8. Drop it from TESTS-NOTES when next touched.

Scoping and validation​

  • findOne scopes OR: [{ orgId }, { isSystem: true }] — a foreign org's template id is a 404, no cross-org leakage. PATCH, DELETE, and clone resolve the id through the same scoping; DELETE's full order is below.
  • list returns org-owned + all system rows ordered isSystem desc, createdAt asc, each with _count.links scoped where: { organizationId: orgId } — the org filter only matters for system rows, and it's what feeds the "Used by N links" badge. The settings tab filters isSystem client-side; system starters appear only in the create picker.
  • assertOrgDomain (create/update): a domainId must be the org's own domain or the shared platform-default row (organizationId: null, hostname CNAME_TARGET), mirroring LinkService.resolveExplicitDomain; violation is 400. CNAME_TARGET is getOrThrow in the service constructor — boot fails without it.
  • Create defaults: deferred: false, matchWindow: 24, clipboardEnabled: true, tags: [].
  • Update is a partial PATCH (UpdateLinkTemplateDto = PartialType(CreateLinkTemplateDto)); it applies live to referencing links because merge-at-read materializes nothing per link.
  • Clone copies all source fields into a new org template; name defaults to `{source.name} (copy)` unless the clone DTO overrides it; domainId is kept only when the source is org-owned (system templates carry none).

Delete: blocked while attached​

delete() runs in a fixed order: findOne (404 scoping) → isSystem 403 → prisma.link.count({ where: { templateId: id } }) → attached links raise ConflictException:

{
"statusCode": 409,
"error": "Conflict",
"message": "Template \"X\" is used by N link(s) — detach the links before deleting it",
"linksUsing": 7
}

Unattached: hard delete, returns { deleted: true }.

The block is structural, not cosmetic: merge-at-read means an attached template's values are live on every referencing link, so cascade-detaching would silently change resolution. Links detached before deletion keep templateId: null and null merge fields and stay resolvable. The UI disables Delete at count > 0, so a 409 means a race.

DTO surface​

src/link-template/dto/create-link-template.dto.ts, mirrored by templateFormSchema in the portal (src/lib/validators.ts):

FieldConstraint
namerequired, ≤100
description≤1000 backend / ≤500 zod (see gotchas)
channelsocial|email|sms|qr|referral|push|web|in_app|custom
domainIdUUID, org-scoped via assertOrgDomain
matchWindow1–720 hours
fallbackUrl, ogImageUrlURL format
utmSource…utmContent≤255
ogTitle / ogDescription≤255 / ≤500
baseParamsobject; deep-merged under link params at read
paramSchemaParamSchemaEntry[]: key ≤100, label ≤100, type string|number|boolean, required?, placeholder ≤255
tagsstring array

paramSchema drives the portal to render one labeled input per entry instead of a raw JSON box on link creation; collected values become the link's params. clipboardEnabled is dead at runtime and hidden from all portal UI by design (F6 — template-form.tsx line 93, src/lib/effective-link.ts header); the column and DTO field remain. Don't wire it up without checking the portal map.

Portal data layer​

src/hooks/use-templates.ts:

  • useTemplates({ enabled }) defers the fetch until the create picker opens.
  • Create/delete invalidate ['templates'] and ['entitlements'], so the quota line refreshes; update invalidates ['templates'] and ['templates', id].
  • 403 handling maps to role-specific copy, deliberately bypassing apiErrorMessage's backend-message-first priority (the backend sends a generic "Insufficient role").
  • The edit view renders "Template not found." only on HTTP 404; any other edit-fetch failure gets QueryErrorBanner + Retry. A list-fetch failure also renders the banner, not a false empty state (fix FB-1).

The settings shell (src/app/(app)/settings/page.tsx) writes ?tab= on every switch and drops edit when leaving the templates tab; legacy ?tab=usage coerces to billing and ?tab=app-config to general. Legacy /templates* routes redirect into the tab with edit params preserved (PAGE-013..016).

Gotchas​

  • Description length mismatch: backend accepts ≤1000, portal zod caps at 500 — a >500-char description can pass the API but never originates from the portal form.
  • All writes 403 after Clerk config changes: if the JWT template stops emitting org_metadata, every role-gated template write fails while reads still work. Check the JWT template first.
  • Concurrent-create overshoot: see the quota section — check-then-write, unguarded.
  • The GET /portal/onboarding/state → 401 blip on hard navigations (self-heals ~2 s after Clerk token refresh) is app-wide, not templates-specific — don't chase it when testing this flow.
  • 429 shape unverified: the 30/min portal preset was never burst-tested against these endpoints; don't document a 429 body from imagination.
  • sys-custom doesn't exist — the legacy reference file listed it for years; the seed has exactly the 7 starters above.

Runtime verification​

Recorded live 2026-09-11 (FLOW-008 UX-7) on the seeded Demo Org; re-verify live on next use. Demo-org token per Real-token HTTP.

Attach a system starter and watch it flow through resolution and analytics:

# 1. Attach a SYSTEM starter to a new link — 201, channel promoted to email
curl -s -X POST "$BASE/portal/links" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"path":"/inbox","templateId":"sys-email-to-app","params":{"campaign":"fall"}}'
# → 201: templateId "sys-email-to-app", channel "email", deferred null (inherit)

# 2. Public resolution — merged effective values (template baseParams under link params)
curl -s "$BASE/demo/$SHORT/data"
# → params {"source":"email","campaign":"fall"} (source merged from baseParams)

# 3. Template analytics — the system template is attributed (seeded clicks land under it too)
curl -s "$BASE/portal/analytics/templates?days=30" -H "Authorization: Bearer $TOKEN"
# → row for sys-email-to-app only; the other 6 starters have NO row (no zero-fill)

# 4. Fixed-id routes accept sys-* ids; foreign-org template id → 404
curl -s "$BASE/portal/templates/sys-email-to-app" -H "Authorization: Bearer $TOKEN" # → 200

Delete is blocked while attached (the 200 / 409 / 403 ladder):

TID=$(curl -s -X POST "$BASE/portal/templates" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"name":"Del check","channel":"email"}' \
| node -pe 'JSON.parse(require("fs").readFileSync(0)).id')
curl -s -X DELETE "$BASE/portal/templates/$TID" -H "Authorization: Bearer $TOKEN" # unattached → 200 {deleted:true}

# attach a link to a second template, then:
curl -s -X DELETE "$BASE/portal/templates/$TID2" -H "Authorization: Bearer $TOKEN"
# → 409 {…"linksUsing":1…}; GET /portal/templates shows the same count in row._count.links

# below-developer role → 403 (RolesGuard fires before the 409 logic)
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$BASE/portal/templates/$TID2" \
-H "Authorization: Bearer $VIEWER_TOKEN" # → 403

Cleanup: delete the attached link, then the template — the quota slot is a live count, so the org returns to its seeded 1/3 immediately.