feat(service+pwa): jobs, positions, contributions — three-tier career history (#24) #175

Merged
james merged 3 commits from 24-jobs-positions-contributions into main 2026-06-20 15:17:48 +00:00
Owner

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 carries user_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's PRAGMA foreign_keys = ON is already on per-connection.

  • Repositories mirror EducationsRepository: listByUserId user-scoped, findById / update / deleteById accept raw ids and let the route layer enforce ownership. Positions get an extra listByJobId and Contributions a listByPositionId for in-parent drill-down.

  • DTOs in lib/dto/job.ts cover all three entities (Create / Update / form-shaped + zod) plus a nestJobs(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]/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, 404 if it's missing or belongs to a different user, then create the child with user_id denormalised from the session.

Tests

  • tests/db/jobs.test.ts (dual engine via describePerEngine) 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.ts covers 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.ts covers create / update validators, length limits, and the nestJobs stitcher.

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. 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.

  • 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 rendering the mono-eyebrow Problem / Value / Accomplishment trio from the design's reading pattern.

Page-level Edit toggle 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.

Test plan

  • npm run lint / npx tsc --noEmit / npm test / npm run build all clean (494 passed across the full matrix).
  • Visual checks at all three drill levels, both themes — view + edit mode.
  • curl probe: 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.
  • HTML inline-style grep on /experience returns only style="color:transparent" from the sidebar <img> (Next.js Image default).

🤖 Generated with Claude Code

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 carries `user_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's `PRAGMA foreign_keys = ON` is already on per-connection. - **Repositories** mirror EducationsRepository: `listByUserId` user-scoped, `findById` / `update` / `deleteById` accept raw ids and let the route layer enforce ownership. Positions get an extra `listByJobId` and Contributions a `listByPositionId` for in-parent drill-down. - **DTOs** in `lib/dto/job.ts` cover all three entities (Create / Update / form-shaped + zod) plus a `nestJobs(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]/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, 404 if it's missing or belongs to a different user, then create the child with `user_id` denormalised from the session. ### Tests - `tests/db/jobs.test.ts` (dual engine via `describePerEngine`) 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.ts` covers 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.ts` covers create / update validators, length limits, and the `nestJobs` stitcher. 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. `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. - **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 rendering the mono-eyebrow Problem / Value / Accomplishment trio from the design's reading pattern. Page-level Edit toggle gates every tool. Per-entity Add buttons are `primary` with a leading `Plus`; per-card edit / delete are ghost `IconButton`s. 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. ## Test plan - [x] `npm run lint` / `npx tsc --noEmit` / `npm test` / `npm run build` all clean (494 passed across the full matrix). - [x] Visual checks at all three drill levels, both themes — view + edit mode. - [x] `curl` probe: 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. - [x] HTML inline-style grep on /experience returns only `style="color:transparent"` from the sidebar `<img>` (Next.js Image default). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(service+pwa): jobs, positions, contributions — three-tier career history (#24)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 36s
PR / Static analysis (pull_request) Successful in 1m1s
PR / npm audit (pull_request) Successful in 1m8s
PR / Package age policy (soft) (pull_request) Successful in 32s
PR / Lint (pull_request) Successful in 1m23s
Secrets / gitleaks (pull_request) Successful in 25s
PR / Typecheck (pull_request) Successful in 1m34s
PR / Test (sqlite) (pull_request) Successful in 1m36s
PR / Test (postgres) (pull_request) Failing after 1m38s
PR / Build (pull_request) Successful in 1m47s
PR / Coverage (soft) (pull_request) Successful in 1m40s
PR / Trivy (image) (pull_request) Failing after 1m54s
570f73fa40
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):

Metric Value Soft target
Lines 66.5% ≥ 50%
Branches 79.2% ≥ 75%
Functions 88.2% informational

Changed files in this PR (source only — tests excluded):

