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).
| Route | Min role | Success | Errors |
|---|---|---|---|
GET /portal/templates | any org role | 200 LinkTemplate[] (org + system, _count.links per row) | 401 |
GET /portal/templates/:id | any org role | 200 LinkTemplate | 404 (foreign/bogus id) |
POST /portal/templates | developer | 201 | 402 quota, 400 validation / foreign domainId |
PATCH /portal/templates/:id | developer | 200 | 403 system, 404, 400 |
DELETE /portal/templates/:id | developer | 200 { deleted: true } | 409 attached, 403 system, 404 |
POST /portal/templates/:id/clone | developer | 201 | 402, 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
findOnescopesOR: [{ 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.listreturns org-owned + all system rows orderedisSystem desc, createdAt asc, each with_count.linksscopedwhere: { organizationId: orgId }— the org filter only matters for system rows, and it's what feeds the "Used by N links" badge. The settings tab filtersisSystemclient-side; system starters appear only in the create picker.assertOrgDomain(create/update): adomainIdmust be the org's own domain or the shared platform-default row (organizationId: null, hostnameCNAME_TARGET), mirroringLinkService.resolveExplicitDomain; violation is 400.CNAME_TARGETisgetOrThrowin 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;domainIdis 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):
| Field | Constraint |
|---|---|
name | required, ≤100 |
description | ≤1000 backend / ≤500 zod (see gotchas) |
channel | social|email|sms|qr|referral|push|web|in_app|custom |
domainId | UUID, org-scoped via assertOrgDomain |
matchWindow | 1–720 hours |
fallbackUrl, ogImageUrl | URL format |
utmSource…utmContent | ≤255 |
ogTitle / ogDescription | ≤255 / ≤500 |
baseParams | object; deep-merged under link params at read |
paramSchema | ParamSchemaEntry[]: key ≤100, label ≤100, type string|number|boolean, required?, placeholder ≤255 |
tags | string 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 → 401blip 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-customdoesn'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.