Skip to content

feat(backend): add match-based email nudges and Clerk-gated admin dashboard - #5

Merged
akesar01 merged 6 commits into
mainfrom
fm/match-nudges-n6
Oct 3, 2026
Merged

akesar01 merged 6 commits into
mainfrom
fm/match-nudges-n6

Conversation

@akesar01

@akesar01 akesar01 commented Oct 3, 2026

Copy link
Copy Markdown
Owner

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 profiles collection). 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:

  1. Recipients and consent: get each user's email from Clerk via @clerk/backend (never the resume-parsed email). Store per-user preferences (subscribed flag, frequency weekly default / daily, paused-until, unsubscribed-at, unsubscribe token) in Postgres via a Prisma migration (email_preferences). Every email has a one-click unsubscribe link (HMAC-signed token over a stored random token, no login) and List-Unsubscribe + List-Unsubscribe-Post headers (RFC 8058). Profile page gets an "Email me matching jobs" toggle with weekly/daily and a 4-week pause. Users who never touched the toggle default to subscribed weekly; the privacy page was rewritten to state this so it is truthful (deliberate decision per the brief).
  2. Matching and content: per recipient pick top N jobs (default 5) that are unexpired, pass evaluateJobGates from ingest-gates.ts, were not already sent to that user, and prefer jobs created since their last nudge (fill with older unsent ones if fewer than N). A title-and-years override lifts roles whose title says SDE-3/III/senior/staff/lead/principal/architect/manager or whose bullets state 5+ years to seniority "senior" so they are never labelled SDE-1/SDE-2 (they still appear, ranked by the lifted seniority, labelled "Senior / SDE-3+"). Skip a user with zero qualifying jobs (status skipped, no email). Email shows title, company, poster name and kind (Hiring manager / Engineer (referral) / Recruiter from the headline), level, mode, absolute posted date, two bullets (preferring years/location), and a match percentage. Links go through a click redirect /go//: the Next.js route on skiptheboard.in proxies to the backend GET /go/... which logs a NudgeClick and 302s to the job's LinkedIn post; site links carry UTM tags (utm_source=nudge). Deliberate scoring tweak: a job with no stack tags gets neutral half credit (20/40) on the stack term instead of 0, so unknown-stack jobs rank between partial and full overlaps.
  3. Sending: runCampaign in nudge-send.ts batches through Resend (100 per batch call), records a NudgeSend row per recipient (status, provider message id, variant, job ids, subject) with a (campaignId,userId) unique index so it is idempotent per user per campaign; rows are written as queued before the provider call, then updated to sent/dry_run/failed. Trigger: one daily Vercel cron 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 a nudges.schedule_paused row 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.
  4. Tracking: POST /api/email/webhook verifies the Svix signature with RESEND_WEBHOOK_SECRET (503 when unset), dedupes by svix-id in email_events, and records delivered, opened, clicked, bounced, complained on the NudgeSend. Hard (Permanent) bounces and complaints auto-unsubscribe the user; soft bounces do not. Clicks also come from the /go redirect.
  5. Admin dashboard at /admin in the frontend, rebuilt on Clerk auth: visible only to admins (it calls GET /api/admin/whoami and renders nothing to others). Firstmate update accepted: admins are Clerk user ids in ADMIN_USER_IDS OR users whose primary Clerk email is in ADMIN_EMAILS (resolved via @clerk/backend, case-insensitive, cached 5 min); both paths tested. Sections: Subscribers (eligible, subscribed, paused, unsubscribed, new this week, by frequency); Email performance (per campaign and per variant recipients, delivered, open rate, click rate, bounces, complaints, unsubscribes; top clicked jobs); Jobs and site (live jobs, jobs added per day by level and source, jobs per recruiter, job clicks from emails); Send controls (preview this campaign's email for any chosen user, send a test to an address, send now, pause/resume the weekly schedule); Experiments (create a campaign with 2+ variants: subject, intro copy, number of jobs, holdout control receiving nothing; random deterministic split by sha256(campaignId:userId) bucket with set percentage weights summing to 100; variant results side by side with raw counts and rates, no significance claims). The pre-existing Submissions and Recruiters admin tabs were kept and migrated to Clerk auth.
  6. Admin API under /api/admin/... in the backend, Clerk-authenticated plus the ADMIN_USER_IDS/ADMIN_EMAILS check (admin-auth.ts); the machine ADMIN_SECRET bearer is still accepted server-side for scripts but ADMIN_SECRET is never used in the browser (the old sessionStorage secret login was removed).
  7. Privacy page rewritten (frontend/src/app/privacy/page.tsx) to be accurate: Clerk accounts, stored parsed resumes and preferences, email nudges default-on with one-click unsubscribe, Resend as processor, how to unsubscribe and request deletion, contact hello@skiptheboard.in; the false "no accounts / never store email / cookieless" claims removed.
  8. Docs: DEPLOYMENT.md updated with RESEND_API_KEY, RESEND_WEBHOOK_SECRET, EMAIL_FROM (default "SkipTheBoard Jobs jobs@mail.skiptheboard.in"), ADMIN_USER_IDS, ADMIN_EMAILS, UNSUBSCRIBE_SECRET, FRONTEND_URL; the webhook URL to register; the cron list; the Vercel DNS records Resend needs for mail.skiptheboard.in (DNS is hosted by Vercel, not GoDaddy); and, per firstmate, that the Resend domain is in region ap-northeast-1 with DNS records already added. AGENTS.md got a pointer paragraph. Production already holds RESEND_API_KEY, UNSUBSCRIBE_SECRET, EMAIL_FROM and ADMIN_EMAILS; RESEND_WEBHOOK_SECRET is added after deploy.
  9. Tests (vitest in backend/tests/, mocking Resend, Clerk and Mongo; an in-memory Prisma fake in tests/helpers/fake-prisma.ts): matching and selection (gates, senior override, already-sent exclusion, empty-skip), unsubscribe token sign and verify, webhook signature accept and reject, idempotent send per user per campaign, variant split proportions and holdout, admin auth rejects non-admins (401 anonymous, 403 non-admin) and accepts by id, by email, and by ADMIN_SECRET. Frontend lint must pass.

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-based email nudges (backend):
    • Adds a Prisma migration for email preferences, campaigns and variants, sends, clicks, webhook events and app settings.
    • Adds a pure tag matcher (match-score.ts). Jobs with no stack tags get half credit on the stack term.
    • Adds per-user job selection (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.
    • Adds plain HTML and text email templates, and a Resend email provider that falls back to dry-run mode when RESEND_API_KEY is not set.
    • runCampaign sends 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.
    • A run that runs out of time continues itself in a new request.
    • One daily cron at 30 2 * * * sends the weekly campaign on Mondays and the daily campaign on other days.
  • Email routes and tracking:
    • One-click unsubscribe using an HMAC-signed token, with List-Unsubscribe and List-Unsubscribe-Post headers.
    • A Resend webhook (/api/email/webhook) that checks the Svix signature and records delivered, opened, clicked, bounced and complained events. Hard bounces and complaints unsubscribe the user.
    • A /go/<sendId>/<jobId> link that logs the click and redirects to the job.
    • Admin APIs authenticated with Clerk: an admin is a user listed in ADMIN_USER_IDS or ADMIN_EMAILS, and the ADMIN_SECRET bearer still works for scripts. The admin APIs cover whoami, stats, preview, test send, send now, pausing the schedule and experiments.
    • The OpenAPI spec is updated, and the new code has vitest coverage using an in-memory Prisma fake.
  • Frontend and docs:
    • /admin is 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.
    • The profile page gets an "Email me matching jobs" setting: weekly or daily, plus a 4-week pause.
    • Adds an /unsubscribe page and a /go proxy route, and rewrites the privacy page so it is accurate.
    • DEPLOYMENT.md, README.md and AGENTS.md now 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.

  • Evidence: Admin Send controls on a fresh deployment: paused banner and Resume schedule button (local file: ~/.no-mistakes/evidence/01M3Z0FHR4NS0RY7PTCSP7Q0A6/send-controls-paused.png)
  • Evidence: Send controls after clicking Resume schedule: status running, banner gone (local file: ~/.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' ]

"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
"5+ years of experience"             minimumYears= 5  mentionsYears= true
"Experience: 2.5+ yrs"               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)
 ✓ tests/nudge-variants.test.ts > variant assignment > is deterministic per campaign and user 2ms
 ✓ tests/nudge-variants.test.ts > variant assignment > splits users roughly by weight, including the holdout 93ms
 ✓ tests/nudge-variants.test.ts > variant assignment > maps bucket edges onto cumulative ranges 1ms
 ✓ tests/nudge-variants.test.ts > variant assignment > validates weights 0ms
 ✓ tests/nudge-select.test.ts > senior override > reads the smallest stated years figure 9ms
 ✓ tests/nudge-select.test.ts > senior override > ignores years figures that are not about experience 0ms
 ✓ tests/nudge-select.test.ts > senior override > reads a decimal figure whole, never from its fractional part 0ms
 ✓ tests/nudge-select.test.ts > senior override > flags SDE III and 5+ years as senior even when stored as mid 1ms
 ✓ tests/nudge-select.test.ts > senior override > leaves genuine SDE-1 / SDE-2 rows alone 0ms
 ✓ tests/nudge-select.test.ts > senior override > does not demote a role that is already senior 0ms
 ✓ tests/nudge-select.test.ts > poster labelling > labels recruiters, hiring managers and engineers 6ms
 ✓ tests/nudge-select.test.ts > poster labelling > uses the first headline segment that names a role, capped at 60 chars 3ms
 ✓ tests/nudge-select.test.ts > pickBullets > prefers bullets that state years or a location, keeping original order 1ms
 ✓ tests/nudge-select.test.ts > pickBullets > prefers any years mention for bullets, even one that does not lift seniority 1ms
 ✓ tests/nudge-select.test.ts > pickBullets > falls back to the first two bullets 0ms
 ✓ tests/nudge-select.test.ts > selectJobsForUser > ranks by match and repairs the company through the ingest gates 3ms
 ✓ tests/nudge-select.test.ts > selectJobsForUser > drops rows that fail the gates: off-target roles, recruiter titles, malformed titles 0ms
 ✓ tests/nudge-select.test.ts > selectJobsForUser > applies the senior override so an SDE III is not shown as SDE-2 9ms
 ✓ tests/nudge-select.test.ts > selectJobsForUser > keeps an SDE-1 post junior when a bullet states the company's age rather than experience 4ms
 ✓ tests/nudge-select.test.ts > selectJobsForUser > never repeats a job already sent to this user 1ms
 ✓ tests/nudge-select.test.ts > selectJobsForUser > skips expired jobs and returns empty picks when nothing qualifies 0ms
 ✓ tests/nudge-select.test.ts > selectJobsForUser > prefers jobs ingested since the last nudge, then fills with older ones 1ms
 ✓ tests/nudge-select.test.ts > selectJobsForUser > honours the limit 1ms
 ✓ tests/admin-auth.test.ts > admin auth > rejects anonymous requests with 401 on every admin path 14ms
 ✓ tests/admin-auth.test.ts > admin auth > rejects a signed-in non-admin with 403 4ms
 ✓ tests/admin-auth.test.ts > admin auth > rejects an invalid Clerk token as anonymous 5ms
 ✓ tests/admin-auth.test.ts > admin auth > allows an admin listed by Clerk user id 3ms
 ✓ tests/admin-auth.test.ts > admin auth > allows an admin listed by primary Clerk email (case-insensitive) 1ms
 ✓ tests/admin-auth.test.ts > admin auth > still accepts the machine ADMIN_SECRET for scripts 1ms
 ✓ tests/admin-auth.test.ts > admin auth > does not consult Clerk for emails when ADMIN_EMAILS is empty 1ms
 ✓ tests/admin-auth.test.ts > admin auth > treats an email lookup failure as not-admin 0ms
 ✓ tests/email-routes.test.ts > POST /api/email/webhook > accepts a correctly signed event and records delivery 21ms
 ✓ tests/email-routes.test.ts > POST /api/email/webhook > rejects a bad signature and records nothing 21ms
 ✓ tests/email-routes.test.ts > POST /api/email/webhook > rejects a tampered body and missing headers 5ms
 ✓ tests/email-routes.test.ts > POST /api/email/webhook > deduplicates by svix-id 3ms
 ✓ tests/email-routes.test.ts > POST /api/email/webhook > auto-unsubscribes on a hard bounce 1ms
 ✓ tests/email-routes.test.ts > POST /api/email/webhook > keeps a soft bounce subscribed 1ms
 ✓ tests/email-routes.test.ts > POST /api/email/webhook > auto-unsubscribes on a complaint 1ms
 ✓ tests/email-routes.test.ts > POST /api/email/webhook > records provider clicks with the job id parsed from the /go link 2ms
 ✓ tests/email-routes.test.ts > POST /api/email/webhook > ignores events for unknown messages 2ms
 ✓ tests/email-routes.test.ts > one-click unsubscribe > GET only redirects to the confirm page with the same token and changes nothing 3ms
 ✓ tests/email-routes.test.ts > one-click unsubscribe > unsubscribes on the RFC 8058 POST and stamps only the most recent send 2ms
 ✓ tests/email-routes.test.ts > one-click unsubscribe > rejects a forged or missing token on POST 1ms
 ✓ tests/email-routes.test.ts > one-click unsubscribe > can resubscribe from the same link 1ms
 ✓ tests/email-routes.test.ts > GET /go/:sendId/:jobId > logs the click and redirects to the original post 2ms
 ✓ tests/email-routes.test.ts > GET /go/:sendId/:jobId > redirects without logging when the job was not part of that send 1ms
 ✓ tests/email-routes.test.ts > GET /go/:sendId/:jobId > falls back to the feed for unknown ids without logging 0ms
 ✓ tests/nudge-send.test.ts > runCampaign > dry-runs without a provider key: renders, records one row per recipient, calls the provider once 19ms
 ✓ tests/nudge-send.test.ts > runCampaign > is idempotent per user per campaign: a re-run sends nothing new 4ms
 ✓ tests/nudge-send.test.ts > runCampaign > never double-sends when a second run of the same campaign overlaps with this one 2ms
 ✓ tests/nudge-send.test.ts > runCampaign > skips a user with zero qualifying jobs instead of sending an empty email 1ms
 ✓ tests/nudge-send.test.ts > runCampaign > never emails unsubscribed or paused users, and daily campaigns only reach daily subscribers 3ms
 ✓ tests/nudge-send.test.ts > runCampaign > splits an experiment deterministically and sends nothing to the holdout arm 20ms
 ✓ tests/nudge-send.test.ts > runCampaign > stops at the time budget and finishes on the next invocation 2ms
 ✓ tests/nudge-send.test.ts > runCampaign > records a failed provider call per row without losing idempotency 1ms
 ✓ tests/nudge-send.test.ts > runCampaign > records a user with no Clerk email as failed rather than mailing the resume address 0ms
 ✓ tests/nudge-send.test.ts > sendTestNudge > mails the typed address with a [TEST] subject, no unsubscribe headers and an inert footer, and records nothing 1ms
 ✓ tests/nudge-send.test.ts > scheduled campaigns > starts paused: with no settings row the cron creates no campaign until an admin resumes 1ms
 ✓ tests/nudge-send.test.ts > scheduled campaigns > the cron endpoint sends nothing on a fresh deployment and sends once the schedule is resumed 20ms
 ✓ tests/nudge-send.test.ts > schedule helpers > names Monday runs by ISO week and other days by date 1ms
 ✓ tests/nudge-send.test.ts > schedule helpers > finds the next 02:30 UTC tick: today when before it, otherwise tomorrow 0ms
 Test Files  5 passed (5)
      Tests  61 passed (61)
  • Evidence: Send controls render harness (entry, bundle, HTML) (local file: ~/.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 in outgoing regardless of which NudgeSend rows were actually inserted. createMany({ skipDuplicates: true }) silently drops rows that lose the (campaignId,userId) race, but the loop still calls provider.sendBatch for them and then nudgeSend.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-select nudgeSend.findMany({ where: { id: { in: rows.map(r =&gt; r.id) } }, select: { id: true } }) (ids are per-run UUIDs) and only hand those rows' emails to sendBatch; count the rest as already-claimed. The fake Prisma already supports id: { 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 from options.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; a take/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 is List-Unsubscribe=One-Click precisely 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, handleUnsubscribe runs, 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=&lt;token&gt; (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.updateMany stamps unsubscribedAt on every sent/delivered row the user ever received, and foldSendCounts (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 (findFirst ordered by sentAt desc, then update). It changes what the metric means, so the author should confirm.
  • ⚠️ backend/src/routes/go.ts:21 - The redirect logs a NudgeClick and stamps clickedAt whenever the sendId resolves, without checking that the jobId belongs to that send or even exists: the send is selected with only id and clickedAt, and nudgeClick.create runs before job is consulted. Concrete input: GET /go/&lt;real sendId&gt;/999999 (sendId is in every link of a forwarded email) with no such job writes a nudge_clicks row with jobId 999999 (no FK on job_id), marks the send clicked, and 302s to the feed. topClickedJobs then returns { jobId: 999999, title: null, company: null } on the dashboard and clickRate counts the send. backend/tests/email-routes.test.ts:175 covers only a valid pair and an unknown sendId. Minimal fix: select jobIds on the send and only log/stamp when send.jobIds.includes(jobId) (still redirect to the job or feed).
  • ⚠️ backend/src/lib/nudge-stats.ts:30 - subscriberStats subtracts unsubscribed, paused and daily counted over every email_preferences row from an eligible figure that counts only Mongo profiles. A preference row exists for any signed-in Clerk user who touched the toggle (PUT /api/email/preferences calls getOrCreatePreference with 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 the findMany to userId: { in: eligibleIds } from listEligibleUserIds() (same count semantics, correct set).
  • ⚠️ backend/src/lib/nudge-stats.ts:148 - foldSendCounts counts opened/clicked over all sent rows but switches the denominator to delivered as soon as one email.delivered event has landed, so the rate can exceed 100% during any live campaign. Concrete input: 100 sent rows, 1 with deliveredAt, 3 with openedAt (Resend delivers delivered/opened events independently per message and the /go redirect sets clickedAt without deliveredAt): Open rate renders as 300.0% in EmailPanel and the variant table. Remedy choice is a product one: either always use sent as the denominator, or use delivered only once it is at least opened/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 &gt; 0)). Concrete input: profile {engineering, junior, in_office, stack: []} and job {engineering, junior, in_office, stack: [&#34;go&#34;]} 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(&#34;1.5+ years of experience&#34;) returns 5 and minimumYears(&#34;2.5 years of experience in Java&#34;) returns 5, so effectiveSeniority({ title: &#34;SDE 1&#34;, description: [&#34;1.5+ years of experience&#34;], seniority: &#34;junior&#34; }) 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 \b with (?&lt;![\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 renders c.totals.recipients + c.totals.holdout under the header "Recipients", while the backend deliberately excludes holdout rows from recipients (nudge-stats.ts:119) and the results breakdown directly below (AdminUi.tsx:106) shows totals.recipients without 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: show recipients alone 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 field todayWouldRun is scheduledCampaignFor(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)" uses totalEmailClicks, which is an unfiltered prisma.nudgeClick.count() (nudge-stats.ts:277), while the webhook writes a NudgeClick row for every email.clicked with jobId: null for 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 with where: { 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 - pickBullets scores bullets with the same minimumYears that 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([&#34;Great culture&#34;, &#34;5-8 years in backend&#34;, &#34;Bangalore office&#34;, &#34;Experience: 3+ years&#34;]) now returns [&#34;Bangalore office&#34;, &#34;Experience: 3+ years&#34;], 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 (?&lt;![\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: &#34;SDE 2&#34;, description: [&#34;4.5-7 years of experience&#34;], seniority: &#34;mid&#34; }) returns "senior" because minimumYears now 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(&#34;1.5+ years of experience&#34;) and mentionsYears(&#34;Experience: 2.5+ yrs&#34;) are false, so pickBullets([&#34;Great culture&#34;, &#34;1.5+ years of experience&#34;, &#34;Free snacks&#34;, &#34;Bangalore office&#34;]) returns ["Great culture", "Bangalore office"], dropping the years bullet the brief says to prefer. The round-2 test pins these wrong values (minimumYears(&#34;1.5-3 years of experience&#34;) asserted as 3, &#34;2.5 years of experience in Java&#34; 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 &amp;&amp; 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=verbose for the nudge, email-route, variant and admin-auth tests, including scheduled campaigns &gt; 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, and senior override &gt; reads a decimal figure whole, never from its fractional part
  • npx tsx decimal-years-demo.mts: called minimumYears, mentionsYears, effectiveSeniority, levelLabel and pickBullets directly on decimal and integer years phrases
  • Bundled 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 screenshot
⚠️ **Document** - 1 info
  • ℹ️ README.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.

akesar01 and others added 6 commits October 2, 2026 21:28
…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>
@akesar01
akesar01 merged commit b3e33be into main Oct 3, 2026
2 checks passed
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