Skip to main content

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:

  1. Template resolution: resolveAttachableTemplate accepts a template that is isSystem: true or owned by the org; anything else is 404. System starters attach directly without cloning.
  2. Domain resolution: first of dto.domainId → template.domainId → platform default. The default is a single shared Domain row (organizationId: null, hostname = CNAME_TARGET env). If that row is missing (unseeded DB), creation throws 500 with a message telling the operator to run pnpm prisma db seed.
  3. Short code: ShortCodeService — validate() when dto.shortCode is set (409 ConflictException on a taken code), else generate() (crypto base62, 8 chars, up to 5 collision retries).
  4. prisma.link.create (field-by-field below).
  5. Response: the row plus a server-composed url from composeShortUrl(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, and params.
  • Snapshotted at creation: channel (dto.channel ?? template.channel ?? null) — deliberate, so an install source never shifts under a live campaign. domainId and shortCode are 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):

  1. Portal builds an effective snapshot (buildSnapshot) of the form.
  2. POST /portal/templates (src/portal/portal-template.controller.ts lines 78–93; @Roles('developer'), checkQuota(org.id, 'templates') → 402).
  3. Re-POST /portal/links with templateId + 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​

RouteRoleNotes
GET /portal/links/short-codes/:code/availableany 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/linksany org rolePaginated rows incl. composed url
GET /portal/links/:idany org roleLink + url + included template
GET /portal/links/:id/qr.svg / qr.pngany org roleQrCodeService.generateSvg/generatePng of the composed URL
GET /portal/templatesany org roleReturns org + system templates together; the picker filters client-side on isSystem — useTemplates is not role-gated
POST /portal/templatesdeveloper402 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 Domain row. A raw DB without pnpm prisma db seed 500s on link creation regardless of payload.
  • clipboardEnabled has 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.tsx layout <main> must not have overflow-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 uses lg: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.