diff --git a/AGENTS.md b/AGENTS.md index 6f62dc8..c82ac0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ Each job post contains: - **Source** — LinkedIn or X, with direct link to original post - **Score** — community votes (upvote/downvote) - **Comments count** -- **Posted time** — relative (e.g. "2h ago"), auto-expires after 7 days +- **Posted time** — relative (e.g. "2h ago"), auto-expires after 30 days ## Backend @@ -73,13 +73,15 @@ The backend is a Hono app with Prisma + PostgreSQL. It provides: - Post ingestion via Apify (LinkedIn/X scraping) - Recruiter suggestions (community submissions) - Voting on job posts -- Admin endpoints (protected by `ADMIN_SECRET`) +- Admin endpoints (`ADMIN_SECRET` bearer, or a Clerk admin per `ADMIN_USER_IDS` / `ADMIN_EMAILS`) - AI-powered post enrichment via Groq The frontend fetches jobs from the backend API. The API contract is aligned with the `Job` interface in `frontend/src/lib/data.ts`. Ingest path: `ingestPosts` in `backend/src/lib/apify.ts` -> `classifyPost` (`llm-classifier.ts`, regex fallback in `classifier.ts`) -> `inferCompany` (`extract-company.ts`) -> deterministic gates in `backend/src/lib/ingest-gates.ts` -> `prisma.job.create`. The feed is engineering-only by default (`INGEST_ROLE_FAMILIES`); skip reasons and the `scripts/reclassify-jobs.ts` cleanup are documented under "Ingest quality gates" in `DEPLOYMENT.md`. Add any new junk pattern as a failing test in `backend/tests/` first; the gates are pure functions and must never call the LLM. +Email nudges: `runCampaign` in `backend/src/lib/nudge-send.ts` -> `selectJobsForUser` (`nudge-select.ts`, pure: re-applies `evaluateJobGates`, lifts SDE-3/5+ year roles to senior, excludes jobs already sent) -> `renderNudgeEmail` (`nudge-render.ts`, plain HTML + text) -> `EmailProvider` (`email.ts`, Resend or dry run without `RESEND_API_KEY`). Recipients are Clerk users with a Mongo profile; their emails come from Clerk, never from the resume. Admin access is `ADMIN_USER_IDS` / `ADMIN_EMAILS` (`admin-auth.ts`); the dashboard at `frontend/src/app/admin/` never sees `ADMIN_SECRET`. Operations, env vars, DNS and the cron list are under "Match-based email nudges" in `DEPLOYMENT.md`. Tests mock Prisma with `backend/tests/helpers/fake-prisma.ts`. + Frontend config (`frontend/src/lib/config.ts`) centralizes `BACKEND_URL` and `API_KEY` — all client-side fetches import from there. ## Deployment (Vercel) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 2d834f8..5a39ed0 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -98,8 +98,26 @@ npx vercel env add APIFY_MAX_CONCURRENT production # Value: 5 npx vercel env add INGEST_ROLE_FAMILIES production # Optional. Value: engineering,ai_ml (default). Comma-separated RoleFamily values allowed into the feed. + +# Email nudges (see "Match-based email nudges" below) +npx vercel env add RESEND_API_KEY production +# Optional. Without it every send is a dry run: rendered and recorded, nothing leaves the server. +npx vercel env add RESEND_WEBHOOK_SECRET production +# Signing secret of the Resend webhook endpoint (starts with whsec_). Without it POST /api/email/webhook returns 503. +npx vercel env add EMAIL_FROM production +# Value: SkipTheBoard Jobs (default when unset) +npx vercel env add UNSUBSCRIBE_SECRET production +# Random 32+ char string. Signs the one-click unsubscribe links; rotating it invalidates links in already-sent emails. +npx vercel env add ADMIN_USER_IDS production +# Comma-separated Clerk user ids allowed into /admin. Either this or ADMIN_EMAILS must list the captain. +npx vercel env add ADMIN_EMAILS production +# Comma-separated primary Clerk emails allowed into /admin (case-insensitive). Resolved through @clerk/backend. +npx vercel env add FRONTEND_URL production +# Value: https://skiptheboard.in — origin used in email links and the /go redirect fallback. ``` +`CLERK_SECRET_KEY` and `MONGODB_URI` are already required for sign-in and profiles; the nudges reuse them for recipient emails and resume profiles. + **`CORS_ORIGIN`** must be a comma-separated list of allowed origins: ``` @@ -138,7 +156,7 @@ npm run dev ### Daily recruiter scrape -`backend/vercel.json` registers a Vercel Cron that hits `GET /api/cron/scrape` every 15 minutes. Vercel sends `Authorization: Bearer $CRON_SECRET` when that env var is set. +`backend/vercel.json` registers a Vercel Cron that hits `GET /api/cron/scrape` once a day at 04:00 UTC. Vercel sends `Authorization: Bearer $CRON_SECRET` when that env var is set. The endpoint starts at most 5 due recruiters per tick (Apify Free concurrent cap), then if more remain it waits 10 minutes and starts the next 5, until everyone due that day is scraped. Unfinished Apify runs stay `RUNNING` and are ingested on a later tick. Set `APIFY_WAIT_MS` (default 180000) if a manual admin scrape should wait longer in-request. @@ -151,6 +169,34 @@ curl -X POST https://backend-umber-nu-43.vercel.app/api/cron/scrape \ Use `?once=1` to scrape a single due recruiter and stop. +### Match-based email nudges + +Signed-in users with a resume profile get an email with the jobs on the board that best match their resume. Everything lives behind `backend/src/lib/email.ts` (provider), `nudge-select.ts` (pure ranking, senior override, gates), `nudge-render.ts` (plain HTML + text template) and `nudge-send.ts` (campaign runner). The send path never calls an LLM. + +**Crons (`backend/vercel.json`, 3 total, all daily or slower as Vercel Hobby requires):** + +| Path | Schedule (UTC) | What it does | +|------|----------------|--------------| +| `/api/cron/expire-jobs` | `0 3 * * *` | Delete jobs older than `JOB_EXPIRY_DAYS`. | +| `/api/cron/scrape` | `0 4 * * *` | Daily recruiter scrape (drains through GitHub Actions). | +| `/api/cron/nudges` | `30 2 * * *` | 08:00 IST. On Mondays it creates and runs the weekly campaign for every subscribed user; on other days it runs a daily campaign only for users who chose daily. Hobby fires it within the hour. | + +A run processes recipients in pages of 100 (one Resend batch call each). If the 240 s budget (`NUDGE_BUDGET_MS`) runs out it POSTs itself `/api/cron/nudges?campaign=` with `CRON_SECRET` via `waitUntil`, so a long send continues in a fresh invocation. The `(campaign, user)` unique index makes any retry safe. **The schedule starts paused.** On a fresh deployment there is no `nudges.schedule_paused` row in `app_settings`, and the cron treats that as paused: it creates no campaign and nothing goes out on its own until an admin presses "Resume schedule" under Send controls (which stores `false`). "Pause schedule" stores `true` again. "Send now" and test sends from the dashboard work either way. + +**Dry run.** With no `RESEND_API_KEY`, sends are rendered and recorded with status `dry_run` and the admin preview, test send and campaigns all work. Nothing is ever sent from tests. + +**Resend setup.** + +1. Domain `mail.skiptheboard.in` is added in Resend in region **ap-northeast-1 (Tokyo)**. Its DNS records are already in place. Because skiptheboard.in's nameservers are Vercel's (`ns1/ns2.vercel-dns.com`), any email DNS change goes into **Vercel → Domains → skiptheboard.in → DNS Records**, not GoDaddy. The records Resend requires are: a TXT at `resend._domainkey.mail` (DKIM), an MX plus a TXT at `send.mail` (SPF / return path), and a TXT at `_dmarc` (`v=DMARC1; p=none; rua=mailto:`, tighten to `p=quarantine` after a few clean weeks). Copy the exact values from the Resend domain page. +2. Webhook: in Resend → Webhooks add `https://backend-umber-nu-43.vercel.app/api/email/webhook` with the events `email.delivered`, `email.opened`, `email.clicked`, `email.bounced`, `email.complained` (sent and delivery_delayed are accepted and ignored). Paste the endpoint's signing secret into `RESEND_WEBHOOK_SECRET` and redeploy. Signatures are verified with Svix; events are deduplicated by `svix-id`. +3. Every email carries `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058) pointing at `POST /api/email/unsubscribe?t=`, plus a footer link to `https://skiptheboard.in/unsubscribe?t=…` (the page asks for one click before it acts). Test sends carry neither the headers nor a working footer link. Hard bounces and complaints unsubscribe the user automatically. + +**Click tracking.** Job links go to `https://skiptheboard.in/go//`; the Next route forwards to the backend `GET /go/...` (rewrite in `vercel.json`), which logs a `nudge_clicks` row and 302s to the LinkedIn post. Site links carry `utm_source=nudge&utm_medium=email&utm_campaign=`. + +**Admin dashboard.** `https://skiptheboard.in/admin` renders only for Clerk users in `ADMIN_USER_IDS` / `ADMIN_EMAILS`; every `/api/admin/*` call is checked on the backend (the machine `ADMIN_SECRET` still works for curl and scripts, but is never sent to a browser). Tabs: Subscribers, Email performance, Jobs & site, Send controls (preview any user, test send, send now, pause/resume), Experiments (2+ variants with weights and an optional holdout, results side by side), Submissions, Hiring managers. + +**Migration.** `backend/prisma/migrations/20261002090000_add_email_nudges` adds `email_preferences`, `campaigns`, `campaign_variants`, `nudge_sends`, `nudge_clicks`, `email_events` and `app_settings`. Apply with `npx prisma migrate deploy` against production before the first send. + ### Ingest quality gates Every scraped post passes through deterministic gates in `backend/src/lib/ingest-gates.ts` before it becomes a `Job`. A post is skipped, and counted in `ApifyRunLog.errorMsg` (for example `not_job=3,off_target=2,duplicate=1`), for one of these reasons: diff --git a/README.md b/README.md index d230004..2027008 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,14 @@ npm run dev **Environment variables:** - `DATABASE_URL` — PostgreSQL connection string - `API_KEY` — Shared API key for feed endpoints -- `ADMIN_SECRET` — Bearer token for admin endpoints +- `ADMIN_SECRET` — Machine bearer token for admin endpoints (scripts and curl; the `/admin` dashboard uses Clerk with `ADMIN_USER_IDS` / `ADMIN_EMAILS` instead) - `APIFY_TOKEN` — Apify API token for LinkedIn/X scraping (`APIFY_API_KEY` is accepted as a fallback) - `APIFY_MAX_CONCURRENT` — Max simultaneous Apify runs (default 5, Apify free-plan cap) - `GROQ_API_KEY` — Groq API key for AI-powered post enrichment - `GROQ_MODEL` — Optional Groq model override (default `openai/gpt-oss-120b`) - `INGEST_ROLE_FAMILIES` — Role families allowed into the feed, comma-separated (default `engineering,ai_ml`); see the ingest quality gates in [DEPLOYMENT.md](./DEPLOYMENT.md) - `CORS_ORIGIN` — Allowed frontend origin(s), comma-separated +- Email nudge and admin access variables (`RESEND_API_KEY`, `UNSUBSCRIBE_SECRET`, `ADMIN_EMAILS`, …) are listed in [DEPLOYMENT.md](./DEPLOYMENT.md) ## API Endpoints diff --git a/backend/package-lock.json b/backend/package-lock.json index 8fab8c1..b7d754d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,7 +13,9 @@ "@vercel/functions": "^3.9.3", "hono": "^4.7.0", "mongodb": "^7.4.0", + "resend": "^6.32.0", "stripe": "^22.3.0", + "svix": "^2.6.1", "unpdf": "^1.6.2", "zod": "^4.4.3" }, @@ -2056,6 +2058,12 @@ "pathe": "^2.0.3" } }, + "node_modules/postal-mime": { + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.6.tgz", + "integrity": "sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2162,6 +2170,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/resend": { + "version": "6.32.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.32.0.tgz", + "integrity": "sha512-bZZUeKlpKINFT+zGG2iIzfDewkyEHRaE7Xk1haseVz3Gfw6ekkizN6KdKgZSMjt5ywE8vMwr3wTdenlF8P475Q==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.6", + "standardwebhooks": "1.1.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2268,9 +2297,9 @@ "license": "MIT" }, "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", "license": "MIT", "dependencies": { "@stablelib/base64": "^1.0.0", @@ -2323,6 +2352,18 @@ } } }, + "node_modules/svix": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-2.6.1.tgz", + "integrity": "sha512-BFB4H1qfbqWNBGHsmfeJxvelqhvfDFV7Il+5ZaF3mMo+ew0BxI6BROJB/agwbEK2DTcP0Ye2NT6+lkTqDfYifA==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/backend/package.json b/backend/package.json index 68393d8..2c1bfd2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,7 +18,9 @@ "@vercel/functions": "^3.9.3", "hono": "^4.7.0", "mongodb": "^7.4.0", + "resend": "^6.32.0", "stripe": "^22.3.0", + "svix": "^2.6.1", "unpdf": "^1.6.2", "zod": "^4.4.3" }, diff --git a/backend/prisma/migrations/20261002090000_add_email_nudges/migration.sql b/backend/prisma/migrations/20261002090000_add_email_nudges/migration.sql new file mode 100644 index 0000000..2d4ff9d --- /dev/null +++ b/backend/prisma/migrations/20261002090000_add_email_nudges/migration.sql @@ -0,0 +1,172 @@ +-- CreateEnum +CREATE TYPE "EmailFrequency" AS ENUM ('weekly', 'daily'); + +-- CreateEnum +CREATE TYPE "CampaignKind" AS ENUM ('weekly', 'daily', 'manual', 'experiment'); + +-- CreateEnum +CREATE TYPE "CampaignStatus" AS ENUM ('draft', 'running', 'completed'); + +-- CreateEnum +CREATE TYPE "SendStatus" AS ENUM ('queued', 'dry_run', 'sent', 'delivered', 'bounced', 'complained', 'failed', 'skipped', 'holdout'); + +-- CreateTable +CREATE TABLE "email_preferences" ( + "id" SERIAL NOT NULL, + "user_id" TEXT NOT NULL, + "email" TEXT, + "subscribed" BOOLEAN NOT NULL DEFAULT true, + "frequency" "EmailFrequency" NOT NULL DEFAULT 'weekly', + "paused_until" TIMESTAMP(3), + "unsubscribed_at" TIMESTAMP(3), + "unsubscribe_reason" TEXT, + "unsubscribe_token" TEXT NOT NULL, + "last_sent_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "email_preferences_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "campaigns" ( + "id" SERIAL NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "kind" "CampaignKind" NOT NULL, + "status" "CampaignStatus" NOT NULL DEFAULT 'draft', + "job_count" INTEGER NOT NULL DEFAULT 5, + "created_by" TEXT, + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "campaigns_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "campaign_variants" ( + "id" SERIAL NOT NULL, + "campaign_id" INTEGER NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "weight" INTEGER NOT NULL, + "is_holdout" BOOLEAN NOT NULL DEFAULT false, + "subject" TEXT, + "intro" TEXT, + "job_count" INTEGER, + + CONSTRAINT "campaign_variants_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "nudge_sends" ( + "id" TEXT NOT NULL, + "campaign_id" INTEGER NOT NULL, + "variant_id" INTEGER, + "user_id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "status" "SendStatus" NOT NULL, + "provider_message_id" TEXT, + "job_ids" INTEGER[], + "subject" TEXT, + "error" TEXT, + "sent_at" TIMESTAMP(3), + "delivered_at" TIMESTAMP(3), + "opened_at" TIMESTAMP(3), + "clicked_at" TIMESTAMP(3), + "bounced_at" TIMESTAMP(3), + "complained_at" TIMESTAMP(3), + "unsubscribed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "nudge_sends_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "nudge_clicks" ( + "id" SERIAL NOT NULL, + "send_id" TEXT NOT NULL, + "job_id" INTEGER, + "source" TEXT NOT NULL, + "url" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "nudge_clicks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "email_events" ( + "id" SERIAL NOT NULL, + "provider_event_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "message_id" TEXT, + "payload" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "email_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "app_settings" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_settings_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "email_preferences_user_id_key" ON "email_preferences"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "email_preferences_unsubscribe_token_key" ON "email_preferences"("unsubscribe_token"); + +-- CreateIndex +CREATE INDEX "email_preferences_subscribed_frequency_idx" ON "email_preferences"("subscribed", "frequency"); + +-- CreateIndex +CREATE UNIQUE INDEX "campaigns_key_key" ON "campaigns"("key"); + +-- CreateIndex +CREATE INDEX "campaigns_status_idx" ON "campaigns"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "campaign_variants_campaign_id_key_key" ON "campaign_variants"("campaign_id", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "nudge_sends_provider_message_id_key" ON "nudge_sends"("provider_message_id"); + +-- CreateIndex +CREATE INDEX "nudge_sends_user_id_idx" ON "nudge_sends"("user_id"); + +-- CreateIndex +CREATE INDEX "nudge_sends_status_idx" ON "nudge_sends"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "nudge_sends_campaign_id_user_id_key" ON "nudge_sends"("campaign_id", "user_id"); + +-- CreateIndex +CREATE INDEX "nudge_clicks_send_id_idx" ON "nudge_clicks"("send_id"); + +-- CreateIndex +CREATE INDEX "nudge_clicks_job_id_idx" ON "nudge_clicks"("job_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "email_events_provider_event_id_key" ON "email_events"("provider_event_id"); + +-- CreateIndex +CREATE INDEX "email_events_message_id_idx" ON "email_events"("message_id"); + +-- AddForeignKey +ALTER TABLE "campaign_variants" ADD CONSTRAINT "campaign_variants_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "nudge_sends" ADD CONSTRAINT "nudge_sends_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "nudge_sends" ADD CONSTRAINT "nudge_sends_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "campaign_variants"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "nudge_clicks" ADD CONSTRAINT "nudge_clicks_send_id_fkey" FOREIGN KEY ("send_id") REFERENCES "nudge_sends"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 03f1514..b814956 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -247,3 +247,163 @@ model JobApplication { @@index([userId]) @@map("job_applications") } + +// ═══════════════════════════════════════════════════════════════════════════════ +// EMAIL NUDGES (match-based job emails to signed-in users) +// ═══════════════════════════════════════════════════════════════════════════════ + +enum EmailFrequency { + weekly + daily +} + +// Per-user email preferences. A user with a resume profile and no row is +// treated as subscribed weekly (the privacy page states this default). +model EmailPreference { + id Int @id @default(autoincrement()) + userId String @unique @map("user_id") // Clerk user id + email String? // Clerk primary email, cached at last send + subscribed Boolean @default(true) + frequency EmailFrequency @default(weekly) + pausedUntil DateTime? @map("paused_until") + unsubscribedAt DateTime? @map("unsubscribed_at") + unsubscribeReason String? @map("unsubscribe_reason") // user | bounce | complaint | admin + unsubscribeToken String @unique @map("unsubscribe_token") + lastSentAt DateTime? @map("last_sent_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([subscribed, frequency]) + @@map("email_preferences") +} + +enum CampaignKind { + weekly + daily + manual + experiment +} + +enum CampaignStatus { + draft + running + completed +} + +model Campaign { + id Int @id @default(autoincrement()) + key String @unique // weekly-2026-W40, daily-2026-10-02, manual-, exp- + name String + kind CampaignKind + status CampaignStatus @default(draft) + jobCount Int @default(5) @map("job_count") + createdBy String? @map("created_by") // admin user id or "cron" + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + + variants CampaignVariant[] + sends NudgeSend[] + + @@index([status]) + @@map("campaigns") +} + +// Experiment arms. Weights are percentages; a holdout arm receives nothing. +model CampaignVariant { + id Int @id @default(autoincrement()) + campaignId Int @map("campaign_id") + campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) + key String // "A", "B", "control" + name String + weight Int // 0-100, all weights in a campaign sum to 100 + isHoldout Boolean @default(false) @map("is_holdout") + subject String? // supports {count} {companies} {name} + intro String? + jobCount Int? @map("job_count") + + sends NudgeSend[] + + @@unique([campaignId, key]) + @@map("campaign_variants") +} + +enum SendStatus { + queued // row written, provider not yet called + dry_run // rendered and recorded; no RESEND_API_KEY + sent // accepted by the provider + delivered + bounced + complained + failed + skipped // zero qualifying jobs + holdout // control arm, nothing sent +} + +// One row per recipient per campaign. The id is the public /go/ key. +model NudgeSend { + id String @id // app-generated UUID + campaignId Int @map("campaign_id") + campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) + variantId Int? @map("variant_id") + variant CampaignVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull) + userId String @map("user_id") + email String + status SendStatus + providerMessageId String? @unique @map("provider_message_id") + jobIds Int[] @map("job_ids") + subject String? + error String? + sentAt DateTime? @map("sent_at") + deliveredAt DateTime? @map("delivered_at") + openedAt DateTime? @map("opened_at") + clickedAt DateTime? @map("clicked_at") + bouncedAt DateTime? @map("bounced_at") + complainedAt DateTime? @map("complained_at") + unsubscribedAt DateTime? @map("unsubscribed_at") + createdAt DateTime @default(now()) @map("created_at") + + clicks NudgeClick[] + + @@unique([campaignId, userId]) // idempotent per user per campaign + @@index([userId]) + @@index([status]) + @@map("nudge_sends") +} + +// A click from the /go redirect or a provider click webhook. +model NudgeClick { + id Int @id @default(autoincrement()) + sendId String @map("send_id") + send NudgeSend @relation(fields: [sendId], references: [id], onDelete: Cascade) + jobId Int? @map("job_id") + source String // redirect | webhook + url String? + createdAt DateTime @default(now()) @map("created_at") + + @@index([sendId]) + @@index([jobId]) + @@map("nudge_clicks") +} + +// Raw provider webhook events, keyed by the Svix message id for dedupe. +model EmailEvent { + id Int @id @default(autoincrement()) + providerEventId String @unique @map("provider_event_id") + type String + messageId String? @map("message_id") + payload Json + createdAt DateTime @default(now()) @map("created_at") + + @@index([messageId]) + @@map("email_events") +} + +// Small key/value store for operator switches (e.g. nudges.schedule_paused). +model AppSetting { + key String @id + value String + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("app_settings") +} diff --git a/backend/src/index.ts b/backend/src/index.ts index e3057b5..3fae7fa 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,8 @@ import postsRoutes from "./routes/posts.js"; import submissionsRoutes from "./routes/submissions.js"; import profileRoutes from "./routes/profile.js"; import billingRoutes from "./routes/billing.js"; +import emailRoutes from "./routes/email.js"; +import goRoutes from "./routes/go.js"; import { clerkOptionalAuth } from "./lib/clerk-auth.js"; import buildOpenApiSpec from "./openapi-spec.js"; @@ -44,6 +46,8 @@ app.route("/api/cron", cronRoutes); app.route("/api/posts", postsRoutes); app.route("/api/profile", profileRoutes); app.route("/api/billing", billingRoutes); +app.route("/api/email", emailRoutes); +app.route("/go", goRoutes); app.route("/api", submissionsRoutes); app.get("/health", (c) => c.json({ status: "ok" })); diff --git a/backend/src/lib/admin-auth.ts b/backend/src/lib/admin-auth.ts new file mode 100644 index 0000000..b9edf8b --- /dev/null +++ b/backend/src/lib/admin-auth.ts @@ -0,0 +1,95 @@ +// Admin access: either the machine ADMIN_SECRET bearer (scripts, curl) or a +// Clerk user whose id is in ADMIN_USER_IDS or whose primary Clerk email is in +// ADMIN_EMAILS. The browser only ever uses the Clerk path. + +import { fetchClerkUsers } from "./clerk-users.js"; + +function csvSet(raw: string | undefined, lower = false): Set { + return new Set( + (raw ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => (lower ? value.toLowerCase() : value)), + ); +} + +export function adminUserIds(env: NodeJS.ProcessEnv = process.env): Set { + return csvSet(env.ADMIN_USER_IDS); +} + +export function adminEmails(env: NodeJS.ProcessEnv = process.env): Set { + return csvSet(env.ADMIN_EMAILS, true); +} + +export function isAdminUserId(userId: string | null | undefined, env: NodeJS.ProcessEnv = process.env): boolean { + if (!userId) return false; + return adminUserIds(env).has(userId); +} + +export function isAdminEmail(email: string | null | undefined, env: NodeJS.ProcessEnv = process.env): boolean { + if (!email) return false; + return adminEmails(env).has(email.trim().toLowerCase()); +} + +export type EmailResolver = (userId: string) => Promise; + +// Clerk lookups are cached per process for a few minutes so a dashboard +// session does not call Clerk on every request. +const EMAIL_CACHE_TTL_MS = 5 * 60 * 1000; +const emailCache = new Map(); + +export const defaultEmailResolver: EmailResolver = async (userId) => { + const cached = emailCache.get(userId); + const now = Date.now(); + if (cached && cached.expiresAt > now) return cached.email; + const users = await fetchClerkUsers([userId]); + const email = users.get(userId)?.email ?? null; + emailCache.set(userId, { email, expiresAt: now + EMAIL_CACHE_TTL_MS }); + return email; +}; + +export function clearAdminEmailCache(): void { + emailCache.clear(); +} + +/** True when the Clerk user is an admin by id or by primary email. */ +export async function isAdminUser( + userId: string | null | undefined, + env: NodeJS.ProcessEnv = process.env, + resolveEmail: EmailResolver = defaultEmailResolver, +): Promise { + if (!userId) return false; + if (isAdminUserId(userId, env)) return true; + if (adminEmails(env).size === 0) return false; + try { + return isAdminEmail(await resolveEmail(userId), env); + } catch (err) { + console.warn("[admin] email lookup failed:", err instanceof Error ? err.message : err); + return false; + } +} + +export type AdminAuthDecision = + | { ok: true; via: "secret" | "clerk"; userId: string | null } + | { ok: false; status: 401 | 403; error: string }; + +export async function decideAdminAuth(options: { + authHeader: string | undefined; + userId: string | null | undefined; + env?: NodeJS.ProcessEnv; + resolveEmail?: EmailResolver; +}): Promise { + const env = options.env ?? process.env; + const secret = env.ADMIN_SECRET; + if (secret && options.authHeader === `Bearer ${secret}`) { + return { ok: true, via: "secret", userId: null }; + } + if (!options.userId) { + return { ok: false, status: 401, error: "Unauthorized" }; + } + if (!(await isAdminUser(options.userId, env, options.resolveEmail))) { + return { ok: false, status: 403, error: "Forbidden" }; + } + return { ok: true, via: "clerk", userId: options.userId }; +} diff --git a/backend/src/lib/clerk-users.ts b/backend/src/lib/clerk-users.ts new file mode 100644 index 0000000..53eda9b --- /dev/null +++ b/backend/src/lib/clerk-users.ts @@ -0,0 +1,43 @@ +// Verified email addresses live in Clerk, not in the resume profile. +// The address parsed from a resume is never mailed. + +import { createClerkClient } from "@clerk/backend"; + +export interface ClerkUserInfo { + userId: string; + email: string | null; + firstName: string | null; +} + +const CLERK_PAGE = 100; + +export async function fetchClerkUsers( + userIds: string[], + env: NodeJS.ProcessEnv = process.env, +): Promise> { + const result = new Map(); + const unique = [...new Set(userIds.filter(Boolean))]; + if (unique.length === 0) return result; + + const secretKey = env.CLERK_SECRET_KEY; + if (!secretKey) { + console.warn("[Clerk] CLERK_SECRET_KEY not set; no recipient emails available"); + return result; + } + + const client = createClerkClient({ secretKey }); + for (let i = 0; i < unique.length; i += CLERK_PAGE) { + const page = unique.slice(i, i + CLERK_PAGE); + const { data } = await client.users.getUserList({ userId: page, limit: CLERK_PAGE }); + for (const user of data) { + const primary = + user.emailAddresses.find((e) => e.id === user.primaryEmailAddressId) ?? user.emailAddresses[0]; + result.set(user.id, { + userId: user.id, + email: primary?.emailAddress ?? null, + firstName: user.firstName ?? null, + }); + } + } + return result; +} diff --git a/backend/src/lib/email-tokens.ts b/backend/src/lib/email-tokens.ts new file mode 100644 index 0000000..c5db44b --- /dev/null +++ b/backend/src/lib/email-tokens.ts @@ -0,0 +1,64 @@ +// Signed, stateless tokens for unsubscribe links. HMAC-SHA256 over the +// payload; verification is constant-time. No login, no database lookup +// needed to reject a forged link. + +import { createHmac, randomBytes, timingSafeEqual } from "crypto"; + +const FALLBACK_WARNING = "[email] UNSUBSCRIBE_SECRET not set; using a development fallback. Set it in production."; +let warned = false; + +export function unsubscribeSecret(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.UNSUBSCRIBE_SECRET?.trim(); + if (explicit) return explicit; + if (!warned) { + warned = true; + console.warn(FALLBACK_WARNING); + } + return env.CRON_SECRET?.trim() || "dev-unsubscribe-secret"; +} + +function b64url(input: Buffer | string): string { + return Buffer.from(input).toString("base64url"); +} + +function hmac(payload: string, secret: string): string { + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +/** `base64url(payload).signature` */ +export function signToken(payload: string, secret: string): string { + const encoded = b64url(payload); + return `${encoded}.${hmac(encoded, secret)}`; +} + +/** The payload when the signature is valid, else null. */ +export function verifyToken(token: string, secret: string): string | null { + if (typeof token !== "string") return null; + const dot = token.lastIndexOf("."); + if (dot <= 0) return null; + const encoded = token.slice(0, dot); + const signature = token.slice(dot + 1); + if (!/^[A-Za-z0-9_-]+$/.test(encoded) || !/^[A-Za-z0-9_-]+$/.test(signature)) return null; + const expected = hmac(encoded, secret); + const a = Buffer.from(signature); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + try { + return Buffer.from(encoded, "base64url").toString("utf8"); + } catch { + return null; + } +} + +/** Random per-user token stored on the preference row; the link carries its signature. */ +export function newUnsubscribeToken(): string { + return randomBytes(18).toString("base64url"); +} + +export function signUnsubscribeToken(storedToken: string, env: NodeJS.ProcessEnv = process.env): string { + return signToken(storedToken, unsubscribeSecret(env)); +} + +export function verifyUnsubscribeToken(signed: string, env: NodeJS.ProcessEnv = process.env): string | null { + return verifyToken(signed, unsubscribeSecret(env)); +} diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts new file mode 100644 index 0000000..73aa5e4 --- /dev/null +++ b/backend/src/lib/email.ts @@ -0,0 +1,110 @@ +// Email provider boundary. Resend today; swap by implementing EmailProvider. +// Without RESEND_API_KEY the DryRunProvider renders and records but never +// calls the network, so development and tests send nothing. + +import { Resend } from "resend"; + +export const EMAIL_BATCH_SIZE = 100; +export const DEFAULT_EMAIL_FROM = "SkipTheBoard Jobs "; + +export interface OutgoingEmail { + to: string; + subject: string; + html: string; + text: string; + headers?: Record; + replyTo?: string; + tags?: { name: string; value: string }[]; +} + +export interface BatchSendResult { + ok: boolean; + /** Provider message id per input email, in order; null where the provider gave none. */ + ids: (string | null)[]; + error?: string; + dryRun: boolean; +} + +export interface EmailProvider { + readonly name: string; + readonly dryRun: boolean; + sendBatch(emails: OutgoingEmail[]): Promise; +} + +export function emailFrom(env: NodeJS.ProcessEnv = process.env): string { + return env.EMAIL_FROM?.trim() || DEFAULT_EMAIL_FROM; +} + +export function emailReplyTo(env: NodeJS.ProcessEnv = process.env): string { + return env.EMAIL_REPLY_TO?.trim() || "hello@skiptheboard.in"; +} + +/** RFC 8058 one-click unsubscribe headers. */ +export function listUnsubscribeHeaders(unsubscribeUrl: string): Record { + return { + "List-Unsubscribe": `<${unsubscribeUrl}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }; +} + +export class DryRunProvider implements EmailProvider { + readonly name = "dry-run"; + readonly dryRun = true; + + async sendBatch(emails: OutgoingEmail[]): Promise { + return { ok: true, ids: emails.map(() => null), dryRun: true }; + } +} + +export class ResendProvider implements EmailProvider { + readonly name = "resend"; + readonly dryRun = false; + private readonly client: Resend; + private readonly from: string; + private readonly replyTo: string; + + constructor(apiKey: string, from: string, replyTo: string) { + this.client = new Resend(apiKey); + this.from = from; + this.replyTo = replyTo; + } + + async sendBatch(emails: OutgoingEmail[]): Promise { + if (emails.length === 0) return { ok: true, ids: [], dryRun: false }; + if (emails.length > EMAIL_BATCH_SIZE) { + return { + ok: false, + ids: emails.map(() => null), + error: `batch of ${emails.length} exceeds ${EMAIL_BATCH_SIZE}`, + dryRun: false, + }; + } + const payload = emails.map((email) => ({ + from: this.from, + to: email.to, + replyTo: email.replyTo ?? this.replyTo, + subject: email.subject, + html: email.html, + text: email.text, + headers: email.headers, + tags: email.tags, + })); + const { data, error } = await this.client.batch.send(payload); + if (error || !data) { + return { + ok: false, + ids: emails.map(() => null), + error: error?.message ?? "Resend returned no data", + dryRun: false, + }; + } + const ids = emails.map((_, index) => data.data[index]?.id ?? null); + return { ok: true, ids, dryRun: false }; + } +} + +export function createEmailProvider(env: NodeJS.ProcessEnv = process.env): EmailProvider { + const apiKey = env.RESEND_API_KEY?.trim(); + if (!apiKey) return new DryRunProvider(); + return new ResendProvider(apiKey, emailFrom(env), emailReplyTo(env)); +} diff --git a/backend/src/lib/match-score.ts b/backend/src/lib/match-score.ts new file mode 100644 index 0000000..eaa5e6b --- /dev/null +++ b/backend/src/lib/match-score.ts @@ -0,0 +1,81 @@ +// Deterministic tag-overlap match score. Backend port of +// frontend/src/lib/match.ts for the email send path (no LLM, no I/O). +// +// The stack term is included whenever either side lists a stack, exactly as +// on the site. One deliberate difference from the frontend: a job with no +// stack tags gets neutral half credit (20/40) on that term instead of 0/40. +// Scoring it 0 buried otherwise good matches (the Zepto, Microsoft and Zomato +// rows in the design snapshot); leaving the term out entirely would rank +// "unknown stack" above a real partial overlap. Every other input scores the +// same here as on the site. + +function norm(value: string): string { + return value.toLowerCase().replace(/[-.]/g, "_").trim(); +} + +const SENIORITY_ADJACENT: Record = { + intern: ["junior"], + junior: ["intern", "mid"], + mid: ["junior", "senior"], + senior: ["mid", "lead"], + lead: ["senior", "staff"], + staff: ["lead", "head"], + head: ["staff"], +}; + +export function isAdjacentSeniority(a: string, b: string): boolean { + return SENIORITY_ADJACENT[norm(a)]?.includes(norm(b)) ?? false; +} + +export interface MatchProfile { + roleFamily: string; + seniority: string; + remoteMode: string; + stack: string[]; +} + +export interface MatchJob { + roleFamily: string; + seniority: string; + remoteMode: string; + stack: string[]; +} + +/** 0-100. Stack 40, role family 30, seniority 20 (10 when adjacent), remote mode 10. */ +export function computeTagOverlapScore(job: MatchJob, profile: MatchProfile): number { + let score = 0; + let maxScore = 0; + + const profileStack = (profile.stack ?? []).map(norm); + const jobStack = (job.stack ?? []).map(norm); + + if (profileStack.length > 0 || jobStack.length > 0) { + maxScore += 40; + if (jobStack.length === 0) { + score += 20; + } else { + const overlap = jobStack.filter((s) => profileStack.includes(s)).length; + const denominator = Math.max(profileStack.length, jobStack.length); + score += (40 * overlap) / denominator; + } + } + + if (profile.roleFamily) { + maxScore += 30; + if (norm(job.roleFamily) === norm(profile.roleFamily)) score += 30; + } + + if (profile.seniority) { + maxScore += 20; + if (norm(job.seniority) === norm(profile.seniority)) score += 20; + else if (isAdjacentSeniority(profile.seniority, job.seniority)) score += 10; + } + + if (profile.remoteMode) { + maxScore += 10; + if (norm(job.remoteMode) === norm(profile.remoteMode)) score += 10; + } + + if (maxScore === 0) return 0; + return Math.round((score / maxScore) * 100); +} diff --git a/backend/src/lib/nudge-render.ts b/backend/src/lib/nudge-render.ts new file mode 100644 index 0000000..3009253 --- /dev/null +++ b/backend/src/lib/nudge-render.ts @@ -0,0 +1,244 @@ +// Plain HTML + text templates for the match nudge. React Email was not used: +// the backend tsconfig compiles only src/**/*.ts and has no React dependency, +// and the template is one table. Everything is escaped before interpolation. + +import type { PosterKind } from "./nudge-select.js"; + +export interface RenderJob { + id: number; + title: string; + company: string; + author: string; + posterKind: PosterKind; + posterLine: string; + levelLabel: string; + remoteLabel: string; + stack: string[]; + postedAt: Date; + bullets: string[]; + matchPercent: number; +} + +export interface RenderInput { + recipientName?: string | null; + jobs: RenderJob[]; + sendId: string; + campaignKey: string; + /** Frontend origin, e.g. https://skiptheboard.in */ + siteUrl: string; + /** Null (test sends) renders an inert footer instead of a working link. */ + unsubscribeUrl: string | null; + preferencesUrl: string; + /** Variant overrides. Templates accept {count} {companies} {name}. */ + subjectTemplate?: string | null; + intro?: string | null; + contactEmail?: string; +} + +export interface RenderedEmail { + subject: string; + preheader: string; + html: string; + text: string; +} + +export const DEFAULT_SUBJECT_TEMPLATE = "{count} jobs that match your resume: {companies}"; +export const DEFAULT_INTRO_TEMPLATE = + "These are the {count} posts on SkipTheBoard that best match your resume right now. Each one links to the original hiring post, so you can message the person who wrote it."; + +export const UTM_SOURCE = "nudge"; +export const TEST_SEND_UNSUBSCRIBE_NOTE = "unsubscribe disabled in test sends"; + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** "23 Sep" in UTC. Absolute dates stay true in an inbox; relative ones go stale. */ +export function formatPostedDate(date: Date): string { + return `${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`; +} + +export function fillTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (whole, key: string) => (key in vars ? vars[key] : whole)); +} + +export function companiesLine(jobs: RenderJob[], max = 3): string { + const seen: string[] = []; + for (const job of jobs) { + const name = job.company.trim(); + if (!name || name.toLowerCase() === "unknown") continue; + if (seen.some((s) => s.toLowerCase() === name.toLowerCase())) continue; + seen.push(name); + if (seen.length >= max) break; + } + return seen.join(", "); +} + +export function goUrl(siteUrl: string, sendId: string, jobId: number): string { + return `${siteUrl.replace(/\/$/, "")}/go/${encodeURIComponent(sendId)}/${jobId}`; +} + +export function siteLink(siteUrl: string, path: string, campaignKey: string, content?: string): string { + const url = new URL(path, siteUrl.replace(/\/$/, "") + "/"); + url.searchParams.set("utm_source", UTM_SOURCE); + url.searchParams.set("utm_medium", "email"); + url.searchParams.set("utm_campaign", campaignKey); + if (content) url.searchParams.set("utm_content", content); + return url.toString(); +} + +function templateVars(input: RenderInput): Record { + const companies = companiesLine(input.jobs); + return { + count: String(input.jobs.length), + companies: companies || "the people hiring", + name: input.recipientName?.trim() || "there", + }; +} + +export function renderSubject(input: RenderInput): string { + const template = input.subjectTemplate?.trim() || DEFAULT_SUBJECT_TEMPLATE; + return fillTemplate(template, templateVars(input)).replace(/\s+/g, " ").trim(); +} + +const STACK_LABELS: Record = { + nodejs: "Node.js", + nextjs: "Next.js", + typescript: "TypeScript", + python: "Python", + java: "Java", + sql: "SQL", + ai: "AI", + aws: "AWS", + langchain: "LangChain", + rag: "RAG", + react: "React", + llm: "LLM", + go: "Go", + rust: "Rust", + docker: "Docker", +}; + +export function stackLabel(stack: string[], max = 3): string { + return stack + .slice(0, max) + .map((s) => STACK_LABELS[s.toLowerCase()] ?? s) + .join(", "); +} + +export function renderNudgeEmail(input: RenderInput): RenderedEmail { + const vars = templateVars(input); + const subject = renderSubject(input); + const intro = fillTemplate(input.intro?.trim() || DEFAULT_INTRO_TEMPLATE, vars); + const preheader = `Your top ${input.jobs.length} matches, picked from posts by the people hiring.`; + const contact = input.contactEmail ?? "hello@skiptheboard.in"; + const feedUrl = siteLink(input.siteUrl, "/", input.campaignKey, "feed"); + const profileUrl = siteLink(input.siteUrl, "/profile", input.campaignKey, "profile"); + const greeting = vars.name === "there" ? "Hi there," : `Hi ${vars.name},`; + const unsubscribeHtml = input.unsubscribeUrl + ? `Unsubscribe in one click` + : `${TEST_SEND_UNSUBSCRIBE_NOTE}`; + const unsubscribeText = input.unsubscribeUrl ? `Unsubscribe in one click: ${input.unsubscribeUrl}` : TEST_SEND_UNSUBSCRIBE_NOTE; + + const jobsHtml = input.jobs + .map((job, index) => { + const meta = [job.levelLabel, job.remoteLabel, stackLabel(job.stack), `Posted ${formatPostedDate(job.postedAt)}`] + .filter(Boolean) + .map(escapeHtml) + .join(" · "); + const bullets = job.bullets.map((b) => `
  • ${escapeHtml(b)}
  • `).join(""); + const open = goUrl(input.siteUrl, input.sendId, job.id); + const dm = siteLink(input.siteUrl, "/", input.campaignKey, `dm-${job.id}`); + return ` + +
    + ${index + 1}. ${escapeHtml(job.title)} · ${escapeHtml(job.company)} + Match ${job.matchPercent}% +
    +
    + Posted by ${escapeHtml(job.author)}${job.posterLine ? ` · ${escapeHtml(job.posterLine)}` : ""} · ${escapeHtml(job.posterKind)} +
    +
    ${meta}
    + ${bullets ? `
      ${bullets}
    ` : ""} + +`; + }) + .join(""); + + const html = ` + + + + +${escapeHtml(subject)} + + +${escapeHtml(preheader)} + + +
    + + + + +${jobsHtml} + + +
    SkipTheBoard
    ${escapeHtml(greeting)}
    ${escapeHtml(intro)}
    + See the full ranked feed →
    + Scores come from your resume on your profile. Update it and next time's picks change. +
    + You get this because you signed in to SkipTheBoard and uploaded a resume. + Change frequency · + ${unsubscribeHtml}
    + SkipTheBoard · ${escapeHtml(contact)} · We never sell or share your email. +
    +
    + +`; + + const textJobs = input.jobs + .map((job, index) => { + const meta = [job.levelLabel, job.remoteLabel, stackLabel(job.stack), `Posted ${formatPostedDate(job.postedAt)}`] + .filter(Boolean) + .join(" · "); + const lines = [ + `${index + 1}. ${job.title} · ${job.company} (Match ${job.matchPercent}%)`, + ` Posted by ${job.author}${job.posterLine ? ` · ${job.posterLine}` : ""} · ${job.posterKind}`, + ` ${meta}`, + ...job.bullets.map((b) => ` • ${b}`), + ` Open the original post: ${goUrl(input.siteUrl, input.sendId, job.id)}`, + ]; + return lines.join("\n"); + }) + .join("\n\n"); + + const text = [ + greeting, + "", + intro, + "", + textJobs, + "", + `See the full ranked feed: ${feedUrl}`, + `Update your resume or preferences: ${profileUrl}`, + "", + "You get this because you signed in to SkipTheBoard and uploaded a resume.", + `Change frequency: ${input.preferencesUrl}`, + unsubscribeText, + `SkipTheBoard · ${contact} · We never sell or share your email.`, + ].join("\n"); + + return { subject, preheader, html, text }; +} diff --git a/backend/src/lib/nudge-select.ts b/backend/src/lib/nudge-select.ts new file mode 100644 index 0000000..79b1709 --- /dev/null +++ b/backend/src/lib/nudge-select.ts @@ -0,0 +1,293 @@ +// Pure selection and labelling for match-based email nudges. +// No Prisma, no Mongo, no LLM: everything here is unit-testable with fixtures. + +import { evaluateJobGates } from "./ingest-gates.js"; +import { isJobExpired } from "./job-expiry.js"; +import { computeTagOverlapScore, type MatchProfile } from "./match-score.js"; + +export interface NudgeJobInput { + id: number; + title: string; + company: string; + author: string; + authorTitle: string; + source: string; + sourceUrl: string; + roleFamily: string; + seniority: string; + remoteMode: string; + stack: string[]; + description: string[]; + rawText?: string | null; + postedAt: Date; + createdAt: Date; +} + +export type PosterKind = "Hiring manager" | "Engineer (referral)" | "Recruiter"; + +export interface RankedJob { + id: number; + title: string; + company: string; + author: string; + authorTitle: string; + sourceUrl: string; + source: string; + roleFamily: string; + /** Stored seniority after the senior-title/years override. */ + seniority: string; + storedSeniority: string; + seniorOverride: boolean; + levelLabel: string; + remoteMode: string; + remoteLabel: string; + stack: string[]; + posterKind: PosterKind; + posterLine: string; + bullets: string[]; + postedAt: Date; + createdAt: Date; + matchPercent: number; + isNew: boolean; +} + +// ─── Senior override ───────────────────────────────────────────────────────── + +const SENIOR_TITLE_RE = + /\bSDE[\s-]?(?:3|III|4|IV)\b|\bSE[\s-]?(?:3|III)\b|\bL[5-9]\b|\bsenior\b|\bsr\.?\s|\bsr\.?$|\bstaff\b|\blead\b|\bprincipal\b|\barchitect\b|\bmanager\b|\bhead\s+of\b|\bdirector\b/i; + +// A years figure counts only as an "N+" form or beside an experience cue in +// the same clause: "5+ years", "7-12 YOE", "minimum 6 yrs", "at least 5 years +// of experience". "a fintech with 12 years in market" does not count. +const YEARS_RE = + /(? 40) continue; + if (!cued && !match[2] && !match[4]) continue; + min = min === null ? years : Math.min(min, years); + } + } + return min; +} + +export function isSeniorByTitle(title: string): boolean { + return SENIOR_TITLE_RE.test(title); +} + +/** True when the title or bullets say this is a senior role. */ +export function isSeniorRole(job: Pick): boolean { + if (isSeniorByTitle(job.title)) return true; + const years = minimumYears([job.title, ...(job.description ?? [])].join("\n")); + return years !== null && years >= SENIOR_YEARS_MIN; +} + +const JUNIOR_LEVELS = new Set(["intern", "junior", "mid"]); + +/** Stored seniority, lifted to "senior" when the title or years say so. */ +export function effectiveSeniority(job: Pick): string { + const stored = (job.seniority ?? "").toLowerCase(); + if (JUNIOR_LEVELS.has(stored) && isSeniorRole(job)) return "senior"; + return stored; +} + +const LEVEL_LABELS: Record = { + intern: "Intern / Fresher", + junior: "SDE-1 level", + mid: "SDE-2 level", + senior: "Senior / SDE-3+", + lead: "Lead", + staff: "Staff", + head: "Head", +}; + +export function levelLabel(seniority: string): string { + return LEVEL_LABELS[seniority.toLowerCase()] ?? seniority; +} + +const REMOTE_LABELS: Record = { + in_office: "In-office", + remote: "Remote", + hybrid: "Hybrid", +}; + +export function remoteLabel(remoteMode: string): string { + return REMOTE_LABELS[remoteMode.toLowerCase()] ?? remoteMode; +} + +// ─── Poster labelling ──────────────────────────────────────────────────────── + +const RECRUITER_RE = /\btalent\b|\brecruit|\bhr\b|\bhuman resources\b|\bstaffing\b|\bsourcing\b|\bpeople (?:ops|operations|partner)\b|\bconsultant\b/i; +const HIRING_MANAGER_RE = + /\bmanager\b|\bhead\b|\blead\b|\bfounder\b|\bco-?founder\b|\bcto\b|\bceo\b|\bcoo\b|\bdirector\b|\bvp\b|\bvice president\b|\bbuilding\b|\bowner\b/i; +const ENGINEER_RE = /\bengineer|\bsde\b|\bdeveloper\b|\bprogrammer\b|\bswe\b/i; + +export function posterKind(authorTitle: string): PosterKind { + const headline = authorTitle ?? ""; + if (RECRUITER_RE.test(headline)) return "Recruiter"; + if (HIRING_MANAGER_RE.test(headline)) return "Hiring manager"; + if (ENGINEER_RE.test(headline)) return "Engineer (referral)"; + return "Recruiter"; +} + +const ROLE_WORD_RE = + /engineer|manager|head|lead|founder|cto|ceo|coo|director|recruit|talent|developer|sde|hiring|\bhr\b|architect|\bvp\b|president|scientist|analyst|product|designer|building|owner/i; + +export const POSTER_LINE_MAX = 60; + +/** First "|" segment of the headline, or the first segment with a role word. */ +export function posterLine(authorTitle: string): string { + const segments = (authorTitle ?? "") + .split("|") + .map((s) => s.trim()) + .filter(Boolean); + if (segments.length === 0) return ""; + let chosen = segments[0]; + if (!ROLE_WORD_RE.test(chosen)) { + const withRole = segments.find((s) => ROLE_WORD_RE.test(s)); + if (withRole) chosen = withRole; + } + if (chosen.length <= POSTER_LINE_MAX) return chosen; + return `${chosen.slice(0, POSTER_LINE_MAX - 1).trimEnd()}…`; +} + +// ─── Bullets ───────────────────────────────────────────────────────────────── + +const LOCATION_RE = + /bangalore|bengaluru|hyderabad|pune|delhi|ncr|gurgaon|gurugram|noida|mumbai|chennai|kolkata|india|remote|hybrid|on-?site|in-?office|wfh/i; +const BULLET_MAX = 160; + +/** Any "N years / yrs / YOE" phrase, with or without an experience cue. */ +export function mentionsYears(text: string): boolean { + return new RegExp(YEARS_RE.source, "i").test(text); +} + +/** First two bullets, preferring those that mention years or a location. */ +export function pickBullets(description: string[], count = 2): string[] { + const scored = (description ?? []) + .map((text, index) => ({ text: text.trim(), index })) + .filter((b) => b.text.length > 0) + .map((b) => { + let score = 0; + if (mentionsYears(b.text)) score += 2; + if (LOCATION_RE.test(b.text)) score += 1; + return { ...b, score }; + }); + return scored + .sort((a, b) => b.score - a.score || a.index - b.index) + .slice(0, count) + .sort((a, b) => a.index - b.index) + .map((b) => (b.text.length > BULLET_MAX ? `${b.text.slice(0, BULLET_MAX - 1).trimEnd()}…` : b.text)); +} + +// ─── Selection ─────────────────────────────────────────────────────────────── + +const POSTER_RANK: Record = { + "Hiring manager": 0, + "Engineer (referral)": 1, + Recruiter: 2, +}; + +export interface SelectOptions { + jobs: NudgeJobInput[]; + profile: MatchProfile; + /** Jobs already emailed to this user; never shown again. */ + excludeJobIds?: Iterable; + /** Jobs created after this instant are preferred ("new since your last nudge"). */ + sinceCreatedAt?: Date | null; + limit?: number; + now?: Date; + env?: NodeJS.ProcessEnv; +} + +export interface SelectResult { + picks: RankedJob[]; + /** Jobs that passed every filter before the limit was applied. */ + poolSize: number; + newCount: number; +} + +export const DEFAULT_NUDGE_JOB_COUNT = 5; + +/** Rank the board for one user. Returns an empty `picks` when nothing qualifies. */ +export function selectJobsForUser(options: SelectOptions): SelectResult { + const now = options.now ?? new Date(); + const limit = options.limit ?? DEFAULT_NUDGE_JOB_COUNT; + const exclude = new Set(options.excludeJobIds ?? []); + const since = options.sinceCreatedAt ?? null; + + const ranked: RankedJob[] = []; + for (const job of options.jobs) { + if (exclude.has(job.id)) continue; + if (isJobExpired(job.postedAt, now)) continue; + const gate = evaluateJobGates( + { + title: job.title, + company: job.company, + roleFamily: job.roleFamily, + authorTitle: job.authorTitle, + rawText: job.rawText ?? null, + }, + options.env, + ); + if (!gate.ok) continue; + + const seniority = effectiveSeniority(job); + const matchPercent = computeTagOverlapScore( + { roleFamily: job.roleFamily, seniority, remoteMode: job.remoteMode, stack: job.stack }, + options.profile, + ); + ranked.push({ + id: job.id, + title: gate.title, + company: gate.company, + author: job.author, + authorTitle: job.authorTitle, + source: job.source, + sourceUrl: job.sourceUrl, + roleFamily: job.roleFamily, + seniority, + storedSeniority: job.seniority, + seniorOverride: seniority !== (job.seniority ?? "").toLowerCase(), + levelLabel: levelLabel(seniority), + remoteMode: job.remoteMode, + remoteLabel: remoteLabel(job.remoteMode), + stack: job.stack ?? [], + posterKind: posterKind(job.authorTitle), + posterLine: posterLine(job.authorTitle), + bullets: pickBullets(job.description), + postedAt: job.postedAt, + createdAt: job.createdAt, + matchPercent, + isNew: since ? job.createdAt.getTime() > since.getTime() : true, + }); + } + + ranked.sort((a, b) => { + if (a.isNew !== b.isNew) return a.isNew ? -1 : 1; + if (a.matchPercent !== b.matchPercent) return b.matchPercent - a.matchPercent; + const poster = POSTER_RANK[a.posterKind] - POSTER_RANK[b.posterKind]; + if (poster !== 0) return poster; + const aKnown = a.company.toLowerCase() !== "unknown" ? 0 : 1; + const bKnown = b.company.toLowerCase() !== "unknown" ? 0 : 1; + if (aKnown !== bKnown) return aKnown - bKnown; + return b.postedAt.getTime() - a.postedAt.getTime(); + }); + + return { + picks: ranked.slice(0, Math.max(0, limit)), + poolSize: ranked.length, + newCount: ranked.filter((j) => j.isNew).length, + }; +} diff --git a/backend/src/lib/nudge-send.ts b/backend/src/lib/nudge-send.ts new file mode 100644 index 0000000..2815357 --- /dev/null +++ b/backend/src/lib/nudge-send.ts @@ -0,0 +1,674 @@ +// Campaign runner for match-based email nudges. +// +// Flow per recipient: preference check -> Clerk email -> Mongo profile -> +// selectJobsForUser (pure) -> renderNudgeEmail (pure) -> NudgeSend row +// (queued) -> provider batch (<=100) -> row updated to sent/dry_run/failed. +// The (campaignId, userId) unique index makes a re-run or retry safe. + +import { randomUUID } from "crypto"; +import { waitUntil } from "@vercel/functions"; +import type { Campaign, CampaignVariant, EmailPreference, Prisma } from "@prisma/client"; +import { fetchClerkUsers, type ClerkUserInfo } from "./clerk-users.js"; +import { createEmailProvider, EMAIL_BATCH_SIZE, listUnsubscribeHeaders, type EmailProvider, type OutgoingEmail } from "./email.js"; +import { newUnsubscribeToken, signUnsubscribeToken } from "./email-tokens.js"; +import { activeJobWhere } from "./job-expiry.js"; +import { getProfilesCollection } from "./mongo.js"; +import { renderNudgeEmail, siteLink, type RenderedEmail } from "./nudge-render.js"; +import { DEFAULT_NUDGE_JOB_COUNT, selectJobsForUser, type NudgeJobInput, type RankedJob } from "./nudge-select.js"; +import { assignVariant, bucketFor } from "./nudge-variants.js"; +import { prisma } from "./prisma.js"; +import { backendUrl, contactEmail, frontendUrl } from "./site-urls.js"; + +export const SCHEDULE_PAUSED_KEY = "nudges.schedule_paused"; +export const DEFAULT_NUDGE_BUDGET_MS = 240_000; +export const RECIPIENT_PAGE_SIZE = EMAIL_BATCH_SIZE; + +/** Send statuses that count as an email the user has already received. */ +export const DELIVERED_LIKE_STATUSES = ["queued", "dry_run", "sent", "delivered", "bounced", "complained"] as const; + +// ─── Profiles (Mongo) ──────────────────────────────────────────────────────── + +export interface NudgeProfile { + userId: string; + name: string | null; + filterSummary: { roleFamily: string; seniority: string; remoteMode: string; stack: string[] }; + createdAt: Date | null; +} + +interface ProfileDocLite { + _id: string; + contact?: { name?: string }; + filterSummary?: { roleFamily?: string; seniority?: string; remoteMode?: string; stack?: string[] }; + createdAt?: Date; +} + +function toNudgeProfile(doc: ProfileDocLite): NudgeProfile | null { + const fs = doc.filterSummary; + if (!fs) return null; + return { + userId: String(doc._id), + name: doc.contact?.name?.trim() || null, + filterSummary: { + roleFamily: fs.roleFamily ?? "", + seniority: fs.seniority ?? "", + remoteMode: fs.remoteMode ?? "", + stack: Array.isArray(fs.stack) ? fs.stack : [], + }, + createdAt: doc.createdAt ?? null, + }; +} + +/** Every user with a resume profile, in a stable order. */ +export async function listEligibleUserIds(): Promise { + const collection = await getProfilesCollection(); + if (!collection) return []; + const docs = await collection + .find({ filterSummary: { $exists: true } }, { projection: { _id: 1 } }) + .sort({ _id: 1 }) + .toArray(); + return docs.map((d) => String(d._id)); +} + +export async function loadProfiles(userIds: string[]): Promise> { + const result = new Map(); + if (userIds.length === 0) return result; + const collection = await getProfilesCollection(); + if (!collection) return result; + const docs = (await collection + .find({ _id: { $in: userIds } } as never, { projection: { _id: 1, contact: 1, filterSummary: 1, createdAt: 1 } }) + .toArray()) as unknown as ProfileDocLite[]; + for (const doc of docs) { + const profile = toNudgeProfile(doc); + if (profile) result.set(profile.userId, profile); + } + return result; +} + +// ─── Preferences ───────────────────────────────────────────────────────────── + +/** Default row for a user who never touched the toggle: subscribed, weekly. */ +export async function ensurePreferences(userIds: string[]): Promise> { + const result = new Map(); + if (userIds.length === 0) return result; + const existing = await prisma.emailPreference.findMany({ where: { userId: { in: userIds } } }); + for (const pref of existing) result.set(pref.userId, pref); + const missing = userIds.filter((id) => !result.has(id)); + if (missing.length > 0) { + await prisma.emailPreference.createMany({ + data: missing.map((userId) => ({ userId, unsubscribeToken: newUnsubscribeToken() })), + skipDuplicates: true, + }); + const created = await prisma.emailPreference.findMany({ where: { userId: { in: missing } } }); + for (const pref of created) result.set(pref.userId, pref); + } + return result; +} + +export async function getOrCreatePreference(userId: string): Promise { + const prefs = await ensurePreferences([userId]); + const pref = prefs.get(userId); + if (!pref) throw new Error(`preference row missing for ${userId}`); + return pref; +} + +/** Should this campaign email this user right now? Pure. */ +export function isRecipientActive( + pref: Pick, + campaignKind: Campaign["kind"], + now: Date, +): boolean { + if (!pref.subscribed) return false; + if (pref.pausedUntil && pref.pausedUntil.getTime() > now.getTime()) return false; + if (campaignKind === "daily" && pref.frequency !== "daily") return false; + return true; +} + +// ─── URLs ──────────────────────────────────────────────────────────────────── + +export function unsubscribePageUrl(pref: Pick, env = process.env): string { + return `${frontendUrl(env)}/unsubscribe?t=${encodeURIComponent(signUnsubscribeToken(pref.unsubscribeToken, env))}`; +} + +/** Endpoint mail clients POST to for RFC 8058 one-click unsubscribe. */ +export function unsubscribeApiUrl(pref: Pick, env = process.env): string { + return `${backendUrl(env)}/api/email/unsubscribe?t=${encodeURIComponent(signUnsubscribeToken(pref.unsubscribeToken, env))}`; +} + +// ─── Job pool ──────────────────────────────────────────────────────────────── + +export async function loadJobPool(now = new Date()): Promise { + const jobs = await prisma.job.findMany({ + where: activeJobWhere(now), + select: { + id: true, + title: true, + company: true, + author: true, + authorTitle: true, + source: true, + sourceUrl: true, + roleFamily: true, + seniority: true, + remoteMode: true, + stack: true, + description: true, + rawText: true, + postedAt: true, + createdAt: true, + }, + }); + return jobs.map((j) => ({ ...j, stack: j.stack as string[] })); +} + +/** Jobs already emailed per user, and when they last got a nudge. */ +export async function loadSendHistory( + userIds: string[], +): Promise; lastSentAt: Date | null }>> { + const result = new Map; lastSentAt: Date | null }>(); + if (userIds.length === 0) return result; + const rows = await prisma.nudgeSend.findMany({ + where: { userId: { in: userIds }, status: { in: [...DELIVERED_LIKE_STATUSES] } }, + select: { userId: true, jobIds: true, sentAt: true, createdAt: true }, + }); + for (const row of rows) { + const entry = result.get(row.userId) ?? { jobIds: new Set(), lastSentAt: null }; + for (const id of row.jobIds) entry.jobIds.add(id); + const at = row.sentAt ?? row.createdAt; + if (!entry.lastSentAt || at.getTime() > entry.lastSentAt.getTime()) entry.lastSentAt = at; + result.set(row.userId, entry); + } + return result; +} + +// ─── Build one nudge ───────────────────────────────────────────────────────── + +export interface BuiltNudge { + rendered: RenderedEmail; + picks: RankedJob[]; + poolSize: number; + email: OutgoingEmail; +} + +export function buildNudge(options: { + sendId: string; + campaign: Pick; + variant: Pick | null; + profile: NudgeProfile; + recipient: ClerkUserInfo & { email: string }; + pref: Pick; + jobs: NudgeJobInput[]; + excludeJobIds: Iterable; + sinceCreatedAt: Date | null; + now: Date; + env?: NodeJS.ProcessEnv; + /** Render with an inert unsubscribe footer and no List-Unsubscribe headers. */ + testSend?: boolean; +}): BuiltNudge | null { + const env = options.env ?? process.env; + const limit = options.variant?.jobCount ?? options.campaign.jobCount ?? DEFAULT_NUDGE_JOB_COUNT; + const selection = selectJobsForUser({ + jobs: options.jobs, + profile: options.profile.filterSummary, + excludeJobIds: options.excludeJobIds, + sinceCreatedAt: options.sinceCreatedAt, + limit, + now: options.now, + env, + }); + if (selection.picks.length === 0) return null; + + const site = frontendUrl(env); + const unsubscribeUrl = options.testSend ? null : unsubscribePageUrl(options.pref, env); + const rendered = renderNudgeEmail({ + recipientName: options.recipient.firstName ?? options.profile.name?.split(/\s+/)[0] ?? null, + jobs: selection.picks, + sendId: options.sendId, + campaignKey: options.campaign.key, + siteUrl: site, + unsubscribeUrl, + preferencesUrl: siteLink(site, "/profile", options.campaign.key, "preferences") + "#email", + subjectTemplate: options.variant?.subject ?? null, + intro: options.variant?.intro ?? null, + contactEmail: contactEmail(env), + }); + + return { + rendered, + picks: selection.picks, + poolSize: selection.poolSize, + email: { + to: options.recipient.email, + subject: rendered.subject, + html: rendered.html, + text: rendered.text, + ...(options.testSend ? {} : { headers: listUnsubscribeHeaders(unsubscribeApiUrl(options.pref, env)) }), + tags: [{ name: "campaign", value: options.campaign.key.replace(/[^a-zA-Z0-9_-]/g, "_") }], + }, + }; +} + +// ─── Schedule ──────────────────────────────────────────────────────────────── + +/** Paused until an admin explicitly resumes: only a stored "false" lets the cron send. */ +export async function isSchedulePaused(): Promise { + const row = await prisma.appSetting.findUnique({ where: { key: SCHEDULE_PAUSED_KEY } }); + return row?.value !== "false"; +} + +export async function setSchedulePaused(paused: boolean): Promise { + await prisma.appSetting.upsert({ + where: { key: SCHEDULE_PAUSED_KEY }, + update: { value: String(paused) }, + create: { key: SCHEDULE_PAUSED_KEY, value: String(paused) }, + }); +} + +/** ISO week key, e.g. 2026-W40 (UTC). */ +export function isoWeekKey(date: Date): string { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const day = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - day); + const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1); + const week = Math.ceil(((d.getTime() - yearStart) / 86_400_000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +export const NUDGE_CRON_UTC = { hour: 2, minute: 30 } as const; + +/** The next 02:30 UTC cron firing strictly after `now`. Pure. */ +export function nextNudgeTick(now: Date): Date { + const tick = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), NUDGE_CRON_UTC.hour, NUDGE_CRON_UTC.minute)); + if (tick.getTime() <= now.getTime()) tick.setUTCDate(tick.getUTCDate() + 1); + return tick; +} + +/** The campaign the daily cron should run today: weekly on Mondays, daily otherwise. Pure. */ +export function scheduledCampaignFor(now: Date): { key: string; name: string; kind: "weekly" | "daily" } { + if (now.getUTCDay() === 1) { + const week = isoWeekKey(now); + return { key: `weekly-${week}`, name: `Weekly matches ${week}`, kind: "weekly" }; + } + const day = now.toISOString().slice(0, 10); + return { key: `daily-${day}`, name: `Daily matches ${day}`, kind: "daily" }; +} + +/** + * Create (or fetch) today's scheduled campaign. Returns null when the + * schedule is paused, or on a non-Monday when nobody chose daily emails. + */ +export async function ensureScheduledCampaign(now = new Date()): Promise { + if (await isSchedulePaused()) return null; + const spec = scheduledCampaignFor(now); + if (spec.kind === "daily") { + const dailyCount = await prisma.emailPreference.count({ where: { frequency: "daily", subscribed: true } }); + if (dailyCount === 0) return null; + } + const existing = await prisma.campaign.findUnique({ where: { key: spec.key } }); + if (existing) return existing; + return prisma.campaign.create({ + data: { key: spec.key, name: spec.name, kind: spec.kind, createdBy: "cron", jobCount: DEFAULT_NUDGE_JOB_COUNT }, + }); +} + +export async function createManualCampaign(createdBy: string, now = new Date()): Promise { + const stamp = now.toISOString().replace(/[:.]/g, "-"); + return prisma.campaign.create({ + data: { + key: `manual-${stamp}`, + name: `Manual send ${now.toISOString().slice(0, 16).replace("T", " ")} UTC`, + kind: "manual", + createdBy, + jobCount: DEFAULT_NUDGE_JOB_COUNT, + }, + }); +} + +// ─── Run ───────────────────────────────────────────────────────────────────── + +export interface RunCampaignOptions { + campaignId: number; + budgetMs?: number; + now?: Date; + env?: NodeJS.ProcessEnv; + provider?: EmailProvider; + pageSize?: number; +} + +export interface RunCampaignResult { + campaignId: number; + campaignKey: string; + dryRun: boolean; + candidates: number; + processed: number; + sent: number; + skipped: number; + holdout: number; + failed: number; + inactive: number; + /** Recipients another run of this campaign claimed first; nothing was sent to them here. */ + alreadyClaimed: number; + remaining: number; + exhaustedBudget: boolean; + completed: boolean; +} + +/** Insert this run's rows and return the ids that landed; a row that lost the (campaignId, userId) index to a concurrent run is absent. */ +async function claimSendRows(rows: Prisma.NudgeSendCreateManyInput[]): Promise> { + if (rows.length === 0) return new Set(); + await prisma.nudgeSend.createMany({ data: rows, skipDuplicates: true }); + const inserted = await prisma.nudgeSend.findMany({ + where: { id: { in: rows.map((r) => r.id) } }, + select: { id: true }, + }); + return new Set(inserted.map((r) => r.id)); +} + +export async function runCampaign(options: RunCampaignOptions): Promise { + const env = options.env ?? process.env; + const now = options.now ?? new Date(); + const provider = options.provider ?? createEmailProvider(env); + const budgetMs = options.budgetMs ?? Number(env.NUDGE_BUDGET_MS ?? DEFAULT_NUDGE_BUDGET_MS); + const pageSize = Math.min(options.pageSize ?? RECIPIENT_PAGE_SIZE, EMAIL_BATCH_SIZE); + const startedAt = Date.now(); + + const campaign = await prisma.campaign.findUnique({ + where: { id: options.campaignId }, + include: { variants: true }, + }); + if (!campaign) throw new Error(`campaign ${options.campaignId} not found`); + + const result: RunCampaignResult = { + campaignId: campaign.id, + campaignKey: campaign.key, + dryRun: provider.dryRun, + candidates: 0, + processed: 0, + sent: 0, + skipped: 0, + holdout: 0, + failed: 0, + inactive: 0, + alreadyClaimed: 0, + remaining: 0, + exhaustedBudget: false, + completed: campaign.status === "completed", + }; + if (campaign.status === "completed") return result; + + if (campaign.status !== "running") { + await prisma.campaign.update({ where: { id: campaign.id }, data: { status: "running", startedAt: now } }); + } + + const candidateIds = await listEligibleUserIds(); + const already = new Set( + (await prisma.nudgeSend.findMany({ where: { campaignId: campaign.id }, select: { userId: true } })).map((r) => r.userId), + ); + const todo = candidateIds.filter((id) => !already.has(id)); + result.candidates = todo.length; + + const variants = [...campaign.variants].sort((a, b) => a.id - b.id); + const jobs = await loadJobPool(now); + + let index = 0; + while (index < todo.length) { + const page = todo.slice(index, index + pageSize); + index += page.length; + + const prefs = await ensurePreferences(page); + const activeIds = page.filter((id) => { + const pref = prefs.get(id); + return pref ? isRecipientActive(pref, campaign.kind, now) : false; + }); + result.inactive += page.length - activeIds.length; + + const [clerkUsers, profiles, history] = await Promise.all([ + fetchClerkUsers(activeIds, env), + loadProfiles(activeIds), + loadSendHistory(activeIds), + ]); + + const rows: Prisma.NudgeSendCreateManyInput[] = []; + const outgoing = new Map(); + + for (const userId of activeIds) { + const pref = prefs.get(userId)!; + const profile = profiles.get(userId); + const clerk = clerkUsers.get(userId); + const variant = variants.length > 0 ? assignVariant(variants, bucketFor(campaign.id, userId)) : null; + const base = { + id: randomUUID(), + campaignId: campaign.id, + variantId: variant?.id ?? null, + userId, + email: clerk?.email ?? pref.email ?? "", + jobIds: [] as number[], + }; + + if (variant?.isHoldout) { + rows.push({ ...base, status: "holdout" }); + continue; + } + if (!clerk?.email) { + rows.push({ ...base, status: "failed", error: "no Clerk email" }); + continue; + } + if (!profile) { + rows.push({ ...base, status: "failed", error: "no resume profile" }); + continue; + } + + const past = history.get(userId); + const built = buildNudge({ + sendId: base.id, + campaign, + variant, + profile, + recipient: { ...clerk, email: clerk.email }, + pref, + jobs, + excludeJobIds: past?.jobIds ?? [], + sinceCreatedAt: past?.lastSentAt ?? null, + now, + env, + }); + if (!built) { + rows.push({ ...base, status: "skipped" }); + continue; + } + rows.push({ ...base, status: "queued", jobIds: built.picks.map((j) => j.id), subject: built.rendered.subject }); + outgoing.set(base.id, built.email); + } + + const claimed = await claimSendRows(rows); + const toSend: { id: string; userId: string; email: OutgoingEmail }[] = []; + for (const row of rows) { + if (!claimed.has(row.id)) { + result.alreadyClaimed += 1; + } else if (row.status === "holdout") { + result.holdout += 1; + } else if (row.status === "failed") { + result.failed += 1; + } else if (row.status === "skipped") { + result.skipped += 1; + } else { + toSend.push({ id: row.id, userId: row.userId, email: outgoing.get(row.id)! }); + } + } + + if (toSend.length > 0) { + const batch = await provider.sendBatch(toSend.map((o) => o.email)); + const sentAt = new Date(); + await Promise.all( + toSend.map(async (o, i) => { + if (!batch.ok) { + result.failed += 1; + await prisma.nudgeSend.update({ + where: { id: o.id }, + data: { status: "failed", error: (batch.error ?? "provider error").slice(0, 500) }, + }); + return; + } + result.sent += 1; + await prisma.nudgeSend.update({ + where: { id: o.id }, + data: { + status: batch.dryRun ? "dry_run" : "sent", + providerMessageId: batch.ids[i] ?? null, + sentAt, + }, + }); + await prisma.emailPreference.update({ + where: { userId: o.userId }, + data: { lastSentAt: sentAt, email: o.email.to }, + }); + }), + ); + } + + result.processed += page.length; + + if (index < todo.length && Date.now() - startedAt >= budgetMs) { + result.exhaustedBudget = true; + break; + } + } + + result.remaining = todo.length - result.processed; + if (result.remaining === 0) { + await prisma.campaign.update({ where: { id: campaign.id }, data: { status: "completed", completedAt: new Date() } }); + result.completed = true; + } + return result; +} + +/** Fire a self-call so a long campaign continues in a fresh invocation. */ +export function continueCampaignLater(campaignId: number, env: NodeJS.ProcessEnv = process.env): void { + const secret = env.CRON_SECRET; + if (!secret) { + console.error("[nudges] CRON_SECRET not set; cannot continue campaign", campaignId); + return; + } + const url = `${backendUrl(env)}/api/cron/nudges?campaign=${campaignId}`; + waitUntil( + fetch(url, { method: "POST", headers: { Authorization: `Bearer ${secret}` } }) + .then(async (res) => { + if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); + }) + .catch((err) => { + console.error(`[nudges] continue failed: ${err instanceof Error ? err.message : err}`); + }), + ); +} + +// ─── Preview and test ──────────────────────────────────────────────────────── + +export interface PreviewResult { + userId: string; + email: string | null; + name: string | null; + campaign: { id: number | null; key: string; name: string }; + variant: { id: number; key: string; name: string; isHoldout: boolean } | null; + rendered: RenderedEmail | null; + picks: RankedJob[]; + poolSize: number; + reason: string | null; +} + +export async function previewNudgeForUser(options: { + userId: string; + campaignId?: number | null; + variantId?: number | null; + now?: Date; + env?: NodeJS.ProcessEnv; + testSend?: boolean; +}): Promise { + const env = options.env ?? process.env; + const now = options.now ?? new Date(); + + const campaign = options.campaignId + ? await prisma.campaign.findUnique({ where: { id: options.campaignId }, include: { variants: true } }) + : null; + const campaignSpec = campaign + ? { id: campaign.id, key: campaign.key, name: campaign.name, jobCount: campaign.jobCount } + : { id: null, key: `preview-${scheduledCampaignFor(now).key}`, name: "Preview", jobCount: DEFAULT_NUDGE_JOB_COUNT }; + let variant: CampaignVariant | null = null; + if (campaign && campaign.variants.length > 0) { + variant = + (options.variantId ? campaign.variants.find((v) => v.id === options.variantId) : null) ?? + assignVariant([...campaign.variants].sort((a, b) => a.id - b.id), bucketFor(campaign.id, options.userId)); + } + + const [pref, clerkUsers, profiles, history, jobs] = await Promise.all([ + getOrCreatePreference(options.userId), + fetchClerkUsers([options.userId], env), + loadProfiles([options.userId]), + loadSendHistory([options.userId]), + loadJobPool(now), + ]); + const clerk = clerkUsers.get(options.userId) ?? { userId: options.userId, email: null, firstName: null }; + const profile = profiles.get(options.userId); + + const base: PreviewResult = { + userId: options.userId, + email: clerk.email, + name: clerk.firstName ?? profile?.name ?? null, + campaign: { id: campaignSpec.id, key: campaignSpec.key, name: campaignSpec.name }, + variant: variant ? { id: variant.id, key: variant.key, name: variant.name, isHoldout: variant.isHoldout } : null, + rendered: null, + picks: [], + poolSize: 0, + reason: null, + }; + if (!profile) return { ...base, reason: "no resume profile" }; + if (variant?.isHoldout) return { ...base, reason: "holdout arm: this user would receive nothing" }; + + const past = history.get(options.userId); + const built = buildNudge({ + sendId: "preview", + campaign: campaignSpec, + variant, + profile, + recipient: { ...clerk, email: clerk.email ?? "preview@example.com" }, + pref, + jobs, + excludeJobIds: past?.jobIds ?? [], + sinceCreatedAt: past?.lastSentAt ?? null, + now, + env, + testSend: options.testSend, + }); + if (!built) return { ...base, reason: "no qualifying jobs: this user would be skipped" }; + return { ...base, rendered: built.rendered, picks: built.picks, poolSize: built.poolSize }; +} + +export async function sendTestNudge(options: { + to: string; + userId: string; + campaignId?: number | null; + variantId?: number | null; + now?: Date; + env?: NodeJS.ProcessEnv; + provider?: EmailProvider; +}): Promise<{ ok: boolean; dryRun: boolean; providerMessageId: string | null; error?: string; subject?: string }> { + const env = options.env ?? process.env; + const provider = options.provider ?? createEmailProvider(env); + const preview = await previewNudgeForUser({ ...options, env, testSend: true }); + if (!preview.rendered) { + return { ok: false, dryRun: provider.dryRun, providerMessageId: null, error: preview.reason ?? "nothing to send" }; + } + const email: OutgoingEmail = { + to: options.to, + subject: `[TEST] ${preview.rendered.subject}`, + html: preview.rendered.html, + text: preview.rendered.text, + tags: [{ name: "campaign", value: "test" }], + }; + const batch = await provider.sendBatch([email]); + return { + ok: batch.ok, + dryRun: batch.dryRun, + providerMessageId: batch.ids[0] ?? null, + error: batch.error, + subject: email.subject, + }; +} diff --git a/backend/src/lib/nudge-stats.ts b/backend/src/lib/nudge-stats.ts new file mode 100644 index 0000000..63c3c49 --- /dev/null +++ b/backend/src/lib/nudge-stats.ts @@ -0,0 +1,319 @@ +// Read-only aggregates for the admin dashboard. Raw counts and simple rates; +// no significance claims. + +import { MS_PER_DAY, activeJobWhere } from "./job-expiry.js"; +import { getProfilesCollection } from "./mongo.js"; +import { listEligibleUserIds } from "./nudge-send.js"; +import { prisma } from "./prisma.js"; + +// ─── Subscribers ───────────────────────────────────────────────────────────── + +export interface SubscriberStats { + eligible: number; + subscribed: number; + paused: number; + unsubscribed: number; + newThisWeek: number; + byFrequency: { weekly: number; daily: number }; + unsubscribeReasons: Record; +} + +export async function subscriberStats(now = new Date()): Promise { + const collection = await getProfilesCollection(); + const weekAgo = new Date(now.getTime() - 7 * MS_PER_DAY); + const [eligibleIds, newThisWeek] = await Promise.all([ + listEligibleUserIds(), + collection ? collection.countDocuments({ filterSummary: { $exists: true }, createdAt: { $gte: weekAgo } }) : 0, + ]); + const eligible = eligibleIds.length; + + const prefs = + eligibleIds.length > 0 + ? await prisma.emailPreference.findMany({ + where: { userId: { in: eligibleIds } }, + select: { subscribed: true, frequency: true, pausedUntil: true, unsubscribeReason: true }, + }) + : []; + let unsubscribed = 0; + let paused = 0; + let daily = 0; + const reasons: Record = {}; + for (const p of prefs) { + if (!p.subscribed) { + unsubscribed += 1; + const reason = p.unsubscribeReason ?? "unknown"; + reasons[reason] = (reasons[reason] ?? 0) + 1; + continue; + } + if (p.pausedUntil && p.pausedUntil.getTime() > now.getTime()) { + paused += 1; + continue; + } + if (p.frequency === "daily") daily += 1; + } + // Users without a row default to subscribed weekly (see privacy page). + const subscribed = Math.max(0, eligible - unsubscribed - paused); + return { + eligible, + subscribed, + paused, + unsubscribed, + newThisWeek, + byFrequency: { weekly: Math.max(0, subscribed - daily), daily }, + unsubscribeReasons: reasons, + }; +} + +// ─── Email performance ─────────────────────────────────────────────────────── + +export interface SendCounts { + recipients: number; + sent: number; + dryRun: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + complained: number; + unsubscribed: number; + failed: number; + skipped: number; + holdout: number; + queued: number; + openRate: number | null; + clickRate: number | null; +} + +function emptyCounts(): SendCounts { + return { + recipients: 0, + sent: 0, + dryRun: 0, + delivered: 0, + opened: 0, + clicked: 0, + bounced: 0, + complained: 0, + unsubscribed: 0, + failed: 0, + skipped: 0, + holdout: 0, + queued: 0, + openRate: null, + clickRate: null, + }; +} + +type SendLite = { + status: string; + variantId: number | null; + deliveredAt: Date | null; + openedAt: Date | null; + clickedAt: Date | null; + bouncedAt: Date | null; + complainedAt: Date | null; + unsubscribedAt: Date | null; +}; + +/** Pure: fold send rows into counts. Open and click rates are per sent email. */ +export function foldSendCounts(rows: SendLite[]): SendCounts { + const c = emptyCounts(); + for (const r of rows) { + switch (r.status) { + case "holdout": + c.holdout += 1; + continue; + case "skipped": + c.skipped += 1; + continue; + case "failed": + c.failed += 1; + c.recipients += 1; + continue; + case "queued": + c.queued += 1; + c.recipients += 1; + continue; + case "dry_run": + c.dryRun += 1; + c.recipients += 1; + continue; + default: + c.recipients += 1; + c.sent += 1; + } + if (r.deliveredAt) c.delivered += 1; + if (r.openedAt) c.opened += 1; + if (r.clickedAt) c.clicked += 1; + if (r.bouncedAt) c.bounced += 1; + if (r.complainedAt) c.complained += 1; + if (r.unsubscribedAt) c.unsubscribed += 1; + } + if (c.sent > 0) { + c.openRate = Math.round((c.opened / c.sent) * 1000) / 10; + c.clickRate = Math.round((c.clicked / c.sent) * 1000) / 10; + } + return c; +} + +export interface CampaignStats { + id: number; + key: string; + name: string; + kind: string; + status: string; + jobCount: number; + createdAt: string; + startedAt: string | null; + completedAt: string | null; + totals: SendCounts; + variants: { + id: number; + key: string; + name: string; + weight: number; + isHoldout: boolean; + subject: string | null; + intro: string | null; + jobCount: number | null; + counts: SendCounts; + }[]; +} + +export async function campaignStats(campaignId?: number): Promise { + const campaigns = await prisma.campaign.findMany({ + where: campaignId ? { id: campaignId } : undefined, + include: { + variants: { orderBy: { id: "asc" } }, + sends: { + select: { + status: true, + variantId: true, + deliveredAt: true, + openedAt: true, + clickedAt: true, + bouncedAt: true, + complainedAt: true, + unsubscribedAt: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + return campaigns.map((c) => ({ + id: c.id, + key: c.key, + name: c.name, + kind: c.kind, + status: c.status, + jobCount: c.jobCount, + createdAt: c.createdAt.toISOString(), + startedAt: c.startedAt?.toISOString() ?? null, + completedAt: c.completedAt?.toISOString() ?? null, + totals: foldSendCounts(c.sends), + variants: c.variants.map((v) => ({ + id: v.id, + key: v.key, + name: v.name, + weight: v.weight, + isHoldout: v.isHoldout, + subject: v.subject, + intro: v.intro, + jobCount: v.jobCount, + counts: foldSendCounts(c.sends.filter((s) => s.variantId === v.id)), + })), + })); +} + +export interface TopClickedJob { + jobId: number; + clicks: number; + title: string | null; + company: string | null; +} + +export async function topClickedJobs(limit = 10, campaignId?: number): Promise { + const groups = await prisma.nudgeClick.groupBy({ + by: ["jobId"], + where: { jobId: { not: null }, ...(campaignId ? { send: { campaignId } } : {}) }, + _count: { _all: true }, + orderBy: { _count: { jobId: "desc" } }, + take: limit, + }); + const ids = groups.map((g) => g.jobId).filter((id): id is number => id !== null); + const jobs = ids.length + ? await prisma.job.findMany({ where: { id: { in: ids } }, select: { id: true, title: true, company: true } }) + : []; + const byId = new Map(jobs.map((j) => [j.id, j])); + return groups + .filter((g): g is typeof g & { jobId: number } => g.jobId !== null) + .map((g) => ({ + jobId: g.jobId, + clicks: g._count._all, + title: byId.get(g.jobId)?.title ?? null, + company: byId.get(g.jobId)?.company ?? null, + })); +} + +// ─── Jobs and site ─────────────────────────────────────────────────────────── + +export interface JobStats { + liveJobs: number; + liveByLevel: Record; + addedPerDay: { day: string; total: number; byLevel: Record; bySource: Record }[]; + perRecruiter: { recruiterId: number; name: string; liveJobs: number; active: boolean; lastScrapedAt: string | null }[]; + jobClicks: TopClickedJob[]; + totalEmailClicks: number; +} + +export async function jobStats(now = new Date(), days = 14): Promise { + const since = new Date(now.getTime() - days * MS_PER_DAY); + const [live, recent, perRecruiterGroups, recruiters, clicks, totalEmailClicks] = await Promise.all([ + prisma.job.findMany({ where: activeJobWhere(now), select: { seniority: true } }), + prisma.job.findMany({ + where: { createdAt: { gte: since } }, + select: { createdAt: true, seniority: true, source: true }, + }), + prisma.job.groupBy({ by: ["recruiterId"], where: activeJobWhere(now), _count: { _all: true } }), + prisma.recruiter.findMany({ select: { id: true, name: true, active: true, lastScrapedAt: true } }), + topClickedJobs(10), + prisma.nudgeClick.count({ where: { jobId: { not: null } } }), + ]); + + const liveByLevel: Record = {}; + for (const j of live) liveByLevel[j.seniority] = (liveByLevel[j.seniority] ?? 0) + 1; + + const perDay = new Map; bySource: Record }>(); + for (let i = days - 1; i >= 0; i -= 1) { + const day = new Date(now.getTime() - i * MS_PER_DAY).toISOString().slice(0, 10); + perDay.set(day, { total: 0, byLevel: {}, bySource: {} }); + } + for (const j of recent) { + const day = j.createdAt.toISOString().slice(0, 10); + const entry = perDay.get(day); + if (!entry) continue; + entry.total += 1; + entry.byLevel[j.seniority] = (entry.byLevel[j.seniority] ?? 0) + 1; + entry.bySource[j.source] = (entry.bySource[j.source] ?? 0) + 1; + } + + const countByRecruiter = new Map(perRecruiterGroups.map((g) => [g.recruiterId, g._count._all])); + const perRecruiter = recruiters + .map((r) => ({ + recruiterId: r.id, + name: r.name, + active: r.active, + liveJobs: countByRecruiter.get(r.id) ?? 0, + lastScrapedAt: r.lastScrapedAt?.toISOString() ?? null, + })) + .sort((a, b) => b.liveJobs - a.liveJobs || a.name.localeCompare(b.name)); + + return { + liveJobs: live.length, + liveByLevel, + addedPerDay: [...perDay.entries()].map(([day, v]) => ({ day, ...v })), + perRecruiter, + jobClicks: clicks, + totalEmailClicks, + }; +} diff --git a/backend/src/lib/nudge-variants.ts b/backend/src/lib/nudge-variants.ts new file mode 100644 index 0000000..609b23e --- /dev/null +++ b/backend/src/lib/nudge-variants.ts @@ -0,0 +1,47 @@ +// Deterministic experiment assignment. The same user always lands in the +// same arm of the same campaign, so a re-run never flips anyone. + +import { createHash } from "crypto"; + +export interface VariantArm { + key: string; + weight: number; + isHoldout: boolean; +} + +/** Bucket in [0, 100) with two decimals of resolution, derived from campaign + user. */ +export function bucketFor(campaignId: number, userId: string): number { + const digest = createHash("sha256").update(`${campaignId}:${userId}`).digest(); + const value = digest.readUInt32BE(0); + return (value % 10_000) / 100; +} + +/** Null when weights are valid; otherwise a human-readable reason. */ +export function validateVariants(variants: VariantArm[]): string | null { + if (variants.length === 0) return "at least one variant is required"; + const keys = new Set(); + let total = 0; + for (const v of variants) { + if (!v.key || !v.key.trim()) return "every variant needs a key"; + if (keys.has(v.key)) return `duplicate variant key "${v.key}"`; + keys.add(v.key); + if (!Number.isInteger(v.weight) || v.weight < 0 || v.weight > 100) { + return `variant "${v.key}" weight must be an integer between 0 and 100`; + } + total += v.weight; + } + if (total !== 100) return `variant weights must sum to 100 (got ${total})`; + if (variants.every((v) => v.isHoldout)) return "at least one variant must send email"; + return null; +} + +/** Pick the arm whose cumulative weight range contains the bucket. */ +export function assignVariant(variants: T[], bucket: number): T | null { + if (variants.length === 0) return null; + let cumulative = 0; + for (const variant of variants) { + cumulative += variant.weight; + if (bucket < cumulative) return variant; + } + return variants[variants.length - 1]; +} diff --git a/backend/src/lib/site-urls.ts b/backend/src/lib/site-urls.ts new file mode 100644 index 0000000..bfc818c --- /dev/null +++ b/backend/src/lib/site-urls.ts @@ -0,0 +1,17 @@ +// Public origins used in emails and redirects. + +import { scrapeSelfUrl } from "./scrape-chain.js"; + +/** Frontend origin, e.g. https://skiptheboard.in (no trailing slash). */ +export function frontendUrl(env: NodeJS.ProcessEnv = process.env): string { + return (env.FRONTEND_URL ?? "https://skiptheboard.in").replace(/\/$/, ""); +} + +/** This backend's public origin (no trailing slash). */ +export function backendUrl(env: NodeJS.ProcessEnv = process.env): string { + return scrapeSelfUrl(env); +} + +export function contactEmail(env: NodeJS.ProcessEnv = process.env): string { + return env.CONTACT_EMAIL?.trim() || "hello@skiptheboard.in"; +} diff --git a/backend/src/openapi-spec.ts b/backend/src/openapi-spec.ts index 4d924f5..0224d57 100644 --- a/backend/src/openapi-spec.ts +++ b/backend/src/openapi-spec.ts @@ -17,7 +17,12 @@ function buildOpenApiSpec(baseUrl: string) { type: "apiKey", in: "header", name: "Authorization", - description: "Bearer ", + description: "Bearer , or a Clerk session token whose user id is in ADMIN_USER_IDS or whose primary email is in ADMIN_EMAILS", + }, + ClerkAuth: { + type: "http", + scheme: "bearer", + description: "Clerk session token", }, CronAuth: { type: "apiKey", @@ -100,6 +105,155 @@ function buildOpenApiSpec(baseUrl: string) { }, }, paths: { + "/api/email/preferences": { + get: { + summary: "Current user's email nudge preferences", + tags: ["Email"], + security: [{ ClerkAuth: [] }], + responses: { "200": { description: "Preferences (defaults when the user never changed them)" }, "401": { description: "Not signed in" } }, + }, + put: { + summary: "Update email nudge preferences", + tags: ["Email"], + security: [{ ClerkAuth: [] }], + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + subscribed: { type: "boolean" }, + frequency: { type: "string", enum: ["weekly", "daily"] }, + pausedUntil: { type: "string", format: "date-time", nullable: true }, + }, + }, + }, + }, + }, + responses: { "200": { description: "Updated preferences" } }, + }, + }, + "/api/email/unsubscribe": { + get: { + summary: "Redirects to the unsubscribe confirm page on the site; does not change anything", + tags: ["Email"], + parameters: [{ name: "t", in: "query", required: true, schema: { type: "string" }, description: "Signed token from the email footer" }], + responses: { "302": { description: "Redirect to FRONTEND_URL/unsubscribe?t=" } }, + }, + post: { + summary: "One-click unsubscribe (signed token, no login); RFC 8058 List-Unsubscribe-Post target", + tags: ["Email"], + parameters: [ + { name: "t", in: "query", required: true, schema: { type: "string" }, description: "Signed token from the email footer" }, + { name: "action", in: "query", schema: { type: "string", enum: ["resubscribe"] } }, + ], + responses: { "200": { description: "Subscription state" }, "400": { description: "Invalid token" } }, + }, + }, + "/api/email/webhook": { + post: { + summary: "Resend webhook (Svix-signed): delivered, opened, clicked, bounced, complained", + tags: ["Email"], + responses: { + "200": { description: "Recorded (or duplicate)" }, + "400": { description: "Bad signature or body" }, + "503": { description: "RESEND_WEBHOOK_SECRET not set" }, + }, + }, + }, + "/go/{sendId}/{jobId}": { + get: { + summary: "Log an email click and redirect to the original post", + tags: ["Email"], + parameters: [ + { name: "sendId", in: "path", required: true, schema: { type: "string" } }, + { name: "jobId", in: "path", required: true, schema: { type: "integer" } }, + ], + responses: { "302": { description: "Redirect to the job's source URL, or to the feed when unknown" } }, + }, + }, + "/api/cron/nudges": { + get: { + summary: "Daily nudge tick: weekly campaign on Mondays, daily campaign otherwise", + tags: ["Cron"], + security: [{ CronAuth: [] }], + parameters: [{ name: "campaign", in: "query", schema: { type: "integer" }, description: "Continue a specific campaign" }], + responses: { "200": { description: "Run summary" }, "401": { description: "Unauthorized" } }, + }, + post: { summary: "Same as GET", tags: ["Cron"], security: [{ CronAuth: [] }], responses: { "200": { description: "Run summary" } } }, + }, + "/api/admin/whoami": { + get: { summary: "Confirm admin access", tags: ["Admin"], security: [{ AdminAuth: [] }], responses: { "200": { description: "ok" }, "401": { description: "Not signed in" }, "403": { description: "Not an admin" } } }, + }, + "/api/admin/stats/subscribers": { + get: { summary: "Subscriber counts", tags: ["Admin"], security: [{ AdminAuth: [] }], responses: { "200": { description: "Counts" } } }, + }, + "/api/admin/stats/email": { + get: { + summary: "Per-campaign and per-variant email performance plus top clicked jobs", + tags: ["Admin"], + security: [{ AdminAuth: [] }], + parameters: [{ name: "campaignId", in: "query", schema: { type: "integer" } }], + responses: { "200": { description: "Stats" } }, + }, + }, + "/api/admin/stats/jobs": { + get: { + summary: "Live jobs, jobs added per day by level and source, jobs per hiring manager, email job clicks", + tags: ["Admin"], + security: [{ AdminAuth: [] }], + parameters: [{ name: "days", in: "query", schema: { type: "integer", default: 14 } }], + responses: { "200": { description: "Stats" } }, + }, + }, + "/api/admin/nudges/users": { + get: { summary: "Eligible recipients for the preview picker", tags: ["Admin"], security: [{ AdminAuth: [] }], parameters: [{ name: "q", in: "query", schema: { type: "string" } }], responses: { "200": { description: "Users" } } }, + }, + "/api/admin/nudges/preview": { + get: { + summary: "Render a user's nudge without sending", + tags: ["Admin"], + security: [{ AdminAuth: [] }], + parameters: [ + { name: "userId", in: "query", required: true, schema: { type: "string" } }, + { name: "campaignId", in: "query", schema: { type: "integer" } }, + { name: "variantId", in: "query", schema: { type: "integer" } }, + ], + responses: { "200": { description: "Subject, html, text, picks, or the skip reason" } }, + }, + }, + "/api/admin/nudges/test": { + post: { + summary: "Send one test email to an address (dry run without RESEND_API_KEY)", + tags: ["Admin"], + security: [{ AdminAuth: [] }], + requestBody: { content: { "application/json": { schema: { type: "object", required: ["to", "userId"], properties: { to: { type: "string" }, userId: { type: "string" }, campaignId: { type: "integer" }, variantId: { type: "integer" } } } } } }, + responses: { "200": { description: "Sent or dry run" }, "422": { description: "Nothing to send" } }, + }, + }, + "/api/admin/nudges/schedule": { + get: { summary: "Weekly schedule state", tags: ["Admin"], security: [{ AdminAuth: [] }], responses: { "200": { description: "paused flag and cron" } } }, + put: { summary: "Pause or resume the schedule", tags: ["Admin"], security: [{ AdminAuth: [] }], requestBody: { content: { "application/json": { schema: { type: "object", required: ["paused"], properties: { paused: { type: "boolean" } } } } } }, responses: { "200": { description: "paused flag" } } }, + }, + "/api/admin/nudges/campaigns": { + get: { summary: "List campaigns", tags: ["Admin"], security: [{ AdminAuth: [] }], responses: { "200": { description: "Campaigns with variants" } } }, + post: { + summary: "Create an experiment with 2+ variants (weights sum to 100; a holdout arm receives nothing)", + tags: ["Admin"], + security: [{ AdminAuth: [] }], + requestBody: { content: { "application/json": { schema: { type: "object", required: ["name", "variants"], properties: { name: { type: "string" }, jobCount: { type: "integer" }, variants: { type: "array", items: { type: "object", properties: { key: { type: "string" }, name: { type: "string" }, weight: { type: "integer" }, isHoldout: { type: "boolean" }, subject: { type: "string", nullable: true }, intro: { type: "string", nullable: true }, jobCount: { type: "integer", nullable: true } } } } } } } } }, + responses: { "201": { description: "Created (draft)" }, "400": { description: "Invalid variants" } }, + }, + }, + "/api/admin/nudges/send-now": { + post: { + summary: "Run a campaign now (creates a manual campaign when none is given); continues itself past the time budget", + tags: ["Admin"], + security: [{ AdminAuth: [] }], + requestBody: { content: { "application/json": { schema: { type: "object", properties: { campaignId: { type: "integer" } } } } } }, + responses: { "200": { description: "Run summary" }, "404": { description: "Campaign not found" }, "409": { description: "Already completed" } }, + }, + }, // ─── Health ─── "/health": { get: { diff --git a/backend/src/routes/admin-nudges.ts b/backend/src/routes/admin-nudges.ts new file mode 100644 index 0000000..b0e9bf4 --- /dev/null +++ b/backend/src/routes/admin-nudges.ts @@ -0,0 +1,229 @@ +// Send controls and experiments. Mounted under /api/admin/nudges behind the +// admin guard. Manual sends run with a short budget and continue themselves. + +import { Hono } from "hono"; +import { fetchClerkUsers } from "../lib/clerk-users.js"; +import { getProfilesCollection } from "../lib/mongo.js"; +import { + continueCampaignLater, + createManualCampaign, + isSchedulePaused, + nextNudgeTick, + previewNudgeForUser, + runCampaign, + scheduledCampaignFor, + sendTestNudge, + setSchedulePaused, +} from "../lib/nudge-send.js"; +import { DEFAULT_NUDGE_JOB_COUNT } from "../lib/nudge-select.js"; +import { validateVariants } from "../lib/nudge-variants.js"; +import { prisma } from "../lib/prisma.js"; + +type Variables = { userId: string | null; adminVia: "secret" | "clerk" }; + +const nudges = new Hono<{ Variables: Variables }>(); + +const MANUAL_BUDGET_MS = 45_000; + +// GET /api/admin/nudges/users?q= — recipients for the preview picker +nudges.get("/users", async (c) => { + const q = (c.req.query("q") ?? "").trim().toLowerCase(); + const limit = Math.min(50, Math.max(1, Number(c.req.query("limit") ?? 25) || 25)); + const collection = await getProfilesCollection(); + if (!collection) return c.json({ users: [] }); + + const docs = (await collection + .find({ filterSummary: { $exists: true } }, { projection: { _id: 1, contact: 1, filterSummary: 1, updatedAt: 1 } }) + .sort({ updatedAt: -1 }) + .limit(500) + .toArray()) as unknown as { + _id: string; + contact?: { name?: string; email?: string }; + filterSummary?: { currentTitle?: string; seniority?: string; roleFamily?: string }; + updatedAt?: Date; + }[]; + + const filtered = q + ? docs.filter((d) => + [d._id, d.contact?.name, d.contact?.email, d.filterSummary?.currentTitle] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)), + ) + : docs; + const page = filtered.slice(0, limit); + const ids = page.map((d) => String(d._id)); + const [clerk, prefs] = await Promise.all([ + fetchClerkUsers(ids), + prisma.emailPreference.findMany({ where: { userId: { in: ids } } }), + ]); + const prefById = new Map(prefs.map((p) => [p.userId, p])); + + return c.json({ + users: page.map((d) => { + const id = String(d._id); + const pref = prefById.get(id); + return { + userId: id, + name: d.contact?.name ?? null, + email: clerk.get(id)?.email ?? null, + currentTitle: d.filterSummary?.currentTitle ?? null, + seniority: d.filterSummary?.seniority ?? null, + roleFamily: d.filterSummary?.roleFamily ?? null, + subscribed: pref?.subscribed ?? true, + frequency: pref?.frequency ?? "weekly", + pausedUntil: pref?.pausedUntil?.toISOString() ?? null, + lastSentAt: pref?.lastSentAt?.toISOString() ?? null, + }; + }), + total: filtered.length, + }); +}); + +// GET /api/admin/nudges/preview?userId=&campaignId=&variantId= +nudges.get("/preview", async (c) => { + const userId = c.req.query("userId")?.trim(); + if (!userId) return c.json({ error: "userId is required" }, 400); + const campaignId = c.req.query("campaignId") ? Number(c.req.query("campaignId")) : null; + const variantId = c.req.query("variantId") ? Number(c.req.query("variantId")) : null; + const preview = await previewNudgeForUser({ + userId, + campaignId: Number.isInteger(campaignId) ? campaignId : null, + variantId: Number.isInteger(variantId) ? variantId : null, + }); + return c.json(preview); +}); + +// POST /api/admin/nudges/test { to, userId, campaignId?, variantId? } +nudges.post("/test", async (c) => { + const body = await c.req.json().catch(() => ({})) as { + to?: string; + userId?: string; + campaignId?: number; + variantId?: number; + }; + const to = body.to?.trim(); + if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) return c.json({ error: "a valid `to` address is required" }, 400); + if (!body.userId) return c.json({ error: "userId is required (whose matches to render)" }, 400); + const result = await sendTestNudge({ + to, + userId: body.userId, + campaignId: body.campaignId ?? null, + variantId: body.variantId ?? null, + }); + return c.json(result, result.ok ? 200 : 422); +}); + +// GET /api/admin/nudges/schedule +nudges.get("/schedule", async (c) => { + const tick = nextNudgeTick(new Date()); + return c.json({ + paused: await isSchedulePaused(), + cron: "30 2 * * *", + description: "Daily at 02:30 UTC (08:00 IST). Mondays send the weekly campaign to everyone; other days send only to users who chose daily.", + nextTickAt: tick.toISOString(), + nextTickWouldRun: scheduledCampaignFor(tick), + }); +}); + +// PUT /api/admin/nudges/schedule { paused } +nudges.put("/schedule", async (c) => { + const body = await c.req.json().catch(() => ({})) as { paused?: unknown }; + if (typeof body.paused !== "boolean") return c.json({ error: "paused must be a boolean" }, 400); + await setSchedulePaused(body.paused); + return c.json({ paused: body.paused }); +}); + +// GET /api/admin/nudges/campaigns +nudges.get("/campaigns", async (c) => { + const campaigns = await prisma.campaign.findMany({ + include: { variants: { orderBy: { id: "asc" } }, _count: { select: { sends: true } } }, + orderBy: { createdAt: "desc" }, + take: 100, + }); + return c.json({ + campaigns: campaigns.map((cp) => ({ + id: cp.id, + key: cp.key, + name: cp.name, + kind: cp.kind, + status: cp.status, + jobCount: cp.jobCount, + createdBy: cp.createdBy, + createdAt: cp.createdAt.toISOString(), + startedAt: cp.startedAt?.toISOString() ?? null, + completedAt: cp.completedAt?.toISOString() ?? null, + sends: cp._count.sends, + variants: cp.variants, + })), + }); +}); + +interface VariantBody { + key?: string; + name?: string; + weight?: number; + isHoldout?: boolean; + subject?: string | null; + intro?: string | null; + jobCount?: number | null; +} + +// POST /api/admin/nudges/campaigns { name, kind?, jobCount?, variants: [...] } +nudges.post("/campaigns", async (c) => { + const body = await c.req.json().catch(() => null) as { + name?: string; + kind?: string; + jobCount?: number; + variants?: VariantBody[]; + } | null; + if (!body?.name?.trim()) return c.json({ error: "name is required" }, 400); + const variants = Array.isArray(body.variants) ? body.variants : []; + if (variants.length < 2) return c.json({ error: "an experiment needs at least 2 variants (one may be a holdout)" }, 400); + + const arms = variants.map((v, i) => ({ + key: (v.key ?? String.fromCharCode(65 + i)).trim(), + name: (v.name ?? `Variant ${String.fromCharCode(65 + i)}`).trim(), + weight: Number(v.weight), + isHoldout: v.isHoldout === true, + subject: v.subject?.trim() || null, + intro: v.intro?.trim() || null, + jobCount: v.jobCount != null && Number.isInteger(Number(v.jobCount)) && Number(v.jobCount) > 0 ? Number(v.jobCount) : null, + })); + const problem = validateVariants(arms); + if (problem) return c.json({ error: problem }, 400); + + const jobCount = Number.isInteger(body.jobCount) && Number(body.jobCount) > 0 ? Number(body.jobCount) : DEFAULT_NUDGE_JOB_COUNT; + const slug = body.name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "experiment"; + const key = `exp-${slug}-${Date.now().toString(36)}`; + const campaign = await prisma.campaign.create({ + data: { + key, + name: body.name.trim(), + kind: "experiment", + jobCount, + createdBy: c.get("userId") ?? "admin-secret", + variants: { create: arms }, + }, + include: { variants: true }, + }); + return c.json({ campaign }, 201); +}); + +// POST /api/admin/nudges/send-now { campaignId? } — run now; continues itself if long +nudges.post("/send-now", async (c) => { + const body = await c.req.json().catch(() => ({})) as { campaignId?: number }; + let campaignId = body.campaignId; + if (!campaignId) { + const created = await createManualCampaign(c.get("userId") ?? "admin-secret"); + campaignId = created.id; + } else { + const exists = await prisma.campaign.findUnique({ where: { id: campaignId } }); + if (!exists) return c.json({ error: "Campaign not found" }, 404); + if (exists.status === "completed") return c.json({ error: "Campaign already completed" }, 409); + } + const result = await runCampaign({ campaignId, budgetMs: MANUAL_BUDGET_MS }); + if (result.remaining > 0) continueCampaignLater(campaignId); + return c.json(result); +}); + +export default nudges; diff --git a/backend/src/routes/admin-stats.ts b/backend/src/routes/admin-stats.ts new file mode 100644 index 0000000..a94f493 --- /dev/null +++ b/backend/src/routes/admin-stats.ts @@ -0,0 +1,26 @@ +// Dashboard read models. Mounted under /api/admin/stats behind the admin guard. + +import { Hono } from "hono"; +import { campaignStats, jobStats, subscriberStats, topClickedJobs } from "../lib/nudge-stats.js"; + +const stats = new Hono(); + +stats.get("/subscribers", async (c) => { + return c.json(await subscriberStats()); +}); + +stats.get("/email", async (c) => { + const campaignId = c.req.query("campaignId") ? Number(c.req.query("campaignId")) : undefined; + const [campaigns, topJobs] = await Promise.all([ + campaignStats(Number.isInteger(campaignId) ? campaignId : undefined), + topClickedJobs(10, Number.isInteger(campaignId) ? campaignId : undefined), + ]); + return c.json({ campaigns, topClickedJobs: topJobs }); +}); + +stats.get("/jobs", async (c) => { + const days = Math.min(60, Math.max(7, Number(c.req.query("days") ?? 14) || 14)); + return c.json(await jobStats(new Date(), days)); +}); + +export default stats; diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 8283e81..112f060 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -3,18 +3,39 @@ import { prisma } from "../lib/prisma.js"; import { scrapeRecruiter } from "../lib/apify.js"; import { JOB_EXPIRY_DAYS } from "../lib/config.js"; import { expiredJobWhere } from "../lib/job-expiry.js"; +import { decideAdminAuth } from "../lib/admin-auth.js"; +import nudgesAdmin from "./admin-nudges.js"; +import statsAdmin from "./admin-stats.js"; -const admin = new Hono(); +type Variables = { + userId: string | null; + adminVia: "secret" | "clerk"; +}; -// Auth middleware — require ADMIN_SECRET +const admin = new Hono<{ Variables: Variables }>(); + +// Auth middleware: ADMIN_SECRET bearer (scripts) or a Clerk user listed in +// ADMIN_USER_IDS / ADMIN_EMAILS (the dashboard). Never ship ADMIN_SECRET to a browser. admin.use("*", async (c, next) => { - const auth = c.req.header("Authorization"); - if (!auth || auth !== `Bearer ${process.env.ADMIN_SECRET}`) { - return c.json({ error: "Unauthorized" }, 401); + const decision = await decideAdminAuth({ + authHeader: c.req.header("Authorization"), + userId: c.get("userId"), + }); + if (!decision.ok) { + return c.json({ error: decision.error }, decision.status); } + c.set("adminVia", decision.via); await next(); }); +// GET /api/admin/whoami — lets the dashboard confirm access before rendering +admin.get("/whoami", (c) => { + return c.json({ ok: true, via: c.get("adminVia"), userId: c.get("userId") }); +}); + +admin.route("/nudges", nudgesAdmin); +admin.route("/stats", statsAdmin); + // POST /api/admin/recruiter — add recruiter admin.post("/recruiter", async (c) => { const body = await c.req.json<{ diff --git a/backend/src/routes/cron.ts b/backend/src/routes/cron.ts index ae23b4a..20928b0 100644 --- a/backend/src/routes/cron.ts +++ b/backend/src/routes/cron.ts @@ -4,6 +4,7 @@ import { JOB_EXPIRY_DAYS } from "../lib/config.js"; import { purgeExpiredJobs } from "../lib/job-expiry.js"; import { scrapeDueBatch } from "../lib/scrape-due.js"; import { shouldStartScrapeDrain } from "../lib/scrape-chain.js"; +import { continueCampaignLater, ensureScheduledCampaign, runCampaign } from "../lib/nudge-send.js"; const cron = new Hono(); @@ -125,9 +126,40 @@ async function handleExpireJobs(c: Context) { }); } +// Daily 02:30 UTC. Mondays: weekly campaign to all subscribers. Other days: +// daily campaign, only when someone chose daily. `?campaign=` continues +// a specific campaign (self-continuation after the time budget). +async function handleNudges(c: Context) { + const explicit = c.req.query("campaign"); + let campaignId: number | null = null; + if (explicit) { + campaignId = Number(explicit); + if (!Number.isInteger(campaignId) || campaignId <= 0) { + return c.json({ error: "campaign must be a positive integer" }, 400); + } + } else { + const campaign = await ensureScheduledCampaign(new Date()); + if (!campaign) { + return c.json({ ok: true, skipped: true, message: "No campaign scheduled today (paused, or no daily subscribers)" }); + } + campaignId = campaign.id; + } + + const result = await runCampaign({ campaignId }); + console.log( + `[cron] nudges: campaign=${result.campaignKey} processed=${result.processed} sent=${result.sent} ` + + `skipped=${result.skipped} holdout=${result.holdout} failed=${result.failed} alreadyClaimed=${result.alreadyClaimed} remaining=${result.remaining}` + + `${result.dryRun ? " dryRun=true" : ""}`, + ); + if (result.remaining > 0) continueCampaignLater(campaignId); + return c.json({ ok: result.failed === 0, ...result }); +} + cron.get("/scrape", handleScrape); cron.post("/scrape", handleScrape); cron.get("/expire-jobs", handleExpireJobs); cron.post("/expire-jobs", handleExpireJobs); +cron.get("/nudges", handleNudges); +cron.post("/nudges", handleNudges); export default cron; diff --git a/backend/src/routes/email.ts b/backend/src/routes/email.ts new file mode 100644 index 0000000..f41fcb3 --- /dev/null +++ b/backend/src/routes/email.ts @@ -0,0 +1,263 @@ +// Public email endpoints: per-user preferences (Clerk), one-click +// unsubscribe (signed token, no login) and the Resend webhook (Svix-signed). + +import { Hono } from "hono"; +import type { Context } from "hono"; +import { Prisma } from "@prisma/client"; +import { Webhook } from "svix"; +import { verifyUnsubscribeToken } from "../lib/email-tokens.js"; +import { getOrCreatePreference } from "../lib/nudge-send.js"; +import { prisma } from "../lib/prisma.js"; +import { frontendUrl } from "../lib/site-urls.js"; + +type Variables = { userId: string | null }; + +const email = new Hono<{ Variables: Variables }>(); + +// ─── Preferences ───────────────────────────────────────────────────────────── + +email.get("/preferences", async (c) => { + const userId = c.get("userId"); + if (!userId) return c.json({ error: "Authentication required" }, 401); + const pref = await prisma.emailPreference.findUnique({ where: { userId } }); + return c.json({ + preferences: { + subscribed: pref?.subscribed ?? true, + frequency: pref?.frequency ?? "weekly", + pausedUntil: pref?.pausedUntil?.toISOString() ?? null, + unsubscribedAt: pref?.unsubscribedAt?.toISOString() ?? null, + lastSentAt: pref?.lastSentAt?.toISOString() ?? null, + isDefault: !pref, + }, + }); +}); + +email.put("/preferences", async (c) => { + const userId = c.get("userId"); + if (!userId) return c.json({ error: "Authentication required" }, 401); + + let body: { subscribed?: unknown; frequency?: unknown; pausedUntil?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const data: Prisma.EmailPreferenceUpdateInput = {}; + if (typeof body.subscribed === "boolean") { + data.subscribed = body.subscribed; + data.unsubscribedAt = body.subscribed ? null : new Date(); + data.unsubscribeReason = body.subscribed ? null : "user"; + } + if (body.frequency !== undefined) { + if (body.frequency !== "weekly" && body.frequency !== "daily") { + return c.json({ error: "frequency must be weekly or daily" }, 400); + } + data.frequency = body.frequency; + } + if (body.pausedUntil !== undefined) { + if (body.pausedUntil === null) { + data.pausedUntil = null; + } else { + const date = new Date(String(body.pausedUntil)); + if (Number.isNaN(date.getTime())) return c.json({ error: "pausedUntil must be an ISO date or null" }, 400); + data.pausedUntil = date; + } + } + + await getOrCreatePreference(userId); + const pref = await prisma.emailPreference.update({ where: { userId }, data }); + return c.json({ + preferences: { + subscribed: pref.subscribed, + frequency: pref.frequency, + pausedUntil: pref.pausedUntil?.toISOString() ?? null, + unsubscribedAt: pref.unsubscribedAt?.toISOString() ?? null, + lastSentAt: pref.lastSentAt?.toISOString() ?? null, + isDefault: false, + }, + }); +}); + +// ─── One-click unsubscribe ─────────────────────────────────────────────────── + +function maskEmail(value: string | null): string | null { + if (!value) return null; + const [local, domain] = value.split("@"); + if (!domain) return null; + const head = local.slice(0, Math.min(2, local.length)); + return `${head}${"*".repeat(Math.max(1, local.length - head.length))}@${domain}`; +} + +async function handleUnsubscribe(c: Context) { + const signed = c.req.query("t") ?? ""; + const storedToken = signed ? verifyUnsubscribeToken(signed) : null; + if (!storedToken) return c.json({ error: "Invalid or expired link" }, 400); + + const pref = await prisma.emailPreference.findUnique({ where: { unsubscribeToken: storedToken } }); + if (!pref) return c.json({ error: "Invalid or expired link" }, 400); + + const resubscribe = c.req.query("action") === "resubscribe"; + const now = new Date(); + const updated = await prisma.emailPreference.update({ + where: { id: pref.id }, + data: resubscribe + ? { subscribed: true, unsubscribedAt: null, unsubscribeReason: null, pausedUntil: null } + : { subscribed: false, unsubscribedAt: pref.unsubscribedAt ?? now, unsubscribeReason: pref.unsubscribeReason ?? "user" }, + }); + if (!resubscribe) { + const latest = await prisma.nudgeSend.findFirst({ + where: { userId: pref.userId, status: { in: ["sent", "delivered"] } }, + orderBy: { sentAt: "desc" }, + select: { id: true, unsubscribedAt: true }, + }); + if (latest && !latest.unsubscribedAt) { + await prisma.nudgeSend.update({ where: { id: latest.id }, data: { unsubscribedAt: now } }); + } + } + return c.json({ ok: true, subscribed: updated.subscribed, email: maskEmail(updated.email) }); +} + +// A bare GET (link scanners, mail-client previews) only lands on the confirm +// page; the page's button and the RFC 8058 POST below are what unsubscribe. +email.get("/unsubscribe", (c) => { + const signed = c.req.query("t"); + const target = `${frontendUrl()}/unsubscribe${signed ? `?t=${encodeURIComponent(signed)}` : ""}`; + c.header("Cache-Control", "no-store"); + return c.redirect(target, 302); +}); +// RFC 8058: mail clients POST `List-Unsubscribe=One-Click` to this URL. +email.post("/unsubscribe", handleUnsubscribe); + +// ─── Resend webhook ────────────────────────────────────────────────────────── + +export interface ResendWebhookEvent { + type: string; + created_at?: string; + data?: { + email_id?: string; + to?: string[]; + subject?: string; + click?: { link?: string; timestamp?: string }; + bounce?: { type?: string; subType?: string; message?: string }; + }; +} + +/** Parse /go// out of a clicked link, if that is what it was. */ +export function jobIdFromGoLink(link: string | undefined): number | null { + if (!link) return null; + const match = /\/go\/[^/]+\/(\d+)(?:[/?#]|$)/.exec(link); + return match ? Number(match[1]) : null; +} + +export async function applyWebhookEvent(event: ResendWebhookEvent, now = new Date()): Promise { + const messageId = event.data?.email_id; + if (!messageId) return "ignored: no email_id"; + const send = await prisma.nudgeSend.findUnique({ where: { providerMessageId: messageId } }); + if (!send) return "ignored: unknown message"; + + const terminal = send.status === "bounced" || send.status === "complained"; + switch (event.type) { + case "email.delivered": + await prisma.nudgeSend.update({ + where: { id: send.id }, + data: { deliveredAt: send.deliveredAt ?? now, ...(terminal ? {} : { status: "delivered" }) }, + }); + return "delivered"; + case "email.opened": + await prisma.nudgeSend.update({ where: { id: send.id }, data: { openedAt: send.openedAt ?? now } }); + return "opened"; + case "email.clicked": { + const link = event.data?.click?.link; + await prisma.$transaction([ + prisma.nudgeSend.update({ where: { id: send.id }, data: { clickedAt: send.clickedAt ?? now } }), + prisma.nudgeClick.create({ + data: { sendId: send.id, jobId: jobIdFromGoLink(link), source: "webhook", url: link?.slice(0, 2000) ?? null }, + }), + ]); + return "clicked"; + } + case "email.bounced": { + const hard = (event.data?.bounce?.type ?? "").toLowerCase() === "permanent"; + await prisma.nudgeSend.update({ + where: { id: send.id }, + data: { bouncedAt: send.bouncedAt ?? now, status: "bounced" }, + }); + if (hard) { + await prisma.emailPreference.updateMany({ + where: { userId: send.userId, subscribed: true }, + data: { subscribed: false, unsubscribedAt: now, unsubscribeReason: "bounce" }, + }); + return "bounced: hard, unsubscribed"; + } + return "bounced: soft"; + } + case "email.complained": + await prisma.$transaction([ + prisma.nudgeSend.update({ + where: { id: send.id }, + data: { complainedAt: send.complainedAt ?? now, unsubscribedAt: send.unsubscribedAt ?? now, status: "complained" }, + }), + prisma.emailPreference.updateMany({ + where: { userId: send.userId, subscribed: true }, + data: { subscribed: false, unsubscribedAt: now, unsubscribeReason: "complaint" }, + }), + ]); + return "complained: unsubscribed"; + default: + return `ignored: ${event.type}`; + } +} + +email.post("/webhook", async (c) => { + const secret = process.env.RESEND_WEBHOOK_SECRET; + if (!secret) return c.json({ error: "Webhook not configured" }, 503); + + const svixId = c.req.header("svix-id"); + const svixTimestamp = c.req.header("svix-timestamp"); + const svixSignature = c.req.header("svix-signature"); + if (!svixId || !svixTimestamp || !svixSignature) { + return c.json({ error: "Missing signature headers" }, 400); + } + + const rawBody = await c.req.text(); + try { + new Webhook(secret).verify(rawBody, { + "svix-id": svixId, + "svix-timestamp": svixTimestamp, + "svix-signature": svixSignature, + }); + } catch (err) { + console.warn("[email] webhook signature rejected:", err instanceof Error ? err.message : err); + return c.json({ error: "Invalid signature" }, 400); + } + + let event: ResendWebhookEvent; + try { + event = JSON.parse(rawBody) as ResendWebhookEvent; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + if (!event || typeof event.type !== "string") return c.json({ error: "Invalid event" }, 400); + + try { + await prisma.emailEvent.create({ + data: { + providerEventId: svixId, + type: event.type, + messageId: event.data?.email_id ?? null, + payload: event as unknown as Prisma.InputJsonValue, + }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { + return c.json({ received: true, duplicate: true }); + } + throw err; + } + + const outcome = await applyWebhookEvent(event); + return c.json({ received: true, outcome }); +}); + +export default email; diff --git a/backend/src/routes/go.ts b/backend/src/routes/go.ts new file mode 100644 index 0000000..df0af37 --- /dev/null +++ b/backend/src/routes/go.ts @@ -0,0 +1,35 @@ +// Click redirect for email links: /go// logs the click when +// that job was in that send, then 302s to the job's original post. Unknown +// jobs fall back to the feed. + +import { Hono } from "hono"; +import { prisma } from "../lib/prisma.js"; +import { frontendUrl } from "../lib/site-urls.js"; + +const go = new Hono(); + +go.get("/:sendId/:jobId", async (c) => { + const sendId = c.req.param("sendId"); + const jobId = Number(c.req.param("jobId")); + const fallback = `${frontendUrl()}/?utm_source=nudge&utm_medium=email`; + if (!sendId || !Number.isInteger(jobId) || jobId <= 0) return c.redirect(fallback, 302); + + const [send, job] = await Promise.all([ + prisma.nudgeSend.findUnique({ where: { id: sendId }, select: { id: true, clickedAt: true, jobIds: true } }), + prisma.job.findUnique({ where: { id: jobId }, select: { sourceUrl: true } }), + ]); + + if (send && send.jobIds.includes(jobId)) { + const now = new Date(); + await prisma.$transaction([ + prisma.nudgeClick.create({ data: { sendId: send.id, jobId, source: "redirect", url: c.req.url.slice(0, 2000) } }), + prisma.nudgeSend.update({ where: { id: send.id }, data: { clickedAt: send.clickedAt ?? now } }), + ]); + } + + const target = job?.sourceUrl && /^https?:\/\//i.test(job.sourceUrl) ? job.sourceUrl : fallback; + c.header("Cache-Control", "no-store"); + return c.redirect(target, 302); +}); + +export default go; diff --git a/backend/tests/admin-auth.test.ts b/backend/tests/admin-auth.test.ts new file mode 100644 index 0000000..4d01108 --- /dev/null +++ b/backend/tests/admin-auth.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.hoisted(() => { + process.env.CLERK_SECRET_KEY = "sk_test_fake"; + process.env.ADMIN_SECRET = "machine-admin-secret"; + process.env.ADMIN_USER_IDS = "user_admin_by_id"; + process.env.ADMIN_EMAILS = "Captain@Example.com"; +}); + +vi.mock("@clerk/backend", () => ({ + verifyToken: vi.fn(async (token: string) => { + if (token.startsWith("clerk:")) return { sub: token.slice("clerk:".length) }; + throw new Error("invalid token"); + }), + createClerkClient: vi.fn(), +})); +vi.mock("../src/lib/clerk-users", () => ({ + fetchClerkUsers: vi.fn(async (ids: string[]) => { + const emails: Record = { user_captain: "captain@example.com", user_nobody: "nobody@example.com" }; + return new Map(ids.filter((id) => emails[id]).map((id) => [id, { userId: id, email: emails[id], firstName: null }])); + }), +})); +vi.mock("../src/lib/prisma", async () => { + const { createFakePrisma } = await import("./helpers/fake-prisma"); + return { prisma: createFakePrisma() }; +}); +vi.mock("../src/lib/mongo", () => ({ getProfilesCollection: async () => null, getDb: async () => null })); + +import app from "../src/index"; +import { decideAdminAuth } from "../src/lib/admin-auth"; + +const ADMIN_PATHS = ["/api/admin/whoami", "/api/admin/stats/subscribers", "/api/admin/nudges/schedule", "/api/admin/recruiters"]; + +describe("admin auth", () => { + it("rejects anonymous requests with 401 on every admin path", async () => { + for (const path of ADMIN_PATHS) { + const res = await app.request(path); + expect(res.status, path).toBe(401); + } + }); + + it("rejects a signed-in non-admin with 403", async () => { + for (const path of ADMIN_PATHS) { + const res = await app.request(path, { headers: { Authorization: "Bearer clerk:user_nobody" } }); + expect(res.status, path).toBe(403); + await expect(res.json()).resolves.toEqual({ error: "Forbidden" }); + } + }); + + it("rejects an invalid Clerk token as anonymous", async () => { + const res = await app.request("/api/admin/whoami", { headers: { Authorization: "Bearer garbage" } }); + expect(res.status).toBe(401); + }); + + it("allows an admin listed by Clerk user id", async () => { + const res = await app.request("/api/admin/whoami", { headers: { Authorization: "Bearer clerk:user_admin_by_id" } }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true, via: "clerk", userId: "user_admin_by_id" }); + }); + + it("allows an admin listed by primary Clerk email (case-insensitive)", async () => { + const res = await app.request("/api/admin/whoami", { headers: { Authorization: "Bearer clerk:user_captain" } }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true, via: "clerk", userId: "user_captain" }); + }); + + it("still accepts the machine ADMIN_SECRET for scripts", async () => { + const res = await app.request("/api/admin/whoami", { headers: { Authorization: "Bearer machine-admin-secret" } }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ ok: true, via: "secret" }); + }); + + it("does not consult Clerk for emails when ADMIN_EMAILS is empty", async () => { + const resolveEmail = vi.fn(async () => "captain@example.com"); + const decision = await decideAdminAuth({ + authHeader: undefined, + userId: "user_captain", + env: { ADMIN_USER_IDS: "" } as NodeJS.ProcessEnv, + resolveEmail, + }); + expect(decision).toEqual({ ok: false, status: 403, error: "Forbidden" }); + expect(resolveEmail).not.toHaveBeenCalled(); + }); + + it("treats an email lookup failure as not-admin", async () => { + const decision = await decideAdminAuth({ + authHeader: undefined, + userId: "user_x", + env: { ADMIN_EMAILS: "captain@example.com" } as NodeJS.ProcessEnv, + resolveEmail: async () => { + throw new Error("clerk down"); + }, + }); + expect(decision).toEqual({ ok: false, status: 403, error: "Forbidden" }); + }); +}); diff --git a/backend/tests/email-routes.test.ts b/backend/tests/email-routes.test.ts new file mode 100644 index 0000000..73cf7e2 --- /dev/null +++ b/backend/tests/email-routes.test.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Webhook } from "svix"; + +const { WEBHOOK_SECRET } = vi.hoisted(() => { + const WEBHOOK_SECRET = `whsec_${Buffer.from("test-webhook-secret-bytes").toString("base64")}`; + process.env.RESEND_WEBHOOK_SECRET = WEBHOOK_SECRET; + process.env.UNSUBSCRIBE_SECRET = "unsub-secret"; + process.env.FRONTEND_URL = "https://skiptheboard.in"; + return { WEBHOOK_SECRET }; +}); + +vi.mock("../src/lib/prisma", async () => { + const { createFakePrisma } = await import("./helpers/fake-prisma"); + return { prisma: createFakePrisma() }; +}); +vi.mock("../src/lib/mongo", () => ({ getProfilesCollection: async () => null, getDb: async () => null })); +vi.mock("../src/lib/clerk-users", () => ({ fetchClerkUsers: vi.fn(async () => new Map()) })); +vi.mock("@vercel/functions", () => ({ waitUntil: vi.fn() })); + +import app from "../src/index"; +import { prisma } from "../src/lib/prisma"; +import { signUnsubscribeToken } from "../src/lib/email-tokens"; + +const db = prisma as unknown as ReturnType; + +function signedHeaders(body: string, id = `msg_${Math.random().toString(36).slice(2)}`, secret = WEBHOOK_SECRET) { + const timestamp = new Date(); + const signature = new Webhook(secret).sign(id, timestamp, body); + return { + "content-type": "application/json", + "svix-id": id, + "svix-timestamp": String(Math.floor(timestamp.getTime() / 1000)), + "svix-signature": signature, + }; +} + +function event(type: string, extra: Record = {}) { + return JSON.stringify({ type, created_at: new Date().toISOString(), data: { email_id: "msg_1", to: ["a@example.com"], ...extra } }); +} + +beforeEach(async () => { + db.$reset(); + await db.campaign.create({ data: { key: "weekly-2026-W40", name: "W40", kind: "weekly", status: "completed", jobCount: 5 } }); + await db.emailPreference.create({ data: { userId: "user_1", email: "a@example.com", subscribed: true, frequency: "weekly", unsubscribeToken: "stored-token-1" } }); + await db.nudgeSend.create({ + data: { id: "send_1", campaignId: 1, userId: "user_1", email: "a@example.com", status: "sent", providerMessageId: "msg_1", jobIds: [23], sentAt: new Date() }, + }); + await db.recruiter.create({ data: { id: 1, name: "R", linkedinUrl: "https://linkedin.com/in/r", active: true } }); + await db.job.create({ + data: { id: 23, recruiterId: 1, title: "SDE 1", company: "M2P", sourceUrl: "https://www.linkedin.com/posts/m2p-sde1", postedAt: new Date(), seniority: "junior", source: "linkedin", roleFamily: "engineering" }, + }); +}); + +describe("POST /api/email/webhook", () => { + it("accepts a correctly signed event and records delivery", async () => { + const body = event("email.delivered"); + const res = await app.request("/api/email/webhook", { method: "POST", body, headers: signedHeaders(body) }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ received: true, outcome: "delivered" }); + const send = await db.nudgeSend.findUnique({ where: { id: "send_1" } }); + expect(send!.status).toBe("delivered"); + expect(send!.deliveredAt).toBeInstanceOf(Date); + expect(await db.emailEvent.count({})).toBe(1); + }); + + it("rejects a bad signature and records nothing", async () => { + const body = event("email.delivered"); + const headers = signedHeaders(body, "msg_bad", `whsec_${Buffer.from("another-secret").toString("base64")}`); + const res = await app.request("/api/email/webhook", { method: "POST", body, headers }); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "Invalid signature" }); + expect(await db.emailEvent.count({})).toBe(0); + expect((await db.nudgeSend.findUnique({ where: { id: "send_1" } }))!.status).toBe("sent"); + }); + + it("rejects a tampered body and missing headers", async () => { + const body = event("email.delivered"); + const headers = signedHeaders(body); + const tampered = await app.request("/api/email/webhook", { method: "POST", body: event("email.complained"), headers }); + expect(tampered.status).toBe(400); + const missing = await app.request("/api/email/webhook", { method: "POST", body, headers: { "content-type": "application/json" } }); + expect(missing.status).toBe(400); + }); + + it("deduplicates by svix-id", async () => { + const body = event("email.opened"); + const headers = signedHeaders(body, "msg_dup"); + expect((await app.request("/api/email/webhook", { method: "POST", body, headers })).status).toBe(200); + const again = await app.request("/api/email/webhook", { method: "POST", body, headers }); + await expect(again.json()).resolves.toMatchObject({ received: true, duplicate: true }); + expect(await db.emailEvent.count({})).toBe(1); + }); + + it("auto-unsubscribes on a hard bounce", async () => { + const body = event("email.bounced", { bounce: { type: "Permanent", subType: "General", message: "mailbox does not exist" } }); + const res = await app.request("/api/email/webhook", { method: "POST", body, headers: signedHeaders(body) }); + expect(res.status).toBe(200); + const pref = await db.emailPreference.findUnique({ where: { userId: "user_1" } }); + expect(pref!.subscribed).toBe(false); + expect(pref!.unsubscribeReason).toBe("bounce"); + expect((await db.nudgeSend.findUnique({ where: { id: "send_1" } }))!.status).toBe("bounced"); + }); + + it("keeps a soft bounce subscribed", async () => { + const body = event("email.bounced", { bounce: { type: "Transient" } }); + await app.request("/api/email/webhook", { method: "POST", body, headers: signedHeaders(body) }); + expect((await db.emailPreference.findUnique({ where: { userId: "user_1" } }))!.subscribed).toBe(true); + }); + + it("auto-unsubscribes on a complaint", async () => { + const body = event("email.complained"); + await app.request("/api/email/webhook", { method: "POST", body, headers: signedHeaders(body) }); + const pref = await db.emailPreference.findUnique({ where: { userId: "user_1" } }); + expect(pref!.subscribed).toBe(false); + expect(pref!.unsubscribeReason).toBe("complaint"); + const send = await db.nudgeSend.findUnique({ where: { id: "send_1" } }); + expect(send!.status).toBe("complained"); + expect(send!.unsubscribedAt).toBeInstanceOf(Date); + }); + + it("records provider clicks with the job id parsed from the /go link", async () => { + const body = event("email.clicked", { click: { link: "https://skiptheboard.in/go/send_1/23" } }); + await app.request("/api/email/webhook", { method: "POST", body, headers: signedHeaders(body) }); + const clicks = await db.nudgeClick.findMany({}); + expect(clicks).toHaveLength(1); + expect(clicks[0]).toMatchObject({ sendId: "send_1", jobId: 23, source: "webhook" }); + expect((await db.nudgeSend.findUnique({ where: { id: "send_1" } }))!.clickedAt).toBeInstanceOf(Date); + }); + + it("ignores events for unknown messages", async () => { + const body = event("email.delivered", { email_id: "msg_unknown" }); + const res = await app.request("/api/email/webhook", { method: "POST", body, headers: signedHeaders(body) }); + await expect(res.json()).resolves.toMatchObject({ outcome: "ignored: unknown message" }); + }); +}); + +describe("one-click unsubscribe", () => { + it("GET only redirects to the confirm page with the same token and changes nothing", async () => { + const token = signUnsubscribeToken("stored-token-1"); + const res = await app.request(`/api/email/unsubscribe?t=${encodeURIComponent(token)}`); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(`https://skiptheboard.in/unsubscribe?t=${encodeURIComponent(token)}`); + expect((await db.emailPreference.findUnique({ where: { userId: "user_1" } }))!.subscribed).toBe(true); + expect((await db.nudgeSend.findUnique({ where: { id: "send_1" } }))!.unsubscribedAt).toBeNull(); + + const forged = `${Buffer.from("stored-token-1").toString("base64url")}.forgedsignature`; + expect((await app.request(`/api/email/unsubscribe?t=${forged}`)).status).toBe(302); + expect((await db.emailPreference.findUnique({ where: { userId: "user_1" } }))!.subscribed).toBe(true); + }); + + it("unsubscribes on the RFC 8058 POST and stamps only the most recent send", async () => { + const older = await db.campaign.create({ data: { key: "weekly-2026-W39", name: "W39", kind: "weekly", status: "completed", jobCount: 5 } }); + await db.nudgeSend.create({ + data: { + id: "send_0", + campaignId: older.id, + userId: "user_1", + email: "a@example.com", + status: "delivered", + providerMessageId: "msg_0", + jobIds: [11], + sentAt: new Date(Date.now() - 7 * 86_400_000), + }, + }); + const token = signUnsubscribeToken("stored-token-1"); + const post = await app.request(`/api/email/unsubscribe?t=${encodeURIComponent(token)}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "List-Unsubscribe=One-Click", + }); + expect(post.status).toBe(200); + await expect(post.json()).resolves.toMatchObject({ ok: true, subscribed: false, email: "a*@example.com" }); + const pref = await db.emailPreference.findUnique({ where: { userId: "user_1" } }); + expect(pref!.subscribed).toBe(false); + expect(pref!.unsubscribeReason).toBe("user"); + expect((await db.nudgeSend.findUnique({ where: { id: "send_1" } }))!.unsubscribedAt).toBeInstanceOf(Date); + expect((await db.nudgeSend.findUnique({ where: { id: "send_0" } }))!.unsubscribedAt).toBeNull(); + }); + + it("rejects a forged or missing token on POST", async () => { + const forged = `${Buffer.from("stored-token-1").toString("base64url")}.forgedsignature`; + expect((await app.request(`/api/email/unsubscribe?t=${forged}`, { method: "POST" })).status).toBe(400); + expect((await app.request("/api/email/unsubscribe", { method: "POST" })).status).toBe(400); + const unknown = signUnsubscribeToken("not-a-stored-token"); + expect((await app.request(`/api/email/unsubscribe?t=${encodeURIComponent(unknown)}`, { method: "POST" })).status).toBe(400); + expect((await db.emailPreference.findUnique({ where: { userId: "user_1" } }))!.subscribed).toBe(true); + }); + + it("can resubscribe from the same link", async () => { + const token = signUnsubscribeToken("stored-token-1"); + await app.request(`/api/email/unsubscribe?t=${encodeURIComponent(token)}`, { method: "POST" }); + expect((await db.emailPreference.findUnique({ where: { userId: "user_1" } }))!.subscribed).toBe(false); + const res = await app.request(`/api/email/unsubscribe?t=${encodeURIComponent(token)}&action=resubscribe`, { method: "POST" }); + await expect(res.json()).resolves.toMatchObject({ ok: true, subscribed: true }); + }); +}); + +describe("GET /go/:sendId/:jobId", () => { + it("logs the click and redirects to the original post", async () => { + const res = await app.request("/go/send_1/23"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("https://www.linkedin.com/posts/m2p-sde1"); + const clicks = await db.nudgeClick.findMany({}); + expect(clicks).toHaveLength(1); + expect(clicks[0]).toMatchObject({ sendId: "send_1", jobId: 23, source: "redirect" }); + expect((await db.nudgeSend.findUnique({ where: { id: "send_1" } }))!.clickedAt).toBeInstanceOf(Date); + }); + + it("redirects without logging when the job was not part of that send", async () => { + await db.job.create({ + data: { id: 24, recruiterId: 1, title: "SDE 2", company: "Zepto", sourceUrl: "https://www.linkedin.com/posts/zepto-sde2", postedAt: new Date(), seniority: "mid", source: "linkedin", roleFamily: "engineering" }, + }); + const res = await app.request("/go/send_1/24"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("https://www.linkedin.com/posts/zepto-sde2"); + expect(await db.nudgeClick.count({})).toBe(0); + expect((await db.nudgeSend.findUnique({ where: { id: "send_1" } }))!.clickedAt).toBeNull(); + + const missing = await app.request("/go/send_1/999999"); + expect(missing.status).toBe(302); + expect(missing.headers.get("location")).toMatch(/^https:\/\/skiptheboard\.in\/\?utm_source=nudge/); + expect(await db.nudgeClick.count({})).toBe(0); + }); + + it("falls back to the feed for unknown ids without logging", async () => { + const res = await app.request("/go/nope/999"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toMatch(/^https:\/\/skiptheboard\.in\/\?utm_source=nudge/); + expect(await db.nudgeClick.count({})).toBe(0); + }); +}); diff --git a/backend/tests/email-tokens.test.ts b/backend/tests/email-tokens.test.ts new file mode 100644 index 0000000..436ead3 --- /dev/null +++ b/backend/tests/email-tokens.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + newUnsubscribeToken, + signToken, + signUnsubscribeToken, + verifyToken, + verifyUnsubscribeToken, +} from "../src/lib/email-tokens"; + +const SECRET = "test-secret"; + +describe("signed tokens", () => { + it("round-trips a payload", () => { + const token = signToken("user_123", SECRET); + expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/); + expect(verifyToken(token, SECRET)).toBe("user_123"); + }); + + it("rejects a tampered payload, a tampered signature and the wrong secret", () => { + const token = signToken("user_123", SECRET); + const [payload, signature] = token.split("."); + const otherPayload = Buffer.from("user_999").toString("base64url"); + expect(verifyToken(`${otherPayload}.${signature}`, SECRET)).toBeNull(); + expect(verifyToken(`${payload}.${signature.slice(0, -1)}x`, SECRET)).toBeNull(); + expect(verifyToken(token, "another-secret")).toBeNull(); + }); + + it("rejects malformed input", () => { + expect(verifyToken("", SECRET)).toBeNull(); + expect(verifyToken("no-dot", SECRET)).toBeNull(); + expect(verifyToken(".sig", SECRET)).toBeNull(); + expect(verifyToken("a.b.c!", SECRET)).toBeNull(); + expect(verifyToken(undefined as unknown as string, SECRET)).toBeNull(); + }); + + it("signs the stored unsubscribe token with UNSUBSCRIBE_SECRET", () => { + const env = { UNSUBSCRIBE_SECRET: "prod-secret" } as NodeJS.ProcessEnv; + const stored = newUnsubscribeToken(); + expect(stored).toHaveLength(24); + const signed = signUnsubscribeToken(stored, env); + expect(verifyUnsubscribeToken(signed, env)).toBe(stored); + expect(verifyUnsubscribeToken(signed, { UNSUBSCRIBE_SECRET: "other" } as NodeJS.ProcessEnv)).toBeNull(); + }); +}); diff --git a/backend/tests/helpers/fake-prisma.ts b/backend/tests/helpers/fake-prisma.ts new file mode 100644 index 0000000..850daad --- /dev/null +++ b/backend/tests/helpers/fake-prisma.ts @@ -0,0 +1,385 @@ +// Minimal in-memory Prisma stand-in for the nudge tables. Supports the +// subset of the client API the nudge code uses: equality / in / not / gt / +// gte / lt / lte filters, one-level relation filters, include resolvers, +// unique constraints (P2002), createMany skipDuplicates, groupBy for clicks. + +import { Prisma } from "@prisma/client"; + +type Row = Record; + +function eq(a: unknown, b: unknown): boolean { + if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); + return a === b; +} + +function cmp(a: unknown, b: unknown): number { + const av = a instanceof Date ? a.getTime() : (a as number); + const bv = b instanceof Date ? b.getTime() : (b as number); + return av < bv ? -1 : av > bv ? 1 : 0; +} + +type RelationResolver = (row: Row) => Row | Row[] | null; + +export class FakeTable { + rows: Row[] = []; + private nextId = 1; + constructor( + public name: string, + private options: { + uniques?: string[][]; + relations?: Record; + stringId?: boolean; + /** Column defaults the real schema would apply. */ + defaults?: Row; + } = {}, + ) {} + + private matches(row: Row, where: Row | undefined): boolean { + if (!where) return true; + for (const [key, cond] of Object.entries(where)) { + if (key === "AND") { + const list = Array.isArray(cond) ? cond : [cond]; + if (!list.every((w) => this.matches(row, w))) return false; + continue; + } + if (key === "OR") { + if (!(cond as Row[]).some((w) => this.matches(row, w))) return false; + continue; + } + const relation = this.options.relations?.[key]; + if (relation) { + const related = relation(row); + if (related === null) return false; + if (Array.isArray(related)) { + const some = (cond as Row).some; + if (some && !related.some((r) => this.matches(r, some))) return false; + continue; + } + if (!this.matches(related, cond as Row)) return false; + continue; + } + const value = row[key]; + if (cond === null) { + if (value !== null && value !== undefined) return false; + continue; + } + if (cond instanceof Date || typeof cond !== "object") { + if (!eq(value, cond)) return false; + continue; + } + const c = cond as Row; + if ("equals" in c && !eq(value, c.equals)) return false; + if ("in" in c && !c.in.some((v: unknown) => eq(v, value))) return false; + if ("notIn" in c && c.notIn.some((v: unknown) => eq(v, value))) return false; + if ("not" in c) { + if (c.not === null) { + if (value === null || value === undefined) return false; + } else if (eq(value, c.not)) return false; + } + if ("gt" in c && !(cmp(value, c.gt) > 0)) return false; + if ("gte" in c && !(cmp(value, c.gte) >= 0)) return false; + if ("lt" in c && !(cmp(value, c.lt) < 0)) return false; + if ("lte" in c && !(cmp(value, c.lte) <= 0)) return false; + if ("has" in c && !(Array.isArray(value) && value.includes(c.has))) return false; + if ("contains" in c) { + const hay = String(value ?? ""); + const needle = String(c.contains); + const ok = c.mode === "insensitive" ? hay.toLowerCase().includes(needle.toLowerCase()) : hay.includes(needle); + if (!ok) return false; + } + } + return true; + } + + private shape(row: Row, args: { select?: Row; include?: Row } = {}): Row { + let out: Row = { ...row }; + if (args.include) { + for (const [name, arg] of Object.entries(args.include)) { + if (!arg) continue; + if (name === "_count") { + const counts: Row = {}; + for (const rel of Object.keys((arg as Row).select ?? {})) { + const related = this.options.relations?.[rel]?.(row); + counts[rel] = Array.isArray(related) ? related.length : related ? 1 : 0; + } + out._count = counts; + continue; + } + const related = this.options.relations?.[name]?.(row) ?? null; + const sub = typeof arg === "object" ? (arg as Row) : {}; + if (Array.isArray(related)) { + let list = related.map((r) => (sub.select ? pick(r, sub.select) : { ...r })); + if (sub.orderBy) list = sortBy(list, sub.orderBy); + out[name] = list; + } else { + out[name] = related ? (sub.select ? pick(related, sub.select) : { ...related }) : null; + } + } + } + if (args.select) out = pick(out, args.select); + return out; + } + + private checkUniques(candidate: Row, ignoreId?: unknown): void { + for (const unique of this.options.uniques ?? []) { + if (unique.some((k) => candidate[k] === null || candidate[k] === undefined)) continue; + const clash = this.rows.find( + (r) => (ignoreId === undefined || r.id !== ignoreId) && unique.every((k) => eq(r[k], candidate[k])), + ); + if (clash) { + throw new Prisma.PrismaClientKnownRequestError(`Unique constraint failed on ${unique.join(",")}`, { + code: "P2002", + clientVersion: "test", + }); + } + } + } + + private isDuplicate(candidate: Row): boolean { + try { + this.checkUniques(candidate); + return false; + } catch { + return true; + } + } + + private fillDefaults(data: Row): Row { + const row: Row = { ...(this.options.defaults ?? {}), ...data }; + if (row.id === undefined) { + if (this.options.stringId) throw new Error(`${this.name}.create needs an id`); + row.id = this.nextId++; + } else if (typeof row.id === "number") { + this.nextId = Math.max(this.nextId, row.id + 1); + } + if (row.createdAt === undefined) row.createdAt = new Date(); + return row; + } + + async findMany(args: Row = {}): Promise { + let list = this.rows.filter((r) => this.matches(r, args.where)); + if (args.orderBy) list = sortBy(list, args.orderBy); + if (args.skip) list = list.slice(args.skip); + if (args.take) list = list.slice(0, args.take); + return list.map((r) => this.shape(r, args)); + } + + async findFirst(args: Row = {}): Promise { + const [first] = await this.findMany({ ...args, take: 1 }); + return first ?? null; + } + + async findUnique(args: Row): Promise { + const where = flattenCompound(args.where); + const row = this.rows.find((r) => this.matches(r, where)); + return row ? this.shape(row, args) : null; + } + + async count(args: Row = {}): Promise { + return this.rows.filter((r) => this.matches(r, args.where)).length; + } + + async create(args: Row): Promise { + const row = this.fillDefaults(args.data); + this.checkUniques(row); + this.rows.push(row); + return this.shape(row, args); + } + + async createMany(args: Row): Promise<{ count: number }> { + let count = 0; + for (const data of args.data as Row[]) { + const row = this.fillDefaults(data); + if (this.isDuplicate(row)) { + if (args.skipDuplicates) continue; + this.checkUniques(row); + } + this.rows.push(row); + count += 1; + } + return { count }; + } + + async update(args: Row): Promise { + const where = flattenCompound(args.where); + const row = this.rows.find((r) => this.matches(r, where)); + if (!row) { + throw new Prisma.PrismaClientKnownRequestError("Record to update not found", { code: "P2025", clientVersion: "test" }); + } + Object.assign(row, args.data); + this.checkUniques(row, row.id); + return this.shape(row, args); + } + + async updateMany(args: Row): Promise<{ count: number }> { + const list = this.rows.filter((r) => this.matches(r, args.where)); + for (const row of list) Object.assign(row, args.data); + return { count: list.length }; + } + + async upsert(args: Row): Promise { + const existing = await this.findUnique({ where: args.where }); + if (existing) return this.update({ where: args.where, data: args.update }); + return this.create({ data: args.create }); + } + + async deleteMany(args: Row = {}): Promise<{ count: number }> { + const before = this.rows.length; + this.rows = this.rows.filter((r) => !this.matches(r, args.where)); + return { count: before - this.rows.length }; + } + + async groupBy(args: Row): Promise { + const [field] = args.by as string[]; + const groups = new Map(); + for (const row of this.rows.filter((r) => this.matches(r, args.where))) { + groups.set(row[field], (groups.get(row[field]) ?? 0) + 1); + } + let out = [...groups.entries()].map(([value, count]) => ({ [field]: value, _count: { _all: count } })); + out.sort((a, b) => b._count._all - a._count._all); + if (args.take) out = out.slice(0, args.take); + return out; + } +} + +function pick(row: Row, select: Row): Row { + const out: Row = {}; + for (const [key, on] of Object.entries(select)) if (on) out[key] = row[key]; + return out; +} + +function sortBy(list: Row[], orderBy: Row | Row[]): Row[] { + const orders = Array.isArray(orderBy) ? orderBy : [orderBy]; + return [...list].sort((a, b) => { + for (const order of orders) { + const [key, dir] = Object.entries(order)[0]; + if (typeof dir === "object") continue; + const c = cmp(a[key], b[key]); + if (c !== 0) return dir === "desc" ? -c : c; + } + return 0; + }); +} + +/** `{ campaignId_userId: { campaignId, userId } }` -> `{ campaignId, userId }` */ +function flattenCompound(where: Row): Row { + const out: Row = {}; + for (const [key, value] of Object.entries(where)) { + if (key.includes("_") && value && typeof value === "object" && !(value instanceof Date) && !("in" in value)) { + Object.assign(out, value); + } else out[key] = value; + } + return out; +} + +export function createFakePrisma() { + const tables: Record = {}; + const t = (name: string, options?: ConstructorParameters[1]) => (tables[name] = new FakeTable(name, options)); + + t("emailPreference", { + uniques: [["userId"], ["unsubscribeToken"]], + defaults: { + email: null, + subscribed: true, + frequency: "weekly", + pausedUntil: null, + unsubscribedAt: null, + unsubscribeReason: null, + lastSentAt: null, + }, + }); + t("campaignVariant", { + uniques: [["campaignId", "key"]], + defaults: { isHoldout: false, subject: null, intro: null, jobCount: null }, + }); + t("campaign", { + uniques: [["key"]], + defaults: { status: "draft", jobCount: 5, createdBy: null, startedAt: null, completedAt: null }, + relations: { + variants: (row) => tables.campaignVariant.rows.filter((v) => v.campaignId === row.id), + sends: (row) => tables.nudgeSend.rows.filter((s) => s.campaignId === row.id), + }, + }); + t("nudgeSend", { + stringId: true, + uniques: [["campaignId", "userId"], ["providerMessageId"]], + defaults: { + variantId: null, + providerMessageId: null, + subject: null, + error: null, + sentAt: null, + deliveredAt: null, + openedAt: null, + clickedAt: null, + bouncedAt: null, + complainedAt: null, + unsubscribedAt: null, + }, + relations: { campaign: (row) => tables.campaign.rows.find((c) => c.id === row.campaignId) ?? null }, + }); + t("nudgeClick", { + defaults: { jobId: null, url: null }, + relations: { send: (row) => tables.nudgeSend.rows.find((s) => s.id === row.sendId) ?? null }, + }); + t("emailEvent", { uniques: [["providerEventId"]] }); + t("appSetting", { uniques: [["key"]] }); + t("job", { + uniques: [["sourceUrl"]], + relations: { recruiter: (row) => tables.recruiter.rows.find((r) => r.id === row.recruiterId) ?? null }, + }); + t("recruiter", { uniques: [["linkedinUrl"]] }); + t("jobApplication", { uniques: [["jobId", "userId"]] }); + + const prisma: Row = { ...tables }; + prisma.$transaction = async (arg: unknown) => { + if (typeof arg === "function") return arg(prisma); + return Promise.all(arg as Promise[]); + }; + prisma.$reset = () => { + for (const table of Object.values(tables)) table.rows = []; + }; + return prisma as typeof prisma & Record & { $reset(): void }; +} + +/** A tiny Mongo collection fake for the `profiles` collection. */ +export function createFakeProfiles(docs: Row[]) { + const matches = (doc: Row, filter: Row = {}): boolean => { + for (const [key, cond] of Object.entries(filter)) { + const value = key.split(".").reduce((acc, part) => acc?.[part], doc); + if (cond && typeof cond === "object" && !(cond instanceof Date)) { + if ("$exists" in cond && (value !== undefined) !== cond.$exists) return false; + if ("$in" in cond && !cond.$in.includes(value)) return false; + if ("$gte" in cond && !(cmp(value, cond.$gte) >= 0)) return false; + } else if (!eq(value, cond)) return false; + } + return true; + }; + return { + docs, + find(filter: Row = {}) { + let list = docs.filter((d) => matches(d, filter)); + const cursor = { + sort(spec: Row) { + const [key, dir] = Object.entries(spec)[0]; + list = [...list].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0) * (dir === -1 ? -1 : 1)); + return cursor; + }, + limit(n: number) { + list = list.slice(0, n); + return cursor; + }, + async toArray() { + return list.map((d) => ({ ...d })); + }, + }; + return cursor; + }, + async findOne(filter: Row) { + return docs.find((d) => matches(d, filter)) ?? null; + }, + async countDocuments(filter: Row = {}) { + return docs.filter((d) => matches(d, filter)).length; + }, + }; +} diff --git a/backend/tests/match-score.test.ts b/backend/tests/match-score.test.ts new file mode 100644 index 0000000..85eda4b --- /dev/null +++ b/backend/tests/match-score.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { computeTagOverlapScore, isAdjacentSeniority } from "../src/lib/match-score"; +import { computeTagOverlapScore as siteScore } from "../../frontend/src/lib/match"; + +type SiteJob = Parameters[0]; +type SiteProfile = Parameters[1]; + +const profile = { roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: ["java", "python", "sql", "aws"] }; + +describe("computeTagOverlapScore", () => { + it("scores a full match 100 and weights stack 40 / role 30 / seniority 20 / remote 10", () => { + expect(computeTagOverlapScore({ roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: ["java", "python", "sql", "aws"] }, profile)).toBe(100); + expect(computeTagOverlapScore({ roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: ["python", "nodejs", "java"] }, profile)).toBe(80); + expect(computeTagOverlapScore({ roleFamily: "product", seniority: "junior", remoteMode: "in_office", stack: ["java", "python", "sql", "aws"] }, profile)).toBe(70); + }); + + it("gives adjacent seniority half credit", () => { + expect(isAdjacentSeniority("junior", "mid")).toBe(true); + expect(isAdjacentSeniority("junior", "senior")).toBe(false); + expect(computeTagOverlapScore({ roleFamily: "engineering", seniority: "mid", remoteMode: "in_office", stack: ["java", "python", "sql", "aws"] }, profile)).toBe(90); + expect(computeTagOverlapScore({ roleFamily: "engineering", seniority: "senior", remoteMode: "in_office", stack: ["java", "python", "sql", "aws"] }, profile)).toBe(80); + }); + + it("treats a job with no stack tags as neutral, not as a zero", () => { + const noStack = computeTagOverlapScore({ roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: [] }, profile); + const halfOverlap = computeTagOverlapScore({ roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: ["java", "python", "nodejs", "go"] }, profile); + const noOverlap = computeTagOverlapScore({ roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: ["go"] }, profile); + expect(noStack).toBe(80); + expect(noStack).toBe(halfOverlap); + expect(noStack).toBeGreaterThan(noOverlap); + }); + + it("agrees with the site for a profile with no stack: the job's stack still counts against it", () => { + const noStackProfile = { roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: [] }; + const goJob = { roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: ["go"] }; + const noStackJob = { ...goJob, stack: [] }; + expect(computeTagOverlapScore(goJob, noStackProfile)).toBe(60); + expect(computeTagOverlapScore(goJob, noStackProfile)).toBe(siteScore(goJob as SiteJob, noStackProfile as SiteProfile)); + expect(computeTagOverlapScore(noStackJob, noStackProfile)).toBe(100); + expect(computeTagOverlapScore(noStackJob, noStackProfile)).toBe(siteScore(noStackJob as SiteJob, noStackProfile as SiteProfile)); + }); + + it("agrees with the site whenever the job lists a stack; only the no-stack job differs by design", () => { + const jobs = [ + { roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: ["java", "python", "sql", "aws"] }, + { roleFamily: "engineering", seniority: "mid", remoteMode: "remote", stack: ["python", "nodejs", "java"] }, + { roleFamily: "product", seniority: "senior", remoteMode: "hybrid", stack: ["go"] }, + ]; + for (const job of jobs) { + expect(computeTagOverlapScore(job, profile)).toBe(siteScore(job as SiteJob, profile as SiteProfile)); + } + const noStackJob = { roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: [] }; + expect(siteScore(noStackJob as SiteJob, profile as SiteProfile)).toBe(60); + expect(computeTagOverlapScore(noStackJob, profile)).toBe(80); + }); + + it("normalises hyphenated frontend spellings against the stored enums", () => { + expect(computeTagOverlapScore({ roleFamily: "ai-ml", seniority: "junior", remoteMode: "in-office", stack: ["nextjs"] }, { roleFamily: "ai_ml", seniority: "junior", remoteMode: "in_office", stack: ["nextjs"] })).toBe(100); + }); + + it("returns 0 when the profile has nothing to match on", () => { + expect(computeTagOverlapScore({ roleFamily: "engineering", seniority: "junior", remoteMode: "remote", stack: [] }, { roleFamily: "", seniority: "", remoteMode: "", stack: [] })).toBe(0); + }); +}); diff --git a/backend/tests/nudge-render.test.ts b/backend/tests/nudge-render.test.ts new file mode 100644 index 0000000..c531cad --- /dev/null +++ b/backend/tests/nudge-render.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { companiesLine, formatPostedDate, renderNudgeEmail, renderSubject, type RenderJob } from "../src/lib/nudge-render"; + +const jobs: RenderJob[] = [ + { + id: 23, + title: "SDE 1", + company: "M2P Fintech", + author: "Srinivasarao Narayanasetty", + posterKind: "Hiring manager", + posterLine: "Engineering Manager at M2P", + levelLabel: "SDE-1 level", + remoteLabel: "In-office", + stack: ["python", "nodejs", "java"], + postedAt: new Date("2026-09-23T10:00:00Z"), + bullets: ["Fast-paced fintech environment where work ships and scales", "Large-scale fintech products by millions"], + matchPercent: 78, + }, + { + id: 91, + title: "Software Engineer", + company: "Zepto", + author: "Sthiti M", + posterKind: "Recruiter", + posterLine: "Talent Acquisition @Zepto", + levelLabel: "SDE-1 level", + remoteLabel: "In-office", + stack: [], + postedAt: new Date("2026-09-28T10:00:00Z"), + bullets: ["1-3 years of experience as an SDE."], + matchPercent: 60, + }, +]; + +const input = { + recipientName: "Ankit", + jobs, + sendId: "send-abc", + campaignKey: "weekly-2026-W40", + siteUrl: "https://skiptheboard.in", + unsubscribeUrl: "https://skiptheboard.in/unsubscribe?t=TOKEN", + preferencesUrl: "https://skiptheboard.in/profile#email", +}; + +describe("renderNudgeEmail", () => { + it("builds the default subject from count and companies", () => { + expect(companiesLine(jobs)).toBe("M2P Fintech, Zepto"); + expect(renderSubject(input)).toBe("2 jobs that match your resume: M2P Fintech, Zepto"); + expect(renderSubject({ ...input, subjectTemplate: "{name}, {count} new matches" })).toBe("Ankit, 2 new matches"); + }); + + it("shows every required field for each job", () => { + const { html, text } = renderNudgeEmail(input); + for (const out of [html, text]) { + expect(out).toContain("SDE 1"); + expect(out).toContain("M2P Fintech"); + expect(out).toContain("Srinivasarao Narayanasetty"); + expect(out).toContain("Hiring manager"); + expect(out).toContain("Recruiter"); + expect(out).toContain("SDE-1 level"); + expect(out).toContain("In-office"); + expect(out).toContain("Posted 23 Sep"); + expect(out).toContain("Posted 28 Sep"); + expect(out).toContain("Python, Node.js, Java"); + expect(out).toContain("Match 78%"); + expect(out).toContain("Match 60%"); + expect(out).toContain("Fast-paced fintech environment"); + expect(out).toContain("1-3 years of experience as an SDE."); + } + }); + + it("routes job links through /go// and tags site links with UTM", () => { + const { html, text } = renderNudgeEmail(input); + expect(html).toContain("https://skiptheboard.in/go/send-abc/23"); + expect(text).toContain("https://skiptheboard.in/go/send-abc/91"); + expect(html).toContain("utm_source=nudge"); + expect(html).toContain("utm_medium=email"); + expect(html).toContain("utm_campaign=weekly-2026-W40"); + expect(html).toContain("https://skiptheboard.in/unsubscribe?t=TOKEN"); + expect(text).toContain("Unsubscribe in one click: https://skiptheboard.in/unsubscribe?t=TOKEN"); + }); + + it("renders an inert footer with no unsubscribe link when no unsubscribe URL is given", () => { + const { html, text } = renderNudgeEmail({ ...input, unsubscribeUrl: null }); + for (const out of [html, text]) { + expect(out).toContain("unsubscribe disabled in test sends"); + expect(out).not.toContain("/unsubscribe"); + } + expect(html).toContain("https://skiptheboard.in/profile#email"); + }); + + it("escapes HTML in job content and uses the variant intro", () => { + const { html, text } = renderNudgeEmail({ ...input, intro: "Hand-picked for {name}." }); + expect(html).toContain("<used>"); + expect(html).not.toContain(""); + expect(html).toContain("Hand-picked for Ankit."); + expect(text).toContain("Hand-picked for Ankit."); + expect(html).toContain("Hi Ankit,"); + }); + + it("formats dates in UTC", () => { + expect(formatPostedDate(new Date("2026-01-05T23:30:00Z"))).toBe("5 Jan"); + }); +}); diff --git a/backend/tests/nudge-select.test.ts b/backend/tests/nudge-select.test.ts new file mode 100644 index 0000000..c3110fd --- /dev/null +++ b/backend/tests/nudge-select.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { + effectiveSeniority, + isSeniorRole, + minimumYears, + pickBullets, + posterKind, + posterLine, + selectJobsForUser, + type NudgeJobInput, +} from "../src/lib/nudge-select"; + +const NOW = new Date("2026-09-29T08:00:00Z"); +const d = (iso: string) => new Date(iso); + +// Fixtures mirror live rows from the design snapshot. +function job(overrides: Partial & { id: number }): NudgeJobInput { + return { + title: "SDE 1", + company: "M2P Fintech", + author: "Srinivasarao Narayanasetty", + authorTitle: "Engineering Manager at M2P", + source: "linkedin", + sourceUrl: `https://www.linkedin.com/posts/example-${overrides.id}`, + roleFamily: "engineering", + seniority: "junior", + remoteMode: "in_office", + stack: ["python", "nodejs", "java"], + description: [ + "Fast-paced fintech environment where work ships and scales", + "Large-scale fintech products used by millions with strong growth paths", + ], + rawText: "We are hiring SDE 1 at M2P Fintech", + postedAt: d("2026-09-23T10:00:00Z"), + createdAt: d("2026-09-26T04:00:00Z"), + ...overrides, + }; +} + +const sde1Profile = { roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: ["java", "python", "sql", "aws"] }; + +describe("senior override", () => { + it("reads the smallest stated years figure", () => { + expect(minimumYears("7-12 YOE in backend")).toBe(7); + expect(minimumYears("5+ years of experience")).toBe(5); + expect(minimumYears("3–5 years of experience building scalable systems")).toBe(3); + expect(minimumYears("Exp. 6 yrs in Java, minimum 4 yrs with Spring")).toBe(4); + expect(minimumYears("at least 8 years")).toBe(8); + expect(minimumYears("6+ yrs")).toBe(6); + expect(minimumYears("no years here")).toBeNull(); + }); + + it("ignores years figures that are not about experience", () => { + expect(minimumYears("a fintech with 12 years in market")).toBeNull(); + expect(minimumYears("products built over 10 years")).toBeNull(); + expect(minimumYears("founded 20 years ago; 3+ years of experience required")).toBe(3); + }); + + it("reads a decimal figure whole, never from its fractional part", () => { + expect(minimumYears("1.5+ years of experience")).toBe(1.5); + expect(minimumYears("2.5 years of experience in Java")).toBe(2.5); + expect(minimumYears("1.5-3 years of experience")).toBe(1.5); + expect(minimumYears("4.5-7 years of experience")).toBe(4.5); + }); + + it("flags SDE III and 5+ years as senior even when stored as mid", () => { + expect(isSeniorRole({ title: "SDE III – Backend", description: [] })).toBe(true); + expect(isSeniorRole({ title: "Backend Engineer", description: ["7-12 YOE", "Hybrid in Delhi NCR"] })).toBe(true); + expect(isSeniorRole({ title: "Backend Engineer", description: ["5+ years of experience"] })).toBe(true); + expect(isSeniorRole({ title: "Staff Engineer", description: [] })).toBe(true); + expect(effectiveSeniority({ title: "SDE III – Backend", description: [], seniority: "mid" })).toBe("senior"); + }); + + it("leaves genuine SDE-1 / SDE-2 rows alone", () => { + expect(isSeniorRole({ title: "Software Engineer", description: ["1-3 years of experience as an SDE."] })).toBe(false); + expect(effectiveSeniority({ title: "DevOps Engineer II", description: ["3–5 years of experience"], seniority: "mid" })).toBe("mid"); + expect(effectiveSeniority({ title: "Associate SDE", description: ["Looking for freshers"], seniority: "intern" })).toBe("intern"); + expect(effectiveSeniority({ title: "SDE 1", description: ["a fintech with 12 years in market", "Bangalore"], seniority: "junior" })).toBe("junior"); + expect(effectiveSeniority({ title: "SDE 1", description: ["1.5+ years of experience"], seniority: "junior" })).toBe("junior"); + expect(effectiveSeniority({ title: "SDE 1", description: ["2.5 years of experience in Java"], seniority: "junior" })).toBe("junior"); + expect(effectiveSeniority({ title: "SDE 2", description: ["4.5-7 years of experience"], seniority: "mid" })).toBe("mid"); + }); + + it("does not demote a role that is already senior", () => { + expect(effectiveSeniority({ title: "Head of Engineering", description: [], seniority: "head" })).toBe("head"); + }); +}); + +describe("poster labelling", () => { + it("labels recruiters, hiring managers and engineers", () => { + expect(posterKind("Talent Acquisition @Zepto")).toBe("Recruiter"); + expect(posterKind("Talent Acquisition Manager at Hevo")).toBe("Recruiter"); + expect(posterKind("Engineering Manager at M2P")).toBe("Hiring manager"); + expect(posterKind("Co-founder & CTO | Building Acme")).toBe("Hiring manager"); + expect(posterKind("Software Engineer II @ Microsoft")).toBe("Engineer (referral)"); + }); + + it("uses the first headline segment that names a role, capped at 60 chars", () => { + expect(posterLine("I talk to strangers for a living… | Talent Acquisition | Baazi Games")).toBe("Talent Acquisition"); + expect(posterLine("Engineering Manager at M2P | Ex-Amazon")).toBe("Engineering Manager at M2P"); + const long = posterLine("Senior Engineering Manager leading platform, payments and infrastructure teams at a fintech"); + expect(long.length).toBeLessThanOrEqual(60); + expect(long.endsWith("…")).toBe(true); + }); +}); + +describe("pickBullets", () => { + it("prefers bullets that state years or a location, keeping original order", () => { + const bullets = pickBullets([ + "High impact, zero micromanagement environment with a modern codebase.", + "1-3 years of experience as a Software Development Engineer (SDE).", + "Join us in Bangalore.", + "Free snacks.", + ]); + expect(bullets).toEqual([ + "1-3 years of experience as a Software Development Engineer (SDE).", + "Join us in Bangalore.", + ]); + }); + + it("prefers any years mention for bullets, even one that does not lift seniority", () => { + const description = ["Great culture", "5-8 years in backend", "Bangalore office", "Experience: 3+ years"]; + expect(pickBullets(description)).toEqual(["5-8 years in backend", "Experience: 3+ years"]); + expect(effectiveSeniority({ title: "SDE 1", description: ["5-8 years in backend"], seniority: "junior" })).toBe("junior"); + expect(pickBullets(["Great culture", "1.5+ years of experience", "Free snacks", "Bangalore office"])).toEqual([ + "1.5+ years of experience", + "Bangalore office", + ]); + }); + + it("falls back to the first two bullets", () => { + expect(pickBullets(["a", "b", "c"])).toEqual(["a", "b"]); + expect(pickBullets([])).toEqual([]); + }); +}); + +describe("selectJobsForUser", () => { + it("ranks by match and repairs the company through the ingest gates", () => { + const jobs = [ + job({ id: 23 }), + job({ id: 91, title: "Software Engineer", company: "Bangalore", authorTitle: "Talent Acquisition @Swiggy", stack: ["go"] }), + ]; + const { picks, poolSize } = selectJobsForUser({ jobs, profile: sde1Profile, now: NOW }); + expect(poolSize).toBe(2); + expect(picks.map((j) => j.id)).toEqual([23, 91]); + expect(picks[0].matchPercent).toBeGreaterThan(picks[1].matchPercent); + expect(picks[1].company).toBe("Swiggy"); + expect(picks[0].posterKind).toBe("Hiring manager"); + expect(picks[0].levelLabel).toBe("SDE-1 level"); + }); + + it("drops rows that fail the gates: off-target roles, recruiter titles, malformed titles", () => { + const jobs = [ + job({ id: 1, roleFamily: "sales", title: "Account Executive" }), + job({ id: 2, title: "Technical Recruiter" }), + job({ id: 3, title: "|| Bangalore", rawText: "Bangalore office opening" }), + job({ id: 4 }), + ]; + const { picks } = selectJobsForUser({ jobs, profile: sde1Profile, now: NOW }); + expect(picks.map((j) => j.id)).toEqual([4]); + }); + + it("applies the senior override so an SDE III is not shown as SDE-2", () => { + const jobs = [job({ id: 8, title: "SDE III – Backend", company: "Baazi Games", seniority: "mid" }), job({ id: 23 })]; + const { picks } = selectJobsForUser({ jobs, profile: sde1Profile, now: NOW }); + const senior = picks.find((j) => j.id === 8)!; + expect(senior.seniority).toBe("senior"); + expect(senior.seniorOverride).toBe(true); + expect(senior.levelLabel).toBe("Senior / SDE-3+"); + expect(picks[0].id).toBe(23); + expect(senior.matchPercent).toBeLessThan(picks[0].matchPercent); + }); + + it("keeps an SDE-1 post junior when a bullet states the company's age rather than experience", () => { + const jobs = [job({ id: 30, description: ["a fintech with 12 years in market", "Bangalore"] })]; + const { picks } = selectJobsForUser({ jobs, profile: sde1Profile, now: NOW }); + expect(picks).toHaveLength(1); + expect(picks[0].seniority).toBe("junior"); + expect(picks[0].seniorOverride).toBe(false); + expect(picks[0].levelLabel).toBe("SDE-1 level"); + }); + + it("never repeats a job already sent to this user", () => { + const jobs = [job({ id: 23 }), job({ id: 24 })]; + const { picks } = selectJobsForUser({ jobs, profile: sde1Profile, excludeJobIds: [23], now: NOW }); + expect(picks.map((j) => j.id)).toEqual([24]); + }); + + it("skips expired jobs and returns empty picks when nothing qualifies", () => { + const jobs = [job({ id: 1, postedAt: d("2026-08-01T00:00:00Z") })]; + const result = selectJobsForUser({ jobs, profile: sde1Profile, now: NOW }); + expect(result.picks).toEqual([]); + expect(result.poolSize).toBe(0); + }); + + it("prefers jobs ingested since the last nudge, then fills with older ones", () => { + const jobs = [ + job({ id: 1, createdAt: d("2026-09-20T00:00:00Z"), stack: ["java", "python", "sql", "aws"] }), + job({ id: 2, createdAt: d("2026-09-28T00:00:00Z"), stack: [] }), + ]; + const since = d("2026-09-22T00:00:00Z"); + const { picks, newCount } = selectJobsForUser({ jobs, profile: sde1Profile, sinceCreatedAt: since, now: NOW, limit: 2 }); + expect(newCount).toBe(1); + expect(picks.map((j) => j.id)).toEqual([2, 1]); + expect(picks[0].isNew).toBe(true); + expect(picks[1].isNew).toBe(false); + }); + + it("honours the limit", () => { + const jobs = Array.from({ length: 8 }, (_, i) => job({ id: i + 1 })); + expect(selectJobsForUser({ jobs, profile: sde1Profile, now: NOW }).picks).toHaveLength(5); + expect(selectJobsForUser({ jobs, profile: sde1Profile, now: NOW, limit: 3 }).picks).toHaveLength(3); + }); +}); diff --git a/backend/tests/nudge-send.test.ts b/backend/tests/nudge-send.test.ts new file mode 100644 index 0000000..464c0af --- /dev/null +++ b/backend/tests/nudge-send.test.ts @@ -0,0 +1,387 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { emails, clerkLookup, CRON_SECRET } = vi.hoisted(() => { + const CRON_SECRET = "cron-test-secret"; + process.env.CRON_SECRET = CRON_SECRET; + const emails = {} as Record; + const clerkLookup = async (ids: string[]) => + new Map(ids.filter((id) => emails[id]).map((id) => [id, { userId: id, email: emails[id], firstName: null }])); + return { emails, clerkLookup, CRON_SECRET }; +}); + +vi.mock("../src/lib/prisma", async () => { + const { createFakePrisma } = await import("./helpers/fake-prisma"); + return { prisma: createFakePrisma() }; +}); +vi.mock("../src/lib/mongo", async () => { + const { createFakeProfiles } = await import("./helpers/fake-prisma"); + const profiles = createFakeProfiles([]); + return { getProfilesCollection: async () => profiles, getDb: async () => null }; +}); +vi.mock("../src/lib/clerk-users", () => ({ fetchClerkUsers: vi.fn(clerkLookup) })); +vi.mock("@vercel/functions", () => ({ waitUntil: vi.fn() })); + +import app from "../src/index"; +import { prisma } from "../src/lib/prisma"; +import { getProfilesCollection } from "../src/lib/mongo"; +import { fetchClerkUsers } from "../src/lib/clerk-users"; +import type { EmailProvider, OutgoingEmail } from "../src/lib/email"; +import { + ensureScheduledCampaign, + isSchedulePaused, + isoWeekKey, + nextNudgeTick, + runCampaign, + scheduledCampaignFor, + sendTestNudge, + setSchedulePaused, + type RunCampaignResult, +} from "../src/lib/nudge-send"; + +const db = prisma as unknown as ReturnType; +const NOW = new Date("2026-09-28T02:30:00Z"); // a Monday + +class RecordingProvider implements EmailProvider { + readonly name = "recording"; + batches: OutgoingEmail[][] = []; + constructor(readonly dryRun: boolean, private fail = false) {} + async sendBatch(emailsIn: OutgoingEmail[]) { + this.batches.push(emailsIn); + if (this.fail) return { ok: false, ids: emailsIn.map(() => null), error: "boom", dryRun: this.dryRun }; + return { ok: true, ids: emailsIn.map((_, i) => (this.dryRun ? null : `msg_${this.batches.length}_${i}`)), dryRun: this.dryRun }; + } + get sent(): OutgoingEmail[] { + return this.batches.flat(); + } +} + +function seedJob(id: number, overrides: Record = {}) { + db.job.rows.push({ + id, + recruiterId: 1, + title: `SDE 1 #${id}`, + company: "M2P Fintech", + author: "Srinivasarao", + authorTitle: "Engineering Manager at M2P", + source: "linkedin", + sourceUrl: `https://www.linkedin.com/posts/job-${id}`, + roleFamily: "engineering", + seniority: "junior", + remoteMode: "in_office", + stack: ["python", "java"], + description: ["2+ years of experience", "Bangalore"], + rawText: "Hiring SDE 1", + postedAt: new Date("2026-09-23T10:00:00Z"), + createdAt: new Date("2026-09-26T04:00:00Z"), + ...overrides, + }); +} + +const sdeProfile = (userId: string) => ({ + _id: userId, + contact: { name: `${userId} Name`, email: "resume@example.com" }, + filterSummary: { currentTitle: "SDE", roleFamily: "engineering", seniority: "junior", remoteMode: "in_office", stack: ["java", "python"] }, + createdAt: new Date("2026-09-01T00:00:00Z"), +}); + +async function seedProfiles(ids: string[]) { + const collection = (await getProfilesCollection()) as unknown as { docs: Record[] }; + collection.docs.length = 0; + for (const id of ids) collection.docs.push(sdeProfile(id)); +} + +async function createCampaign(kind: "weekly" | "daily" | "manual" | "experiment" = "weekly", variants: Record[] = []) { + const campaign = await db.campaign.create({ + data: { key: `${kind}-${Math.random().toString(36).slice(2)}`, name: "Test", kind, status: "draft", jobCount: 5, createdBy: "test" }, + }); + for (const v of variants) await db.campaignVariant.create({ data: { campaignId: campaign.id, ...v } }); + return campaign; +} + +beforeEach(async () => { + db.$reset(); + for (const key of Object.keys(emails)) delete emails[key]; + await seedProfiles([]); + for (let i = 1; i <= 6; i += 1) seedJob(i); +}); + +describe("runCampaign", () => { + it("dry-runs without a provider key: renders, records one row per recipient, calls the provider once", async () => { + await seedProfiles(["user_a", "user_b"]); + emails.user_a = "a@example.com"; + emails.user_b = "b@example.com"; + const campaign = await createCampaign(); + const provider = new RecordingProvider(true); + + const result = await runCampaign({ campaignId: campaign.id, provider, now: NOW }); + + expect(result).toMatchObject({ dryRun: true, candidates: 2, processed: 2, sent: 2, skipped: 0, failed: 0, remaining: 0, completed: true }); + expect(provider.batches).toHaveLength(1); + expect(provider.sent.map((e) => e.to).sort()).toEqual(["a@example.com", "b@example.com"]); + expect(provider.sent[0].headers?.["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click"); + expect(provider.sent[0].headers?.["List-Unsubscribe"]).toMatch(/^$/); + + const rows = await db.nudgeSend.findMany({}); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.status).toBe("dry_run"); + expect(row.jobIds).toHaveLength(5); + expect(row.subject).toContain("5 jobs that match your resume"); + expect(row.providerMessageId).toBeNull(); + } + const prefs = await db.emailPreference.findMany({}); + expect(prefs).toHaveLength(2); + expect(prefs.every((p) => p.lastSentAt instanceof Date && p.email)).toBe(true); + expect((await db.campaign.findUnique({ where: { id: campaign.id } }))!.status).toBe("completed"); + }); + + it("is idempotent per user per campaign: a re-run sends nothing new", async () => { + await seedProfiles(["user_a"]); + emails.user_a = "a@example.com"; + const campaign = await createCampaign(); + const provider = new RecordingProvider(false); + + const first = await runCampaign({ campaignId: campaign.id, provider, now: NOW }); + expect(first.sent).toBe(1); + const row = (await db.nudgeSend.findMany({}))[0]; + expect(row.status).toBe("sent"); + expect(row.providerMessageId).toBe("msg_1_0"); + + // Reopen the campaign as if a retry fired before completion was recorded. + await db.campaign.update({ where: { id: campaign.id }, data: { status: "running" } }); + const second = await runCampaign({ campaignId: campaign.id, provider, now: NOW }); + expect(second.candidates).toBe(0); + expect(second.sent).toBe(0); + expect(provider.batches).toHaveLength(1); + expect(await db.nudgeSend.count({})).toBe(1); + }); + + it("never double-sends when a second run of the same campaign overlaps with this one", async () => { + await seedProfiles(["user_a"]); + emails.user_a = "a@example.com"; + const campaign = await createCampaign(); + const provider = new RecordingProvider(false); + + // The overlapping run starts after this run has listed its recipients but + // before it has written its rows, and finishes first. + const overlapping: RunCampaignResult[] = []; + vi.mocked(fetchClerkUsers).mockImplementationOnce(async (ids) => { + overlapping.push(await runCampaign({ campaignId: campaign.id, provider, now: NOW })); + return clerkLookup(ids); + }); + + const late = await runCampaign({ campaignId: campaign.id, provider, now: NOW }); + const [early] = overlapping; + + expect(early.sent).toBe(1); + expect(late.sent).toBe(0); + expect(late.alreadyClaimed).toBe(1); + expect(late.failed).toBe(0); + expect(late.completed).toBe(true); + expect(provider.sent.map((e) => e.to)).toEqual(["a@example.com"]); + const rows = await db.nudgeSend.findMany({}); + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe("sent"); + expect((await db.campaign.findUnique({ where: { id: campaign.id } }))!.status).toBe("completed"); + }); + + it("skips a user with zero qualifying jobs instead of sending an empty email", async () => { + await seedProfiles(["user_a"]); + emails.user_a = "a@example.com"; + const earlier = await createCampaign(); + await db.nudgeSend.create({ + data: { id: "prev", campaignId: earlier.id, userId: "user_a", email: "a@example.com", status: "sent", jobIds: [1, 2, 3, 4, 5, 6], sentAt: new Date("2026-09-21T02:30:00Z") }, + }); + const campaign = await createCampaign(); + const provider = new RecordingProvider(true); + + const result = await runCampaign({ campaignId: campaign.id, provider, now: NOW }); + + expect(result.skipped).toBe(1); + expect(result.sent).toBe(0); + expect(provider.batches).toHaveLength(0); + const row = await db.nudgeSend.findUnique({ where: { campaignId_userId: { campaignId: campaign.id, userId: "user_a" } } }); + expect(row!.status).toBe("skipped"); + }); + + it("never emails unsubscribed or paused users, and daily campaigns only reach daily subscribers", async () => { + await seedProfiles(["user_unsub", "user_paused", "user_weekly", "user_daily"]); + for (const id of ["user_unsub", "user_paused", "user_weekly", "user_daily"]) emails[id] = `${id}@example.com`; + await db.emailPreference.create({ data: { userId: "user_unsub", subscribed: false, frequency: "weekly", unsubscribeToken: "t1", unsubscribedAt: NOW } }); + await db.emailPreference.create({ data: { userId: "user_paused", subscribed: true, frequency: "weekly", unsubscribeToken: "t2", pausedUntil: new Date("2026-10-30T00:00:00Z") } }); + await db.emailPreference.create({ data: { userId: "user_daily", subscribed: true, frequency: "daily", unsubscribeToken: "t3" } }); + + const weekly = await createCampaign("weekly"); + const weeklyProvider = new RecordingProvider(true); + const weeklyResult = await runCampaign({ campaignId: weekly.id, provider: weeklyProvider, now: NOW }); + expect(weeklyResult.inactive).toBe(2); + expect(weeklyProvider.sent.map((e) => e.to).sort()).toEqual(["user_daily@example.com", "user_weekly@example.com"]); + + const daily = await createCampaign("daily"); + const dailyProvider = new RecordingProvider(true); + await runCampaign({ campaignId: daily.id, provider: dailyProvider, now: NOW }); + expect(dailyProvider.sent.map((e) => e.to)).toEqual(["user_daily@example.com"]); + }); + + it("splits an experiment deterministically and sends nothing to the holdout arm", async () => { + const ids = Array.from({ length: 40 }, (_, i) => `user_${i}`); + await seedProfiles(ids); + for (const id of ids) emails[id] = `${id}@example.com`; + const campaign = await createCampaign("experiment", [ + { key: "A", name: "Short subject", weight: 40, isHoldout: false, subject: "{count} matches for {name}", intro: null, jobCount: 3 }, + { key: "B", name: "Default", weight: 40, isHoldout: false, subject: null, intro: null, jobCount: null }, + { key: "control", name: "Holdout", weight: 20, isHoldout: true, subject: null, intro: null, jobCount: null }, + ]); + const variants = await db.campaignVariant.findMany({ where: { campaignId: campaign.id } }); + const byKey = Object.fromEntries(variants.map((v) => [v.key, v.id])); + const provider = new RecordingProvider(false); + + const result = await runCampaign({ campaignId: campaign.id, provider, now: NOW }); + + const rows = await db.nudgeSend.findMany({ where: { campaignId: campaign.id } }); + expect(rows).toHaveLength(40); + expect(rows.every((r) => r.variantId !== null)).toBe(true); + const holdout = rows.filter((r) => r.variantId === byKey.control); + const armA = rows.filter((r) => r.variantId === byKey.A); + const armB = rows.filter((r) => r.variantId === byKey.B); + expect(holdout.length).toBeGreaterThan(0); + expect(armA.length).toBeGreaterThan(0); + expect(armB.length).toBeGreaterThan(0); + expect(holdout.every((r) => r.status === "holdout" && r.jobIds.length === 0)).toBe(true); + expect(result.holdout).toBe(holdout.length); + expect(provider.sent).toHaveLength(40 - holdout.length); + expect(armA.every((r) => r.jobIds.length === 3 && /^\d+ matches for /.test(r.subject))).toBe(true); + expect(armB.every((r) => r.jobIds.length === 5 && r.subject.startsWith("5 jobs that match"))).toBe(true); + expect(armA.every((r) => r.status === "sent" && r.providerMessageId)).toBe(true); + for (const email of provider.sent) { + expect(holdout.some((h) => h.email === email.to)).toBe(false); + } + }); + + it("stops at the time budget and finishes on the next invocation", async () => { + await seedProfiles(["user_a", "user_b", "user_c"]); + for (const id of ["user_a", "user_b", "user_c"]) emails[id] = `${id}@example.com`; + const campaign = await createCampaign(); + const provider = new RecordingProvider(true); + + const first = await runCampaign({ campaignId: campaign.id, provider, now: NOW, pageSize: 1, budgetMs: 0 }); + expect(first.processed).toBe(1); + expect(first.remaining).toBe(2); + expect(first.exhaustedBudget).toBe(true); + expect(first.completed).toBe(false); + expect((await db.campaign.findUnique({ where: { id: campaign.id } }))!.status).toBe("running"); + + const second = await runCampaign({ campaignId: campaign.id, provider, now: NOW }); + expect(second.processed).toBe(2); + expect(second.remaining).toBe(0); + expect(second.completed).toBe(true); + expect(await db.nudgeSend.count({})).toBe(3); + }); + + it("records a failed provider call per row without losing idempotency", async () => { + await seedProfiles(["user_a"]); + emails.user_a = "a@example.com"; + const campaign = await createCampaign(); + const provider = new RecordingProvider(false, true); + + const result = await runCampaign({ campaignId: campaign.id, provider, now: NOW }); + expect(result.failed).toBe(1); + const row = (await db.nudgeSend.findMany({}))[0]; + expect(row.status).toBe("failed"); + expect(row.error).toBe("boom"); + }); + + it("records a user with no Clerk email as failed rather than mailing the resume address", async () => { + await seedProfiles(["user_noemail"]); + const campaign = await createCampaign(); + const provider = new RecordingProvider(true); + const result = await runCampaign({ campaignId: campaign.id, provider, now: NOW }); + expect(result.failed).toBe(1); + expect(provider.sent).toHaveLength(0); + const row = (await db.nudgeSend.findMany({}))[0]; + expect(row.error).toBe("no Clerk email"); + expect(row.email).toBe(""); + }); +}); + +describe("sendTestNudge", () => { + it("mails the typed address with a [TEST] subject, no unsubscribe headers and an inert footer, and records nothing", async () => { + await seedProfiles(["user_a"]); + emails.user_a = "a@example.com"; + const provider = new RecordingProvider(true); + + const result = await sendTestNudge({ to: "captain@example.com", userId: "user_a", provider, now: NOW }); + + expect(result.ok).toBe(true); + expect(result.subject).toMatch(/^\[TEST\] 5 jobs that match your resume/); + expect(provider.sent).toHaveLength(1); + const email = provider.sent[0]; + expect(email.to).toBe("captain@example.com"); + expect(email.headers).toBeUndefined(); + for (const out of [email.html, email.text]) { + expect(out).toContain("unsubscribe disabled in test sends"); + expect(out).not.toContain("/unsubscribe?t="); + } + expect(await db.nudgeSend.count({})).toBe(0); + }); +}); + +describe("scheduled campaigns", () => { + it("starts paused: with no settings row the cron creates no campaign until an admin resumes", async () => { + expect(await isSchedulePaused()).toBe(true); + expect(await ensureScheduledCampaign(NOW)).toBeNull(); + expect(await db.campaign.count({})).toBe(0); + + await setSchedulePaused(false); + expect(await isSchedulePaused()).toBe(false); + const campaign = await ensureScheduledCampaign(NOW); + expect(campaign).toMatchObject({ key: "weekly-2026-W40", kind: "weekly", createdBy: "cron" }); + expect(await db.campaign.count({})).toBe(1); + + await setSchedulePaused(true); + expect(await isSchedulePaused()).toBe(true); + expect(await ensureScheduledCampaign(new Date("2026-10-05T02:30:00Z"))).toBeNull(); + expect(await db.campaign.count({})).toBe(1); + }); + + it("the cron endpoint sends nothing on a fresh deployment and sends once the schedule is resumed", async () => { + await seedProfiles(["user_daily"]); + emails.user_daily = "daily@example.com"; + await db.emailPreference.create({ data: { userId: "user_daily", subscribed: true, frequency: "daily", unsubscribeToken: "t-daily" } }); + db.job.rows.length = 0; + seedJob(1, { postedAt: new Date(), createdAt: new Date() }); + const headers = { Authorization: `Bearer ${CRON_SECRET}` }; + + const paused = await app.request("/api/cron/nudges", { method: "POST", headers }); + expect(paused.status).toBe(200); + await expect(paused.json()).resolves.toMatchObject({ ok: true, skipped: true }); + expect(await db.campaign.count({})).toBe(0); + expect(await db.nudgeSend.count({})).toBe(0); + + await setSchedulePaused(false); + const resumed = await app.request("/api/cron/nudges", { method: "POST", headers }); + expect(resumed.status).toBe(200); + await expect(resumed.json()).resolves.toMatchObject({ ok: true, sent: 1, remaining: 0, completed: true, dryRun: true }); + expect(await db.campaign.count({})).toBe(1); + const rows = await db.nudgeSend.findMany({}); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ userId: "user_daily", status: "dry_run" }); + }); +}); + +describe("schedule helpers", () => { + it("names Monday runs by ISO week and other days by date", () => { + expect(isoWeekKey(new Date("2026-09-28T02:30:00Z"))).toBe("2026-W40"); + expect(scheduledCampaignFor(new Date("2026-09-28T02:30:00Z"))).toMatchObject({ key: "weekly-2026-W40", kind: "weekly" }); + expect(scheduledCampaignFor(new Date("2026-09-29T02:30:00Z"))).toMatchObject({ key: "daily-2026-09-29", kind: "daily" }); + expect(isoWeekKey(new Date("2027-01-01T00:00:00Z"))).toBe("2026-W53"); + }); + + it("finds the next 02:30 UTC tick: today when before it, otherwise tomorrow", () => { + expect(nextNudgeTick(new Date("2026-09-28T01:00:00Z")).toISOString()).toBe("2026-09-28T02:30:00.000Z"); + expect(nextNudgeTick(new Date("2026-09-28T02:30:00Z")).toISOString()).toBe("2026-09-29T02:30:00.000Z"); + expect(nextNudgeTick(new Date("2026-09-27T23:00:00Z")).toISOString()).toBe("2026-09-28T02:30:00.000Z"); + expect(scheduledCampaignFor(nextNudgeTick(new Date("2026-09-27T23:00:00Z")))).toMatchObject({ key: "weekly-2026-W40", kind: "weekly" }); + expect(scheduledCampaignFor(nextNudgeTick(new Date("2026-09-28T10:00:00Z")))).toMatchObject({ key: "daily-2026-09-29", kind: "daily" }); + }); +}); diff --git a/backend/tests/nudge-stats.test.ts b/backend/tests/nudge-stats.test.ts new file mode 100644 index 0000000..36e9ca3 --- /dev/null +++ b/backend/tests/nudge-stats.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/lib/prisma", async () => { + const { createFakePrisma } = await import("./helpers/fake-prisma"); + return { prisma: createFakePrisma() }; +}); +vi.mock("../src/lib/mongo", async () => { + const { createFakeProfiles } = await import("./helpers/fake-prisma"); + const profiles = createFakeProfiles([]); + return { getProfilesCollection: async () => profiles, getDb: async () => null }; +}); +vi.mock("../src/lib/clerk-users", () => ({ fetchClerkUsers: vi.fn(async () => new Map()) })); +vi.mock("@vercel/functions", () => ({ waitUntil: vi.fn() })); + +import { prisma } from "../src/lib/prisma"; +import { getProfilesCollection } from "../src/lib/mongo"; +import { foldSendCounts, jobStats, subscriberStats } from "../src/lib/nudge-stats"; + +const db = prisma as unknown as ReturnType; +const NOW = new Date("2026-09-29T08:00:00Z"); +const blank = { variantId: null, deliveredAt: null, openedAt: null, clickedAt: null, bouncedAt: null, complainedAt: null, unsubscribedAt: null }; + +async function seedProfiles(ids: string[]) { + const collection = (await getProfilesCollection()) as unknown as { docs: Record[] }; + collection.docs.length = 0; + for (const id of ids) { + collection.docs.push({ _id: id, filterSummary: { roleFamily: "engineering" }, createdAt: new Date("2026-09-01T00:00:00Z") }); + } +} + +beforeEach(async () => { + db.$reset(); + await seedProfiles([]); +}); + +describe("foldSendCounts", () => { + it("divides open and click rates by emails sent, never by delivered", () => { + const rows = Array.from({ length: 100 }, (_, i) => ({ + ...blank, + status: "sent", + deliveredAt: i === 0 ? NOW : null, + openedAt: i < 3 ? NOW : null, + clickedAt: i < 2 ? NOW : null, + })); + expect(foldSendCounts(rows)).toMatchObject({ sent: 100, delivered: 1, opened: 3, clicked: 2, openRate: 3, clickRate: 2 }); + }); + + it("reports no rate until something was sent", () => { + const counts = foldSendCounts([ + { ...blank, status: "holdout" }, + { ...blank, status: "skipped" }, + { ...blank, status: "dry_run" }, + ]); + expect(counts).toMatchObject({ recipients: 1, sent: 0, dryRun: 1, holdout: 1, skipped: 1, openRate: null, clickRate: null }); + }); +}); + +describe("subscriberStats", () => { + it("counts preferences only for users who have a resume profile", async () => { + const ids = Array.from({ length: 10 }, (_, i) => `user_${i}`); + await seedProfiles(ids); + await db.emailPreference.create({ + data: { userId: "no_resume", subscribed: false, frequency: "weekly", unsubscribeToken: "t0", unsubscribeReason: "user" }, + }); + await db.emailPreference.create({ data: { userId: "user_1", subscribed: true, frequency: "daily", unsubscribeToken: "t1" } }); + + const stats = await subscriberStats(NOW); + + expect(stats).toMatchObject({ + eligible: 10, + subscribed: 10, + unsubscribed: 0, + paused: 0, + newThisWeek: 0, + byFrequency: { weekly: 9, daily: 1 }, + unsubscribeReasons: {}, + }); + }); +}); + +describe("jobStats", () => { + it("counts only clicks on jobs in the email click total", async () => { + await db.recruiter.create({ data: { id: 1, name: "R", linkedinUrl: "https://linkedin.com/in/r", active: true } }); + const campaign = await db.campaign.create({ data: { key: "w", name: "W", kind: "weekly", status: "completed", jobCount: 5 } }); + await db.nudgeSend.create({ + data: { id: "s1", campaignId: campaign.id, userId: "u", email: "u@example.com", status: "sent", jobIds: [23], sentAt: NOW }, + }); + await db.nudgeClick.create({ data: { sendId: "s1", jobId: 23, source: "redirect" } }); + await db.nudgeClick.create({ data: { sendId: "s1", jobId: null, source: "webhook", url: "https://skiptheboard.in/profile" } }); + + const stats = await jobStats(NOW); + + expect(stats.totalEmailClicks).toBe(1); + expect(stats.jobClicks).toEqual([{ jobId: 23, clicks: 1, title: null, company: null }]); + }); +}); diff --git a/backend/tests/nudge-variants.test.ts b/backend/tests/nudge-variants.test.ts new file mode 100644 index 0000000..b4329f9 --- /dev/null +++ b/backend/tests/nudge-variants.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { assignVariant, bucketFor, validateVariants } from "../src/lib/nudge-variants"; + +describe("variant assignment", () => { + const arms = [ + { id: 1, key: "A", weight: 45, isHoldout: false }, + { id: 2, key: "B", weight: 45, isHoldout: false }, + { id: 3, key: "control", weight: 10, isHoldout: true }, + ]; + + it("is deterministic per campaign and user", () => { + expect(bucketFor(7, "user_1")).toBe(bucketFor(7, "user_1")); + expect(bucketFor(7, "user_1")).not.toBe(bucketFor(8, "user_1")); + const b = bucketFor(7, "user_1"); + expect(b).toBeGreaterThanOrEqual(0); + expect(b).toBeLessThan(100); + }); + + it("splits users roughly by weight, including the holdout", () => { + const counts: Record = { A: 0, B: 0, control: 0 }; + const total = 20_000; + for (let i = 0; i < total; i += 1) { + const arm = assignVariant(arms, bucketFor(42, `user_${i}`))!; + counts[arm.key] += 1; + } + expect(counts.A / total).toBeGreaterThan(0.42); + expect(counts.A / total).toBeLessThan(0.48); + expect(counts.B / total).toBeGreaterThan(0.42); + expect(counts.B / total).toBeLessThan(0.48); + expect(counts.control / total).toBeGreaterThan(0.08); + expect(counts.control / total).toBeLessThan(0.12); + }); + + it("maps bucket edges onto cumulative ranges", () => { + expect(assignVariant(arms, 0)!.key).toBe("A"); + expect(assignVariant(arms, 44.99)!.key).toBe("A"); + expect(assignVariant(arms, 45)!.key).toBe("B"); + expect(assignVariant(arms, 89.99)!.key).toBe("B"); + expect(assignVariant(arms, 90)!.key).toBe("control"); + expect(assignVariant(arms, 99.99)!.key).toBe("control"); + expect(assignVariant([], 10)).toBeNull(); + }); + + it("validates weights", () => { + expect(validateVariants(arms)).toBeNull(); + expect(validateVariants([{ key: "A", weight: 60, isHoldout: false }, { key: "B", weight: 60, isHoldout: false }])).toMatch(/sum to 100/); + expect(validateVariants([{ key: "A", weight: 50, isHoldout: false }, { key: "A", weight: 50, isHoldout: false }])).toMatch(/duplicate/); + expect(validateVariants([{ key: "A", weight: 100, isHoldout: true }])).toMatch(/must send/); + expect(validateVariants([{ key: "A", weight: 50.5, isHoldout: false }, { key: "B", weight: 49.5, isHoldout: false }])).toMatch(/integer/); + expect(validateVariants([])).toMatch(/at least one/); + }); +}); diff --git a/backend/vercel.json b/backend/vercel.json index 78ce09e..1281b16 100644 --- a/backend/vercel.json +++ b/backend/vercel.json @@ -1,7 +1,8 @@ { "rewrites": [ { "source": "/api/:path*", "destination": "/api/[[...route]]" }, - { "source": "/(health|docs)", "destination": "/api/[[...route]]" } + { "source": "/(health|docs)", "destination": "/api/[[...route]]" }, + { "source": "/go/:path*", "destination": "/api/[[...route]]" } ], "functions": { "api/**/*.ts": { @@ -16,6 +17,10 @@ { "path": "/api/cron/scrape", "schedule": "0 4 * * *" + }, + { + "path": "/api/cron/nudges", + "schedule": "30 2 * * *" } ] } diff --git a/frontend/src/app/admin/AdminDashboard.tsx b/frontend/src/app/admin/AdminDashboard.tsx index 820b347..23029bd 100644 --- a/frontend/src/app/admin/AdminDashboard.tsx +++ b/frontend/src/app/admin/AdminDashboard.tsx @@ -2,407 +2,100 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; -import { BACKEND_URL } from "@/lib/config"; +import { useAuth } from "@clerk/nextjs"; import BrandLogo from "@/components/BrandLogo"; - -const ADMIN_SECRET_KEY = "skiptheboard_admin_secret"; - -interface Submission { - id: number; - name: string; - linkedinUrl: string; - company: string | null; - title: string | null; - note: string | null; - status: "pending" | "approved" | "rejected"; - recruiterId: number | null; - submittedAt: string; - reviewedAt: string | null; -} - -interface Recruiter { - id: number; - name: string; - linkedinUrl: string; - active: boolean; - scrapeIntervalHours: number; - addedAt: string; - lastScrapedAt: string | null; -} - -type Tab = "submissions" | "recruiters"; +import { adminApi } from "@/lib/admin"; +import SubscribersPanel from "./SubscribersPanel"; +import EmailPanel from "./EmailPanel"; +import JobsPanel from "./JobsPanel"; +import SendPanel from "./SendPanel"; +import ExperimentsPanel from "./ExperimentsPanel"; +import { RecruitersPanel, SubmissionsPanel } from "./RecruitersPanel"; + +type Tab = "subscribers" | "email" | "jobs" | "send" | "experiments" | "submissions" | "recruiters"; + +const TABS: { id: Tab; label: string }[] = [ + { id: "subscribers", label: "Subscribers" }, + { id: "email", label: "Email performance" }, + { id: "jobs", label: "Jobs & site" }, + { id: "send", label: "Send controls" }, + { id: "experiments", label: "Experiments" }, + { id: "submissions", label: "Submissions" }, + { id: "recruiters", label: "Hiring managers" }, +]; export default function AdminDashboard() { - const [secret, setSecret] = useState(""); - const [authed, setAuthed] = useState(false); - const [tab, setTab] = useState("submissions"); - const [submissions, setSubmissions] = useState([]); - const [recruiters, setRecruiters] = useState([]); - const [loading, setLoading] = useState(false); - const [actionId, setActionId] = useState(null); - const [error, setError] = useState(""); + const { isLoaded, isSignedIn, getToken } = useAuth(); + const [access, setAccess] = useState<"checking" | "ok" | "denied">("checking"); + const [tab, setTab] = useState("subscribers"); - // Check for stored secret on mount - useEffect(() => { - const stored = sessionStorage.getItem(ADMIN_SECRET_KEY); - if (stored) { - setSecret(stored); - setAuthed(true); - } - }, []); - - const authHeaders = useCallback( - () => ({ - "Content-Type": "application/json", - Authorization: `Bearer ${secret}`, - }), - [secret], - ); - - const fetchSubmissions = useCallback(async () => { - setLoading(true); - setError(""); - try { - const res = await fetch(`${BACKEND_URL}/api/admin/submissions?status=pending`, { - headers: authHeaders(), - }); - if (!res.ok) throw new Error("Failed to fetch submissions"); - const data = await res.json(); - setSubmissions(data.submissions); - } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error"); - } finally { - setLoading(false); - } - }, [authHeaders]); - - const fetchRecruiters = useCallback(async () => { - setLoading(true); - setError(""); - try { - const res = await fetch(`${BACKEND_URL}/api/admin/recruiters`, { - headers: authHeaders(), - }); - if (!res.ok) throw new Error("Failed to fetch recruiters"); - const data = await res.json(); - setRecruiters(data.recruiters); - } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error"); - } finally { - setLoading(false); - } - }, [authHeaders]); + const token = useCallback(async () => { + const t = await getToken(); + if (!t) throw new Error("Not signed in"); + return t; + }, [getToken]); useEffect(() => { - if (!authed) return; - if (tab === "submissions") fetchSubmissions(); - else fetchRecruiters(); - }, [authed, tab, fetchSubmissions, fetchRecruiters]); - - function handleLogin(e: React.FormEvent) { - e.preventDefault(); - if (!secret.trim()) return; - sessionStorage.setItem(ADMIN_SECRET_KEY, secret); - setAuthed(true); - } - - function handleLogout() { - sessionStorage.removeItem(ADMIN_SECRET_KEY); - setSecret(""); - setAuthed(false); - setSubmissions([]); - setRecruiters([]); - } - - async function approveSubmission(id: number) { - setActionId(id); - try { - const res = await fetch(`${BACKEND_URL}/api/admin/submissions/${id}/approve`, { - method: "POST", - headers: authHeaders(), - }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error ?? "Failed to approve"); - } - setSubmissions((prev) => prev.filter((s) => s.id !== id)); - } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error"); - } finally { - setActionId(null); + if (!isLoaded) return; + if (!isSignedIn) { + setAccess("denied"); + return; } - } - - async function rejectSubmission(id: number) { - setActionId(id); - try { - const res = await fetch(`${BACKEND_URL}/api/admin/submissions/${id}/reject`, { - method: "POST", - headers: authHeaders(), - }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error ?? "Failed to reject"); - } - setSubmissions((prev) => prev.filter((s) => s.id !== id)); - } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error"); - } finally { - setActionId(null); - } - } - - async function triggerScrape(recruiterId: number) { - setActionId(recruiterId); - setError(""); - try { - const res = await fetch(`${BACKEND_URL}/api/admin/scrape`, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ recruiterId }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Scrape failed"); - const created = data.jobsCreated ?? 0; - const skipped = data.jobsSkipped ?? 0; - const pending = data.pending === true; - setError( - pending - ? (data.message ?? "Run started in Apify. Listings update after ingest — wait a few minutes or hit cron.") - : `Scrape finished: ${created} new/updated jobs, ${skipped} skipped.`, - ); - fetchRecruiters(); - } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error"); - } finally { - setActionId(null); - } - } - - // ─── Login screen ─────────────────────────────────────────────────────────── - if (!authed) { + token() + .then((t) => adminApi.whoami(t)) + .then((r) => setAccess(r.ok ? "ok" : "denied")) + .catch(() => setAccess("denied")); + }, [isLoaded, isSignedIn, token]); + + // Non-admins (and signed-out visitors) see an empty page: nothing to probe. + if (access !== "ok") { return ( -
    -
    - - - SkipTheBoard - -

    Admin

    -

    Enter your admin secret to continue.

    -
    - setSecret(e.target.value)} - placeholder="Admin secret" - autoFocus - className="w-full rounded-lg border border-card-border bg-card-bg px-3 py-2.5 text-sm text-foreground placeholder:text-muted-light focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/20" - /> - -
    -
    -
    +
    + {access === "denied" &&

    Nothing here.

    } +
    ); } - // ─── Dashboard ────────────────────────────────────────────────────────────── return (
    - {/* Header */}
    -
    +
    - - SkipTheBoard - + SkipTheBoard / admin
    - + + My profile +
    -
    - {/* Tabs */} -
    - - -
    - - {error && ( -
    - {error} -
    - )} - - {loading ? ( -
    Loading...
    - ) : tab === "submissions" ? ( - /* ─── Submissions tab ─── */ - submissions.length === 0 ? ( -
    - No pending submissions. -
    - ) : ( -
    - {submissions.map((s) => ( -
    -
    -
    -
    -

    {s.name}

    - {s.company && ( - @ {s.company} - )} -
    - {s.title && ( -

    {s.title}

    - )} - - {s.linkedinUrl} - - {s.note && ( -

    - {s.note} -

    - )} -

    - Submitted {new Date(s.submittedAt).toLocaleDateString()} -

    -
    -
    - - -
    -
    -
    - ))} -
    - ) - ) : ( - /* ─── Recruiters tab ─── */ - recruiters.length === 0 ? ( -
    - No recruiters being tracked yet. -
    - ) : ( -
    - - - - - - - - - - - - {recruiters.map((r) => ( - - - - - - - - ))} - -
    NameStatusLast ScrapedAddedActions
    -

    {r.name}

    - - LinkedIn profile - -
    - - {r.active ? "Active" : "Inactive"} - - - {r.lastScrapedAt - ? new Date(r.lastScrapedAt).toLocaleString() - : "Never"} - - {new Date(r.addedAt).toLocaleDateString()} - - -
    -
    - ) - )} +
    + + + {tab === "subscribers" && } + {tab === "email" && } + {tab === "jobs" && } + {tab === "send" && } + {tab === "experiments" && } + {tab === "submissions" && } + {tab === "recruiters" && }
    ); diff --git a/frontend/src/app/admin/AdminUi.tsx b/frontend/src/app/admin/AdminUi.tsx new file mode 100644 index 0000000..0adb82f --- /dev/null +++ b/frontend/src/app/admin/AdminUi.tsx @@ -0,0 +1,169 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { SendCounts, VariantStats } from "@/lib/admin"; + +export function Card({ title, children, action }: { title?: string; children: ReactNode; action?: ReactNode }) { + return ( +
    + {(title || action) && ( +
    + {title &&

    {title}

    } + {action} +
    + )} + {children} +
    + ); +} + +export function StatTile({ label, value, hint }: { label: string; value: string | number; hint?: string }) { + return ( +
    +

    {label}

    +

    {value}

    + {hint &&

    {hint}

    } +
    + ); +} + +export function ErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () => void }) { + return ( +
    + {message} + {onDismiss && ( + + )} +
    + ); +} + +export function Notice({ message }: { message: string }) { + return
    {message}
    ; +} + +export function Empty({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + +export function Button({ + children, + onClick, + disabled, + variant = "primary", + type = "button", +}: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: "primary" | "secondary" | "danger"; + type?: "button" | "submit"; +}) { + const styles = + variant === "primary" + ? "bg-accent text-white hover:bg-accent-hover" + : variant === "danger" + ? "bg-red-600 text-white hover:bg-red-700" + : "border border-card-border text-foreground hover:bg-surface"; + return ( + + ); +} + +export const inputClass = + "w-full rounded-lg border border-card-border bg-card-bg px-3 py-2 text-sm text-foreground placeholder:text-muted-light focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/20"; + +export function pct(value: number | null): string { + return value === null ? "—" : `${value.toFixed(1)}%`; +} + +export function fmtDate(value: string | null | undefined): string { + return value ? new Date(value).toLocaleString() : "—"; +} + +export function StatusPill({ status }: { status: string }) { + const tone = + status === "completed" || status === "delivered" || status === "sent" + ? "bg-green-50 text-green-700" + : status === "running" + ? "bg-accent-light text-accent" + : status === "failed" || status === "bounced" || status === "complained" + ? "bg-red-50 text-red-700" + : "bg-surface text-muted"; + return {status}; +} + +const COUNT_COLUMNS: { key: keyof SendCounts; label: string }[] = [ + { key: "recipients", label: "Recipients" }, + { key: "delivered", label: "Delivered" }, + { key: "openRate", label: "Open rate" }, + { key: "clickRate", label: "Click rate" }, + { key: "opened", label: "Opened" }, + { key: "clicked", label: "Clicked" }, + { key: "bounced", label: "Bounces" }, + { key: "complained", label: "Complaints" }, + { key: "unsubscribed", label: "Unsubscribes" }, + { key: "skipped", label: "Skipped" }, + { key: "failed", label: "Failed" }, +]; + +function countCell(counts: SendCounts, key: keyof SendCounts): string { + const value = counts[key]; + if (key === "openRate" || key === "clickRate") return pct(value as number | null); + return String(value); +} + +/** Variant results side by side, with the campaign totals as the first column. */ +export function VariantResults({ totals, variants }: { totals: SendCounts; variants: VariantStats[] }) { + const columns = [{ label: "All", counts: totals, sub: null as string | null }].concat( + variants.map((v) => ({ + label: `${v.key} · ${v.name}`, + counts: v.counts, + sub: v.isHoldout ? `holdout · ${v.weight}% · ${v.counts.holdout} users got nothing` : `${v.weight}%${v.jobCount ? ` · ${v.jobCount} jobs` : ""}`, + })), + ); + return ( +
    + + + + + {columns.map((c) => ( + + ))} + + + + {COUNT_COLUMNS.map((col) => ( + + + {columns.map((c) => ( + + ))} + + ))} + +
    Metric +
    {c.label}
    + {c.sub &&
    {c.sub}
    } +
    {col.label} + {countCell(c.counts, col.key)} +
    + {totals.dryRun > 0 && ( +

    {totals.dryRun} recorded as dry run (no RESEND_API_KEY at send time); they never reached a provider.

    + )} + {variants.length > 0 && ( +

    Raw counts. Open and click rates divide by emails sent. No significance test is applied.

    + )} +
    + ); +} diff --git a/frontend/src/app/admin/EmailPanel.tsx b/frontend/src/app/admin/EmailPanel.tsx new file mode 100644 index 0000000..f40e3b3 --- /dev/null +++ b/frontend/src/app/admin/EmailPanel.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { adminApi, type EmailStats } from "@/lib/admin"; +import { Card, Empty, ErrorBanner, StatusPill, VariantResults, fmtDate } from "./AdminUi"; + +export default function EmailPanel({ getToken }: { getToken: () => Promise }) { + const [stats, setStats] = useState(null); + const [error, setError] = useState(""); + const [open, setOpen] = useState(null); + + useEffect(() => { + getToken() + .then((t) => adminApi.email(t)) + .then((s) => { + setStats(s); + setOpen(s.campaigns[0]?.id ?? null); + }) + .catch((e: unknown) => setError(e instanceof Error ? e.message : "Failed to load")); + }, [getToken]); + + if (error) return ; + if (!stats) return Loading…; + + return ( +
    + + {stats.campaigns.length === 0 ? ( + No campaigns yet. The first weekly send creates one, or use Send now. + ) : ( +
    + + + + + + + + + + + + + + + + {stats.campaigns.map((c) => ( + setOpen(open === c.id ? null : c.id)} + className={`border-b border-border-light cursor-pointer hover:bg-surface ${open === c.id ? "bg-surface" : ""}`} + > + + + + + + + + + + + ))} + +
    CampaignStatusRecipientsDeliveredOpenClickBounce / ComplaintUnsubStarted
    +

    {c.name}

    +

    {c.key} · {c.kind}{c.variants.length ? ` · ${c.variants.length} variants` : ""}

    +
    {c.totals.recipients}{c.totals.delivered}{c.totals.openRate === null ? "—" : `${c.totals.openRate}%`}{c.totals.clickRate === null ? "—" : `${c.totals.clickRate}%`}{c.totals.bounced} / {c.totals.complained}{c.totals.unsubscribed}{fmtDate(c.startedAt)}
    +
    + )} +
    + + {open !== null && stats.campaigns.find((c) => c.id === open) && ( + c.id === open)!.name}`}> + c.id === open)!.totals} + variants={stats.campaigns.find((c) => c.id === open)!.variants} + /> + + )} + + + {stats.topClickedJobs.length === 0 ? ( +

    No clicks recorded yet.

    + ) : ( + + + {stats.topClickedJobs.map((j) => ( + + + + + ))} + +
    + {j.title ?? `Job #${j.jobId}`} + {j.company && · {j.company}} + {j.clicks}
    + )} +
    +
    + ); +} diff --git a/frontend/src/app/admin/ExperimentsPanel.tsx b/frontend/src/app/admin/ExperimentsPanel.tsx new file mode 100644 index 0000000..8cb476f --- /dev/null +++ b/frontend/src/app/admin/ExperimentsPanel.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { adminApi, type CampaignStats, type CampaignVariantInput } from "@/lib/admin"; +import { Button, Card, Empty, ErrorBanner, Notice, StatusPill, VariantResults, fmtDate, inputClass } from "./AdminUi"; + +type Draft = Omit & { weight: string; jobCount: string }; + +const emptyVariant = (key: string, name: string, weight: string): Draft => ({ + key, + name, + weight, + isHoldout: false, + subject: "", + intro: "", + jobCount: "", +}); + +export default function ExperimentsPanel({ getToken }: { getToken: () => Promise }) { + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [campaigns, setCampaigns] = useState([]); + const [open, setOpen] = useState(null); + const [busy, setBusy] = useState(null); + const [name, setName] = useState(""); + const [jobCount, setJobCount] = useState("5"); + const [variants, setVariants] = useState([ + emptyVariant("A", "Default subject", "45"), + emptyVariant("B", "Alternative subject", "45"), + { ...emptyVariant("control", "Holdout (no email)", "10"), isHoldout: true }, + ]); + + const fail = (e: unknown) => setError(e instanceof Error ? e.message : "Request failed"); + + const load = useCallback(async () => { + const t = await getToken(); + const s = await adminApi.email(t); + const experiments = s.campaigns.filter((c) => c.kind === "experiment"); + setCampaigns(experiments); + setOpen((prev) => prev ?? experiments[0]?.id ?? null); + }, [getToken]); + + useEffect(() => { + load().catch(fail); + }, [load]); + + const weightTotal = variants.reduce((sum, v) => sum + (Number(v.weight) || 0), 0); + + function update(index: number, patch: Partial) { + setVariants((prev) => prev.map((v, i) => (i === index ? { ...v, ...patch } : v))); + } + + async function create() { + setBusy("create"); + setError(""); + try { + const t = await getToken(); + const body = { + name, + jobCount: Number(jobCount) || 5, + variants: variants.map((v) => ({ + key: v.key.trim(), + name: v.name.trim(), + weight: Number(v.weight), + isHoldout: v.isHoldout, + subject: v.isHoldout ? null : v.subject?.trim() || null, + intro: v.isHoldout ? null : v.intro?.trim() || null, + jobCount: v.isHoldout || !v.jobCount ? null : Number(v.jobCount), + })), + }; + const { campaign } = await adminApi.createCampaign(t, body); + setNotice(`Created "${campaign.name}" (${campaign.key}). It is a draft until you run it.`); + setName(""); + await load(); + setOpen(campaign.id); + } catch (e) { + fail(e); + } finally { + setBusy(null); + } + } + + async function run(campaign: CampaignStats) { + if (!window.confirm(`Run "${campaign.name}" now? Recipients are split ${campaign.variants.map((v) => `${v.key} ${v.weight}%`).join(", ")}.`)) return; + setBusy(`run-${campaign.id}`); + setError(""); + try { + const t = await getToken(); + const result = await adminApi.sendNow(t, campaign.id); + setNotice( + `${result.dryRun ? "Dry run" : "Run"} ${result.campaignKey}: ${result.sent} emailed, ${result.holdout} holdout, ${result.skipped} skipped, ${result.failed} failed` + + (result.alreadyClaimed > 0 ? `, ${result.alreadyClaimed} already handled by another run` : "") + + (result.remaining > 0 ? `; ${result.remaining} continue in the background.` : "."), + ); + await load(); + } catch (e) { + fail(e); + } finally { + setBusy(null); + } + } + + const selected = campaigns.find((c) => c.id === open) ?? null; + + return ( +
    + {error && setError("")} />} + {notice && } + + +
    + + +
    +
    + {variants.map((v, i) => ( +
    + +
    + + {!v.isHoldout && ( + <> + +