Skip to content

Act on many at once: bulk approve the queue, and import a list you already own - #56

Merged
ralyodio merged 1 commit into
mainfrom
feat/bulk-approve
Aug 19, 2026
Merged

ralyodio merged 1 commit into
mainfrom
feat/bulk-approve

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

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

"can we just have a mass approve all? i don't need to manually a button 5k times"

What the queue actually holds

Measured in prod on wsp_04wz5p9… before building anything, because it changes what the button should do:

count
refresh_research 179 Internal. Costs nothing, no prospect ever sees it — and it took a human click each.
reply (social) 25 The only cards currently approvable.
send_email 34 All 34 already held by the shared-inbox address caps.

A 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.

dryRun runs 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

"can i import a csv of contacts… 17k profullstack app users who opted in… the list is very dirty"

The cleaning is calibrated differently, and that is the substance

isLikelyRoleAccount judges 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 called chovy and dave2.

So strictness moves from the name to the address:

  • A junk name is repaired from the local part — dave.mackenzie@ becomes Dave Mackenzie — never fatal. The mailbox is the only thing on the row with value.
  • A junk address is fatal, because there is nobody to reach.

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@ and support@ 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 +tags treated 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_identities rows; 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.subtle deliberately 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_contact is added to the server's dispatcher in the same commit as the job kind. A kind with no handler is how refresh_research came 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 typecheck clean; apps/web typechecked separately (root config excludes it)
  • bun test — 1343 pass, 0 fail across 88 files
  • bun run format:check clean; next build succeeds with /import registered
  • 51 new tests. The ones worth reading: a suppressed person is still held under bulk approve; chovy, dave2 and admin@ all survive cleaning; imported people clear the confidence threshold; re-import merges; MD5 matches the published vectors.

Deploy note

Migration 0024 applies 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

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>
@ralyodio
ralyodio merged commit c1e502a into main Aug 19, 2026
4 checks passed
@ralyodio ralyodio changed the title Approve the whole queue at once, without weakening what approval means Act on many at once: bulk approve the queue, and import a list you already own Aug 19, 2026
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