feat: add Local Lead Scout extension - #27
Merged
Merged
Conversation
Registers "local-lead-scout" in the extension catalog and sidebar icon map so it can be installed/enabled per workspace like any other extension. Feature logic lands in follow-up commits.
Adds the DB tables backing Local Lead Scout: campaigns, per-provider source config, scan runs, leads, prototypes, outreach drafts, and a suppression list, for both the Postgres and SQLite dialects. Schema only — repo layer and feature logic land in follow-up commits.
Adds typed record interfaces and dialect-agnostic CRUD methods for campaigns, source configs, scan runs, leads, prototypes, outreach drafts, suppressions, and encrypted email settings — implemented for both Postgres and SQLite. Lead/outreach-draft status transitions use atomic compare-and-swap updates (claimLeadScoutLeadStatus, claimLeadScoutOutreachDraftStatus) so approval/send can never race.
Adds a workspace-scoped email settings module (SMTP/Resend/Mailgun/ custom) with encrypted-at-rest credentials, a client-safe redacted shape, dry-run mode (on by default), a connection test, a test-send action, and a daily send-limit reservation used by the outreach flow. Nothing is dispatched — only settings storage and transport helpers.
Adds the LeadSourceProvider interface (searchBusinesses / getBusinessDetails / normalizeBusiness / sourcePolicy) plus three compliant adapters: manual_csv (user-supplied CSV import), google_places_api (official Places API Text Search, New — minimal FieldMask, no Maps scraping, disabled without a configured key), and a minimal custom_api placeholder. Also adds a shared Nominatim geocoding helper (postal code -> lat/lon, rate-limited to the service's 1 req/s policy) used by google_places_api and, in a follow-up, osm_overpass.
Adds the osm_overpass adapter: geocodes the campaign's postal code via Nominatim (rate-limited to its 1 req/s policy), queries the public Overpass API for shop/amenity/office/craft nodes within the resulting bounding box (descriptive User-Agent, capped results, no scraping), and normalizes tags into a lead — missing_website only when no website/contact:website tag is present, with a separate missing_email note since OSM rarely carries contact emails. Also adds a small provider registry mapping LeadScoutProvider -> adapter. Nominatim/Overpass calls aren't reachable from this sandbox's network policy, so the live request/response shape is unverified here — the query format follows each service's documented API.
runLeadScoutScan dispatches a campaign's configured provider, filters by minConfidence, reconciles results against already-ingested leads (by campaign+provider+sourceId) so re-scans update in place instead of duplicating, and records a scan_run with counts/summary — mirrors runSeoAnalysis's audit+notify shape. Scheduled campaigns are polled alongside due SEO projects; manual_csv campaigns can't run unattended (no CSV to read), so a scheduled one auto-disables its schedule.
Dispatches the campaign's content agent (auto-provisioned once, no special tools needed) through the existing task/agent-run flow to draft a lightweight website concept per lead, then — only once that prototype is explicitly approved — an outreach email body. Sender identity and opt-out text are appended programmatically from email settings rather than left to the model. Lead status moves through an explicit state machine (new/reviewed -> prototype_requested/ready -> email_drafted) via the atomic claim added earlier, so a lead can't be double-dispatched or skip the review gate when requireApprovalBeforePrototype is set.
Adds approve/reject/send for outreach drafts: approving only unlocks a send attempt (nothing is dispatched), sending checks outreach mode, requires a real (never guessed) lead email, checks the suppression list by email and domain, enforces per-campaign and daily send limits, and claims both the draft and lead atomically so a duplicate click can't send twice. A sent lead is permanently done until an explicit reset action reopens it. Also fixes lead-scout-email.ts to check dryRunMode before validating provider credentials, so dry-run mode actually works without live credentials configured.
Wires campaigns, source configs, scans, leads, prototypes, outreach drafts, suppressions, and email settings into a leadScout tRPC namespace, following the existing workspaceProcedure/protectedProcedure + requireEntityWorkspaceOwner pattern. Client-facing reads through toClientSafeLeadScoutSourceConfig / toClientSafeLeadScoutEmailSettings so encrypted credentials never leave the server. Also applies biome formatting to the lead-scout modules added in prior commits.
Adds the extension page (campaigns, run-scan/CSV-import control, leads table, lead detail with prototype/draft review and approve/reject/send actions, scan history, suppression list, and email settings) plus the hand-written trpc.ts client contract for the new leadScout router. Wires it into the shared extensions/[key] page. Verified end-to-end in a real browser: install -> create campaign -> CSV scan -> lead detail -> mark reviewed -> email settings save/test. Fixes a bug found along the way: the email settings form initialized its fields from the query result before it had loaded, so previously saved settings appeared blank until edited — now synced in via a targeted effect keyed on the settings row id.
Targeted tests for lead normalization (manual_csv CSV parsing, osm_overpass tag mapping), missing-website/missing-email detection, secret stripping (source config apiKey, email settings credentials encrypted at rest and redacted client-side), scan dedup/reconciliation, prototype/draft status gating, and the full approval-gated send flow (draft-only mode, no-guessed-email, suppression, per-campaign/daily limits, duplicate-send prevention, explicit resend reset).
Whitespace-only fixes: schema/repo files added in earlier commits were never run through biome, and extensions.ts predates the project's space-indent convention (its diff here is reformatting only, no logic change). Also drops an unnecessary non-null assertion in a new test by reusing the campaign already in scope.
rgxdev
marked this pull request as ready for review
July 3, 2026 19:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Local Lead Scout, an installable NyxelOS extension that finds local businesses with no website via compliant sources, generates a website prototype concept and outreach email draft through the existing agent/task system, and only sends after explicit human approval.
manual_csv(CSV import),osm_overpass(free, no key, Nominatim geocoding + Overpass API),google_places_api(official Text Search API, minimal FieldMask, disabled without a configured key),custom_api(minimal generic placeholder). No Google Maps scraping/DOM automation anywhere.draft_onlyoutreach mode; no email address is ever guessed; suppression list + per-campaign/daily send limits; atomic compare-and-swap status transitions prevent double-send; a sent lead requires an explicit reset action before it can be emailed again; every scan/prototype/draft/send is audit-logged.What works
manual_csvscan end-to-end — verified live in a browser (install → create campaign → paste CSV → scan → leads list with correctmissing_website/has_websitebadges → mark lead reviewed → email settings save/prefill/test-connection/send-test in dry-run mode), zero console errors.Remaining risks / deferred
osm_overpassandgoogle_places_apiHTTP calls to Nominatim/Overpass/Places couldn't be live-verified from this sandbox (network policy blocks those hosts) — request/response shapes follow each service's documented API but are otherwise unverified end-to-end.custom_apiis intentionally a minimal generic REST placeholder, per the task's scope.Test plan
bun run --cwd packages/db typecheckbun run --cwd apps/server typecheckbun run --cwd apps/web typecheckbunx biome checkon all touched filesbun test— 38/38 passing across the new lead-scout test files + extended secrets-encryption testGenerated by Claude Code