Skip to content

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) on github.com/JustinWieb/creatortrack, worktree ~/forge-suite-wt/links-core, dev DB clone ct_links_core.
  • Forge (edge Worker + sync script + this doc): branch links-core-edge (01fe680) on github.com/JustinWieb/forge.

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)

  1. Started the CT dev server (next dev --webpack -p 3066) against ct_links_core, dev-auth as [email protected] with ct_active_ws=85 (Gus The Bass, one of the two allowlisted workspaces) cookie.
  2. Created a page (POST /api/links {action:createPage}), added an email-capture block with brand: "gusoutdoor", tags: "July 2026 Merch Drop, VIP".
  3. GET /api/links/publish?pageId=<id> (dry-run render endpoint) returned HTML containing data-capture="1" data-brand="gusoutdoor" data-tags="July 2026 Merch Drop, VIP", the detectSource() function body, and a POST-body template of { email, brand: formBrand, source: source, pageId: "links", tags: tags }.
  4. Confirmed GET /link-in-bio shows >Links< and GET /link-in-bio/stats renders the new tables once seeded with data (see Task C).
  5. Directly exercised forge_links_sync.py's sync_captures() + fan_out_mautic() against ct_links_core with a monkeypatched d1_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 with source='youtube', tags=['July 2026 Merch Drop','VIP']; the fake Mautic client's upsert_contact was called with tags ['brand:gusoutdoor','source:youtube','page:links','July 2026 Merch Drop','VIP']; synced_to_mautic flipped true. Cleaned up the test row afterward.
  6. Ran PGDATABASE=ct_links_core python3 forge/scripts/forge_links_sync.py --once --dry-run for 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 4 with no exception, proving the script runs cleanly end to end with the new code paths present (argument parsing, secrets loading, D1 fetch).
  7. npx tsc --noEmit in the creatortrack worktree: clean (exit 0).

Typecheck status

  • creatortrack: npx tsc --noEmit clean.
  • links-edge: NOT compiled. npm install in forge/infra/links-edge is blocked by this sandbox's context-mode tooling (intercepts npm install, never actually runs it; no node_modules present, so npx tsc/wrangler are unavailable). The capture.ts diff is small and uses only patterns already present in the file (Record<string, unknown> field parsing, Array.isArray narrowing, string concatenation) -- reviewed by hand, no new type surfaces introduced. Before merging, run cd forge/infra/links-edge && npm install && npx tsc to confirm mechanically.

Go-live steps (in order)

  1. Review + merge links-core (creatortrack) and links-core-edge (forge) into their respective main branches (PR review, not this worker's call).
  2. Apply the CT migration to prod lifeos: PGDATABASE=lifeos WORKSPACE_MIGRATIONS_DIR=db/migrations python3 ~/forge/scripts/forge_workspace_migrate.py (adds links.captures.tags). Confirm the migration filename appears in the "applied" list before deploying code that reads/writes it.
  3. Apply the D1 migration to prod links-events:
    cd forge/infra/links-edge && set -a; source ~/.forge-secrets/cloudflare.env; set +a && \
    npx wrangler d1 execute links-events --remote --file=migrations/0001_captures_tags_column.sql
    
    ("duplicate column name" on a re-run is safe to ignore.)
  4. 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).
  5. 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).
  6. Smoke-test: publish a page live (LINKS_PUBLISH_LIVE=1 is already set in prod per the existing publish gate), load it with ?src=youtube, submit the email-capture form, confirm wrangler tail links-edge shows the capture with source":"youtube" and the expected tags, then run python3 forge/scripts/forge_links_sync.py --once (no --dry-run) and confirm the row lands in links.captures with tags populated 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 = 14 for 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 on links.blocks.data (or a page-level default) to route through forge_links_sync.py. Straightforward follow-on once Justin wants per-campaign segmentation beyond the shared brand tag.