CODEX // oaoisme wiki

§ unswayed-backend

Discovery & applications (Phase 5)

updated 2026-06-09

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:

  1. Validate the EEO / eligibility answers (is_adult, authorized_to_work, …, have_disability, is_veteran).
  2. Reject a closed deadline (past, but today is still allowed) and a duplicate apply — both 422.
  3. Snapshot the resume. The resume field in the request is an ApplicantResume id, but what gets stored is a copy of that résumé's filtered_resume path (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.
  4. Persist each uploaded documents[] file through the storage port.
  5. Write the first history row, type = application_submittedinside 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 real 404, a bad answer or a closed deadline is a 422.

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 (404 on 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 a 404, never a 500). The embedded job here is the trimmed JobDetailResource, the same for every viewer.
  • POST /application/{id}/archive — toggles the viewer's own visibility flag (employer flips employer_visible, applicant flips applicant_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 a SaveJob row (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 and is_saved gets populated. POST /applicant/external-job-save (§39) snapshots the external job's display fields into external_save_jobs so 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 a meta block.
  • GET /applicant/career-jobs (§33) is the unified discovery feed: internal SQL-filtered jobs + external (JSearch) jobs, merged internal-first, manually paginated under a pagination block. 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.