feat: Education feature — per-user education history (#23) #128
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!128
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "23-education"
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?
Summary
Closes #23. Single-table per-user entity with a constrained
typeenum, 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)
iduser_idinstitutiontype'School' | 'Training' | 'Certificate' | 'Other'— portable across SQLite + Postgres, avoids Postgres-onlyCREATE TYPEenums per ADR-0002focusstart_dateYYYY-MM-DDend_datedescriptioncreated_at/updated_atIndex 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_TYPESenum inlib/dto/education.tsis 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
Cross-user → 404 everywhere via the
loadOwned*sentinel (never 403; don't leak existence).DTO subtlety: optional dates
The
endDatefield is optional on both create and update, but with different semantics:""/nullall collapse tonull(one canonical "no end date" value).undefined(preserved) so the.refine"at least one field provided" check fires correctly on an emptyPATCHbody;""/nullstill collapse tonull(explicit clear).Two
optionalDate*transformers inlib/dto/education.tscapture the split.UI
/experiencebecomes 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 viawindow.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_datenullable round-trips, cross-user isolation.tests/api/educations.test.ts(14): cross-user 404 on every mutation, zod 400 on invalid type / malformedstartDate/ empty institution, open-ended entry shape (missingendDate→null), happy paths.npm run build— succeeds./stays○ Static(ADR-0008),/experienceisƒ Dynamicper theforce-dynamicflag./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
tests/db/educations.test.ts).Files
New:
db/migrations/008_education.ts,db/entities/education.ts,db/repositories/educations.tslib/dto/education.tsapp/api/educations/route.ts,app/api/educations/[id]/route.tsapp/(app)/experience/education-client.tsxtests/db/educations.test.ts,tests/api/educations.test.tsModified:
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 witheducations).Closes #23. Part of epic #4.
🤖 Generated with Claude Code
📊 Test coverage
Patch coverage: 96.7% (266/275 added lines) ✅ (soft target ≥ 80%)
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Changed files in this PR (source only — tests excluded):
app/api/educations/[id]/route.tsapp/api/educations/route.tsdb/migrations/008_education.tsdb/migrator.tsdb/repositories/educations.tslib/dto/education.tsSoft thresholds per ADR-0019. Coverage is informational and does not block merge.