feat(api+client): contracts feature (#25) #250

Merged
james merged 6 commits from 25-contracts into main 2026-06-23 14:34:33 +00:00
Owner

Closes #25.

Summary

Carol now ships a Contracts feature alongside Jobs in the Experience screen. A Contract is a job-shaped record that carries exactly one Position with its Contributions; it shares storage with the Jobs entity and the two are discriminated by an is_contract flag on the row.

Model-shape decision (A vs B)

Went with option A: a single jobs table with is_contract BOOLEAN NOT NULL DEFAULT FALSE (stored as integer 0/1 per the project's portability rule). The existing Job → Position[] → Contribution[] DDL, cascade machinery, and route plumbing are reused unchanged; the discriminator filter on every read keeps the two families disjoint.

Why not option B (separate contracts entity tree):

  • Duplicating Position + Contribution schemas + repos + DTOs across two parallel trees is a lot of code for a 1-column gain.
  • The DTO surface on the wire is identical to a Job — option B would still emit the same JSON, just from a parallel route family.
  • The cascade story (delete the parent, lose the children) is already correct.

The downside is that the "exactly one Position" invariant becomes an app-level check rather than a schema constraint:

  • SQLite can't write a CHECK that reads another table's column.
  • A partial-UNIQUE on positions(job_id) WHERE jobs.is_contract = 1 would need engine-specific syntax (and SQLite + Postgres handle the WHERE-on-UNIQUE differently for cross-table predicates).
  • The route layer is the only writer of Positions; the check + the insert share a request. For Carol's single-user-editing workload that's fine.

If concurrent agent runtime writes ever surface as a real attack, we'd revisit with SERIALIZABLE-style isolation or engine-bespoke partial unique indexes — not today.

Migration shape

apps/api/db/migrations/012_jobs_is_contract.ts:

  • ALTER TABLE jobs ADD COLUMN is_contract INTEGER NOT NULL DEFAULT 0
  • New index (user_id, is_contract, start_date) to back the contracts list scan.
  • Both up and down work unchanged on SQLite and Postgres (Kysely's alterTable.addColumn lowers to the right DDL on each engine).
  • Existing Job rows stay Jobs (is_contract = 0). No data migration — converting a Job to a Contract is a user-initiated Edit, not in scope.

Routes

Method Path Notes
GET /api/contracts Cursor-paginated list (mirrors /api/educations).
POST /api/contracts Always sets is_contract = 1; ignores any isContract in the body (the flag is route-set, not client-set).
GET /api/contracts/{id} Returns the nested tree (Contract + 0-or-1 Position + Contributions), same one-fetch ergonomics as GET /api/jobs.
PATCH /api/contracts/{id} Flat DTO.
DELETE /api/contracts/{id} Cascades to Position + Contributions via DDL.
POST /api/contracts/{id}/positions Refuses with 409 contract_has_position if a Position already exists.

Existing /api/jobs/{id}, /api/jobs/{id}/positions, and GET /api/jobs now 404 / hide rows where is_contract = 1 so the two families don't bleed across the kind boundary.

Constraint enforcement note

The single-Position invariant lives in apps/api/app/api/contracts/[id]/positions/route.ts: after the ownership check, it lists existing Positions for the contract and returns 409 contract_has_position if any exist. The check + insert share a single connection, single request. The route file's header comment records the dual-engine rationale and the trade-off in full.

Test plan

  • Migration applies cleanly on SQLite and Postgres (Kysely alterTable.addColumn works identically on both).
  • JobsRepository.listByUserId hides contracts; new listContractsPaginatedByUserId returns only contracts.
  • JobsRepository.create defaults is_contract = 0; isContract: true persists 1.
  • Cursor pagination on /api/contracts matches the /api/notes envelope and is stable across overlapping start_date values.
  • POSTing to /api/jobs with isContract: true does NOT create a contract (route ignores the body field).
  • CRUD on /api/contracts round-trips correctly; the flat isContract: true is surfaced on every response.
  • POST /api/contracts/{id}/positions 201s the first time, 409s with contract_has_position thereafter.
  • Cross-user reads on every endpoint return 404 (don't-leak-existence).
  • Cross-kind reads return 404: a Contract id is 404 from /api/jobs/{id}; a Job id is 404 from /api/contracts/{id}.
  • GET /api/contracts/{id} returns the nested tree (Position + Contributions).
  • api-client hooks re-export through the top barrel; query keys are sibling-not-nested with jobs.
  • Client UI: Education / Jobs / Contracts segmented control, drill-down with the Set position button hidden once a Position exists.
  • OpenAPI drift gate green; coverage gate confirms every new route file is registered.

Surprises

One real one: GET /api/contracts needed to be paginated per the ticket's scope language, but to give the UI a one-fetch drill-down (parity with useJobs()'s nested tree), I made GET /api/contracts/{id} return the nested ContractWithChildrenDto. The list-vs-detail asymmetry is intentional and documented in docs/api-conventions.md.

Out of scope

  • Convert Job ↔ Contract UI (user-initiated edit only, no migration helper).
  • Cross-references between Contracts and Applications (#129's epic).
  • Importing contract data from external sources.

See idea.md §Experience for the product framing.

Closes #25. ## Summary Carol now ships a Contracts feature alongside Jobs in the Experience screen. A Contract is a job-shaped record that carries exactly one Position with its Contributions; it shares storage with the Jobs entity and the two are discriminated by an `is_contract` flag on the row. ## Model-shape decision (A vs B) Went with **option A**: a single `jobs` table with `is_contract BOOLEAN NOT NULL DEFAULT FALSE` (stored as integer 0/1 per the project's portability rule). The existing Job → Position[] → Contribution[] DDL, cascade machinery, and route plumbing are reused unchanged; the discriminator filter on every read keeps the two families disjoint. Why not option B (separate `contracts` entity tree): - Duplicating Position + Contribution schemas + repos + DTOs across two parallel trees is a lot of code for a 1-column gain. - The DTO surface on the wire is identical to a Job — option B would still emit the same JSON, just from a parallel route family. - The cascade story (delete the parent, lose the children) is already correct. The downside is that the "exactly one Position" invariant becomes an app-level check rather than a schema constraint: - SQLite can't write a CHECK that reads another table's column. - A partial-UNIQUE on `positions(job_id) WHERE jobs.is_contract = 1` would need engine-specific syntax (and SQLite + Postgres handle the WHERE-on-UNIQUE differently for cross-table predicates). - The route layer is the only writer of Positions; the check + the insert share a request. For Carol's single-user-editing workload that's fine. If concurrent agent runtime writes ever surface as a real attack, we'd revisit with SERIALIZABLE-style isolation or engine-bespoke partial unique indexes — not today. ## Migration shape `apps/api/db/migrations/012_jobs_is_contract.ts`: - `ALTER TABLE jobs ADD COLUMN is_contract INTEGER NOT NULL DEFAULT 0` - New index `(user_id, is_contract, start_date)` to back the contracts list scan. - Both up and down work unchanged on SQLite and Postgres (Kysely's `alterTable.addColumn` lowers to the right DDL on each engine). - Existing Job rows stay Jobs (`is_contract = 0`). No data migration — converting a Job to a Contract is a user-initiated Edit, not in scope. ## Routes | Method | Path | Notes | |---|---|---| | GET | `/api/contracts` | Cursor-paginated list (mirrors `/api/educations`). | | POST | `/api/contracts` | Always sets `is_contract = 1`; ignores any `isContract` in the body (the flag is route-set, not client-set). | | GET | `/api/contracts/{id}` | Returns the nested tree (Contract + 0-or-1 Position + Contributions), same one-fetch ergonomics as `GET /api/jobs`. | | PATCH | `/api/contracts/{id}` | Flat DTO. | | DELETE | `/api/contracts/{id}` | Cascades to Position + Contributions via DDL. | | POST | `/api/contracts/{id}/positions` | Refuses with `409 contract_has_position` if a Position already exists. | Existing `/api/jobs/{id}`, `/api/jobs/{id}/positions`, and `GET /api/jobs` now 404 / hide rows where `is_contract = 1` so the two families don't bleed across the kind boundary. ## Constraint enforcement note The single-Position invariant lives in `apps/api/app/api/contracts/[id]/positions/route.ts`: after the ownership check, it lists existing Positions for the contract and returns `409 contract_has_position` if any exist. The check + insert share a single connection, single request. The route file's header comment records the dual-engine rationale and the trade-off in full. ## Test plan - [x] Migration applies cleanly on SQLite and Postgres (Kysely `alterTable.addColumn` works identically on both). - [x] `JobsRepository.listByUserId` hides contracts; new `listContractsPaginatedByUserId` returns only contracts. - [x] `JobsRepository.create` defaults `is_contract = 0`; `isContract: true` persists 1. - [x] Cursor pagination on `/api/contracts` matches the `/api/notes` envelope and is stable across overlapping `start_date` values. - [x] POSTing to `/api/jobs` with `isContract: true` does NOT create a contract (route ignores the body field). - [x] CRUD on `/api/contracts` round-trips correctly; the flat `isContract: true` is surfaced on every response. - [x] `POST /api/contracts/{id}/positions` 201s the first time, 409s with `contract_has_position` thereafter. - [x] Cross-user reads on every endpoint return 404 (don't-leak-existence). - [x] Cross-kind reads return 404: a Contract id is 404 from `/api/jobs/{id}`; a Job id is 404 from `/api/contracts/{id}`. - [x] GET `/api/contracts/{id}` returns the nested tree (Position + Contributions). - [x] api-client hooks re-export through the top barrel; query keys are sibling-not-nested with jobs. - [x] Client UI: Education / Jobs / Contracts segmented control, drill-down with the Set position button hidden once a Position exists. - [x] OpenAPI drift gate green; coverage gate confirms every new route file is registered. ## Surprises One real one: `GET /api/contracts` needed to be paginated per the ticket's scope language, but to give the UI a one-fetch drill-down (parity with `useJobs()`'s nested tree), I made `GET /api/contracts/{id}` return the nested `ContractWithChildrenDto`. The list-vs-detail asymmetry is intentional and documented in `docs/api-conventions.md`. ## Out of scope - Convert Job ↔ Contract UI (user-initiated edit only, no migration helper). - Cross-references between Contracts and Applications (#129's epic). - Importing contract data from external sources. See [`idea.md`](idea.md#experience) §Experience for the product framing.
Carol models a Contract as a Job with `is_contract = 1` and exactly
one Position. Migration 012 adds the integer-boolean column, defaults
existing rows to 0, and indexes `(user_id, is_contract, start_date)`
so the contracts list scan stays a single-column predicate.

JobsRepository grows a contracts-only paginated list and the existing
`listByUserId` now filters to `is_contract = 0`. The two route
families that read this table — /api/jobs and /api/contracts — keep
disjoint views without any change at the call site. Tests live in
the dual-engine matrix so the same code path is asserted on SQLite
and Postgres.

Part of #25.
New route family under /api/contracts mirrors /api/jobs but always
flips is_contract on create and filters to the Contract subset on
read. The list endpoint paginates by (start_date, id) DESC; the
detail endpoint returns the nested tree (Contract + 0-or-1 Position
+ Contributions) so a drill-down view loads in one round trip — same
ergonomics as `GET /api/jobs`.

POST /api/contracts/{id}/positions enforces "exactly one Position"
at the handler: count first, refuse with 409 contract_has_position
when one already exists. Done at app level rather than a DB CHECK or
partial unique index because the two engines diverge there and the
route layer is the only writer. The check + create share a single
request, which is fine for the single-user-editing workload Carol
targets.

Cross-kind isolation is symmetric: /api/jobs/{id} and
/api/jobs/{id}/positions 404 a Contract id, /api/contracts/{id} 404s
a Job id. The Jobs nested-tree GET no longer surfaces Contracts.
POST /api/jobs ignores `isContract` in the body — the flag is route-
set, not client-set, so neither family can be tricked into minting
into the wrong half.

Tests cover happy CRUD, cursor pagination, cross-user isolation,
cross-kind isolation, and the 409 branch.

Part of #25.
Adds useContracts / useContract / useCreateContract / useUpdateContract
/ useDeleteContract for the new route family, plus
useCreateContractPosition for the single-Position endpoint (which is
named for its kind so callsites carry the constraint context).

Positions and Contributions are kind-agnostic on the wire — the same
endpoints serve both Jobs and Contracts — so their hooks now
invalidate both keys.jobs.all and keys.contracts.all on every
mutation. Either drill-down reflects the change without the caller
having to know which parent owned the Position.

Part of #25.
Adds experience.contracts.* for the Contracts tab — drill-down
labels, empty states, the form copy that's shared with the Jobs
form layer, and `positionOnly` for the "exactly one position"
hint surfaced on the contract detail view. The pre-existing
experience.comingSoon.contracts placeholder is removed (the screen
ships).

errors.contract.hasPosition mirrors the API's
`contract_has_position` 409 code so clients can render a localised
message when a user tries to add a second Position.

Part of #25.
Adds Contracts as the third segment in the experience segmented
control alongside Education and Jobs. The drill-down folds the
"Positions" level out — a Contract has exactly one Position by
invariant, so the list goes straight from contract row to a single
detail screen that surfaces the Position fields, the Contributions
list, and a "Set position" affordance that disappears once a
Position exists. The form components (JobForm / PositionForm /
ContributionForm) are reused; only the wiring + i18n keys change.

The detail view uses useContract(id) (returns the nested tree from
one round trip) so position + contribution edits flow through cache
invalidation against keys.contracts.all without the screen having
to refetch by hand.

Part of #25.
docs(api): note /api/contracts in the paginated-endpoints list
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 9s
PR / OSV-Scanner (pull_request) Successful in 1m54s
PR / Client (web export smoke) (pull_request) Successful in 2m11s
PR / Static analysis (pull_request) Successful in 2m11s
PR / Lint (pull_request) Successful in 2m20s
PR / Typecheck (pull_request) Successful in 2m24s
PR / pnpm audit (pull_request) Successful in 2m33s
PR / Build (pull_request) Successful in 2m52s
PR / OpenAPI (pull_request) Successful in 2m57s
PR / Package age policy (soft) (pull_request) Successful in 46s
PR / Test (postgres) (pull_request) Successful in 3m16s
Secrets / gitleaks (pull_request) Successful in 55s
PR / Test (sqlite) (pull_request) Successful in 3m16s
PR / Coverage (soft) (pull_request) Successful in 1m28s
PR / Trivy (image) (pull_request) Successful in 1m44s
0282cf21b9
Cross-references the new route family with the existing Jobs
nested-tree GET so future contributors can see both halves of the
shared-storage discriminator pattern at once.

Part of #25.

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 82.9% ≥ 50%
Branches 75.6% ≥ 75%
Functions 91.8% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 82.9% ✅ | ≥ 50% | | Branches | 75.6% ✅ | ≥ 75% | | Functions | 91.8% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james force-pushed 25-contracts from 0282cf21b9
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 9s
PR / OSV-Scanner (pull_request) Successful in 1m54s
PR / Client (web export smoke) (pull_request) Successful in 2m11s
PR / Static analysis (pull_request) Successful in 2m11s
PR / Lint (pull_request) Successful in 2m20s
PR / Typecheck (pull_request) Successful in 2m24s
PR / pnpm audit (pull_request) Successful in 2m33s
PR / Build (pull_request) Successful in 2m52s
PR / OpenAPI (pull_request) Successful in 2m57s
PR / Package age policy (soft) (pull_request) Successful in 46s
PR / Test (postgres) (pull_request) Successful in 3m16s
Secrets / gitleaks (pull_request) Successful in 55s
PR / Test (sqlite) (pull_request) Successful in 3m16s
PR / Coverage (soft) (pull_request) Successful in 1m28s
PR / Trivy (image) (pull_request) Successful in 1m44s
to f0c3174a33
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 9s
PR / OSV-Scanner (pull_request) Successful in 1m30s
PR / pnpm audit (pull_request) Successful in 2m2s
PR / Static analysis (pull_request) Successful in 2m9s
PR / Client (web export smoke) (pull_request) Successful in 2m23s
PR / OpenAPI (pull_request) Successful in 2m42s
PR / Package age policy (soft) (pull_request) Successful in 34s
PR / Test (postgres) (pull_request) Failing after 2m54s
PR / Lint (pull_request) Successful in 3m0s
PR / Typecheck (pull_request) Successful in 3m5s
PR / Test (sqlite) (pull_request) Successful in 3m11s
Secrets / gitleaks (pull_request) Successful in 52s
PR / Coverage (soft) (pull_request) Successful in 1m32s
PR / Build (pull_request) Successful in 3m23s
PR / Trivy (image) (pull_request) Successful in 2m7s
2026-06-23 14:09:56 +00:00
Compare
james merged commit e20a9e8714 into main 2026-06-23 14:34:33 +00:00
james deleted branch 25-contracts 2026-06-23 14:34:34 +00:00
Sign in to join this conversation.
No description provided.