Skip to main content

Link management semantics

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

This page covers everything that happens to a link after creation: the list query, CSV export, bulk actions, update clearability rules, how the links quota interacts with activate/delete, and who consumes the merge-at-read contract. The POST /portal/links path — guard stack, 428/402 gate order, template and domain resolution — lives in Link creation pipeline and is not repeated here.

Route inventory​

All management routes live in src/portal/portal-link.controller.ts (prefix portal/links), guarded ClerkAuthGuard → RolesGuard → RateLimitGuard plus AuditInterceptor; rate limit preset portal = 30 req/s, prefix rl:portal (src/rate-limiting/rate-limit.decorator.ts:23).

RouteRole floorNotes
GET /portal/linksany org memberQuery: page, limit (≤100; server default 20, portal sends 10), isActive, search, sort, createdAfter/createdBefore, tag, format=json|csv → {data, total, page, limit}; rows carry url + clickCount, no template
GET /portal/links/tagsany org member{tags: string[]} — all tags across the org's links, deduped, localeCompare order
POST /portal/links/bulkdeveloper+Body {ids: UUID[] (1–100), action: 'deactivate'|'delete'} → {action, requested, affected, notFound}
GET /portal/links/short-codes/:code/availablenone (deliberate){available: boolean}; code 3–100 [a-zA-Z0-9_-]; ?excludeLinkId=<uuid>
GET /portal/links/:idany org memberFull link + domain + full template + composed url; no clickCount
PATCH /portal/links/:iddeveloper+UpdateLinkRequest; returns the bare row; @Auditable UPDATE
DELETE /portal/links/:iddeveloper+Cascades ClickEvent rows; @Auditable DELETE
GET /portal/links/:id/qr.svg / qr.pngany org memberCache-Control: public, max-age=86400

The portal attaches the admin ?orgId passthrough to every link request (api-client withOrgPath/withOrgId), feeding the ADMIN_PASSTHROUGH audit actor chain.

List query: findAll​

LinkService.findAll (src/link/link.service.ts:192) builds the where clause from:

  • search: insensitive contains across shortCode, path, title.
  • tag: exact array membership (tags: { has: tag }).
  • createdAfter/createdBefore: normalized to UTC day bounds.
  • isActive: see the Transform trap below.

Rows include domain: {domain, status} and _count.clickEvents; sorting goes through buildListOrderBy — default createdAt:desc, clicks maps to {clickEvents: {_count}}, every sort tiebreaks on {id: 'asc'}. The DTO validates sort against ^(createdAt|clicks|shortCode):(asc|desc)$; the portal UI only exposes Clicks/Created headers even though shortCode is accepted server-side (and advertised in the use-links.ts LinkListParams doc-comment — a doc-comment/UI mismatch, not a bug).

isActive Transform trap: the query DTO keeps only the exact lowercase string 'true'. Every other value — 'false', '1', 'TRUE', garbage — becomes false, i.e. filters to inactive links. The portal only ever sends true/false, so this is only reachable via hand-built requests.

CSV export: buffered, not streamed​

findAllCsv (link.service.ts:226) reuses buildListWhere/buildListOrderBy and ignores page/limit, so the export is the whole matching view. Columns: shortCode,url,path,title,tags,status,clicks,createdAt, tags joined with '; '. The result is buffered in memory — the ponytail: comment at link.service.ts:228 notes the ceiling: org link count is quota-bounded, so the buffer is bounded by construction. The controller sets text/csv and attachment; filename="links-YYYY-MM-DD.csv" (portal-link.controller.ts:319). The Swagger wording says "streams"; it does not — don't copy that word.

Bulk actions​

LinkService.bulkAction (link.service.ts:323) scopes to {id: {in: ids}, organizationId}:

  • deactivate → updateMany {isActive: false}.
  • delete → deleteMany; the ClickEvent FK is onDelete: Cascade, so click history goes with the links.

