feat(api+client): contracts feature (#25) #250
No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!250
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "25-contracts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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_contractflag on the row.Model-shape decision (A vs B)
Went with option A: a single
jobstable withis_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
contractsentity tree):The downside is that the "exactly one Position" invariant becomes an app-level check rather than a schema constraint:
positions(job_id) WHERE jobs.is_contract = 1would need engine-specific syntax (and SQLite + Postgres handle the WHERE-on-UNIQUE differently for cross-table predicates).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(user_id, is_contract, start_date)to back the contracts list scan.alterTable.addColumnlowers to the right DDL on each engine).is_contract = 0). No data migration — converting a Job to a Contract is a user-initiated Edit, not in scope.Routes
/api/contracts/api/educations)./api/contractsis_contract = 1; ignores anyisContractin the body (the flag is route-set, not client-set)./api/contracts/{id}GET /api/jobs./api/contracts/{id}/api/contracts/{id}/api/contracts/{id}/positions409 contract_has_positionif a Position already exists.Existing
/api/jobs/{id},/api/jobs/{id}/positions, andGET /api/jobsnow 404 / hide rows whereis_contract = 1so 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 returns409 contract_has_positionif 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
alterTable.addColumnworks identically on both).JobsRepository.listByUserIdhides contracts; newlistContractsPaginatedByUserIdreturns only contracts.JobsRepository.createdefaultsis_contract = 0;isContract: truepersists 1./api/contractsmatches the/api/notesenvelope and is stable across overlappingstart_datevalues./api/jobswithisContract: truedoes NOT create a contract (route ignores the body field)./api/contractsround-trips correctly; the flatisContract: trueis surfaced on every response.POST /api/contracts/{id}/positions201s the first time, 409s withcontract_has_positionthereafter./api/jobs/{id}; a Job id is 404 from/api/contracts/{id}./api/contracts/{id}returns the nested tree (Position + Contributions).Surprises
One real one:
GET /api/contractsneeded to be paginated per the ticket's scope language, but to give the UI a one-fetch drill-down (parity withuseJobs()'s nested tree), I madeGET /api/contracts/{id}return the nestedContractWithChildrenDto. The list-vs-detail asymmetry is intentional and documented indocs/api-conventions.md.Out of scope
See
idea.md§Experience for the product framing.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.📊 Test coverage
Patch coverage: no testable lines changed.
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Soft thresholds per ADR-0019. Coverage is informational and does not block merge.
0282cf21b9f0c3174a33