Two routes under apps/workspace/app/(workspace)/marketing-attribution/, both gated by
org:marketing:view (layout.tsx). They answer "which ad, campaign, and content path produced
each lead" and close the loop by minting the trackable URLs that feed those journeys.
/marketing-attribution#A client page that fetches GET /api/workspace/marketing-attribution?days= with a range switch
(7d / 30d / 90d / All, default 30d), a CSV export
(/api/workspace/marketing-attribution/export?days=), and a manual refresh.
JourneyVisual) or data form./marketing-attribution/tracking-links#TrackingLinksPage loads the destination catalog server-side via listDestinations() once per page
load and hands it to TrackingLinksWorkspace, so the DestinationPicker filters in the browser
without spending requests. TrackingLinkBuilder mints through useMintSubmission:
| Action | Route | Gate |
|---|---|---|
| List links | GET /api/workspace/tracking-links | org:marketing:view |
| Mint a link | POST /api/workspace/tracking-links | org:marketing:edit |
| Read one link | GET /api/workspace/tracking-links/[code] | org:marketing:view |
| Edit / archive | PATCH /api/workspace/tracking-links/[code] | org:marketing:edit |
| Bulk mint | POST /api/workspace/tracking-links/bulk | org:marketing:edit |
| Dashboard data | GET /api/workspace/tracking-links/performance?days=7|30|90 | org:marketing:view |
The page layout and every route above gate on the same family, org:marketing:*, so a role that can
open the page can use it. Tracking links belong to the Lead Attribution section, whose nav entry and
sibling APIs are already org:marketing:*, and the pipeline panel exposes lead data that
org:content:view holders such as hr_manager, legal_counsel and developer are not meant to
read. Roles with only org:marketing:view (content_editor, publisher) can read but not mint,
archive or bulk-mint. The UI reflects this: TrackingLinksWorkspace checks can("org:marketing:edit")
(usePermission), shows a "Read-only access" notice, renders the builder (a disabled <fieldset>)
and the row actions (Edit labels, Pause/Resume, Archive, Restore, Duplicate) disabled with a
tooltip, and leaves copy, QR, Performance and CSV export enabled.
Mint → ?mt=<code> (the TRACKING_PARAM in lib/tracking/url.ts) → the public site's telemetry
capture attaches trackingCode (lib/telemetry/build-event.ts, lib/telemetry/schema.ts) → the
registry APIs above resolve it back to channel/campaign/destination. TrackingLinkTable manages the
registry with CSV export; QrCodeButton renders a scannable code per link.
/marketing-attribution/[code] and /marketing-attribution/tracking-links/performance#Two read-only pages sit on one payload from GET /api/workspace/tracking-links/performance
(loadTrackingPerformance in lib/services/tracking-link.service.ts). One request returns the daily
rollup rows (EngagementStore.trackingLinkPerformance), the registry in EVERY status (an archived
link's rows remain, and aggregateBy drops a row whose link is absent), and the destination pages'
own engagement (contentPerformance, reduced to the paths links point at). The rollup half is cached
60 s; the registry half is not, so a pause shows immediately. Ranges are 7, 30 or 90 days; changing
range is an explicit click and the only thing that refetches (reads share a 60/min per-IP bucket).
days UTC days ending YESTERDAY (performanceWindow). The rollup cron folds yesterday
only, so today never has a row; charts end at the latest rolled-up day (dataThroughDate) and the page
says "Through <date>", so a not-yet-folded day is never drawn as zero traffic. A link's series also
starts at its creation day.LinkPerformance): headline tiles, a visits-per-day line (VisitsTrend, with a
table view), and "Page context": the link's and the page's read rates shown side by side with NO
verdict, plus the link's share of the page's traffic. The measures differ (link: distinct readers over
arrivals, reads credited via the session; page: distinct readers over all pageviews of the path), so a
gap between them has no reliable sign. Engaged time (visit-weighted median of daily medians against a path median) is likewise not compared. An unmintable code 404s.CampaignRollup): totals, the unrecognised-codes card, and aggregateBy tables by
campaign, channel or destination, with visits per link so a channel with many links is not
mistaken for a better one. Selected toggles use the ink pair with a border and underline (not the accent pair, WEB-121), not just colour.What the numbers mean, and what is deliberately not shown:
| Rule | Why |
|---|---|
| "Visitor-days", never "unique visitors" | uniqueVisitors is summed per day; a person back on two days counts twice (lib/tracking/counters.ts). |
| Video completions are not displayed | videoCompletions is computed but reads zero in production: the only video_progress producer (lib/telemetry/clicks.ts) never emits depthPct, which the rollup requires to be 100. "0 completions" would claim nobody finished a video. |
mt_unknown is shown, in its own card | A well-formed code that resolved to nothing: a truncated or mangled paste or a failed lookup. It is not a link anyone minted, and is excluded from the campaign, channel and page totals. |
| Paused and archived links keep history, marked as such | They stop counting at the status change but their rows remain. The detail page says counting stopped and when data last arrived; the trend line ends at the last counted day instead of running to zero; rollups mark "N stopped". |
Rates need 20+ visits (MIN_VISITS_FOR_RATE) | Below that a percentage is noise; a dash is shown instead. Rates are clamped to 100%. |
The page figure and the link figure are not independent samples, and read depth counts distinct readers per day against arrivals, so treat a few points of difference as noise.
?mt= → journey)org:marketing:view, org:content:view/edit