Repository navigation
feat(backend): add match-based email nudges and Clerk-gated admin dashboard - #5
Merged
Merged
Conversation
…hooks, admin API Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cribe, privacy rewrite, docs Frontend: /admin rebuilt on Clerk auth with subscribers, email performance, jobs and site, send controls, experiments, submissions and hiring-manager tabs; profile email toggle with weekly/daily and pause; one-click /unsubscribe page; /go/<sendId>/<jobId> redirect proxy; accurate privacy page. Docs: env vars, Resend webhook and DNS (Vercel DNS, region ap-northeast-1), cron list, migration note; AGENTS.md pointer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ibe, cue-gated years
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.
Intent
Build match-based email nudges for SkipTheBoard to bring signed-in users back: each nudge emails a user the jobs already on the board that best match their resume, plus an admin dashboard where the captain tracks results, controls sends, and runs experiments. Design research is in the engagement-plan report; this brief wins where they differ.
Captain's fixed decisions: Recipients are existing users who signed in with Clerk and uploaded a resume (Mongo
profilescollection). No public email-only signup and no homepage email box. Content is jobs already on the board, ranked with the existing deterministic tag matcher ported to the backend as a pure function (backend/src/lib/match-score.ts); do NOT call an LLM in the send path. No manual job entry. Provider is Resend behind a small backend/src/lib/email.ts interface so it can be swapped; plain HTML + text templates were chosen instead of React Email because the backend tsconfig compiles only src/**/*.ts and has no React dependency (nudge-render.ts). Dashboard tracks subscribers, email performance, jobs and site, has send controls, and experiments.Required behavior:
30 2 * * *(/api/cron/nudges, 08:00 IST): on Mondays it creates/runs the weekly campaign for all subscribers (so the Monday 02:30 UTC weekly send is met), other days a daily campaign only for users who chose daily (and only if any exist). Final cron list (3, within Hobby): expire-jobs 0 3 * * *, scrape 0 4 * * *, nudges 30 2 * * *. Manual "send now" from the dashboard runs with a 45s budget. If a run cannot finish within its budget (240s default) it continues via a self-continuing POST to /api/cron/nudges?campaign= using waitUntil and CRON_SECRET (chosen over the GitHub Actions drain). Schedule pause is anudges.schedule_pausedrow in app_settings; manual sends ignore the pause. Without RESEND_API_KEY everything works in dry-run mode: render and record (status dry_run) but never call the provider. Test sends go to a typed address with a [TEST] subject prefix and are not recorded as campaign sends.Constraints: No real emails are sent during development or tests; no production data access. Do not change the scrape pipeline, ingest gates, recruiter tables, payments, or pricing. Keep the cron count within Vercel Hobby limits.
Acceptance: with no Resend key an admin can preview a user's nudge and run a dry-run campaign that records sends with variants; with keys set a test send to one address works end to end and the dashboard shows delivered, open, and click counts from webhooks and the redirect; unsubscribe works in one click and stops future sends; all tests and lint pass.
What Changed
match-score.ts). Jobs with no stack tags get half credit on the stack term.nudge-select.ts). It re-applies the ingest gates, skips jobs already sent, prefers jobs added since the user's last nudge, and labels SDE-3, senior or 5+ year roles as senior.RESEND_API_KEYis not set.runCampaignsends in batches and records each send. A unique key on campaign and user means a user can't get the same campaign twice. Experiment variants and holdout groups are assigned by a deterministic hash.30 2 * * *sends the weekly campaign on Mondays and the daily campaign on other days.List-UnsubscribeandList-Unsubscribe-Postheaders./api/email/webhook) that checks the Svix signature and records delivered, opened, clicked, bounced and complained events. Hard bounces and complaints unsubscribe the user./go/<sendId>/<jobId>link that logs the click and redirects to the job.ADMIN_USER_IDSorADMIN_EMAILS, and theADMIN_SECRETbearer still works for scripts. The admin APIs cover whoami, stats, preview, test send, send now, pausing the schedule and experiments./adminis rebuilt on Clerk auth and the old browser login with the admin secret is removed. It has panels for Subscribers, Email, Jobs, Send controls, Experiments, and the existing Submissions and Recruiters tabs./unsubscribepage and a/goproxy route, and rewrites the privacy page so it is accurate.DEPLOYMENT.md,README.mdandAGENTS.mdnow document the new env vars, the webhook URL, the cron list and the Resend DNS records.🤖 Generated with Claude Code
Risk Assessment
✅ Low: The latest fix round makes two small changes and both do what was asked: decimal years figures are now read whole (1.5, 4.5 and 2.5 never lift a role to senior and still count as years bullets), and the scheduled cron is paused unless an explicit "false" is stored, with tests that drive the cron endpoint and a paused banner in Send controls.
Testing
I ran the 10 backend vitest files covering the nudge feature, and all 88 tests passed. These include the new tests showing that on a fresh deployment the /api/cron/nudges endpoint creates no campaign and sends nothing until the schedule is resumed. I also called the years-parsing functions directly with tsx. They read 1.5+ as 1.5, 4.5-7 as 4.5 and 2.5 as 2.5. An SDE-1 post asking for 1.5+ years stays at SDE-1 level and an SDE-2 post asking for 4.5-7 years stays at SDE-2 level, while 5+ years still makes a post senior. The years bullet is now preferred in pickBullets. For the dashboard, the full /admin page needs a Clerk sign-in and a live backend, so I rendered the real SendPanel component on its own with the app's theme and a stub backend that reports the schedule as paused. Screenshots show the amber 'Scheduled sends are paused' banner with an orange 'Resume schedule' button. After clicking it, the panel shows 'running' and the 'Schedule resumed' notice. I left the worktree unchanged.
~/.no-mistakes/evidence/01M3Z0FHR4NS0RY7PTCSP7Q0A6/send-controls-paused.png)~/.no-mistakes/evidence/01M3Z0FHR4NS0RY7PTCSP7Q0A6/send-controls-resumed.png)Evidence: Decimal-years transcript (minimumYears / seniority / pickBullets)
"1.5+ years of experience" minimumYears= 1.5 mentionsYears= true "4.5-7 years of experience" minimumYears= 4.5 mentionsYears= true "2.5 years of experience in Java" minimumYears= 2.5 mentionsYears= true SDE 1 + '1.5+ years' -> junior SDE-1 level SDE 2 + '4.5-7 years' -> mid SDE-2 level SDE 2 + '5+ years' -> senior Senior / SDE-3+ pickBullets -> [ '1.5+ years of experience', 'Bangalore office' ]Evidence: Verbose list of nudge, email-route, variant and admin-auth tests (paused schedule, decimal years)
~/.no-mistakes/evidence/01M3Z0FHR4NS0RY7PTCSP7Q0A6/sendpanel-harness)Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 5 issues found → auto-fixed (3) ✅
backend/src/lib/nudge-send.ts:467- runCampaign sends to every email inoutgoingregardless of which NudgeSend rows were actually inserted.createMany({ skipDuplicates: true })silently drops rows that lose the (campaignId,userId) race, but the loop still callsprovider.sendBatchfor them and thennudgeSend.update({ where: { id } })throws P2025 for the dropped ids. Concrete path: the weekly cron run exhausts its budget and self-continues in the background; meanwhile the admin opens Send controls (the running campaign is listed) or clicks "Continue" on a running experiment (ExperimentsPanel.tsx:211) and triggers a second runCampaign for the same campaign. Both runs page through the same remaining users; whichever createMany runs second inserts nothing yet still emails the whole page, so every user in the overlapping page receives two emails and the second run 500s mid-way. This defeats the stated "idempotent per user per campaign" invariant the unique index was meant to enforce. Fix at this boundary: after createMany, re-selectnudgeSend.findMany({ where: { id: { in: rows.map(r => r.id) } }, select: { id: true } })(ids are per-run UUIDs) and only hand those rows' emails tosendBatch; count the rest as already-claimed. The fake Prisma already supportsid: { in }, so the existing idempotency test can be extended with an interleaved second run.backend/src/lib/nudge-send.ts:635- sendTestNudge reuses the previewed user's real signed unsubscribe token: the List-Unsubscribe / List-Unsubscribe-Post headers point at that user's/api/email/unsubscribe?t=…, and the rendered footer link does too (buildNudge builds it fromoptions.pref). Whoever receives the [TEST] email (the typed address, or anyone it is forwarded to) can unsubscribe or resubscribe that user without login, and an admin who hits their mail client's native "Unsubscribe" on the test will silently unsubscribe the real user. The smallest remedy is to render test sends with a non-functional unsubscribe URL and drop the RFC 8058 headers; because that changes what the admin sees in the test email, it needs the author's call.backend/src/lib/nudge-select.ts:60- minimumYears matches any "N years/yrs/YOE" phrase, not just experience requirements, and isSeniorRole lifts the job to senior when the smallest such figure is >= 5. A genuine SDE-1 post whose bullets include company copy such as "a fintech with 12 years in market" or "products built over 10 years" (and no explicit experience line) is relabelled "Senior / SDE-3+", scored against the profile as senior, and shown with the wrong level without any error. The brief asks for lifting when bullets state 5+ years of experience; tightening the pattern to require an experience cue (experience/exp/YOE/minimum/at least/"+") or to ignore sentences without one is a heuristic change the author should confirm.backend/src/lib/nudge-stats.ts:180- campaignStats (called by the Email performance and Experiments tabs on every load with no campaignId) selects every NudgeSend row for every campaign and folds them in memory; nothing bounds it by campaign count or age, so it grows by one row per subscriber per weekly/daily campaign indefinitely. Admin-only and fine at current scale; atake/date window on campaigns or a SQL groupBy per (campaignId, variantId, status) is the follow-up when it becomes slow.frontend/src/app/unsubscribe/UnsubscribeClient.tsx:35- The /unsubscribe page POSTs the unsubscribe as soon as it mounts, with no user action. Email security scanners that execute JavaScript when pre-visiting links (some enterprise gateways do) will unsubscribe the user without them clicking. The brief asks for one-click unsubscribe, which this satisfies, and the "Undo, keep sending" button mitigates it; noting the tradeoff only so the captain can decide whether a confirm button on the landing page is preferable while keeping the RFC 8058 header path fully automatic.🔧 Fix: Gate sends on inserted rows, inert test unsubscribe, cue-gated years
12 issues (8 warnings, 4 infos) still open:
backend/src/routes/email.ts:116- The backend unsubscribe handler is bound to GET as well as POST, so a bare GET of the List-Unsubscribe URL (/api/email/unsubscribe?t=…) unsubscribes the user with no click and no body check. RFC 8058 defines the one-click action as a POST whose body isList-Unsubscribe=One-Clickprecisely so a sender can tell a mail client's unsubscribe from a security scanner fetching the header URL; corporate link scanners are known to GET List-Unsubscribe URLs. Concrete path: a nudge lands in a scanned mailbox, the gateway fetches the header URL,handleUnsubscriberuns, the user is marked unsubscribed with reason "user" and never sees a page. This is the same hazard the captain closed on the /unsubscribe page in round 1, on the more exposed surface; the fix round left it untouched and backend/tests/email-routes.test.ts:138 asserts the GET behaviour. Smallest remedy: drop the GET binding or make GET 302 to${FRONTEND_URL}/unsubscribe?t=<token>(the confirm page, which POSTs), keep POST as is, and update that test. It changes behaviour the author tested, so it needs the captain's call.backend/src/routes/email.ts:108- On unsubscribe,nudgeSend.updateManystampsunsubscribedAton every sent/delivered row the user ever received, andfoldSendCounts(backend/src/lib/nudge-stats.ts:146) counts each stamped row, so the per-campaign and per-variant "Unsub"/"Unsubscribes" columns (frontend/src/app/admin/EmailPanel.tsx:63, AdminUi.tsx:114) report "recipients who have since unsubscribed" rather than unsubscribes from that send. Concrete input: user_1 received weekly W40, experiment X (variant A) and weekly W41, then unsubscribes from W41; all three rows are stamped, so W40 and variant A each gain an unsubscribe they did not cause and an experiment's unsubscribe rate drifts upward every week as recipients churn from unrelated sends. Smallest remedy: stamp only the user's most recent sent/delivered row (findFirstordered bysentAt desc, thenupdate). It changes what the metric means, so the author should confirm.backend/src/routes/go.ts:21- The redirect logs a NudgeClick and stampsclickedAtwhenever the sendId resolves, without checking that the jobId belongs to that send or even exists: the send is selected with onlyidandclickedAt, andnudgeClick.createruns beforejobis consulted. Concrete input:GET /go/<real sendId>/999999(sendId is in every link of a forwarded email) with no such job writes anudge_clicksrow with jobId 999999 (no FK onjob_id), marks the send clicked, and 302s to the feed.topClickedJobsthen returns{ jobId: 999999, title: null, company: null }on the dashboard andclickRatecounts the send. backend/tests/email-routes.test.ts:175 covers only a valid pair and an unknown sendId. Minimal fix: selectjobIdson the send and only log/stamp whensend.jobIds.includes(jobId)(still redirect to the job or feed).backend/src/lib/nudge-stats.ts:30-subscriberStatssubtractsunsubscribed,pausedanddailycounted over everyemail_preferencesrow from aneligiblefigure that counts only Mongo profiles. A preference row exists for any signed-in Clerk user who touched the toggle (PUT /api/email/preferencescallsgetOrCreatePreferencewith no profile check), so a user without a resume skews the numbers. Concrete input: 10 profiles all subscribed weekly; one profile-less user PUTs{subscribed:false}: the panel shows eligible 10, unsubscribed 1, subscribed 9, weekly 9, although all 10 eligible users are subscribed. Minimal fix: restrict thefindManytouserId: { in: eligibleIds }fromlistEligibleUserIds()(same count semantics, correct set).backend/src/lib/nudge-stats.ts:148-foldSendCountscountsopened/clickedover all sent rows but switches the denominator todeliveredas soon as oneemail.deliveredevent has landed, so the rate can exceed 100% during any live campaign. Concrete input: 100sentrows, 1 withdeliveredAt, 3 withopenedAt(Resend delivers delivered/opened events independently per message and the /go redirect setsclickedAtwithoutdeliveredAt): Open rate renders as 300.0% in EmailPanel and the variant table. Remedy choice is a product one: either always usesentas the denominator, or usedeliveredonly once it is at leastopened/clicked, so the captain should pick.backend/src/lib/match-score.ts:51- The intent says the matcher is "the existing deterministic tag matcher ported to the backend" with exactly one deliberate tweak (a job with no stack gets 20/40). The port contains a second, undocumented-in-the-brief divergence: the frontend (frontend/src/lib/match.ts:31) includes the 40-point stack term when either side has a stack, while the backend includes it only when the profile has one (if (profileStack.length > 0)). Concrete input: profile{engineering, junior, in_office, stack: []}and job{engineering, junior, in_office, stack: ["go"]}scores 60 on the site but 100 in the email, so every user whose resume yielded no stack sees inflated match percentages that disagree with the feed. The header comment acknowledges it, and it is arguably better behaviour, but it contradicts the stated single-tweak port and no test pins it; the captain should confirm or align with the frontend.backend/src/lib/nudge-select.ts:62- The cue-gated YEARS_RE still starts at a\b, and the boundary between "." and a digit is a word boundary, so a decimal figure is read from its fractional part. Executed against the current module:minimumYears("1.5+ years of experience")returns 5 andminimumYears("2.5 years of experience in Java")returns 5, soeffectiveSeniority({ title: "SDE 1", description: ["1.5+ years of experience"], seniority: "junior" })returns "senior". An SDE-1 post with the common "1.5+ years of experience" line is labelled "Senior / SDE-3+", ranked against junior profiles as senior, and shown with the wrong level without any error. This predates the fix round (the old pattern behaved the same) and the captain's listed cases still pass. Smallest remedy: replace the leading\bwith(?<![\d.])so a digit preceded by a dot or digit is not a figure start, plus a regression case for "1.5+ years of experience". It is a heuristic change, so the author should confirm.frontend/src/app/admin/ExperimentsPanel.tsx:207- The experiments list rendersc.totals.recipients + c.totals.holdoutunder the header "Recipients", while the backend deliberately excludes holdout rows fromrecipients(nudge-stats.ts:119) and the results breakdown directly below (AdminUi.tsx:106) showstotals.recipientswithout holdout. Concrete input: an experiment with 90 emailed and 10 holdout shows Recipients 100 in the list row and Recipients 90 in the "All" column of the breakdown for the same campaign. Holdout users received nothing. Minimal remedy: showrecipientsalone or relabel the list column "Assigned"; which one is the captain's call since it is a visible label.frontend/src/app/admin/SendPanel.tsx:137- The schedule card says "Next tick would run: <name> (<kind>)" but the backend fieldtodayWouldRunisscheduledCampaignFor(new Date())(admin-nudges.ts:118), the campaign for today's date, while the cron fires at 02:30 UTC. Concrete input: an admin opens the panel on Monday at 10:00 UTC and reads "Weekly matches 2026-W41 (weekly)", but the next tick is Tuesday 02:30 UTC, a daily campaign; on Sunday 23:00 UTC it reads a daily campaign though the next tick is Monday's weekly. Remedy is either to relabel the line "Today's campaign" or have the backend compute the spec for the next 02:30 UTC occurrence; the captain should pick.frontend/src/app/admin/JobsPanel.tsx:86- The card titled "Job clicks from emails (N total)" usestotalEmailClicks, which is an unfilteredprisma.nudgeClick.count()(nudge-stats.ts:277), while the webhook writes a NudgeClick row for everyemail.clickedwithjobId: nullfor feed/profile/preferences links (email.ts:162). Concrete input: one user clicks the "profile" link in an email: the header says "(1 total)" and the body says "No email clicks yet." Minimal remedy: count withwhere: { jobId: { not: null } }or retitle the card to "Email clicks"; a visible label choice for the captain.frontend/src/app/admin/SendPanel.tsx:87- The preview is fetched with the campaign selected at click time (line 55), but "Send test" reads the current dropdown value, so changing the campaign after previewing sends a different email than the one on screen. Concrete input: preview a user with "New manual campaign", switch the dropdown to an experiment, click Send test: the backend renders that experiment's arm for the user (a different subject/intro, or a 422 "holdout arm") while the iframe still shows the manual-campaign preview. Minimal remedy: re-run the preview when the dropdown changes, or disable Send test until it matches.backend/src/lib/nudge-select.ts:177-pickBulletsscores bullets with the sameminimumYearsthat the fix round cue-gated for the senior override, so a years bullet without an experience cue no longer gets the +2 preference the brief asks for ("two bullets, preferring years/location"). Executed:pickBullets(["Great culture", "5-8 years in backend", "Bangalore office", "Experience: 3+ years"])now returns["Bangalore office", "Experience: 3+ years"], dropping the "5-8 years in backend" bullet that was preferred before the fix round. The senior-lift gating was the captain's decision; whether bullet preference should keep an ungated years match for scoring only is a product choice. Not a blocker.🔧 Fix: Non-mutating GET unsubscribe, scoped stats, site-aligned scores
1 warning still open:
backend/src/lib/nudge-select.ts:62- The round-2 lookbehind(?<![\d.])suppresses the fractional digits of a decimal years figure instead of reading the decimal, so two wrong results remain reachable without erroring. (1) Senior lift:effectiveSeniority({ title: "SDE 2", description: ["4.5-7 years of experience"], seniority: "mid" })returns "senior" becauseminimumYearsnow reads the range's upper bound 7 (the stated minimum is 4.5, below SENIOR_YEARS_MIN); the post is relabelled "Senior / SDE-3+". (2) Bullet preference:mentionsYears("1.5+ years of experience")andmentionsYears("Experience: 2.5+ yrs")are false, sopickBullets(["Great culture", "1.5+ years of experience", "Free snacks", "Bangalore office"])returns ["Great culture", "Bangalore office"], dropping the years bullet the brief says to prefer. The round-2 test pins these wrong values (minimumYears("1.5-3 years of experience")asserted as 3,"2.5 years of experience in Java"asserted as null). Verified by executing the functions with tsx. Remedy: capture the fractional part in the figure,(\d{1,2}(?:\.\d+)?), keeping the lookbehind, so 1.5+ -> 1.5, 4.5-7 -> 4.5, 2.5 -> 2.5 (none lift seniority, all count as years bullets), and change the three assertions in backend/tests/nudge-select.test.ts:59-63 to 1.5, 2.5 and 1.5. This modifies the regex the captain prescribed in round 2, which is why it needs a decision rather than an auto-fix.🔧 Fix: Read decimal years whole, start nudge schedule paused
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
cd backend && npx vitest run tests/nudge-select.test.ts tests/nudge-send.test.ts tests/email-routes.test.ts tests/nudge-variants.test.ts tests/admin-auth.test.ts tests/email-tokens.test.ts tests/match-score.test.ts tests/nudge-stats.test.ts tests/nudge-render.test.ts tests/cron.test.ts(10 files, 88 tests, all pass)npx vitest run ... --reporter=verbosefor the nudge, email-route, variant and admin-auth tests, includingscheduled campaigns > starts paused: with no settings row the cron creates no campaign until an admin resumes,the cron endpoint sends nothing on a fresh deployment and sends once the schedule is resumed, andsenior override > reads a decimal figure whole, never from its fractional partnpx tsx decimal-years-demo.mts: called minimumYears, mentionsYears, effectiveSeniority, levelLabel and pickBullets directly on decimal and integer years phrasesBundled the real frontend SendPanel.tsx with esbuild against a stub backend that returns the schedule as paused (a fresh deployment), rendered it in Chrome with the app's globals.css theme, took a screenshot, clicked 'Resume schedule' and took a second screenshotREADME.md:78- The README 'API Endpoints' table hand-copies routes that backend/src/openapi-spec.ts (served at /docs) already owns. It does not list the new nudge, email and /go endpoints, and its 'Admin' auth label no longer says that Clerk admins are accepted too. A possible follow-up: replace the table with a pointer to /docs instead of adding more rows by hand.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.