URL: https://mkdocs.justinsforge.com/memory/handoffs/links-core-status-2026-07-11/
Links module foundation build, 2026-07-11¶
Unattended Opus worker session. Four tasks (A-D), sequenced because they share files. Branches pushed, not merged, not deployed, no prod migrations run.
- CreatorTrack: branch
links-core(ab527e6) ongithub.com/JustinWieb/creatortrack, worktree~/forge-suite-wt/links-core, dev DB clonect_links_core. - Forge (edge Worker + sync script + this doc): branch
links-core-edge(01fe680) ongithub.com/JustinWieb/forge.
A. Rename "Link In Bio" -> "Links" (display only)¶
Changed: catalog name in lib/apps.ts, the list-page <h1> (LinkInBioClient.tsx), and every
PersonalAppEmptyState appLabel/title string across page.tsx, [pageId]/page.tsx,
stats/page.tsx. Route (/link-in-bio), the links.* schema, node/app id (link-in-bio), and
internal code comments/filenames are unchanged on purpose (task scope was user-facing only).
Verified: GET /link-in-bio HTML contains >Links< and no longer contains
lib-title">Link In Bio<.
B. Source passthrough¶
lib/links/render.ts's captureScript no longer hardcodes source: 'links'. Client-side
detectSource():
1. Reads ?src=<channel> off window.location.search (a creator drops this on the link they
post per platform, e.g. gusthebass.com/links?src=youtube).
2. Falls back to classifying document.referrer into a coarse channel: instagram, youtube,
tiktok, facebook, twitter, reddit, pinterest, snapchat, linkedin, google, bing, duckduckgo,
else referral.
3. No referrer at all -> direct.
Edge capture.ts already accepted {source} and stores it verbatim (no change needed there
beyond the tags addition in Task D). forge_links_sync.py already tags Mautic
source:<value>; no change needed for source itself (only tags, see D).
Verified: rendered publish HTML (dry-run) contains the detectSource function, reads
URLSearchParams, and the POST body template is source: source (dynamic), not a literal
string.
C. Source breakdown in stats¶
getLinkStats (lib/links-store.ts) already had a bySource capture rollup from a prior
session; this task's addition is byReferrer: a click rollup grouped by the raw
links.clicks.referer column (top 15, readily available, no extra join). Stats page
(stats/page.tsx) gained a "Clicks by referrer" table plus a Tags column on "Recent signups"
(reads the new links.captures.tags array, see D).
Verified: seeded one Postgres links.captures row (source=youtube, tags=['July 2026
Merch Drop']) and one links.clicks row (referer=https://www.youtube.com/) directly into
ct_links_core, hit /link-in-bio/stats, confirmed all four tables rendered correctly
(referrer host, tags list, source in the existing bySource table), then deleted the seed rows.
D. Email-capture config panel (brand + custom tags)¶
BlockEditor.tsx's email-capture fields gained:
- Brand target (block.data.brand, dropdown over BRAND_OPTIONS): overrides the page's
brand for that one form's captures/Mautic tags. Blank = use the page brand.
- Custom tags (block.data.tags, freeform comma-separated string, e.g. "July 2026 Merch
Drop"): applied to every signup from that block.
Threaded end to end:
LinkBioPreview.tsx stamps data-brand/data-tags on the <form> in publish mode ->
render.ts's captureScript reads them per-form (form.getAttribute('data-brand'/'data-tags'),
falling back to the page-level brand) -> POST /c body carries brand (resolved) + tags:
string[] -> edge capture.ts comma-joins + stores in a new captures.tags D1 column ->
forge_links_sync.py splits it back into a text[] and writes links.captures.tags (new
Postgres column) -> fan_out_mautic appends each custom tag verbatim (unnamespaced, unlike the
existing brand:/source:/page: tags) to the Mautic contact's tag list.
MVP scope only: brand + custom tags. Segment selection and welcome-email-id selection per block are explicitly deferred (see "Deferred" below).
Files changed¶
creatortrack (branch links-core, worktree ~/forge-suite-wt/links-core):
- lib/apps.ts -- catalog display name
- app/(suite)/link-in-bio/page.tsx, [pageId]/page.tsx, stats/page.tsx -- rename strings,
new stats tables
- components/apps/link-in-bio/LinkInBioClient.tsx -- rename <h1>
- components/apps/link-in-bio/BlockEditor.tsx -- brand/tags config fields
- components/apps/link-in-bio/LinkBioPreview.tsx -- data-brand/data-tags form attrs
- lib/links/render.ts -- detectSource(), per-form brand/tags in captureScript
- lib/links-store.ts -- byReferrer + tags on getLinkStats
- db/schema.ts -- linkCaptures.tags (drizzle)
- db/migrations/20260711T214757_links_core_links_captures_tags_column_for_custom_capture_tags.sql
-- ALTER TABLE links.captures ADD COLUMN IF NOT EXISTS tags text[]. Applied to the
ct_links_core dev clone only.
forge (branch links-core-edge):
- infra/links-edge/src/capture.ts -- accepts + stores tags
- infra/links-edge/schema.sql -- captures.tags TEXT (fresh-DB CREATE TABLE IF NOT EXISTS
only; does NOT retroactively alter the existing prod D1)
- infra/links-edge/migrations/0001_captures_tags_column.sql -- new, the hand-apply ALTER
for the existing prod links-events D1 (not run by this worker)
- scripts/forge_links_sync.py -- mirrors tags into Postgres, fans custom tags into Mautic
- memory/general/reference_links_module.md -- documents this update
- memory/handoffs/links-core-status-2026-07-11.md -- this file
Verification performed (proof, not hand-waved)¶
- Started the CT dev server (
next dev --webpack -p 3066) againstct_links_core, dev-auth as[email protected]withct_active_ws=85(Gus The Bass, one of the two allowlisted workspaces) cookie. - Created a page (
POST /api/links {action:createPage}), added an email-capture block withbrand: "gusoutdoor",tags: "July 2026 Merch Drop, VIP". GET /api/links/publish?pageId=<id>(dry-run render endpoint) returned HTML containingdata-capture="1" data-brand="gusoutdoor" data-tags="July 2026 Merch Drop, VIP", thedetectSource()function body, and a POST-body template of{ email, brand: formBrand, source: source, pageId: "links", tags: tags }.- Confirmed
GET /link-in-bioshows>Links<andGET /link-in-bio/statsrenders the new tables once seeded with data (see Task C). - Directly exercised
forge_links_sync.py'ssync_captures()+fan_out_mautic()againstct_links_corewith a monkeypatchedd1_query(one fake row: source=youtube, tags="July 2026 Merch Drop,VIP") and a stubbed Mautic client (no real network calls to the production Mautic instance -- avoided creating a junk contact there). Confirmed: the Postgres row landed withsource='youtube',tags=['July 2026 Merch Drop','VIP']; the fake Mautic client'supsert_contactwas called with tags['brand:gusoutdoor','source:youtube','page:links','July 2026 Merch Drop','VIP'];synced_to_mauticflipped true. Cleaned up the test row afterward. - Ran
PGDATABASE=ct_links_core python3 forge/scripts/forge_links_sync.py --once --dry-runfor real (safe: dry-run performs no writes, D1 query is a read-only Cloudflare API call against prod, watermark means it only re-reads rows already synced) -- exited 0, printed+2 clicks, +1 captures mirrored, 0 captures -> Mautic segment 4with no exception, proving the script runs cleanly end to end with the new code paths present (argument parsing, secrets loading, D1 fetch). npx tsc --noEmitin the creatortrack worktree: clean (exit 0).
Typecheck status¶
- creatortrack:
npx tsc --noEmitclean. - links-edge: NOT compiled.
npm installinforge/infra/links-edgeis blocked by this sandbox's context-mode tooling (interceptsnpm install, never actually runs it; nonode_modulespresent, sonpx tsc/wranglerare unavailable). Thecapture.tsdiff is small and uses only patterns already present in the file (Record<string, unknown>field parsing,Array.isArraynarrowing, string concatenation) -- reviewed by hand, no new type surfaces introduced. Before merging, runcd forge/infra/links-edge && npm install && npx tscto confirm mechanically.
Go-live steps (in order)¶
- Review + merge
links-core(creatortrack) andlinks-core-edge(forge) into their respectivemainbranches (PR review, not this worker's call). - Apply the CT migration to prod lifeos:
PGDATABASE=lifeos WORKSPACE_MIGRATIONS_DIR=db/migrations python3 ~/forge/scripts/forge_workspace_migrate.py(addslinks.captures.tags). Confirm the migration filename appears in the "applied" list before deploying code that reads/writes it. - Apply the D1 migration to prod
links-events: ("duplicate column name" on a re-run is safe to ignore.) - Deploy CreatorTrack prod (standard build+swap, see reference_creatortrack_dev_testing.md's "Prod deploy while the shared tree is busy" section, or the normal deploy path if the tree is free).
- Redeploy the edge Worker:
cd forge/infra/links-edge && npm install && npx tsc && set -a; source ~/.forge-secrets/cloudflare.env; set +a && npx wrangler deploy(install + typecheck first, per the Typecheck status note above). - Smoke-test: publish a page live (
LINKS_PUBLISH_LIVE=1is already set in prod per the existing publish gate), load it with?src=youtube, submit the email-capture form, confirmwrangler tail links-edgeshows the capture withsource":"youtube"and the expected tags, then runpython3 forge/scripts/forge_links_sync.py --once(no--dry-run) and confirm the row lands inlinks.captureswithtagspopulated and reaches Mautic.
Deferred / out of scope¶
- Per-block Mautic segment selection (still hardcoded to segment 4 "Gus The Bass" for every capture regardless of brand/tags).
- Per-block welcome-email id selection (still hardcoded to
WELCOME_EMAIL_ID = 14for every new contact). - These both need a Mautic-side picker (segment list / email list API) surfaced in
BlockEditor.tsx's config panel plus a column each onlinks.blocks.data(or a page-level default) to route throughforge_links_sync.py. Straightforward follow-on once Justin wants per-campaign segmentation beyond the shared brand tag.