Link creation pipeline
Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (B-014 re-verification pass).
This page explains how a link gets created on the backend: the gate order on POST /portal/links, where each value comes from, and the inherit-at-read contract that makes template edits propagate. The portal-side mirror of the merge logic is the client preview; the read side (resolution, detail pages) is what consumes its output.
Request path and gate order
src/portal/portal-link.controller.ts (line 223) stacks @UseGuards(ClerkAuthGuard, RolesGuard, RateLimitGuard) in the standard portal order. POST /portal/links is @Roles('developer'), so anything below DEVELOPER dies in RolesGuard with 403 before touching business logic.
Inside the handler (portal-link.controller.ts lines 274–301), two gates run in a fixed order:
@OrgFromUser(OrgResolutionPipe) Clerk IDs → Organization row
↓
zero-AppConfig check 0 rows → 428 AppConfigRequiredException
↓
EntitlementsService.checkQuota(org.id, 'links') over limit → 402 QuotaExceededException
↓
LinkService.create
428 comes before 402. An org with no AppConfig rows always gets 428, even if it is also over quota; tests that expect 402 on an un-onboarded org will see 428 instead (recorded in docs/TESTS-NOTES.md). The 428 mirrors the plan === null hard gate: creation is impossible until the org has registered an app.
The create route also carries @Auditable({ action: 'CREATE', entityType: 'Link' }) (line 283); AuditInterceptor picks it up. Rate limiting is RateLimitPresets.portal on every route, including the availability probe.
LinkService.create
src/link/link.service.ts (line 40). Five steps:
- Template resolution:
resolveAttachableTemplateaccepts a template that isisSystem: trueor owned by the org; anything else is 404. System starters attach directly without cloning. - Domain resolution: first of
dto.domainId→template.domainId→ platform default. The default is a single sharedDomainrow (organizationId: null, hostname =CNAME_TARGETenv). If that row is missing (unseeded DB), creation throws 500 with a message telling the operator to runpnpm prisma db seed. - Short code:
ShortCodeService—validate()whendto.shortCodeis set (409ConflictExceptionon a taken code), elsegenerate()(crypto base62, 8 chars, up to 5 collision retries). prisma.link.create(field-by-field below).- Response: the row plus a server-composed
urlfromcomposeShortUrl(domain, orgKey, shortCode). Clients never compose the URL themselves; the portal preview recomputes it client-side for display only.
Custom codes are 3–100 chars [a-zA-Z0-9_-] (DTO regex, create-link.dto.ts lines 25–27) and unique per org — the DB-level uniqueness is org-scoped, not global.
What's stored null vs snapshotted
The inherit contract (link.service.ts lines ~62–85):
// null = inherit from the template at read time (never
// materialize — template edits must propagate live).
deferred: dto.deferred ?? null,
matchWindow: dto.matchWindow ?? null,
clipboardEnabled: dto.clipboardEnabled ?? null,
// P6-001: channel is inherited from the template when the dto omits it.
channel: dto.channel ?? template?.channel ?? null,
- Stored null (merge at read):
deferred,matchWindow,clipboardEnabled,fallbackUrl,ogTitle/ogDescription/ogImageUrl,utmSource/Medium/Campaign/Term/Content, andparams. - Snapshotted at creation:
channel(dto.channel ?? template.channel ?? null) — deliberate, so an install source never shifts under a live campaign.domainIdandshortCodeare also frozen at creation. - Link-only:
path,title,tags,expiry— no template inheritance.
Merge-at-read lives in src/link/link-template-merge.ts (header comment is the design record):
effective field = link.field ?? template.field ?? schema default
params: { ...template.baseParams, ...link.params } // recursive deep-merge, template as base
No versioning, no propagation jobs: editing a template is immediately visible to every referencing link. src/lib/effective-link.ts in the portal is the client-side port of this logic with provenance tracking (link/template/org/default badges); the two must stay in sync. The portal never resolves a link server-side for preview — assertions that it does are wrong by design.
Save-as-template: attach-after-save
"Save as template" during link creation is a client-orchestrated sequence in create/page.tsx (the savedTemplateId contract, lines ~89–94, 461–496):
- Portal builds an effective snapshot (
buildSnapshot) of the form. POST /portal/templates(src/portal/portal-template.controller.tslines 78–93;@Roles('developer'),checkQuota(org.id, 'templates')→ 402).- Re-
POST /portal/linkswithtemplateId+stripConfig: true— config overrides (deferred/matchWindow/OG/UTM) are dropped from the link payload since the template now carries them; path/shortCode/params/title/tags/expiry/domain are kept.
If the template POST succeeded but the link re-POST fails, a retry resubmits only the link — the savedTemplateId guard prevents duplicating the template.
Supporting endpoints
| Route | Role | Notes |
|---|---|---|
GET /portal/links/short-codes/:code/available | any org role (no @Roles, deliberate) | Non-throwing ShortCodeService.isAvailable(code, organizationId, excludeLinkId?); 200 { available: boolean }; 400 on invalid :code (3–100, [a-zA-Z0-9_-]) or non-UUID excludeLinkId |
GET /portal/links | any org role | Paginated rows incl. composed url |
GET /portal/links/:id | any org role | Link + url + included template |
GET /portal/links/:id/qr.svg / qr.png | any org role | QrCodeService.generateSvg/generatePng of the composed URL |
GET /portal/templates | any org role | Returns org + system templates together; the picker filters client-side on isSystem — useTemplates is not role-gated |
POST /portal/templates | developer | 402 quota:"templates" when over |
Quota limits come from seeded plan_config, mirrored in src/entitlements/plans.config.ts (lines 86–202): links/templates 10/3 (STARTER), 50/5 (SOLO), 200/10 (GROWTH), 1000/50 (SCALE), unlimited with onExhausted: 'overage' (ENTERPRISE). The 402 body forwards code: "QUOTA_EXCEEDED" and quota (see src/entitlements/quota-exceeded.exception.ts).
Gotchas
- Seed dependency: every create path ends at the platform default
Domainrow. A raw DB withoutpnpm prisma db seed500s on link creation regardless of payload. clipboardEnabledhas no portal affordance (F6, intentional so far). The DTO field and DB column exist; the create UI doesn't expose it. Don't "wire it up" without checking the portal map first.- Sticky preview rail regression guard:
app-sidebar.tsxlayout<main>must not haveoverflow-auto— it becomes the sticky scrollport with zero travel and silently unpins the rail. Removed once with an explanatory comment in the file; the create grid useslg:items-stretch. - Client/server regex duplication: the short-code regex is duplicated in the portal (
src/hooks/use-links.ts) and must match the DTO consts — change both together.