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).
| Route | Role floor | Notes |
|---|---|---|
GET /portal/links | any org member | Query: 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/tags | any org member | {tags: string[]} — all tags across the org's links, deduped, localeCompare order |
POST /portal/links/bulk | developer+ | Body {ids: UUID[] (1–100), action: 'deactivate'|'delete'} → {action, requested, affected, notFound} |
GET /portal/links/short-codes/:code/available | none (deliberate) | {available: boolean}; code 3–100 [a-zA-Z0-9_-]; ?excludeLinkId=<uuid> |
GET /portal/links/:id | any org member | Full link + domain + full template + composed url; no clickCount |
PATCH /portal/links/:id | developer+ | UpdateLinkRequest; returns the bare row; @Auditable UPDATE |
DELETE /portal/links/:id | developer+ | Cascades ClickEvent rows; @Auditable DELETE |
GET /portal/links/:id/qr.svg / qr.png | any org member | Cache-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: insensitivecontainsacrossshortCode,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; theClickEventFK isonDelete: 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 null | Fields |
|---|---|
| Cleared → inherit | fallbackUrl, 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 all | shortCode, 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-scopedfindFirst {id, organizationId}— a foreign org's id is a 404, indistinguishable from missing. Includesdomain, the fulltemplate, and the composedurl; noclickCount.- Portal detail/preview merge:
src/lib/effective-link.tsresolveSourcedLink(link, template, org)is the client-side port ofsrc/link/link-template-merge.tsplus provenance labels. The badge rendersOverridefor 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/:shortCodeserves the redirect HTML page (200 for an active link; bots get minimal OG-only HTML) andGET /:orgKey/:shortCode/datareturns JSON for SDKs.orgKeyis exactly 4 alphanumerics (ParseOrgKeyPipe,^[A-Za-z0-9]{4}$); rate limit 500 req/s (rl:resolve); click tracking is fire-and-forget. Status semantics fromresolveByShortCode(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.pngresponses 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 isexpiry, notexpiresAt.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.ClickEventFKonDelete: 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-defaultDomainrow (organizationId: null); consumed by default-domain resolution andcomposeShortUrl(https://{domain}/{orgKey}/{shortCode},link.service.ts:101; 404 if the org row is missing).CLERK_OPS_ORG_ID(optional): ops-org bypass inRolesGuard;auditBulkis 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):
| File | Role |
|---|---|
src/app/(app)/links/page.tsx | List page: URL-param filters (debounced search), SortHeader (aria-sort), bulk toolbar, CSV export handler |
src/app/(app)/links/[id]/page.tsx | Detail 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.tsx | Five-section form; 11 tri-state fields; read-only channel row |
src/app/(app)/links/[id]/update-payload.ts | buildUpdatePayload: 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.ts | useLinks, useLink, useUpdateLink, useDeleteLink, useBulkLinks, useLinkTags, useLinksFilters, useShortCodeAvailability (400 ms debounce, min 3 chars) |
src/lib/effective-link.ts | resolveSourcedLink — client mirror of the backend merge with provenance |
Gotchas
isActivequery param accepts only exact lowercase'true'; anything else filters to inactive (Transform trap above).- PATCH body is bare — code that reads
urlortemplateoff the PATCH response getsundefined; refetchfindOneinstead. paramscan't be cleared withnull— send{}.shortCodesortability 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.mdPAGE-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 anddocs/FLOW-007.md.- E2E seeding: any test that creates links via
POST /portal/linksneeds anAppConfigrow first (428 gate) and should preferplan: 'starter'orgs (create also upserts a Domain row that counts against thedomainsquota). Operational detail lives in the test stack runbook.