§ unswayed-backend
Discovery & applications (Phase 5)
Discovery & applications (Phase 5)
Phase 5 is where a logged-in applicant finds jobs and applies to them. It's the demand side of the board, built on Phase 4's job postings, and it's the largest phase so far — two modules (src/applications/ and src/discovery/), ~18 endpoints across nine § surfaces, an external integration, and an application state machine that's only half-built on purpose.
Applying to a job
POST /api/applicant/job/{id}/apply (§32) is the heart of the phase. It runs as one database transaction:
- Validate the EEO / eligibility answers (
is_adult,authorized_to_work, …,have_disability,is_veteran). - Reject a closed deadline (past, but today is still allowed) and a duplicate apply — both
422. - Snapshot the resume. The
resumefield in the request is anApplicantResumeid, but what gets stored is a copy of that résumé'sfiltered_resumepath (the anonymised one). The application keeps the snapshot so it survives later résumé edits — and so the employer only ever sees the anonymised version. - Persist each uploaded
documents[]file through the storage port. - Write the first history row,
type = application_submitted— inside the same transaction.
After the transaction commits, three best-effort side effects fire: an in-app notification to the employer, and two queued emails (one to the applicant, one to the employer with the résumé + documents attached). A failure there never rolls back the application. The application_number is server-generated with the same insert-then-retry-on-collision trick as the requisition number.
Where the legacy wrapped everything in a broad
try/catch → 500, the rebuild uses typed exceptions: a missing job or résumé is a real404, a bad answer or a closed deadline is a422.
The dashboard
Five endpoints (§34/§22) let an applicant follow their applications:
GET /my-applications— filtered by an archive flag (type=unarchived(default) /archived).POST /application-tracking— look an application up by the job's requisition number (404on a miss).GET /my-activities— the history timeline across all the applicant's applications.GET /application-details/{id}— the full detail, ownership-scoped (an employer must own the job, an applicant must be the applicant — otherwise a404, never a500). The embeddedjobhere is the trimmedJobDetailResource, the same for every viewer.POST /application/{id}/archive— toggles the viewer's own visibility flag (employer flipsemployer_visible, applicant flipsapplicant_visible). The messages are verbatim, including the capital U in "Application has been Unarchived."
Discovery: internal, external, and the merge
There are two sources of jobs, and the code keeps them carefully separate:
- Internal platform jobs live in
employer_jobs.GET /job-detail/{id}(§21) returns a viewer-specific resource;POST /applicant/job-save/{id}(§31) toggles aSaveJobrow (its 404 is "Job not found!" — with an exclamation, distinct from job-detail's "Job not found."). - External jobs come from JSearch (RapidAPI).
GET /applicant/external-jobs(§12) is public / optional-auth — a guest gets results too; an authenticated applicant's first five profile skills feed the query andis_savedgets populated.POST /applicant/external-job-save(§39) snapshots the external job's display fields intoexternal_save_jobsso the saved list survives the cache TTL.
JSearch lives behind an injectable ExternalJobProvider port with a real-or-stub adapter: the real HTTP client when RAPIDAPI_KEY is set, a deterministic stub otherwise so the app boots and tests run without network. The provider is a thin client (raw JSearch objects); the messy normalization — parsing "Seattle, WA, US" style locations, resolving country/state ids ↔ names, setting is_saved — happens in the service. A missing key is a 503, a JSearch failure a 502, and the cache never stores empty or error payloads (so an outage can't poison it for 30 minutes).
Two endpoints merge the sources, and they deliberately use different pagination keys — preserve both exactly:
GET /applicant/saved-jobs(§31) merges saved internal jobs (open positions only) then saved external jobs, internal-first, manually paginated under ametablock.GET /applicant/career-jobs(§33) is the unified discovery feed: internal SQL-filtered jobs + external (JSearch) jobs, merged internal-first, manually paginated under apaginationblock. Its external filters are opt-in — applied only when the client actually sent that param — which fixes a legacy bug where AND-combining every filter dropped the result set to zero. The country matcher is the legacy rule ported faithfully (exact code → exact name → name-superset, split a compound country on the first comma, no 2–3-letter substring matching) and is unit-tested against"US, Virgin Islands"and"India, Delhi".
Job alerts
GET/POST/DELETE /api/applicant/job-alerts (§37) is saved-search CRUD. Alerts are scoped by user_id (not applicant_id), store their criteria as JSON id arrays plus a single country, and the list response hydrates each *_ids array into {id, name} objects. The JobAlert model itself was added back in Phase 4 (read-only) so the job-create notification fan-out could match against it; Phase 5 adds the write surface. The matching logic stays in Phase 4's queued handler.
What's deliberately deferred
JobApplication.status is a plain String defaulting to 'pending' — the only value Phase 5 ever writes. The full lifecycle (interview_*, offer_*, position_filled, rejected) is driven by the Phase 6 hiring pipeline, which owns the state machine and will type the column as an enum then. The interview/offer response and detail endpoints are deferred for the same reason — they need the JobInterview/JobOffer models.
How it was built
The phase is too interconnected for one parallel fan-out, so it was built in two workflow rounds: round 1 the four independent slices (apply, internal discovery, external JSearch, alerts) in parallel TDD; round 2 the two dependent ones (the dashboard reuses the internal job-detail resource; career-jobs reuses the external provider and the internal search). The orchestrator landed the shared schema + the ExternalJobProvider port + the JSearch config first, ran an adversarial wire-parity review after each round, then wired the controllers/modules, fixed the findings, and drove the e2e. The gate (npm run verify) is green at 1514 unit tests at ≥90% coverage + 160 e2e, the e2e exercising every route with JSearch faked through the stub.
See docs/roadmap/phases/PHASE-05-discovery-applications.md for the build spec, ADR-0028 for the decisions, and docs/API-CONTRACT.md for the catalogued deviations.