File Patch coverage Overall lines Branches
app/(app)/experience/jobs-section.tsx 0.0% (0/803) 0.0% 0.0%
app/api/contributions/[id]/route.ts 67.2% (41/61) 67.2% 55.0%
app/api/jobs/[id]/positions/route.ts 67.6% (25/37) 67.6% 62.5%
app/api/jobs/[id]/route.ts 72.1% (44/61) 72.1% 60.0%
app/api/jobs/route.ts 90.7% (39/43) 90.7% 66.7%
app/api/positions/[id]/contributions/route.ts 67.6% (25/37) 67.6% 62.5%
app/api/positions/[id]/route.ts 55.7% (34/61) 55.7% 57.1%
db/migrations/010_jobs.ts 94.7% (72/76) 94.7% 100.0%
db/migrator.ts 100.0% (1/1) 68.4% 50.0%
db/repositories/contributions.ts 90.9% (60/66) 90.9% 54.5%
db/repositories/jobs.ts 100.0% (57/57) 100.0% 50.0%
db/repositories/positions.ts 100.0% (66/66) 100.0% 53.8%
lib/dto/job.ts 100.0% (152/152) 100.0% 92.3%

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

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 40.5% (616/1521 added lines) ⚠️ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 66.5% ✅ | ≥ 50% | | Branches | 79.2% ✅ | ≥ 75% | | Functions | 88.2% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `app/(app)/experience/jobs-section.tsx` | 0.0% (0/803) | 0.0% | 0.0% | | `app/api/contributions/[id]/route.ts` | 67.2% (41/61) | 67.2% | 55.0% | | `app/api/jobs/[id]/positions/route.ts` | 67.6% (25/37) | 67.6% | 62.5% | | `app/api/jobs/[id]/route.ts` | 72.1% (44/61) | 72.1% | 60.0% | | `app/api/jobs/route.ts` | 90.7% (39/43) | 90.7% | 66.7% | | `app/api/positions/[id]/contributions/route.ts` | 67.6% (25/37) | 67.6% | 62.5% | | `app/api/positions/[id]/route.ts` | 55.7% (34/61) | 55.7% | 57.1% | | `db/migrations/010_jobs.ts` | 94.7% (72/76) | 94.7% | 100.0% | | `db/migrator.ts` | 100.0% (1/1) | 68.4% | 50.0% | | `db/repositories/contributions.ts` | 90.9% (60/66) | 90.9% | 54.5% | | `db/repositories/jobs.ts` | 100.0% (57/57) | 100.0% | 50.0% | | `db/repositories/positions.ts` | 100.0% (66/66) | 100.0% | 53.8% | | `lib/dto/job.ts` | 100.0% (152/152) | 100.0% | 92.3% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |
fix(pwa): form inputs overflow their container on every screen (#24 follow-up)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 9s
PR / OSV-Scanner (pull_request) Successful in 22s
PR / Static analysis (pull_request) Successful in 28s
PR / Package age policy (soft) (pull_request) Successful in 12s
Secrets / gitleaks (pull_request) Successful in 13s
PR / Trivy (image) (pull_request) Failing after 1m9s
PR / Typecheck (pull_request) Successful in 1m32s
PR / npm audit (pull_request) Successful in 2m8s
PR / Lint (pull_request) Successful in 2m18s
PR / Test (sqlite) (pull_request) Successful in 2m28s
PR / Build (pull_request) Successful in 2m43s
PR / Test (postgres) (pull_request) Failing after 52s
PR / Coverage (soft) (pull_request) Has been cancelled
2be45c9f61
Native `<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>
test(service): wipe jobs/positions/contributions between Postgres runs (#24 follow-up)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 22s
PR / Trivy (image) (pull_request) Failing after 30s
PR / Static analysis (pull_request) Successful in 36s
PR / Package age policy (soft) (pull_request) Successful in 13s
Secrets / gitleaks (pull_request) Successful in 13s
PR / Lint (pull_request) Successful in 2m25s
PR / npm audit (pull_request) Successful in 2m32s
PR / Typecheck (pull_request) Successful in 2m42s
PR / Test (sqlite) (pull_request) Successful in 2m56s
PR / Build (pull_request) Successful in 2m58s
PR / Coverage (soft) (pull_request) Successful in 2m56s
PR / Test (postgres) (pull_request) Successful in 3m3s
ed5b9e79e9
The Postgres engine in `describePerEngine` drops every Kysely table
before re-running migrations so the test database starts each suite
clean. The new jobs / positions / contributions tables weren't in
`KYSELY_TABLES`, so they survived between suites and broke any
follow-up suite that hit the same FKs.

SQLite is unaffected — it uses `sqlite::memory:` which gets a fresh
DB per `setup()` call. Postgres reuses the connection's schema and
relies on the explicit drop list.

Add the three new tables to `KYSELY_TABLES` in child-first order so
the drops succeed against Postgres's eager FK enforcement.

Verified locally with `TEST_POSTGRES_URL=...`: full suite goes from
601 → 601 passing across both engines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james merged commit c2f3834341 into main 2026-06-20 15:17:48 +00:00
james deleted branch 24-jobs-positions-contributions 2026-06-20 15:17:48 +00:00
Sign in to join this conversation.
No description provided.