feat: Education feature — per-user education history (#23) #128

Merged
james merged 1 commit from 23-education into main 2026-06-19 13:20:17 +00:00
Owner

Summary

Closes #23. Single-table per-user entity with a constrained type enum, mounted as the first subsection of /experience. Mirrors the notes / skills pattern from ADR-0012; smaller scope (no nesting, no ordering).

Shape

Backend (migration 008)

Column Type Notes
id text PK
user_id text FK→users CASCADE
institution text required
type text CHECK in 'School' | 'Training' | 'Certificate' | 'Other' — portable across SQLite + Postgres, avoids Postgres-only CREATE TYPE enums per ADR-0002
focus text nullable (Focus / Degree)
start_date text YYYY-MM-DD
end_date text nullable — models open-ended ("ongoing") entries
description text nullable
created_at / updated_at text ISO timestamps

Index on (user_id, start_date) backs the dominant "list per user, most recent first" query. DESC is applied in the query (engine-portable direction).

The EDUCATION_TYPES enum in lib/dto/education.ts is the authoritative source; the DB CHECK is the second line of defence. The two must stay in sync — extending one requires extending the other.

API

GET    /api/educations               # scoped to authenticated user
POST   /api/educations                # zod-validated create
PATCH  /api/educations/[id]
DELETE /api/educations/[id]

Cross-user → 404 everywhere via the loadOwned* sentinel (never 403; don't leak existence).

DTO subtlety: optional dates

The endDate field is optional on both create and update, but with different semantics:

  • Create: missing / "" / null all collapse to null (one canonical "no end date" value).
  • Update: missing → undefined (preserved) so the .refine "at least one field provided" check fires correctly on an empty PATCH body; "" / null still collapse to null (explicit clear).

Two optionalDate* transformers in lib/dto/education.ts capture the split.

UI

/experience becomes a server component with HydrationBoundary; renders an Education subsection (jobs / contracts will slot in alongside without restructuring). TanStack Query + Form: list + a single inline form that doubles as create-and-edit. Delete confirmation via window.confirm.

Test plan

  • npm run typecheck — clean.
  • npm run lint — clean.
  • npm test — 357 passed / 86 skipped (was 332 / 75; +25 net new).
    • tests/db/educations.test.ts (11): describePerEngine, CRUD, ordering, cascade on user delete, CHECK constraint rejects "Bootcamp" (proves the portable enum holds), end_date nullable round-trips, cross-user isolation.
    • tests/api/educations.test.ts (14): cross-user 404 on every mutation, zod 400 on invalid type / malformed startDate / empty institution, open-ended entry shape (missing endDatenull), happy paths.
  • npm run build — succeeds. / stays ○ Static (ADR-0008), /experience is ƒ Dynamic per the force-dynamic flag.
  • Manual browser smoke (deferred to reviewer, per CLAUDE.md "UI is browser-tested"): visit /experience, add an entry with each of the four type values, edit one (verify form pre-fills), delete one (confirmation), refresh and confirm persistence. Register a second user and confirm entries don't bleed.

Acceptance criteria

  • Add, edit, delete, list works end-to-end (covered by API tests; UI surfaces them).
  • Type values are constrained on both engines (DB CHECK constraint, exercised in tests/db/educations.test.ts).
  • User A's list does not include any of User B's entries (explicit cross-user isolation tests at both DB + API layers).

Files

New:

  • db/migrations/008_education.ts, db/entities/education.ts, db/repositories/educations.ts
  • lib/dto/education.ts
  • app/api/educations/route.ts, app/api/educations/[id]/route.ts
  • app/(app)/experience/education-client.tsx
  • tests/db/educations.test.ts, tests/api/educations.test.ts

Modified:

  • db/schema.ts, db/migrator.ts (register migration 008).
  • app/(app)/experience/page.tsx (placeholder → server-component with HydrationBoundary prefetch).
  • tests/db/_engines.ts (postgres cleanup list extended with educations).

Closes #23. Part of epic #4.

🤖 Generated with Claude Code

## Summary Closes #23. Single-table per-user entity with a constrained `type` enum, mounted as the first subsection of `/experience`. Mirrors the notes / skills pattern from ADR-0012; smaller scope (no nesting, no ordering). ## Shape ### Backend (migration 008) | Column | Type | Notes | |---|---|---| | `id` | text | PK | | `user_id` | text | FK→users CASCADE | | `institution` | text | required | | `type` | text | **CHECK in `'School' \| 'Training' \| 'Certificate' \| 'Other'`** — portable across SQLite + Postgres, avoids Postgres-only `CREATE TYPE` enums per ADR-0002 | | `focus` | text | nullable (Focus / Degree) | | `start_date` | text | `YYYY-MM-DD` | | `end_date` | text | nullable — models open-ended ("ongoing") entries | | `description` | text | nullable | | `created_at` / `updated_at` | text | ISO timestamps | Index on `(user_id, start_date)` backs the dominant "list per user, most recent first" query. DESC is applied in the query (engine-portable direction). The `EDUCATION_TYPES` enum in `lib/dto/education.ts` is the **authoritative source**; the DB CHECK is the second line of defence. The two must stay in sync — extending one requires extending the other. ### API ``` GET /api/educations # scoped to authenticated user POST /api/educations # zod-validated create PATCH /api/educations/[id] DELETE /api/educations/[id] ``` Cross-user → **404 everywhere** via the `loadOwned*` sentinel (never 403; don't leak existence). ### DTO subtlety: optional dates The `endDate` field is optional on both create and update, but with **different semantics**: - **Create**: missing / `""` / `null` all collapse to `null` (one canonical "no end date" value). - **Update**: missing → `undefined` (preserved) so the `.refine` "at least one field provided" check fires correctly on an empty `PATCH` body; `""` / `null` still collapse to `null` (explicit clear). Two `optionalDate*` transformers in `lib/dto/education.ts` capture the split. ### UI `/experience` becomes a server component with HydrationBoundary; renders an Education subsection (jobs / contracts will slot in alongside without restructuring). TanStack Query + Form: list + a single inline form that doubles as create-and-edit. Delete confirmation via `window.confirm`. ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean. - [x] `npm test` — 357 passed / 86 skipped (was 332 / 75; **+25 net new**). - `tests/db/educations.test.ts` (11): `describePerEngine`, CRUD, ordering, cascade on user delete, **CHECK constraint rejects `"Bootcamp"`** (proves the portable enum holds), `end_date` nullable round-trips, cross-user isolation. - `tests/api/educations.test.ts` (14): cross-user 404 on every mutation, zod 400 on invalid type / malformed `startDate` / empty institution, open-ended entry shape (missing `endDate` → `null`), happy paths. - [x] `npm run build` — succeeds. `/` stays `○ Static` (ADR-0008), `/experience` is `ƒ Dynamic` per the `force-dynamic` flag. - [x] **Manual browser smoke** (deferred to reviewer, per CLAUDE.md "UI is browser-tested"): visit `/experience`, add an entry with each of the four type values, edit one (verify form pre-fills), delete one (confirmation), refresh and confirm persistence. Register a second user and confirm entries don't bleed. ## Acceptance criteria - [x] Add, edit, delete, list works end-to-end (covered by API tests; UI surfaces them). - [x] Type values are constrained on both engines (DB CHECK constraint, exercised in `tests/db/educations.test.ts`). - [x] User A's list does not include any of User B's entries (explicit cross-user isolation tests at both DB + API layers). ## Files **New:** - `db/migrations/008_education.ts`, `db/entities/education.ts`, `db/repositories/educations.ts` - `lib/dto/education.ts` - `app/api/educations/route.ts`, `app/api/educations/[id]/route.ts` - `app/(app)/experience/education-client.tsx` - `tests/db/educations.test.ts`, `tests/api/educations.test.ts` **Modified:** - `db/schema.ts`, `db/migrator.ts` (register migration 008). - `app/(app)/experience/page.tsx` (placeholder → server-component with HydrationBoundary prefetch). - `tests/db/_engines.ts` (postgres cleanup list extended with `educations`). Closes #23. Part of epic #4. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: Education feature — per-user education history (#23)
All checks were successful
PR / Static analysis (pull_request) Successful in 52s
PR / Typecheck (pull_request) Successful in 58s
PR / Lint (pull_request) Successful in 1m0s
PR / OSV-Scanner (pull_request) Successful in 26s
Secrets / gitleaks (pull_request) Successful in 18s
PR / Build (pull_request) Successful in 1m35s
PR / Trivy (image) (pull_request) Successful in 1m20s
PR / npm audit (pull_request) Successful in 1m46s
PR / Test (sqlite) (pull_request) Successful in 2m2s
PR / Test (postgres) (pull_request) Successful in 2m12s
PR / Coverage (soft) (pull_request) Successful in 1m49s
Commits / Conventional Commits (pull_request) Successful in 11s
fda4a04b7c
Closes #23. Single-table per-user entity with a constrained `type`
enum, mounted as the first subsection of /experience. Mirrors the
notes / skills pattern from ADR-0012; smaller scope (no nesting,
no ordering).

Backend (migration 008):
- educations table: user_id FK→users CASCADE, institution (required),
  type (CHECK in 'School'|'Training'|'Certificate'|'Other'), focus
  (nullable), start_date (required, YYYY-MM-DD string), end_date
  (nullable — open-ended), description (nullable), created_at /
  updated_at.
- Index on (user_id, start_date) supports "list per user, most recent
  first" via DESC in the query (engine-portable direction).
- Type CHECK constraint is the second line of defence; the
  EDUCATION_TYPES enum in lib/dto/education.ts is authoritative. The
  two must stay in sync.

API:
- GET /api/educations — list scoped to authenticated user.
- POST /api/educations — zod-validated create.
- PATCH /api/educations/[id] — partial update. The endDate update
  schema preserves `undefined` (so the "at least one field" refine
  stays meaningful) while collapsing null/"" to null.
- DELETE /api/educations/[id].
- Cross-user → 404 (don't leak existence) via the loadOwned* sentinel.

DTO (lib/dto/education.ts):
- z.enum on EDUCATION_TYPES for the type field.
- Date strings constrained to YYYY-MM-DD by regex (looser than
  z.date(), more portable than z.datetime()).
- Separate optionalDate transformers for create vs update — create
  collapses missing → null, update preserves missing → undefined so
  the "at least one field" refine can fire.
- Form schema for TanStack Form: plain strings (no transforms) so
  the input/output types match defaultValues.

UI (app/(app)/experience/{page,education-client}.tsx):
- /experience now a server component with HydrationBoundary; renders
  Education subsection. Jobs / Contracts subsections (epic #4)
  slot in alongside without restructuring.
- TanStack Query + Form. List + create/edit form (add-entry button
  toggles inline form; Edit button opens the same form pre-filled).
  Delete confirmation via window.confirm.

Tests (+25):
- tests/db/educations.test.ts (11): describePerEngine, CRUD,
  ordering, cascade on user delete, **CHECK constraint rejects
  invalid type values**, end_date nullable, cross-user isolation.
- tests/api/educations.test.ts (14): cross-user 404 on every
  mutation, zod 400 on invalid type / malformed date / empty
  institution, open-ended entry shape, happy paths.

Verification:
- typecheck / lint / build clean.
- npm test — 357 passed / 86 skipped (was 332 / 75; +25 net new).
- Build: / stays ○ Static (ADR-0008), /experience is ƒ Dynamic
  per the force-dynamic flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: 96.7% (266/275 added lines) (soft target ≥ 80%)

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

Metric Value Soft target
Lines 86.1% ≥ 50%
Branches 80.7% ≥ 75%
Functions 90.8% informational

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

File Patch coverage Overall lines Branches
app/api/educations/[id]/route.ts 90.6% (48/53) 90.6% 66.7%
app/api/educations/route.ts 95.1% (39/41) 95.1% 81.8%
db/migrations/008_education.ts 93.8% (30/32) 93.8% 100.0%
db/migrator.ts 100.0% (1/1) 66.7% 50.0%
db/repositories/educations.ts 100.0% (63/63) 100.0% 72.2%
lib/dto/education.ts 100.0% (85/85) 100.0% 75.0%

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

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 96.7% (266/275 added lines) ✅ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 86.1% ✅ | ≥ 50% | | Branches | 80.7% ✅ | ≥ 75% | | Functions | 90.8% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `app/api/educations/[id]/route.ts` | 90.6% (48/53) | 90.6% | 66.7% | | `app/api/educations/route.ts` | 95.1% (39/41) | 95.1% | 81.8% | | `db/migrations/008_education.ts` | 93.8% (30/32) | 93.8% | 100.0% | | `db/migrator.ts` | 100.0% (1/1) | 66.7% | 50.0% | | `db/repositories/educations.ts` | 100.0% (63/63) | 100.0% | 72.2% | | `lib/dto/education.ts` | 100.0% (85/85) | 100.0% | 75.0% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit c88bf0cfb3 into main 2026-06-19 13:20:17 +00:00
james deleted branch 23-education 2026-06-19 13:20:17 +00:00
Sign in to join this conversation.
No description provided.