CareerCRM
AI-Powered Career Intelligence Platform
- Role
- Personal project — sole designer and developer
- Period
- 2026 — Present
- Status
- In active personal use
Problem
A serious job search generates more state than a spreadsheet can hold: roles across a dozen boards, recruiters whose names blur together, which CV went to which company, what was said in a screening call three weeks ago, and which follow-up is now overdue. The information is not hard to find — it is hard to keep, and it decays fastest exactly when the search is most active.
Solution
A single private application that owns the whole pipeline: opportunities, companies, contacts, tasks, messages, calendar, documents and resume workflows, with an AI layer that summarises, drafts and answers questions over that data rather than over a blank page.
What I built
- A full application pipeline with 19 stages from draft through offer, negotiation and acceptance, rendered as both a table and a kanban board.
- Opportunities, companies, contacts, tasks, messages, calendar and documents modules, each with a typed server-only data layer and validated Server Actions.
- A Resume AI workflow: deterministic ATS scoring, then AI review, section rewriting, cover-letter drafting, interview-question generation and LinkedIn optimisation.
- A public, anonymous demo of the resume analyser, throttled per visitor and bounded by its own token budget so a stranger can try the product without an account.
- A durable background job system for summarisation, sync and notification work.
Architecture
Next.js App Router with Server Components reading Postgres directly and Server Actions for every mutation, over a Supabase database where Row Level Security — not application code — is the authorization boundary. Every AI call is funnelled through a single gateway; every asynchronous task goes through a Postgres-backed queue. Migrations are additive-only and idempotent, so a schema change is safe to re-run against a live database.
AI implementation
One gateway is the only path to a model provider, so no caller can obtain a completion that skipped policy. A request passes through a feature flag, a burst rate limit, a versioned prompt registry, secret and PII redaction, an atomic token-budget reservation, the provider call, structured-output validation, consequence-classed tool authorization, and an audit row — in that order. The gateway depends on an AiProvider interface and never on a vendor SDK, which is verified by a test suite that exercises the whole file against a provider that has never heard of Anthropic.
Tech stack
- Next.js 15 (App Router)
- React 19
- TypeScript
- Tailwind CSS
- Supabase / PostgreSQL 17
- Anthropic API
- Vercel
- Sentry
- Vitest
- Playwright
Key technical decisions
Provider-agnostic by construction, not by intention
The gateway is written against an interface, and the neutrality test runs it end to end against a fake provider. That converts 'we could swap models later' from a claim into something CI fails on if it stops being true.
Token budget as one atomic SQL statement
Deriving a daily spend total by aggregating an audit log is racy — two concurrent calls both read the pre-spend total and both proceed — and gets slower with every call ever made. Budget enforcement is instead a single conditional INSERT … ON CONFLICT DO UPDATE against a counter row: correct under concurrency, constant time, and safe under a transaction-mode connection pooler.
Fail closed on money, fail open on convenience
If the budget ledger cannot be read, the AI call is refused — an outage must never become unbounded spend. If the burst rate limiter cannot be read, the call is allowed, because the budget still bounds it underneath and refusing everything would turn a degraded database into a total outage.
An append-only AI decision log enforced by trigger, not policy
Row Level Security is bypassed by the service-role key that server code holds, so RLS alone cannot make a table immutable. A BEFORE UPDATE OR DELETE trigger can — triggers are not bypassed by service_role — so the record of why the system did something cannot be rewritten by the system.
Additive-only, idempotent migrations
Every migration guards each statement and never alters or drops an existing object, so it is safe to re-run and a partially applied migration is recoverable by running it again rather than by restoring a backup.
Challenges
Billing accuracy for cached prompt tokens
The first budget implementation counted only input and output tokens. Providers also bill cache writes and cache reads, so the daily ceiling could be overspent by the size of the cached prefix on every single call. The fix was to make the budget count every token class the provider charges for, locked in by a token-accounting test suite.
Claiming queued work safely behind a connection pooler
Supabase's transaction-mode pooler makes it unsafe to hold a transaction open across an HTTP round trip, which rules out the usual select-then-update claim. Jobs are leased in one statement using FOR UPDATE SKIP LOCKED, which also reclaims stale leases from workers that died mid-task.
Stopping work, not just hiding it
Cancelling a streamed AI answer originally stopped the display while the provider call — and the billing — ran to completion. Breaking out of the stream now unwinds the gateway's cleanup path so the budget is reconciled against what was actually spent.
Authentication is not authorization
The admin allowlist was originally enforced only at signup. Supabase's auth endpoint is reachable directly with the public anon key, so an account could be created without that route ever running. The allowlist was moved to every access point — middleware, API routes and Server Actions — and a regression suite now asserts that an authenticated non-admin is refused at each one.
Outcome
The system is in daily personal use for a live job search. It ships with 1,241 unit tests across 78 files, twelve architecture decision records, and a CI pipeline that enforces lint, typecheck, tests and a production build on every pull request.
Interview talking points
- Why the AI gateway is a chokepoint rather than a helper library — and what that buys you when a second AI feature is added.
- Why the token budget is a single SQL statement instead of a read-then-write in application code.
- How fail-closed and fail-open were chosen deliberately per control, rather than applied uniformly.
- Why Row Level Security is the authorization boundary when the database is reachable over PostgREST and the web app is not the only client.
- How a durable Postgres queue replaced the need for external queue infrastructure at this scale.
- What an append-only audit table actually requires once server code holds a key that bypasses RLS.