Event-driven API
Job Hunt CRM: an event-driven application tracker
An event-driven CRM whose core is a guarded state machine over an append-only event log. Legal-edge-only transitions, idempotent webhook ingestion, and a pluggable AI service with a deterministic offline fallback.
- Role
- Author, open-source portfolio project
- Period
- 2025
- Stack
- NestJS · TypeScript · TypeORM · PostgreSQL / SQLite · EventEmitter2 · @nestjs/schedule
- Source
- GitHub ↗
This one is open source, so you can read the whole thing. It is a job-application tracker, built like a production system to show the reliability patterns I use in my paid fintech work.
Most job trackers are CRUD over a spreadsheet. I built this one around the application lifecycle instead of the storage. It uses the same reliability patterns as my fintech work: guarded transitions, an immutable event log, and exactly-once ingestion.
The problem
An Application is not a row you edit. It is an entity with a lifecycle. It can
only move along legal edges, so you cannot jump SAVED → OFFER. Terminal outcomes
have to lock. The funnel has to stay honest even when an application gets rejected
after reaching a late stage. And inbound signals, like a recruiter reply or a
calendar confirmation, can show up more than once. As plain CRUD, each of those
becomes a bug.
Architecture
The write path is the Applications module. It is the aggregate root, guarded by a
finite state machine. Every accepted transition appends an immutable event to
the log, in the same database transaction as the status update, and then emits a
domain event onto an in-process bus. History is never computed from the
mutable status field. The append-only log is the source of truth. Two consumers
subscribe to the bus without ever touching the write path: a cron reminder
worker and a derived analytics read model. Inbound webhooks have to clear an
idempotency ledger before they can cause any effect. The tailor service is
pluggable, using the model when a key is set and a deterministic heuristic when it
is not.
Decisions and tradeoffs
A declarative guarded state machine, not scattered if checks
Every legal transition lives in one adjacency table, behind a
canTransition(from, to) guard. Terminal states (ACCEPTED, REJECTED,
WITHDRAWN) have no outgoing edges at all. An illegal move comes back as an HTTP
409 that quotes the machine’s own reasoning
(Illegal transition SAVED -> OFFER. Allowed: APPLIED, WITHDRAWN). It never
silently does nothing. And because that same table also defines the funnel
ordering, the guard and the analytics cannot drift apart.
Rejected: status as a free string with validation sprinkled across controllers. It drifts, and “which transitions are legal?” stops having a single answer.
Analytics derived from the event log, not counters
The funnel is derived from the STATUS_CHANGED event stream rather than a mutable
counter. That is what lets a candidate who reached INTERVIEW and was then
rejected still count toward interview conversion. The history is a sequence of things
that happened, not a counter you reset. Events are insert-only. A
@BeforeInsert hook stamps an ISO-8601 UTC timestamp once, and nothing ever
updates it.
Rejected: per-stage counters on the application row. Fast to read. But they erase the history the moment a status changes, and they quietly lie after any out-of-order update.
Idempotent webhooks via a unique (provider, dedupeKey) ledger
Inbound events are deduped by claiming a receipt first: an insert into a table
with a unique (provider, dedupeKey) constraint. A redelivered event trips the
constraint, gets caught, and comes back as { status: 'duplicate' }. Nothing is
processed twice. The check behaves the same on both drivers (Postgres 23505,
SQLite SQLITE_CONSTRAINT), so correctness does not hang on which database is
running.
Rejected: “look it up, then insert if it is missing.” Under concurrent delivery there is a race between the read and the write. The database’s unique constraint is the only referee that actually settles it. This is the same exactly-once pattern I ran on production payment callbacks; the Vida case study has the details.
Event-driven decoupling so new consumers never touch the write path
Modules publish domain events over EventEmitter2 instead of calling each other
directly. The reminder worker, the analytics read model, and any notifier I add
later all subscribe to the same application.* stream. Adding an email or Slack
consumer is just a new subscriber. The Applications module never has to know it
exists.
Tradeoff: in-process events do not survive a crash. At this scale that is an acceptable tradeoff. The upgrade path is in the roadmap: an outbox for guaranteed delivery and BullMQ/Redis for the cron scan. The worker’s scan was written so that port stays clean.
A pluggable AI service with a deterministic offline fallback
Tailoring takes a job description and the stored resume and produces CV bullets and
a cover letter. With ANTHROPIC_API_KEY set, it calls the model. The resume goes
in a cached system prefix (cache_control: ephemeral), so repeat tailorings reuse
it at roughly 10x cheaper input while the job description varies per request. With
no key set, it drops to a deterministic keyword-overlap heuristic behind the
exact same interface. The endpoint always works, offline and in CI, and the tests
never reach for the network.
Rejected: making the LLM a hard dependency. That leaves the feature untestable without a key, flaky in CI, and dead offline.
One model call, on tailor only
Tracking a posting is free. Capture pulls the title and company out with cheap
regex heuristics (labels, “<title> at <Company>”, the URL domain) and drops them
into an editable confirm you can eyeball in a second. No model call. The one model
call happens on tailor, and only for jobs you actually pursue. Re-tailoring
inserts a new version instead of overwriting the old one, the same append-only
habit as the event log.
Rejected: an LLM call on every capture. It burns tokens on postings the user never pursues, and it makes the fast path depend on a model that might be down.
Reliability details
- Atomic writes. The application row and its opening
CREATEDevent are saved in one transaction. A status change and itsSTATUS_CHANGEDevent go the same way. The log can never disagree with the row. - Cross-database by construction. Timestamps are stored as ISO-8601 UTC
strings. They sort chronologically and keep millisecond precision that
second-granular SQLite datetime would lose. One schema runs unchanged on SQLite
for zero-ops local dev and on Postgres in production. It is a runtime
DB_TYPEswitch, with no code change. - The cron worker snoozes. When a follow-up fires, it pushes
nextFollowUpAtforward, so a quiet application is nudged once rather than every hour until the user acts. - Tested where it matters. Unit tests cover legal and illegal transitions, terminal locking, funnel ordering, and the tailoring heuristic’s keyword overlap plus its no-overlap fallback.
Impact
This is a portfolio system, so I will be straight about the impact: it is architectural, not a business number. What it shows is guarded state transitions, an immutable audit log the analytics are derived from, exactly-once ingestion, event-driven decoupling, and an external dependency that degrades gracefully when it is gone. Those are the same properties that matter when the domain is money instead of job applications.