Skip to content

Plan: CreatorTrack "Campaigns" app (Mautic + Resend, read-only v1)

URL: https://mkdocs.justinsforge.com/memory/plans/creatortrack-campaigns-app-2026-07-06/

Date: 2026-07-06 Approved spec: New global "Campaigns" route-app in CreatorTrack with a brand switcher across all 6 Resend-verified brands (Sip N Serve, CreatorTrack, Gus Outdoor, Gus The Bass, Nova, JustinWieb). Read-only surfaces: per-brand dashboard (segment counts, sends over time, open/click/fail rates), campaign/email list, per-email report. Write-shaped actions deep-link to mautic.justinsforge.com. Data layer clones the finance module pattern: campaigns.* PG schema as read copy, TS ingest jobs pulling Mautic REST (LAN http://192.168.86.87:8090/api) and polling the Resend API, token-authed /api/campaigns/jobs/[job] route, systemd timers forge-creatortrack-campaigns-*. Brand mapping keys off Mautic category + from-address domain (never segment visibility). Idempotent upserts on Mautic/Resend IDs; job failures logged to campaigns.job_runs and fail loud. Out of scope: email builder, send/schedule actions, contact editing, Resend webhooks, list-growth tooling, non-owner visibility.

Dev rig: branch campaigns, worktree ~/forge-suite-wt/campaigns/, DB ct_campaigns (TEMPLATE clone of creatortrack_dev_base), dev server next dev --webpack on :3063 (preview http://100.97.43.104:3063; :3061/:3062 held by existing slots). Bootstrap: bash ~/forge/scripts/forge_creatortrack_agent_bootstrap.sh campaigns 3063.

Task list

Task 1: Create campaigns schema migration + brand constant

  • Files: ~/forge-suite-wt/campaigns/db/migrations/<stamp>_campaigns_campaigns_schema.sql (create with forge_workspace_new_migration.py from the worktree), ~/forge-suite-wt/campaigns/lib/campaigns/brands.ts
  • What: Idempotent DDL for schema campaigns, all tables workspace-scoped with the finance-style workspace_isolation RLS policy + lifeos_app grants: emails (workspace_id, mautic_id, brand_key, name, subject, from_address, email_type, is_published, publish_up, date_added, sent_count, read_count, failed_count, synced_at, UNIQUE(workspace_id, mautic_id)), segments (workspace_id, mautic_id, brand_key, name, alias, contact_count, synced_at, UNIQUE(workspace_id, mautic_id)), send_events (workspace_id, source in ('mautic','resend'), source_id, email_mautic_id, brand_key, recipient_hash, event, event_at, UNIQUE(workspace_id, source, source_id, event)), job_runs (mirror of finance.job_runs). Brand map is NOT a table (single source of truth): lib/campaigns/brands.ts exports the 6-brand constant (key, name, mauticCategoryId: gusoutdoor 1/nova 2/gusbass 3/justinwieb 4/sipnserve null/creatortrack null, fromDomain, mauticBaseUrl deep-link helper).
  • Verification: cd ~/forge-suite-wt/campaigns && PGDATABASE=ct_campaigns WORKSPACE_MIGRATIONS_DIR=db/migrations python3 ~/forge/scripts/forge_workspace_migrate.py then psql \dt campaigns.* shows 4 tables.
  • Commit: feat(campaigns): campaigns schema + brand map

Task 2: Mautic read client

  • Files: ~/forge-suite-wt/campaigns/lib/campaigns/mautic-read.ts
  • What: Server-only read client reusing mauticEnv() creds from lib/mautic.ts (do not touch the signup-sync code). Functions: listMauticEmails() (GET /api/emails, paginated; includes sentCount/readCount/category/fromAddress), listMauticSegments() (GET /api/segments), fetchEmailStatRows(since) (GET /api/stats/email_stats windowed). Brand resolution helper: category id first, from-address domain fallback.
  • Verification: cd ~/forge-suite-wt/campaigns && npx tsx -e "import('./lib/campaigns/mautic-read.ts').then(async m => console.log((await m.listMauticEmails()).length))" prints >= 8.
  • Commit: feat(campaigns): mautic read client

Task 3: Resend read client

  • Files: ~/forge-suite-wt/campaigns/lib/campaigns/resend-read.ts
  • What: Server-only client using RESEND_API_KEY via lib/secrets.ts (resend.env). Functions: listResendDomains() (GET /domains), listRecentResendEmails() (GET /emails, paginated window). Map each email to brand by from-address domain.
  • Verification: npx tsx -e "import('./lib/campaigns/resend-read.ts').then(async m => console.log((await m.listResendDomains()).map(d => d.name)))" prints 6 domains.
  • Commit: feat(campaigns): resend read client

Task 4: Ingest jobs (mautic-sync, resend-poll)

  • Files: ~/forge-suite-wt/campaigns/lib/campaigns-jobs.ts (dispatcher: isKnownJob/runJob, mirroring lib/finance-jobs.ts), ~/forge-suite-wt/campaigns/lib/campaigns-jobs/mautic-sync.ts, ~/forge-suite-wt/campaigns/lib/campaigns-jobs/resend-poll.ts
  • What: mautic-sync upserts brands' emails/segments/stat rows into campaigns.*; resend-poll upserts delivery/bounce events into campaigns.send_events. Upsert keys = Mautic/Resend IDs (safe to run twice). Every run logged to campaigns.job_runs; errors re-raise after logging.
  • Verification: npx tsx -e "import('./lib/campaigns-jobs.ts').then(m => m.runJob('mautic-sync'))" then psql -c "select count(*) from campaigns.emails" > 0 and select * from campaigns.job_runs order by id desc limit 2 shows ok=true.
  • Commit: feat(campaigns): mautic-sync + resend-poll ingest jobs

Task 5: Token-authed jobs route

  • Files: ~/forge-suite-wt/campaigns/app/api/campaigns/jobs/[job]/route.ts; add CAMPAIGNS_JOBS_TOKEN to ~/.forge-secrets/creatortrack-auth.env
  • What: POST route, dynamic="force-dynamic", bearer auth via timingSafeEqual against CAMPAIGNS_JOBS_TOKEN (same shape as app/api/finance/jobs/[job]/route.ts), dispatches to runJob, 404 on unknown job.
  • Verification: curl -fsS -X POST -H "Authorization: Bearer $CAMPAIGNS_JOBS_TOKEN" http://127.0.0.1:3061/api/campaigns/jobs/mautic-sync returns {"ok":true,...}; wrong token returns 401.
  • Commit: feat(campaigns): token-authed jobs route

Task 6: Read-layer queries

  • Files: ~/forge-suite-wt/campaigns/lib/campaigns.ts
  • What: Server queries for the UI: listBrands() (with per-brand rollups), brandOverview(brandKey) (segment counts, 30/90-day send + open/click/fail series from send_events + email stats), listCampaignEmails(brandKey), emailReport(mauticId). Pure reads via lib/db.ts.
  • Verification: npx tsx -e "import('./lib/campaigns.ts').then(async m => console.log(await m.listBrands()))" prints 6 brands with counts.
  • Commit: feat(campaigns): read-layer queries

Task 7: Register app + dashboard page

  • Files: ~/forge-suite-wt/campaigns/lib/apps.ts, ~/forge-suite-wt/campaigns/app/campaigns/page.tsx, shared UI components under ~/forge-suite-wt/campaigns/components/campaigns/
  • What: Add { id: "campaigns", name: "Campaigns", route: "/campaigns", ownerOnly: true, newLabel: true } route-app (News-hub pattern). Dashboard page: brand switcher (all 6), per-brand stat tiles (subscribers, sends, open/click/fail rates), sends-over-time chart, recent campaigns list. "Open in Mautic" + "New email" deep links to https://mautic.justinsforge.com/s/emails.
  • Verification: dev server on :3061, curl -fsS http://127.0.0.1:3061/campaigns | grep -c Campaigns >= 1; visual check via agent-browser (npx agent-browser open http://127.0.0.1:3061/campaigns).
  • Commit: feat(campaigns): register Campaigns app + brand dashboard

Task 8: Per-email report page

  • Files: ~/forge-suite-wt/campaigns/app/campaigns/email/[id]/page.tsx
  • What: Detail view for one Mautic email: subject/from/status, sent/read/click/fail counts, Resend delivery/bounce breakdown from send_events, "Edit in Mautic" deep link (/s/emails/view/<id>).
  • Verification: curl -fsS http://127.0.0.1:3061/campaigns/email/4 renders the CreatorTrack Updates report.
  • Commit: feat(campaigns): per-email report page

Task 9: Systemd timer + trigger script (forge repo)

  • Files: /home/justinwieb/forge/scripts/forge_creatortrack_campaigns_jobs.sh, /home/justinwieb/forge/scripts/systemd/forge-creatortrack-campaigns-sync.service, /home/justinwieb/forge/scripts/systemd/forge-creatortrack-campaigns-sync.timer
  • What: Trigger script curls the prod jobs route (mautic-sync then resend-poll) with the token from creatortrack-auth.env, mirroring forge_creatortrack_finance_jobs.sh. Timer every 30min. Units NOT enabled until merge to main + prod deploy (note in script header).
  • Verification: bash -n scripts/forge_creatortrack_campaigns_jobs.sh + dry-run against :3061 succeeds.
  • Commit (forge repo): feat(campaigns): systemd sync timer + trigger script

Task 10: Register and document

  • Files: /home/justinwieb/.claude/projects/-home-justinwieb-forge/memory/MEMORY.md, /home/justinwieb/forge/memory/general/reference_creatortrack_campaigns_app.md
  • What: Topic file (schema, jobs, route, timers, brand-mapping rule, deep-link paths, merge/deploy steps) + MEMORY.md index line. Run bash /home/justinwieb/forge/scripts/forge_eval_run.sh.
  • Verification: eval run passes; topic file carries mkdocs URL line.
  • Commit (forge repo): docs(campaigns): register CreatorTrack Campaigns app

Merge / deploy (post-plan, integrator step)

Serial AI-reconciled merge of campaigns into main (single merge, per WDA doctrine), apply migration to live lifeos via forge_workspace_migrate.py, restart creatortrack-prod.service, add CAMPAIGNS_JOBS_TOKEN to prod env, enable timer, teardown slot (git -C ~/forge-suite worktree remove ~/forge-suite-wt/campaigns + DROP DATABASE ct_campaigns).