feat(service+pwa): jobs, positions, contributions — three-tier career history (#24) #175
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!175
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "24-jobs-positions-contributions"
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 #24. Part of epic #4.
Summary
The most structurally complex piece of the career domain: three nested entities — Jobs own Positions own Contributions — with full CRUD on the API and a drill-down UI inside the /experience Jobs tab.
Backend
Migration 010 creates
jobs,positions,contributions. Every row carriesuser_id, including the children where it's denormalised away from the parent FK so every isolation check is a one-column filter on the leaf table — no join through the parent.Cascade wired up via DDL (
ON DELETE CASCADE). Both engines honour the same statement; SQLite'sPRAGMA foreign_keys = ONis already on per-connection.Repositories mirror EducationsRepository:
listByUserIduser-scoped,findById/update/deleteByIdaccept raw ids and let the route layer enforce ownership. Positions get an extralistByJobIdand Contributions alistByPositionIdfor in-parent drill-down.DTOs in
lib/dto/job.tscover all three entities (Create / Update / form-shaped + zod) plus anestJobs(jobs, positions, contributions)stitcher used by the GET /api/jobs collection endpoint.API routes (six
route.ts):GET/POST /api/jobs(list-nested / create)GET/PATCH/DELETE /api/jobs/[id]POST /api/jobs/[id]/positionsGET/PATCH/DELETE /api/positions/[id]POST /api/positions/[id]/contributionsGET/PATCH/DELETE /api/contributions/[id]Child-creation routes do a two-step ownership check: load the parent, 404 if it's missing or belongs to a different user, then create the child with
user_iddenormalised from the session.Tests
tests/db/jobs.test.ts(dual engine viadescribePerEngine) covers per-entity CRUD plus cascade behaviour identical across engines: deleting a Job removes Positions + Contributions; deleting a Position removes its Contributions but leaves the Job; deleting the user removes everything they owned. Cross-user isolation asserted at every nesting level.tests/api/jobs.test.tscovers cross-user 404s on GET / PATCH / DELETE at every entity, nested-GET bucketing, parent-ownership 404 on child-creation routes, and route-layer cascade behaviour reflected in the nested GET.tests/dto/job.test.tscovers create / update validators, length limits, and thenestJobsstitcher.Total: 494 → expanded count (passing). The full suite was green; lint, types, and build are clean.
UI
The /experience Jobs tab is no longer a planned-note.
JobsSectionowns a drill-down stack (selectedJobId,selectedPositionId) and switches between three views off one["jobs"]query — the same nested tree the server prefetches on page render.Page-level Edit toggle gates every tool. Per-entity Add buttons are
primarywith a leadingPlus; per-card edit / delete are ghostIconButtons. Done / Cancel are no-op exits — every mutation persists eagerly, matching the Education subsection's model.Three forms (Job / Position / Contribution) use the DS
Field/Input/Textareaprimitives and validate against the form-shaped zod schemas. Carol DS token aliases throughout; no inline styles.Test plan
npm run lint/npx tsc --noEmit/npm test/npm run buildall clean (494 passed across the full matrix).curlprobe: GET /api/jobs returns nested tree; cross-user requests 404 at every nesting level; cascade-on-delete verified end-to-end via the nested GET.style="color:transparent"from the sidebar<img>(Next.js Image default).🤖 Generated with Claude Code
Adds the most structurally complex piece of the career domain: Jobs own Positions own Contributions, with full CRUD on the API and a drill-down UI inside the /experience Jobs tab. Backend ------- Migration 010 creates three tables. Every row carries `user_id`, including Positions and Contributions where it's denormalised away from the parent FK — that keeps every isolation check a one-column filter on the leaf table, no join through the parent. ON DELETE CASCADE wires up the parent-child chain; both SQLite and Postgres honour the same DDL, and the existing connection setup already keeps `PRAGMA foreign_keys = ON` on for SQLite. Repositories follow the EducationsRepository shape: `listByUserId` is user-scoped; `findById` / `update` / `deleteById` accept a raw id and let the route layer enforce ownership before mutating (CLAUDE.md "don't leak existence"). Positions get an extra `listByJobId` and Contributions an extra `listByPositionId` for in-job and in-position drill-down. DTOs (`lib/dto/job.ts`) cover all three entities (Create / Update / form-shaped variants + zod schemas) plus a `nestJobs` helper that buckets flat user-scoped query results into the `JobWithChildrenDto[]` shape the GET /api/jobs collection endpoint returns — one fetch hydrates the whole tree. API routes (six route.ts files): GET/POST /api/jobs (list / create) GET/PATCH/DELETE /api/jobs/[id] POST /api/jobs/[id]/positions GET/PATCH/DELETE /api/positions/[id] POST /api/positions/[id]/contributions GET/PATCH/DELETE /api/contributions/[id] Child-creation routes do a two-step ownership check: load the parent and 404 if it doesn't exist or belongs to a different user, then create the child with the same `user_id` denormalised from the session. Single-entity routes (`/api/positions/[id]` etc.) use the denormalised `user_id` on the row directly — no join needed. Tests ----- `tests/db/jobs.test.ts` runs per engine and covers per-entity CRUD plus cascade behaviour at every level: deleting a Job removes its Positions and Contributions; deleting a Position removes its Contributions but leaves the Job; deleting the user removes everything they owned. Cross-user isolation is asserted at each nesting level. `tests/api/jobs.test.ts` covers cross-user 404s at every entity (Job, Position, Contribution), nested-GET buckets the tree correctly, parent-ownership 404 on child-creation routes, and route-layer cascade behaviour reflected in the nested GET. `tests/dto/job.test.ts` covers create / update validators and the `nestJobs` stitcher. UI -- The /experience Jobs tab is no longer a planned-note. The new `JobsSection` owns a drill-down stack (`selectedJobId`, `selectedPositionId`) and switches between three views off one `["jobs"]` query — the same nested tree the server prefetches on page render. View levels: - Jobs list — company / dates / positions count, opens the job. - Positions — back-link, job header, position cards (title / dates / contributions count), opens the position. - Contributions — back-link, position header, contribution cards with the mono-eyebrow "Problem / Value / Accomplishment" trio from the design's reading pattern. Page-level Edit toggle still gates every tool. Per-entity Add buttons are primary with a leading Plus; per-card edit / delete are ghost IconButtons. Done / Cancel are no-op exits — every mutation persists eagerly, matching the Education subsection's model. Three forms (Job / Position / Contribution) use the DS Field / Input / Textarea primitives and validate against the form-shaped zod schemas. Carol DS token aliases throughout; no inline styles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>📊 Test coverage
Patch coverage: 40.5% (616/1521 added lines) ⚠️ (soft target ≥ 80%)
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Changed files in this PR (source only — tests excluded):
app/(app)/experience/jobs-section.tsxapp/api/contributions/[id]/route.tsapp/api/jobs/[id]/positions/route.tsapp/api/jobs/[id]/route.tsapp/api/jobs/route.tsapp/api/positions/[id]/contributions/route.tsapp/api/positions/[id]/route.tsdb/migrations/010_jobs.tsdb/migrator.tsdb/repositories/contributions.tsdb/repositories/jobs.tsdb/repositories/positions.tslib/dto/job.tsSoft thresholds per ADR-0019. Coverage is informational and does not block merge.
Trivy (container image)
Threshold:
high· Total findings: 121 · At/above threshold: 16.27.0, 7.28.0, 8.5.0Native `<input>` / `<textarea>` / `<select>` default to `box-sizing: content-box`. The DS primitives set `width: 100%` and horizontal padding, so the rendered width is 100% + padding + border — enough to push the right edge past the wrapping card. Visible everywhere a form sits inside a Card / surface: the new Jobs forms, Education, Profile basics, the Skills inline inputs, PAT create. Add `app/themes/base.css` with a universal `*, *::before, *::after { box-sizing: border-box }` and import it before the theme files. Theme-agnostic so it lives outside the light/dark/auto files; loading first means later cascade ordering isn't affected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>