Repository navigation
Act on many at once: bulk approve the queue, and import a list you already own - #56
Merged
Merged
Conversation
238 pending cards, cleared one button at a time. Most of them are not a judgement call: 179 are `refresh_research`, an internal action that costs nothing and that no prospect ever sees, and each took a human click. `POST /recommendations/approve-all`, scoped to the tabs the reviewer is looking at rather than the whole queue, because "approve all" next to a channel filter can only safely mean the list on screen. The approval itself is extracted rather than reimplemented. Approving one card and approving two hundred now run the same function, so the bulk path cannot drift from the single one — and the bulk path is precisely the one nobody would notice drifting. It keeps the policy recheck, the approval and action rows, the audit entry, the inline email send and the research job enqueue. Sequentially, never in parallel. The address caps are counted from rows the previous approval wrote, so concurrency would let thirty cards behind one `support@` all read a count of zero and all pass — the exact spam the caps exist to stop. Slower is the feature. A refusal is a result rather than an error, so one held card does not abort the other hundred and ninety-nine, and holds come back grouped by gate: two hundred cards behind four shared inboxes is four facts. `dryRun` runs the same engine and writes nothing. The UI uses it as a first press, because the interesting answer here is usually not "approved 200" but "approved 25, held 34" — and a reviewer who expected the first number and got the second cannot tell whether the product is broken. Previewing makes the hold the expected outcome rather than a surprise. Bounded at 200 a request. The work is sequential and an email approval goes out through somebody's SMTP server, so an unbounded loop is a request that runs for an hour against a client that gave up. The response says whether more remain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Two changes, both about acting on many records instead of one at a time. They share edits to
apps/api/src/app.ts, which is why they are one PR.1. Approve the whole queue at once
What the queue actually holds
Measured in prod on
wsp_04wz5p9…before building anything, because it changes what the button should do:refresh_researchreply(social)approvable.send_emailA naive "approve all" would clear 204 cards and approve zero emails. Correct — but only if the button says so, hence the preview.
The change
POST /api/v1/recommendations/approve-all, scoped to the tabs on screen.The approval is extracted, not reimplemented. Approving one card and approving two hundred now call the same function — a bulk path with its own copy would be a second place to forget the policy recheck, the audit row, or that approving an email is the instruction to send it, and it is the path nobody would notice drifting.
Sequentially, never in parallel. The address caps are counted from rows the previous approval wrote. Concurrency would let thirty cards behind one
support@all read zero and all pass — the precise spam #48 exists to stop. There is a test that a hold still holds under bulk.dryRunruns the same engine and writes nothing. The UI uses it as a mandatory first press, because the interesting answer is usually "approved 25, held 34", and a reviewer expecting the first number cannot otherwise tell whether the product is broken.Bounded at 200; the response carries
more.2. Import a list you already own
The cleaning is calibrated differently, and that is the substance
isLikelyRoleAccountjudges a name scraped out of a page, where the prior is hostile: a single lowercase token or a digit is good evidence of page furniture. Every row of a signup export is someone who typed their own address into a form. Applying the same rules would have silently deleted real users calledchovyanddave2.So strictness moves from the name to the address:
dave.mackenzie@becomesDave Mackenzie— never fatal. The mailbox is the only thing on the row with value.The two failure directions are not symmetric. Letting junk through costs a bounce; rejecting a real user is silent, permanent, and removes exactly the person who opted in. So
admin@andsupport@are kept — on a signup list that is frequently a founder — and only mailboxes that cannot receive a human reply are dropped.Rejected: malformed, undeliverable domains (
example.com,.local), disposable mailboxes,noreply@-class addresses, placeholders (test@,asdf@,qwerty@), keyboard mash, and duplicates — with Gmail dots and+tagstreated as one mailbox.Imported people are contactable
They land at 0.9 identity confidence. The workspace refuses to contact anything under 0.85, so importing at the crawler's 0.35 would have produced 17,000 prospects the policy engine will not let anyone mail — worse than not importing them. There is a test pinning this.
Rejects are stored, not counted
"We dropped 900 rows" is not actionable. "412 were placeholders, here are twenty" lets somebody fix the export — and lets the rules be argued with when one is wrong.
Consent is recorded per person
Everything else this product contacts is reached on a basis that travels with the record: the page it came from. An imported list arrives with none, and once the spreadsheet is closed nothing distinguishes your own signups from a purchased list. Import time is the only moment the answer is cheap, and it is what a deliverability complaint actually asks for. The UI will not import without it.
Enrichment: Gravatar
It works because the person opted into it — a Gravatar profile is public by construction and its linked accounts are ones the owner chose to publish. On a developer audience it returns what is most often missing: a GitHub handle, a Mastodon account. Those become
social_identitiesrows; services this product cannot act through are ignored rather than stored as a channel we do not have.There is no free route to LinkedIn or a phone number, and none is pretended.
MD5 is vendored (20 lines) because
crypto.subtledeliberately refuses to provide it and it is the only index Gravatar accepts. It is tested against the RFC 1321 vectors — a subtly wrong hash fails as a uniform zero hit rate that reads exactly like "Gravatar just doesn't have these people".enrich_contactis added to the server's dispatcher in the same commit as the job kind. A kind with no handler is howrefresh_researchcame to write three rows and do nothing.Scale
17k rows is not a request. The browser parses the CSV it already has and posts 500-row chunks; cleaning still happens server-side on every row, because a client that decides which rows are real can be told to lie. Re-running the same file merges rather than duplicating — enforced by a unique index on the normalised address, not by the importer checking first, since chunks arrive concurrently and check-then-insert is a reachable race here.
Verification
bun run typecheckclean;apps/webtypechecked separately (root config excludes it)bun test— 1343 pass, 0 fail across 88 filesbun run format:checkclean;next buildsucceeds with/importregisteredchovy,dave2andadmin@all survive cleaning; imported people clear the confidence threshold; re-import merges; MD5 matches the published vectors.Deploy note
Migration
0024applies at boot. Gravatar needs no key or configuration.Splitting
Happy to split these into two PRs if you would rather — the second commit sits inside context the first created in
app.ts, so it needs a conflict resolution rather than a clean cherry-pick.🤖 Generated with Claude Code