Response is {action, requested, affected, notFound} where notFound = requested − affected. Foreign-org ids and duplicates fold silently into notFound — there is no per-id breakdown. Neither action touches the quota code path (see below for why deleting still frees a slot). Auditing is manual via auditBulk (fire-and-forget, ops-passthrough aware), not the @Auditable interceptor.

update: what null clears and what it can't​

LinkService.update (link.service.ts:380) validates:

  • changed shortCode → ShortCodeService → 409 "already in use in this organization" on collision (src/link/short-code.service.ts:34-38);
  • templateId → resolveAttachableTemplate (system or org-owned) else 404;
  • domainId → resolveExplicitDomain (org-owned or the shared platform default), must be VERIFIED else 400.

Clearability splits three ways:

Behavior on explicit nullFields
Cleared → inheritfallbackUrl, expiry, deferred, matchWindow, clipboardEnabled, utm* ×5, og.title/description/imageUrl
Ignored (old value kept)params — a truthiness check means null does not clear; the portal sends params: {} for "all removed"
Not clearable at allshortCode, path, title, tags, isActive

The DTO Transform maps '' → null on fallbackUrl before @IsUrl (update-link.dto.ts:120-122), so an emptied field clears to inherit instead of failing URL validation.

The PATCH response is the bare updated row — no url, no includes. The portal doesn't use the body: it refetches via findOne and remounts the edit form on key={link.updatedAt} so every field re-seeds from the saved state.

Quota interplay​

Only create enforces the links quota — on both surfaces: entitlements.checkQuota(org.id, 'links') runs on POST /portal/links (portal-link.controller.ts:296-299) and, mirrored by the SERVER-tier API path since #33 (659ce07), on POST /links (link.controller.ts:99-108, 428-before-402 again). Usage is a live count — prisma.link.count({organizationId}) (entitlements.service.ts:276-277) — and recordUsage is never called for links (it early-returns for everything except clicks_per_month, entitlements.service.ts:115). Consequences:

  • Deactivated links still count; deactivating frees nothing.
  • Any delete (single or bulk) frees slots immediately, with no quota call — the count just drops.
  • The 428-before-402 gate order on create is documented in Link creation pipeline.

Read-side consumers​

  • findOne (link.service.ts:356): org-scoped findFirst {id, organizationId} — a foreign org's id is a 404, indistinguishable from missing. Includes domain, the full template, and the composed url; no clickCount.
  • Portal detail/preview merge: src/lib/effective-link.ts resolveSourcedLink(link, template, org) is the client-side port of src/link/link-template-merge.ts plus provenance labels. The badge renders Override for link-sourced values and the raw source (template/org/default) otherwise (src/components/links/resolved-values-table.tsx:33). The two merge implementations must stay in sync; the portal never asks the server to resolve values for preview.
  • Public resolution (src/resolution/resolution.controller.ts:27): GET /:orgKey/:shortCode serves the redirect HTML page (200 for an active link; bots get minimal OG-only HTML) and GET /:orgKey/:shortCode/data returns JSON for SDKs. orgKey is exactly 4 alphanumerics (ParseOrgKeyPipe, ^[A-Za-z0-9]{4}$); rate limit 500 req/s (rl:resolve); click tracking is fire-and-forget. Status semantics from resolveByShortCode (link.service.ts:452): inactive → 410 "This link has been deactivated"; expired → 410 "This link has expired"; org not ACTIVE → 410 "This link is no longer available"; unknown or deleted → 404 "Link not found".
  • QR: the qr.svg/qr.png responses cache for 24 h, so a stale QR can survive a domain/short-code change by up to a day.

Writer census: no cron or webhook writes Link rows. The domain grace scheduler and the billing overage sweep touch other tables (src/domain/domain-scheduler.service.ts, src/billing/schedulers/billing-scheduler.ts). The only writers are the portal controller and the API-key mirror.

API-key mirror​

