Skip to content

feat(leads): per-report share cards + "watch this URL" recurring capture - #117

Merged
ralyodio merged 1 commit into
masterfrom
feat/lead-engine
Jul 26, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/lead-engine

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Ships M1 and M2 of the lead engine (docs/lead-engine-prd.md, included).

M1 — Shareable scorecard

Every /r/<token> link previously pointed at the same static /banner.png, so a report for acme.com and one for example.org produced byte-identical previews in Slack, X, and LinkedIn. Reports now generate their own card naming the scanned site and its score. This retroactively upgrades every report ever generated.

  • lib/audit/share-card.ts — the card model, kept pure and tested because the branching is easy to get backwards: slop and AEO scores run in opposite directions (0 is pristine for slop), and audits.score is not the number a slop report displays — that's summary.slopScore. On a live prod scan those are 45 and 40 on the same row, so the old title would have printed "45/100" beside a card reading 40. The title now derives from the same model.
  • app/r/[token]/opengraph-image.tsx — the renderer.
  • /slop landing page + its own static card, with the "observable defects, never AI-written" line stated as a feature.

Two wiring gotchas, documented in the PRD for whoever adds the next card:

  1. A page declaring openGraph.images overrides the generated opengraph-image — the static banner had to be removed for the card to take effect.
  2. A page declaring an openGraph block but no twitter block inherits the root layout's twitter.images, which also outranks the file convention. /slop initially shipped the new card everywhere and the old banner on X alone.

M2 — Watch this URL

The report stays fully public — gating it would kill the sharing loop M1 exists to create. What costs an email is the ongoing relationship, and the ask is self-qualifying: whoever wants a site re-scanned weekly is the person responsible for it.

  • Double opt-in by construction — the row is inert until verified_at is set. Capped per address and per IP, honours the global marketing suppression list, RFC 8058 one-click stop on every send.
  • Re-scans restricted to the free self-hosted engines, so a watch can never become recurring LLM spend on an address we never charged.
  • Cron ticks every 15 min and does two things in order: deliver re-scans finished on an earlier tick, then enqueue those now due.
  • Emails send only when the score really moved (2+ points; smaller deltas are run-to-run jitter). Copy accounts for the inverted slop dial, so 60 → 40 reads as the good news it is.
  • Resulting audits are tagged to the existing Prospects org, so /recent's outreach form can work them.

⚠️ Deploy order

20260726120000_scan_watches.sql is not applied. Apply it after this deploys — it schedules a pg_cron job POSTing to /api/cron/scan-watches every 15 minutes, which 404s until the code is live. It also widens audits_triggered_by_check to allow 'watch' (checked against prod: 4,017 rows / 29 MB, so validation is milliseconds).

Verification

  • npm run typecheck clean; 534 tests pass (39 new across tests/share-card.test.ts and tests/watches.test.ts); npm run build exits 0.
  • Cards rendered against two live prod scans (one slop, one AEO) and inspected as images — 1200×630, correct tone bands and headlines.
  • Verified in rendered HTML that the generated card replaces the banner in both og:image and twitter:image.
  • Cron smoke-tested: 401 without the secret, reaches the query with it. The full loop is unexercised until the migration is applied — that's the one step not verified end-to-end.

Note

Also includes docs/reshare-network-prd.md, which was already untracked in the working tree.

🤖 Generated with Claude Code

Two milestones of the lead engine (docs/lead-engine-prd.md).

M1 — shareable scorecard. Every /r/<token> link previously shared the one
static /banner.png, so a report for acme.com and one for example.org
produced byte-identical previews in Slack, X, and LinkedIn. Reports now
generate their own card naming the scanned site and its score.

Two wiring gotchas, both documented in the PRD: a page declaring
openGraph.images overrides the generated opengraph-image (the banner had
to be removed), and a page declaring an openGraph block but no twitter
block inherits the root layout's twitter.images — which also outranks the
file convention, so /slop briefly shipped the new card everywhere and the
old banner on X alone.

The card model is pure and tested rather than baked into the renderer,
because the branching is easy to get backwards: slop and AEO scores run in
OPPOSITE directions, and audits.score is not the number a slop report
shows (that lives in summary.slopScore). The page title is now derived from
the same model, so the text preview and the image can't disagree.

Also adds the /slop landing page and its own static card, with the
"observable defects, never AI-written" positioning stated as a feature.

M2 — watch this URL. The report stays fully public; what costs an email is
the ongoing relationship. A watch is double opt-in by construction (the row
is inert until verified_at is set), capped per address and per IP, honours
the global marketing suppression list, and carries an RFC 8058 one-click
stop on every send. Re-scans are restricted to the free self-hosted
engines, so a watch can never become recurring LLM spend on an address we
never charged.

The cron ticks every 15 minutes and does two things in order: deliver
re-scans that finished on an earlier tick, then enqueue those now due.
Emails only send when the score really moved (2+ points — smaller deltas
are run-to-run jitter, and mailing about them trains people to ignore us),
and the copy accounts for the inverted slop dial so "60 → 40" reads as the
good news it is. Resulting audits are tagged to the existing Prospects org.

The migration is NOT yet applied. Apply it after this deploys — it
schedules a pg_cron job that POSTs to a route that won't exist until then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit 9856c0c into master Jul 26, 2026
9 of 11 checks passed
@ralyodio
ralyodio deleted the feat/lead-engine branch July 26, 2026 07:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant