Changelog
What’s new
Visible record of what shipped, in chronological order. Newest first. Production lives at vettika.com.
- LatestFeatureFixInfra
Trusted devices inventory + ownership audit + Google secret rotation
Post-2FA follow-ups. Users can now see and revoke each "Remember this device for 30 days" trust cookie on /account, with human labels like "Chrome on macOS · 102.x.y.z" instead of a random suffix. A candidate-side RSC ownership audit closed two read holes. The Google OAuth client secret was rotated end-to-end after the 2026-05-23 chat leak.
- Trusted devices list + per-row revoke + revoke-all on /account
- Human labels from User-Agent: "Chrome on macOS", "Safari on iPhone", etc.
- IP shown alongside each trusted browser (best-effort header parse)
- Better Auth hooks.after writes sidecar on /two-factor verify
- TrustedDeviceMeta sidecar via direct SQL on Neon (skip Prisma db push)
- Card hides itself entirely when 2FA off — no empty-list noise
- Revoke deletes Verification row -> trust cookie silently expires next sign-in
- "Remember this device for 30 days" toggle on /two-factor sign-in
- GOOGLE_CLIENT_SECRET rotated in Google Console + Vercel (3 scopes)
- F1 probe verified: /api/auth/sign-in/social returns valid OAuth URL
- CLAUDE.md secret-leak flag closed; GH #17 closed
- /sessions/[id] now gates on recruiterId — closed a read hole
- Candidate-page URL consistency: campaignId mismatch -> 404
- previewCandidateFeedback gated via assertCandidateOwnership
- listAutomationActivity + retryAutomationActivity scoped by rule.recruiterId
- eslint.config.mjs ignores *.test.{ts,tsx} (fixes import/named false positives)
- 2FA backup-code regeneration button on TwoFactorCard
- Agent: resolve_temperature() extracted to pure helper + 7 unit tests
- E3 LLM temperature override loop verified end-to-end
- 2FA + trust-device smoke test parked at docs/qa/2fa-smoke.md
- GH #40 (RSS), #41 (share buttons), #42 (comments) filed for jobs board
Commits: f26817d, b8b98dd, 50dd52f, 16edd16, 6d7f17f, 2384f6b, 1e8d8aa, 64ee372, 5d7a6cd, dc7035c
- FeatureFixDesign
Candidate flow: kill slots, gate on join code, provision Interview row
Three fixes on the candidate path. Time-slot picking was throwing "Selected slot is no longer available" for every slot the candidate tried — replaced with a simpler "open vs limited" cap. The 6-digit join code emailed at apply time is now a real server-checked gate on /i/[token]/check before LiveKit will mint a token. And /i/[token]/live was silently falling back to a mock LiveKit URL when no Interview row existed, which immediately disconnected the candidate to a generic /error — now provisions the row server-side from the campaign config and forwards LiveKit DisconnectReason through ?reason= so the error page can render specifics.
- Apply link: removed time slots, calendar picker, fallback self-schedule; now only enabled + maxApplications
- ApplyLink.slots JSON column kept as no-op for forward compat (no migration needed)
- POST /api/public/invite/[token]/verify-code: matches Candidate.joinCode, sets HTTP-only cookie ic_v_<token> (2h, sameSite=lax, secure)
- CandidateInvitePublic.joinCode dropped from public payload → requiresJoinCode boolean (stops leaking the code)
- SystemCheck: numeric join-code Card after mic/speaker pass, inputMode=numeric + autoComplete=one-time-code for iOS autofill
- /i/[token]/live server guard: missing cookie → redirect /check
- ensureInterviewForCandidate snapshots role/intro/closing/questions/rubric/duration from Campaign on first /live load
- Killed startInterview() mock fallback in <LiveInterview> (was returning wss://example.livekit.cloud + MOCK_LIVEKIT_JWT)
- Connecting preview rebranded: in-call orb + first name + "Setting up your interview" headline
- friendlyDisconnectReason() maps LiveKit JOIN_FAILURE / SERVER_SHUTDOWN / SIGNAL_CLOSE / STATE_MISMATCH / DUPLICATE_IDENTITY to actionable copy via ?reason=
- /error page: AlertTriangle icon plate, eyebrow "Connection interrupted", support@vettika.com footer with truncated invite token
- Brute-force guard on verify-code: 10 attempts/min per (IP + token) via existing checkRateLimit
Commits: de86d9b, a7b386d, bd01dbb
- FeatureInfraFix
Account security hardening — TOTP 2FA, Turnstile, HIBP, re-auth
Closes the user-account security track. Sign-in is gated by Cloudflare Turnstile, passwords are checked against the HIBP breach corpus, sensitive actions require a fresh session, new devices trigger an email alert, and TOTP two-factor auth ships end-to-end (enable on /account, verify on sign-in, backup codes, disable).
- TOTP 2FA enable flow on /account: password → QR + backup codes → TOTP verify
- /two-factor sign-in step with TOTP + backup-code fallback, preserves ?next=
- authClient twoFactorClient plugin: onTwoFactorRedirect → /two-factor
- Login page reads twoFactorRedirect from signIn.email response and routes through 2FA
- Better Auth twoFactor plugin server-side, issuer "Vettika", skipVerificationOnEnable false
- Cloudflare Turnstile widget on /login, /signup, /forgot-password (env-gated)
- HIBP k-anonymity password breach check on signup + password change
- New-device email alert: session.create.after hook + KnownDevice fingerprint table
- Fresh-session gate on delete-account, unlink-Google with ReauthDialog replay
- Profile card: Resend verification button when emailVerified=false
- Sessions card: first-seen / last-seen device history from KnownDevice join
- Account self-service: export progress toast with file size
- Server Actions proxy (lib/actions.ts) keeps Prisma out of browser bundles
- Single email delivery path lib/email/send.ts; killed typeof-window send gate
- aiRouteUrl() helper stops /api/ai/* self-loop on server context
- encryptToken returns Uint8Array<ArrayBuffer> for Prisma 6 Bytes typing
- Neon keepalive: /api/health route + cron-job.org every 5 min
- Polar.sh stays default merchant-of-record; Stripe webhook signature verification live
Commits: dc7035c, 65a659c, 0fa6596, f6f1e0c, d4ec400, e861b17, 453e53c, eebc3d7
- FeatureInfra
Auth + Settings + Billing — Session 1 recap
Closing the auth + billing + settings track. Real user identities, settings that actually persist, billing wired end-to-end with Polar as the default merchant-of-record and Stripe waiting for an Atlas-shaped US entity.
- Route groups: marketing, app, candidate, auth
- Prisma Session → Interview rename, @@map preserves data
- Better Auth schema generated, four tables on Neon
- /api/auth handler + Edge middleware gate
- Email + password + verify + reset + welcome
- Sign-up gate: prompt for new Google identity
- Last-used "Continue as you" login card
- 68 unit tests + 19-check auth smoke script
- XSS-safe email template escaping
- Group layouts: MarketingHeader + MegaFooter
- Auto-scaling wordmark in the marketing footer
- Account page: profile, password, sessions, danger
- Link / unlink Google on /account
- WorkspaceCard counts on /account
- Workspace prefs persist on the User row
- Integrations flipped honest: all "coming soon"
- Data-privacy retention persist + JSON export
- Notification prefs persist; candidate_applied email lives
- Billing page: real usage, free tier honest
- Stripe checkout, portal, signed webhook handler
- Script: auto-create the four Stripe prices
- Multi-provider checkout abstraction (one file per provider)
- Polar.sh wired as default merchant-of-record
- Stripe Atlas activation path documented in CLAUDE.md
- Double-rendered settings chrome fixed
Commits: f43cfed, 57125cd, e145285, 69b9d1f, 0bc9736, 75de80a, 94036bc, 22fc784, dd7cfa6, c7f3929, 21e62e5, a4bee8b, ff472dd, a7e6492, 67a6e54, 25a4725, 2697a0b
- Docs
Session recap — what shipped end-to-end
Single ledger of every capability landed across the multi-day persistence + product push. Each bullet is one feature — tight on purpose so /changelog reads as a complete shipped-list at a glance.
- Jobs board with JobPosting JSON-LD
- Recruiter inline job-meta editor
- Automation execution engine + approval inbox
- Candidate feedback flow with LLM tips
- Live recruiter observer mode
- Real LiveKit room on candidate side
- Shortlist preset tabs + bulk actions
- Marketing-vs-app nav split
- Session-aware nav with dashboard CTA
- Inline edit + delete on notes
- Regenerate join code + resend invite
- Persist Campaign, Candidate, ApplyLink to Postgres
- Persist AI profile fields and default flag
- Persist email dispatches + feedback tokens
- Persist interview reports with agent bridge
- Persist pending dispatches with audit trail
- Persist integrations + live analytics counts
- Persist automation templates + activity log
- Resend bounce webhook + providerId capture
- Public reschedule endpoint from candidate error page
- Auth-context helper threads recruiterId everywhere
- Real companyName resolved across emails + pages
- Rate-limit on submitApplication
- Atomic getPublicJob lazy-create
- DB-backed dispatcher idempotency surviving restarts
- Cursor pagination on candidates, emails, activity
- Friendly Google OAuth no-account card
- Observer null-recruiter bypass closed
- Fixed /apply/invalid 404 status
Commits: 0a4e903, 4b8876a, 61c6be8, d69e098, ff533d0, 7085a20, c537241, 6aac04d, 1233d1d, 9c1a1cf, 8a24ad9, 9c8a5ea, 467e1f6, 0b5c33b, 533092b, d10da61, 2c82edf, be15701, 3d62798, 9f2454c, 4f22062, 50057fe
- FixDesign
Sign-out state isolation + empty-state polish
Closes the last persisted-state leak between recruiters on shared devices and tightens two bare-text empty states to match the rest of the dashboard chrome.
- New lib/auth/clear-local-state.ts wired into the avatar-menu sign-out handler — resets useCampaignDraft + useUIPrefs + useAITokens and removes the onboarding-draft-v1 localStorage key before the Better Auth round-trip. Before this commit, signing out and signing back in as a different recruiter on the same device surfaced the previous recruiter's in-flight draft campaign, recent search history, recently-viewed candidate ids, mock AI token balance, and onboarding wizard progress. useConsent is left alone — keyed by candidate token, not recruiter, so safe to leak.
- CampaignCandidatesTable empty state now distinguishes "no candidates ever" from "filter / search hides them all" and quotes the search term back ("No candidates match \"foo\" in this view.") so the CTA points at the right action.
- Dashboard "Recent results" tile swapped bare paragraph for a dashed-card empty state with icon + headline + sub-copy, matching the campaigns-empty and live-now tiles.
Commits: fad9eae
- FixDesign
Drain final mock placeholders from user-facing surfaces
Candidate-facing layouts, the 404-on-bogus-apply-code page, the social OG preview, and the automation dispatcher fallback all stopped pretending to be "Acme Inc." and stopped reaching for seed data.
- Candidate apply layout + interview layout headers swapped hardcoded "Acme Inc." for the product brand ("Vettika"). Per-recruiter company name still surfaces inside the page body via getApplyPublic.companyName + getInvite.companyName, both already resolved through resolveCampaignRecruiter → User.companyName.
- /apply/<bogus> 404 page dropped the mailto:recruiting@example.com button. The recruiting team behind a given apply link is per-campaign and unknown when the link is bogus, so the page now directs candidates to "the recruiter who shared the link" instead and keeps only the Go-home button.
- OpenGraph image alt swapped from "About Acme" to "Vettika — voice-AI screening interviews" so Slack / X / LinkedIn previews carry the product brand.
- lib/automation/dispatcher.ts dropped its MOCK_INTERVIEWS[candidateId] fallback. Real recruiters could template-render mock transcript / score data into approval-queue previews if a candidate hit the dispatcher before the report row landed. Real interview only now; the existing if (report) guard below skips the feedback branch when interview is null.
Commits: dd153e1
- FixInfra
Lock recruiter ownership on listCampaigns / getCampaign / getCampaignReport
Closes a tenant-isolation hole: anyone with a valid Better Auth session could previously read any campaign id, edit it, or open its report, and listCampaigns returned every campaign in the system instead of just the recruiter's own.
- All three lib/api.ts read paths now resolve the active recruiter via getCurrentRecruiter() (lib/auth-context.ts) and gate on campaign.recruiterId === recruiter.id. Same denial rule as the observer-token route from 3929520: anonymous callers, legacy null-owner rows, and recruiter-mismatch rows all return null / [].
- listCampaigns — where: { recruiterId: recruiter.id }; anonymous returns [].
- getCampaign — returns null when not owned; callers already notFound() on null so the page surfaces as 404.
- getCampaignReport — signature widened to Promise<CampaignReport | null>; report page now also notFound()s when report is null.
- Middleware already redirects signed-out users from /campaigns/* to /login, so the anonymous-denial branch is defense-in-depth (closes any future non-route caller that doesn't go through middleware).
- Follow-up audit needed for the same hole class on listCandidates, getCandidate, getInterview, and candidate-level reads — flagged in PROTOTYPE_STATUS.md backlog.
Commits: 40c7998
- FixInfraFeature
Hardening pass — rate-limits, race-free dispatch, pagination, friendlier no-account UX
Closes the last set of ship-blockers before real users hit the app: input validation, atomic upserts, persistent dispatcher idempotency, cursor pagination on heavy lists, and a clear "no account yet" card on /login.
- submitApplication rate-limited 5 attempts per 60s per (apply code × email) — friendly error instead of letting one bad actor flood a campaign. Bucket is in-memory today; TODO(prod) flag swaps for Vercel KV under horizontal scale.
- updateApplyLink validates maxApplications > 0 and expiresAt > now; clearing (null) still allowed so recruiters can lift caps.
- getPublicJob lazy-create wrapped in db.$transaction with P2002 retry — two concurrent first visitors to a freshly published role can no longer race-create duplicate apply links.
- AutomationActivity gained idempotencyKey String? @unique. Dispatcher swapped the in-memory firedKeys Set for a DB-backed hasAlreadyFired() — dedupe now survives restarts and works across horizontally-scaled instances. persistActivity swallows P2002 so concurrent fires lose silently.
- listCandidates / listEmailDispatches / listAutomationActivity now cursor-paginated (default 50, returns { items, nextCursor }). EmailDispatchesPanel + ActivityTable + CandidateTable use useInfiniteQuery with "Load more" / "All loaded" controls. TanStack Table row selection survives across pages via getRowId so bulk actions stay correct when the list grows.
- New listAllCandidatesForCampaign helper for SSR paths that genuinely need the full set (dashboard analytics, candidate-review sibling walker for ↑/↓ navigation).
- /login renders a friendly card when Google OAuth resolves to an email with no account: "No account yet for <email>." with a primary "Sign up with this Google account" (re-triggers signIn.social with requestSignUp: true) + outline "Try a different account" (clears localStorage hint, drops the error param). Stacks vertically below 480px. Same email retried inside 5min gets a terser one-line nudge via sessionStorage instead of the full card.
- Observer null-recruiterId bypass closed — /api/token observer branch now requires a non-null recruiterId match instead of falling through on legacy seeds; localhost:3001 + :3002 whitelisted as trusted dev origins for Better Auth.
Commits: 4f22062, 3929520, 642cb55
- FeatureFix
Real recruiter ownership + company brand resolved everywhere
Drops the recruiter@acme.example + "You" placeholders and the hardcoded "Acme Inc." company name. Every audit field + candidate-facing surface now reflects the actual signed-in recruiter and their company.
- New lib/auth-context.ts: server-only getCurrentRecruiter() reads the Better Auth session via auth.api.getSession({ headers: await headers() }) and returns { id, name, email, companyName } from the user row.
- createCampaign / createAIProfile / createAutomationRule now write recruiterId from the session so new rows are scoped to the creator.
- addNote credits the real recruiter (authorName = session.user.name) and stores recruiterId on the note for joins.
- approvePendingDispatch + rejectPendingDispatch set decidedBy to the signing recruiter's email — audit trail no longer attributed to a demo address.
- resolveCompanyName(recruiterId) helper threads through every candidate-facing surface: sendEmail templates, getApplyPublic (apply landing), getInvite (interview join page), getPublicJob (public jobs board), getFeedbackPublic (candidate /feedback/[token] page). Falls back to "Vettika" when recruiter is null or companyName is empty, so legacy seeds keep working.
Commits: 9f2454c
- InfraFeatureFix
Agent → interview_report bridge + Resend bounce tracking + final mock drain
Three small follow-ups that close the persistence loop end-to-end. Agent posts now flow into the recruiter-facing report table, Resend bounce/complaint events update dispatch status, and the last in-memory log buffers (AutomationTemplate + AutomationActivity) moved to Postgres.
- Parallel sync-v1 work added Interview.candidateId/campaignId/recruiterId FKs. /api/sessions/[id]/result now dual-writes the agent-posted transcript + report into interview_report when candidateId is set, so the recruiter review summary tab + candidate /feedback/[token] page read live data without manual override.
- sendEmail captures the Resend message id on the EmailDispatch row. New POST /api/webhooks/resend verifies Svix signatures against RESEND_WEBHOOK_SECRET and maps delivered / bounced / complained / delivery_delayed events to email_dispatch.status. Unknown events are acked so Resend stops retrying.
- AutomationTemplate + AutomationActivity moved off MOCK arrays to dedicated Postgres tables. dispatcher.persistActivity writes every fire; rejectPendingDispatch writes straight into the activity log instead of an in-memory array. Stale deepClone helper deleted now that every DB read returns a fresh object.
- Last MOCK_AI_PROFILES / MOCK_AUTOMATIONS / MOCK_AUTOMATION_ACTIVITY references dropped from lib/api.ts — api.ts mock-import surface is now zero.
- AIProfile default lookup in createCampaign resolves through Prisma (caller's profile id → workspace default → first by name) instead of MOCK_AI_PROFILES.
- Candidate /i/[token]/error page Reschedule button now hits POST /api/public/invite/:token/reschedule for real — clears scheduledFor, drops status back to invited, and queues a manual notification email to the recruiter so they see the request without polling.
Commits: 1c6b59d, be15701, 2c82edf, 51030d6, 3d62798
- Feature
AI Campaign Builder + campaign edit / report / duplicate routes
Full-page chat-driven alternative to the 4-step wizard, plus the missing lifecycle surfaces — edit any campaign inline, view a pipeline report, duplicate to a draft, all wired end-to-end.
- /campaigns/new/ai — split-pane (chat left / editable preview right) on desktop, tabbed on mobile. Chat accepts text + .txt/.md upload + Web Speech voice dictation. One POST /api/ai/generate-campaign call (Gemini generateObject) drafts the entire campaign — basics, public-board meta, screening questions, scoring rubric — with a canned fallback when GOOGLE_GENERATIVE_AI_API_KEY is missing.
- Preview pane is fully inline-editable: job basics, job-board meta (department / employment type / seniority / location / comp / requirements), questions w/ follow-up depth, rubric criteria + weights, AI interviewer (profile picker + collapsible overrides incl. temperature slider 0-2), and a candidates card with shared CSV + XLSX uploader (lib/parse-candidates.ts, also used by the manual wizard) + manual-add row.
- Mock token meter (lib/store/ai-tokens.ts) burns 1200 per full generate, 600 per per-card regenerate; manual edits are free. Save-draft re-saves patch the same id so repeated clicks don't mint duplicates.
- /campaigns/[id]/edit mounts the same shell pre-hydrated from getCampaign; CandidatesCard is hidden in edit mode (link to detail page Add dialog instead) so accidental re-invites can't happen.
- /campaigns/[id]/report — new getCampaignReport(id) aggregates from candidate + interviewReport.report JSON into KPI cards (candidates / completed / avg score / avg duration), pipeline-by-status bars, score distribution histogram, recommendation breakdown, last-7-day funnel.
- duplicateCampaign(id) deep-clones source row's job / AI / rubric / meta, mints a fresh slug, resets to draft status with zero candidates, "Copy of …" title.
- campaign-actions-menu-v2 now wires Edit, View report, Duplicate, Pause/Resume, Archive — all to real mutations with TanStack invalidation. Orphan V1 menu file deleted.
Commits: 1b6a79e
- Fix
/apply/[code] returns proper 404 on unknown codes
Production was serving 200 with the not-found body for unknown apply codes because Vercel's CDN cached the body separately from the status. Adding force-dynamic + revalidate=0 fixes the response header.
- Prior commit f78d214 had removed `export const dynamic = 'force-dynamic'` thinking it was redundant; without it the page rendered the not-found UI with a 200 status header from CDN.
- Re-added `dynamic = 'force-dynamic'` + `revalidate = 0` to app/(candidate)/apply/[code]/page.tsx. generateMetadata's notFound() still drives the actual 404 header before any body streams.
Commits: 9de5a20
- InfraFix
Auth go-live + billing webhook discovery — two ops blockers cleared, one surfaced
Cookie-domain mismatch on the custom domain is fixed; Stripe CLI is wired for local webhook smoke; a missing Stripe webhook secret in Vercel is now tracked so billing can be verified end-to-end before the first paid sign-up.
- `BETTER_AUTH_URL` set to `https://airecruiter.nexprove.com` in Vercel Production (was an empty string — Better Auth was self-detecting the request origin so cookies bound to whichever URL landed first). Takes effect on the next push.
- `/api/auth/ok` confirmed 200 on both the custom domain and the meetmindv3.vercel.app alias.
- Stripe CLI v1.42.1 installed locally for `stripe listen --forward-to` + `stripe trigger` round-trips against the dev server.
- Billing webhook smoke surfaced: `STRIPE_WEBHOOK_SECRET` is missing in Vercel Production scope AND in `.env.local`; prod `/api/webhooks/stripe` returns `400 webhook misconfigured`. Provision via Stripe Dashboard → Developers → Webhooks → `https://airecruiter.nexprove.com/api/webhooks/stripe` and paste the `whsec_*` into Vercel + local `.env.local`.
- Final auth-go-live blocker still in your hands: F1 — paste the 3 Google OAuth callback URIs (prod, alias, localhost) into the Google Cloud Console OAuth client.
- InfraFeature
Data-layer go-real — 10 entities migrated off in-memory mocks onto Neon Postgres
Every entity the recruiter app touches now persists to Postgres. Hundreds of cold-start data losses, gone. The prototype label is retiring.
- Workspace + recruiter ownership on Campaign / Candidate / AIProfile / Integration / AutomationRule via `recruiterId` FK; rows survive every cold start.
- Candidate.joinCode now a persisted 6-digit column with regenerate + resend endpoints; MOCK_INVITE deleted.
- AIProfile gained avatarKey gradient picker, personalityTraits tags, isDefault transaction so only one default per recruiter; duplicate creates a clean copy.
- RecruiterNote shipped with edit + delete bonus operations the original brief did not promise.
- InterviewReport relational model keyed by candidateId — transcripts, scoring breakdown, strengths, weaknesses, recruiter notes all live in one row. Agent posts on call-end via the shared-secret callback.
- EmailDispatch logs every Resend send with status (queued / sent / failed / bounced) + providerId; resendEmail mints a new row distinct from the original.
- PendingAutomationDispatch approval queue persists awaiting_approval rows with snapshot fields (rule name, candidate name) so the audit log outlives deletes; recruiter approval / rejection workflow ships with sandboxed iframe previews.
- CandidateFeedbackToken on Prisma with expiresAt + revokedAt; expired or revoked tokens return null end-to-end.
- Integration directory + IntegrationNotifyRequest waitlist on DB; connectedAt and config survive restarts; FK satisfied so notify-me requests no longer 500.
- Analytics tiles + weekly chart now derive from prisma.count + date_trunc raw queries against real Candidate + Campaign rows; no MOCK_ANALYTICS singleton.
- 56 integration tests across 11 files round-trip every entity against live Neon in ~190s. CI gate `pnpm test:int` green on every push.
- docs/plan/data-layer-go-real.md shipping log + docs/plan/contracts/endpoints.md updated to reflect 10/10 user-spec gaps complete. Two follow-up tasks (#25 MOCK_AUTOMATION_ACTIVITY, #26 MOCK_AUTOMATIONS legacy templates) tracked separately.
Commits: 1233d1d, 9c1a1cf, 3e10dee, 8a24ad9, 9c8a5ea, d10da61, 467e1f6, 533092b, 0b5c33b
- Feature
Pricing v3 — packs, monthly caps, no-shows free
Pricing rebuilt for fairness on both sides. $9-per-interview retired in favour of pre-paid packs and monthly plans with a transparent cap. An interview only counts when the candidate audibly speaks.
- Free tier unchanged: 3 lifetime interviews, no card.
- Per-interview replaced with two pre-paid packs — Starter $19/10 ($1.90 effective) and Growth $79/50 ($1.58 effective), each with a 12-month expiry.
- Monthly tiers gain a hard cap + transparent overage: Pro $49/mo for 30 interviews ($1.50 overage), Agency $149/mo for 100 interviews ($1.00 overage, white-label, 10 seats).
- "No-shows free" applied across every tier — an interview only counts when the candidate has audibly spoken and the AI has asked at least one question.
- Packs are non-refundable; materially-broken interviews credited back on request. Pack credits draw down before any monthly cap is consumed.
- Terms § 8 fees rewritten to spell out the counting rule, pack expiry, no-refund policy, and cancellation/upgrade behaviour.
Commits: c566db3
- FeatureInfra
Recruiter ops loop — jobs board, automations, observer mode, shortlist tabs, feedback
Five recruiter-side capabilities landed in a single push: a public jobs board, an automation execution engine with manual approval, observer mode for live interviews, shortlist preset tabs on the candidate table, and a candidate-facing feedback page.
- Public `/jobs` board lists every published campaign with filters (department, employment type, location type); `/jobs/[slug]` renders the role detail with JobPosting JSON-LD for Google Jobs eligibility, validThrough = publishedAt + 60 days.
- Automation execution engine (`lib/automation/dispatcher.ts` + `condition-eval.ts`) is idempotent per rule+candidate+trigger; auto rules fire immediately, manual rules push to `/automations/pending` for recruiter approval. Email body preview renders in a sandboxed iframe so untrusted recruiter edits can’t break out.
- Recruiter observer mode: `/live` indexes in-progress interviews; `/live/[sessionId]` joins the candidate’s LiveKit room with a read-only token (canPublish=false, canSubscribe=true). 1-hour TTL, identity `observer_<userId>_<timestamp>`.
- Shortlist preset tabs above the candidate table: All / Shortlist / Needs review / Live now. Shortlist = `recommendation ∈ {strong_yes, yes}` and not rejected. Bulk Advance / Reject / Send email floating bar appears on selection.
- Candidate-facing `/feedback/[token]`: 30-day token-scoped public page showing strengths, weaknesses (gated by rule toggle), and AI-generated improvement tips. Marked noindex/nofollow.
- `/api/ai/generate-tips` adds Gemini-backed tips with a canned fallback when `GOOGLE_GENERATIVE_AI_API_KEY` is missing.
- Foundation types extended: `Campaign` now carries `publicSlug`, `publishedAt`, `location`, `comp`, `employmentType`, `seniority`, `department`, `requirements`. New types for pending dispatches, feedback payloads/tokens, observer tokens, and live session summaries. `EmailKind` adds `candidate_feedback`.
Commits: 29c3a2e, 0a4e903, 61c6be8, ff533d0, 7085a20
- FixInfra
Auth hardening — middleware closes `/live`, email-template XSS regression suite
Two hardening passes on the auth surface. The recruiter observer pages are now session-gated at the edge; auth-related email templates escape every user-controlled field, with a vitest suite that locks the behaviour in.
- Middleware matcher extended with `/live` and `/live/:path*` so unauthenticated visitors hit the existing redirect-to-login flow before any candidate names or job titles render.
- `lib/email/auth-templates.ts` now escapes every user-controlled field through `escapeHtml`; verify and reset URLs flow through `escapeUrl`, which rewrites `javascript:` / `data:` / `vbscript:` schemes to `#`.
- New `lib/auth-validators.ts` exposes `isValidEmail`, `isValidPassword`, `isValidName`, and `validateSignup` — `EMAIL_REGEX` matches the WHATWG `<input type="email">` standard, hard 254-char cap per RFC 5321.
- 68 unit tests + 19 curl smoke checks cover OWASP-five XSS, the three blocked URL schemes, mailto/tel pass-through, and the signup validator order.
Commits: 5a40ab1, 75de80a
- DesignFix
GTM Wave 3.3 — vs/* + use-cases/* + product/* pages shipped, build unblocked
Wave 3.3 of the marketing IA lands: vs/* competitor pages, use-cases/* segment pages, product/* deep-dives. Build went green after a prettier + escape-entity sweep across 14 files.
- New landing FAQ anchor `faq-ll144` so the `/product/compliance` CTA can deep-link straight to the LL144 row.
- Prettier + ESLint sweep across 14 wave-3.3 surfaces; two `'` escapes in vs/hireflix + vs/karat; five unused lucide icons pruned. Marketing tree clean again.
Commits: 8458f09, 6ab019a
- Design
Footer refresh
Marketing footer rebuilt around what visitors actually click — Product, Legal, and a "For recruiters" column — replacing the LiveKit byline.
- Dropped the "Built on" column (LiveKit / LiveKit Agents / Next.js / Prisma) — internal stack detail that didn’t earn footer space.
- Dropped the standalone "Compliance" column; AI compliance now sits inside the new Legal column with Privacy, Terms, Cookies, and Data.
- New "For recruiters" column with Sign in, Dashboard, Help, and Support — the routes a visitor most likely needs after Product.
- Bottom strip credit changed from "Voice on LiveKit" to a link pointing at vettika.com.
Commits: cb2774c
- FeatureDesign
Better Auth Phase 4 — sign-up gate, intent-aware login, last-used email memory
The login flow now distinguishes sign-up from sign-in, remembers the last signed-in account in the browser, and surfaces a friendly prompt when someone tries to sign in without an account.
- Login page renders two distinct buttons — "Sign in with Google" and "Create account with Google" — each writing a short-lived `auth_intent` cookie before redirecting to Google.
- `databaseHooks.user.create.before` blocks brand-new User rows when the cookie says `signin`, throwing `UNAUTHORIZED` so Better Auth aborts the callback.
- Failed sign-ins land on `/login?error=no_account` with an inline "No account found — create one?" prompt instead of a generic error.
- AvatarMenu persists the active user (`{ email, name, image }`) to localStorage under `recruiter:last-user`; the login page reads it on mount and offers a "Continue as <name>" card with the Google avatar.
- Middleware matcher doubled to include both `/path` and `/path/:path*` for every protected prefix so exact path hits can’t slip past.
- Repo README rewritten as an Vettika quickstart (clone → install → env → db push → dev); upstream LiveKit starter README kept below as reference.
Commits: 69b9d1f
- FeatureInfra
Better Auth Phase 3 — sign-in is live
End-to-end Google OAuth on top of the Phase 2 schema. Marketing and candidate routes stay open; recruiter routes are now session-gated at the edge.
- `/api/auth/[...all]` catch-all Better Auth handler via `toNextJsHandler`.
- `lib/auth-client.ts` exposes `signIn` / `signOut` / `useSession` / `getSession` from `better-auth/react`.
- New `(auth)/login` page with a Google sign-in button that respects `?next=` for post-login redirect and surfaces OAuth errors inline.
- Edge-safe middleware (`getSessionCookie`) gates `/dashboard`, `/account`, `/campaigns`, `/automations`, `/settings`, `/sessions`, `/interviews`. 33 kB bundle.
- AvatarMenu dropped the demo recruiter stub and pulls live `name` / `email` / `image` from `useSession`; sign-out now calls `authClient.signOut` so the server session cookie is actually cleared.
Commits: e145285
- Infra
Better Auth Phase 2 — Prisma rename + auth schema
Made room for Better Auth on the Prisma side without losing a single row in production. The old `Session` model is now `Interview`, table name preserved via `@@map`; four new auth tables (`user` / `session` / `account` / `verification`) joined the schema.
- `model Session` → `model Interview` with `@@map("Session")` so the existing Neon table data stays exactly where it is.
- Better Auth canonical models generated via `@better-auth/cli` — `User`, `Session`, `Account`, `Verification` — mapped to lowercase tables.
- 16 call sites swept from `db.session.*` to `db.interview.*` across app routes, API routes, and helpers; `lib/sessions.ts` aliases `Interview as PrismaSession` so `SessionDTO` / `toDTO` stay untouched in this phase.
- `lib/auth.ts` introduces `betterAuth()` with the Prisma adapter and Google provider — instance live, route handler arrives in Phase 3.
Commits: 57125cd
- FeatureDesignInfra
Route groups + settings split + candidate invite share
Settings now lives in per-section routes with a responsive nav, every candidate row can share its own interview link, and the App Router tree is reorganized into clearly named groups.
- Each settings section is its own route: Workspace, Notifications, Data & privacy, Danger zone — alongside the existing AI profiles, Integrations, and Billing pages.
- New shared settings shell wraps every sub-route with a sticky sidebar on large screens and a horizontally scrollable underlined tab strip on mobile/tablet.
- Each candidate row now has an inline "copy invite link" icon and a "Share invite…" dialog with a prefilled mailto body (first name, campaign title, scheduled slot) and a QR placeholder.
- App Router tree split into URL-transparent `(marketing) / (app) / (candidate)` groups — no paths changed.
- Recruiter AI generate routes (`/api/ai/generate-{questions,criteria,copy}`) backed by `@ai-sdk/google` ship behind the wizard.
- Resend email backend (`/api/email/send` + `lib/email/templates.ts`) is wired and idle until `RESEND_API_KEY` is set.
- Inline editable scoring criteria + required questions added to `/campaigns/[id]?edit=1`.
- Marketing: pricing page, landing rebuild with rotating headline, demo widget, and transcript loop; SEO baseline (`robots.ts`, `sitemap.ts`).
Commits: f43cfed, d39895e, dcb9842
- Infra
Production domain + analytics
Vettika is live at airecruiter.nexprove.com with TLS, with `@vercel/analytics` wired in the root layout. Lint warnings across the agents-ui and ai-elements scaffolds were cleaned up.
- Custom domain `airecruiter.nexprove.com` issued and serving production.
- `@vercel/analytics` enabled in `app/layout.tsx`.
- Lint pass across `components/agents-ui` and `components/ai-elements`.
- `PROTOTYPE_STATUS.md` updated to reflect session 3 deploy state.
Commits: a55be45, 74ef348, 760e6de
- FeatureDesign
Recruiter + candidate prototype IA
End-to-end recruiter dashboard, campaigns, settings, automations, and candidate flow shipped against a mock API layer so we can iterate on the IA before wiring real endpoints.
- Recruiter dashboard, campaigns list + detail, automations, sessions, interviews — all driven by `web/lib/api.ts` mocks.
- Candidate flow: `/i/[token]` consent → system check → live interview → done/error.
- Public apply link card + per-link interview slot scheduling.
- 15 follow-up tasks captured in `web/PROTOTYPE_STATUS.md`.
Commits: bd83c81
- DesignDocs
Rebrand to Vettika
"AI Interviewer" → "Vettika" across owned surfaces — landing, headers, footers, metadata, and copy.
- Brand name applied to header, footer, page titles, and marketing copy.
- Vettika brand linked from app footer.
Commits: 6bfe69b
- FeatureDesign
/legal hub + footer split
New /legal landing with 7 policy sub-pages, and a footer split — rich `SiteFooter` for marketing routes vs compact `AppFooter` for app routes.
- Pages: privacy, terms, cookies, data, AI compliance, help, support.
- Marketing routes render `SiteFooter`; app routes render `AppFooter`.
Commits: 551e5db
- FeatureInfra
Candidate prejoin + Neon Postgres
Candidates run a mic + name check before the call connects. The token route is hardened and the DB moved to Neon Postgres on Vercel.
- `/i/[token]/check` mic + camera + name capture before connecting to LiveKit.
- `/api/token` is session-scoped; dev-only guard removed.
- Prisma datasource on Postgres (Neon marketplace integration).
Commits: 5b267ec, 10b0a51, 7ee9e8b, 51068f0
- Infra
Initial commit
AI Interviewer scaffold — Next.js App Router + LiveKit Agents Python runner + Prisma SQLite, deployed on Vercel.
- Next.js 15 + App Router on Vercel.
- LiveKit Cloud + Agents (Deepgram STT, Gemini LLM, Cartesia TTS).
- Python interviewer agent at `agent/interviewer.py`.
Commits: d2c4b8e
Want to follow along?
Source lives on GitHub. Every entry above maps to one or more commits.