src/link/link.controller.ts (@Controller('links'), @RequireApiKeyTier(ApiKeyTier.SERVER)) exposes the same LinkService with a subset of the surface: isActive filter + pagination only — no search, sort, date/tag filters, CSV, bulk, or tags endpoint. This is the FLOW-010 surface; feature parity with the portal list is not a goal. Create is not a bare pass-through either: since #33 (659ce07) it mirrors the portal's write gates — zero AppConfig rows → 428, then checkQuota(org.id, 'links') → 402 — so plan limits hold no matter which surface created the link.

Prisma shape notes​

prisma/schema.prisma (Link at line 412, LinkTemplate 375, ClickEvent 491):

  • @@unique([organizationId, shortCode]) — uniqueness is per org, not global.
  • expiry DateTime? — the column is expiry, not expiresAt.
  • tags String[], params Json?.
  • Inherit-nullable booleans/ints/strings implement the merge-at-read contract (see Link creation pipeline).
  • @@index([organizationId, isActive]) backs the list default filter.
  • ClickEvent FK onDelete: Cascade — deleting a link deletes its clicks.

Config surface​

  • CNAME_TARGET (required, fail-fast at boot, src/config/env.validation.ts:14): hostname of the shared platform-default Domain row (organizationId: null); consumed by default-domain resolution and composeShortUrl (https://{domain}/{orgKey}/{shortCode}, link.service.ts:101; 404 if the org row is missing).
  • CLERK_OPS_ORG_ID (optional): ops-org bypass in RolesGuard; auditBulk is ops-passthrough aware.

Portal file map (fix-pass result)​

The v2.2.0 fix pass left this surface (per-file changelog in docs/FLOW-007.md, "Fix pass summary"; backend 6774ea3/3749618/b7d98cc, portal 2a1726d/9ab3a39/0ff7d9a/4bfe50d/5328bb7):

FileRole
src/app/(app)/links/page.tsxList page: URL-param filters (debounced search), SortHeader (aria-sort), bulk toolbar, CSV export handler
src/app/(app)/links/[id]/page.tsxDetail page: resolveSourcedLink render, template strip, activate switch, 404-vs-error split, edit remount keyed on link.updatedAt
src/app/(app)/links/[id]/edit-link-form.tsxFive-section form; 11 tri-state fields; read-only channel row
src/app/(app)/links/[id]/update-payload.tsbuildUpdatePayload: dirty-only PATCH (cleared override → null, all-removed params → {})
src/components/links/*deactivate-link-dialog, qr-preview, resolved-values-table (SourceBadge), form-section, template-picker-dialog, preview-rail, tri-state-field
src/hooks/use-links.tsuseLinks, useLink, useUpdateLink, useDeleteLink, useBulkLinks, useLinkTags, useLinksFilters, useShortCodeAvailability (400 ms debounce, min 3 chars)
src/lib/effective-link.tsresolveSourcedLink — client mirror of the backend merge with provenance

Gotchas​

  • isActive query param accepts only exact lowercase 'true'; anything else filters to inactive (Transform trap above).
  • PATCH body is bare — code that reads url or template off the PATCH response gets undefined; refetch findOne instead.
  • params can't be cleared with null — send {}.
  • shortCode sortability is accepted server-side and advertised in a portal doc-comment, but the list UI renders sortable headers for Clicks/Created only.
  • CSV is buffered, despite the "streams" Swagger wording.
  • docs/v2.1.0/PORTAL-MAP.md PAGE-010/PAGE-012 are stale (pre-fix-pass): they still claim search is a no-op, edits are limited to four fields, there is no active toggle, no query-error state, and an inline-built QR endpoint. All false since the fix pass — trust the code and docs/FLOW-007.md.
  • E2E seeding: any test that creates links via POST /portal/links needs an AppConfig row first (428 gate) and should prefer plan: 'starter' orgs (create also upserts a Domain row that counts against the domains quota). Operational detail lives in the test stack runbook.