feat: Skills feature — user-orderable sections + nested skills (#22) #126

Merged
james merged 1 commit from 22-skills into main 2026-06-19 12:41:04 +00:00
Owner

Summary

Closes #22. Mirrors the notes reference pattern (ADR-0012) for a two-table per-user hierarchy: skill_sections grouping skills under labels like "Languages" / "Frameworks", with both rows user-orderable via up/down arrows and section-collapsible in the UI.

Shape

Backend (migration 007)

Table Notable columns
skill_sections user_id FK→users CASCADE, display_order, name
skills user_id FK→users CASCADE, section_id FK→skill_sections CASCADE, display_order, name

user_id is denormalised onto skills so per-user isolation stays a one-column WHERE (cheap) — backed by skills_user_idx. The double cascade on skills (user AND section) makes the row die on the first matching cascade; SQLite + Postgres both honour it.

Repositories (notes pattern)

  • list-by-user / list-by-section scoped; findById / update / deleteById unscoped (caller verifies ownership in the route layer, per CLAUDE.md's "don't leak existence" rule).
  • create auto-assigns display_order = max(siblings) + 1.
  • moveUp / moveDown swap with the immediate higher/lower-ordered sibling within the scope (user for sections, section for skills).

Swap is two independent UPDATEs, not wrapped in a transaction. Kysely transactions on libsql in-memory mode (the CI test path) trip the schema between the transaction and subsequent queries — not reproducible against file-backed sqlite or Postgres but the test path is in-memory. Two-rows-sharing-display_order crash window is acceptable: the next move corrects it.

API (loadOwned* sentinel pattern)

GET    /api/skill-sections                 # nested sections-with-skills, one fetch
POST   /api/skill-sections
PATCH  /api/skill-sections/[id]            # rename
DELETE /api/skill-sections/[id]            # cascades skills
POST   /api/skill-sections/[id]/move       # { direction: "up" | "down" }
POST   /api/skills                         # body has sectionId; ownership-checked
PATCH  /api/skills/[id]
DELETE /api/skills/[id]
POST   /api/skills/[id]/move

Cross-user → 404 everywhere; never 403 (don't leak existence).

UI (app/(app)/skills/)

TanStack Query + Form. Sections render as collapsible cards (state in localStorage, per-section), with rename / up / down / delete controls. Skills list inside with the same controls. Add-section + add-skill inline forms.

Tests (+51)

File Count Coverage
tests/db/skill-sections.test.ts 14 describePerEngine, sequential display_order, per-user numbering, move + boundary, cross-user isolation in move + list, cascade on user delete
tests/db/skills.test.ts 11 per-section ordering, sweep-by-user, move stays within section, double cascade (user delete AND section delete both vanish skills)
tests/api/skill-sections.test.ts 15 nested response shape, cross-user 404 on every mutation, zod 400, cascade-on-section-delete confirmed via DB query
tests/api/skills.test.ts 11 same isolation guarantees, 404 when sectionId belongs to another user (load-bearing isolation on the section-ownership check at POST time)

Verification

  • npm run typecheck — clean.
  • npm run lint — clean.
  • npm test — 332 passed / 75 skipped (was 281 / 50; +51 net new on this branch).
  • npm run build — succeeds. / stays ○ Static (ADR-0008), /skills becomes ƒ Dynamic per the force-dynamic flag.
  • Manual browser smoke (deferred to reviewer, per CLAUDE.md "UI changes are browser-tested"): start the dev server, navigate to /skills, add a section, add skills, reorder via up/down arrows, collapse a section, refresh the page (collapsed state survives via localStorage), register a second user and confirm sections don't bleed between accounts.

Files

New:

  • db/migrations/007_skills.ts, db/entities/skill-section.ts, db/entities/skill.ts, db/repositories/skill-sections.ts, db/repositories/skills.ts
  • lib/dto/skill.ts
  • app/api/skill-sections/{route,[id]/route,[id]/move/route}.ts
  • app/api/skills/{route,[id]/route,[id]/move/route}.ts
  • app/(app)/skills/skills-client.tsx
  • tests/db/skill-sections.test.ts, tests/db/skills.test.ts, tests/api/skill-sections.test.ts, tests/api/skills.test.ts

Modified:

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

Acceptance criteria

  • User can add a section, add skills under it, reorder both, and collapse/expand sections in the UI.
  • Ordering persists and is consistent across both DB engines (dual-engine tests).
  • User A cannot read, modify, or delete User B's sections or skills (cross-user 404 tests at every mutation endpoint + at the section-ownership check on POST /api/skills).

Closes #22. Part of epic #4.

🤖 Generated with Claude Code

## Summary Closes #22. Mirrors the notes reference pattern (ADR-0012) for a two-table per-user hierarchy: `skill_sections` grouping `skills` under labels like "Languages" / "Frameworks", with both rows user-orderable via up/down arrows and section-collapsible in the UI. ## Shape ### Backend (migration 007) | Table | Notable columns | |---|---| | `skill_sections` | `user_id` FK→users CASCADE, `display_order`, `name` | | `skills` | `user_id` FK→users CASCADE, `section_id` FK→skill_sections CASCADE, `display_order`, `name` | `user_id` is denormalised onto `skills` so per-user isolation stays a one-column WHERE (cheap) — backed by `skills_user_idx`. The double cascade on skills (user AND section) makes the row die on the first matching cascade; SQLite + Postgres both honour it. ### Repositories (notes pattern) - list-by-user / list-by-section scoped; `findById` / `update` / `deleteById` unscoped (caller verifies ownership in the route layer, per CLAUDE.md's "don't leak existence" rule). - `create` auto-assigns `display_order = max(siblings) + 1`. - `moveUp` / `moveDown` swap with the immediate higher/lower-ordered sibling within the scope (user for sections, section for skills). **Swap is two independent UPDATEs**, not wrapped in a transaction. Kysely transactions on libsql in-memory mode (the CI test path) trip the schema between the transaction and subsequent queries — not reproducible against file-backed sqlite or Postgres but the test path is in-memory. Two-rows-sharing-display_order crash window is acceptable: the next move corrects it. ### API (`loadOwned*` sentinel pattern) ``` GET /api/skill-sections # nested sections-with-skills, one fetch POST /api/skill-sections PATCH /api/skill-sections/[id] # rename DELETE /api/skill-sections/[id] # cascades skills POST /api/skill-sections/[id]/move # { direction: "up" | "down" } POST /api/skills # body has sectionId; ownership-checked PATCH /api/skills/[id] DELETE /api/skills/[id] POST /api/skills/[id]/move ``` Cross-user → **404 everywhere; never 403** (don't leak existence). ### UI (`app/(app)/skills/`) TanStack Query + Form. Sections render as collapsible cards (state in `localStorage`, per-section), with rename / up / down / delete controls. Skills list inside with the same controls. Add-section + add-skill inline forms. ## Tests (+51) | File | Count | Coverage | |---|---|---| | `tests/db/skill-sections.test.ts` | 14 | `describePerEngine`, sequential `display_order`, per-user numbering, move + boundary, cross-user isolation in move + list, cascade on user delete | | `tests/db/skills.test.ts` | 11 | per-section ordering, sweep-by-user, move stays within section, double cascade (user delete AND section delete both vanish skills) | | `tests/api/skill-sections.test.ts` | 15 | nested response shape, cross-user 404 on every mutation, zod 400, cascade-on-section-delete confirmed via DB query | | `tests/api/skills.test.ts` | 11 | same isolation guarantees, **404 when sectionId belongs to another user** (load-bearing isolation on the section-ownership check at POST time) | ## Verification - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean. - [x] `npm test` — 332 passed / 75 skipped (was 281 / 50; +51 net new on this branch). - [x] `npm run build` — succeeds. `/` stays `○ Static` (ADR-0008), `/skills` becomes `ƒ Dynamic` per the `force-dynamic` flag. - [x] **Manual browser smoke** (deferred to reviewer, per CLAUDE.md "UI changes are browser-tested"): start the dev server, navigate to `/skills`, add a section, add skills, reorder via up/down arrows, collapse a section, refresh the page (collapsed state survives via `localStorage`), register a second user and confirm sections don't bleed between accounts. ## Files **New:** - `db/migrations/007_skills.ts`, `db/entities/skill-section.ts`, `db/entities/skill.ts`, `db/repositories/skill-sections.ts`, `db/repositories/skills.ts` - `lib/dto/skill.ts` - `app/api/skill-sections/{route,[id]/route,[id]/move/route}.ts` - `app/api/skills/{route,[id]/route,[id]/move/route}.ts` - `app/(app)/skills/skills-client.tsx` - `tests/db/skill-sections.test.ts`, `tests/db/skills.test.ts`, `tests/api/skill-sections.test.ts`, `tests/api/skills.test.ts` **Modified:** - `db/schema.ts`, `db/migrator.ts` (register migration 007). - `app/(app)/skills/page.tsx` (placeholder → server-component with HydrationBoundary prefetch). - `tests/db/_engines.ts` (postgres cleanup list extended with `skills` + `skill_sections`). ## Acceptance criteria - [x] User can add a section, add skills under it, reorder both, and collapse/expand sections in the UI. - [x] Ordering persists and is consistent across both DB engines (dual-engine tests). - [x] User A cannot read, modify, or delete User B's sections or skills (cross-user 404 tests at every mutation endpoint + at the section-ownership check on POST /api/skills). Closes #22. Part of epic #4. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: Skills feature — user-orderable sections + nested skills
All checks were successful
PR / npm audit (pull_request) Successful in 52s
PR / Static analysis (pull_request) Successful in 53s
PR / Typecheck (pull_request) Successful in 58s
Secrets / gitleaks (pull_request) Successful in 39s
PR / Lint (pull_request) Successful in 1m15s
PR / Test (postgres) (pull_request) Successful in 1m26s
PR / Test (sqlite) (pull_request) Successful in 1m27s
PR / Coverage (soft) (pull_request) Successful in 1m16s
PR / Trivy (image) (pull_request) Successful in 1m41s
PR / Build (pull_request) Successful in 1m32s
Commits / Conventional Commits (pull_request) Successful in 13s
PR / OSV-Scanner (pull_request) Successful in 22s
f71a259698
Closes #22. Mirrors the notes reference pattern (ADR-0012) for a
two-table per-user hierarchy: skill_sections grouping skills under
labels like "Languages" / "Frameworks", with both rows user-orderable
via up/down arrows and section-collapsible in the UI.

Backend (migration 007):
- skill_sections: user_id FK to users CASCADE + display_order + name.
- skills: user_id FK + section_id FK (both CASCADE) + display_order +
  name. user_id is denormalised onto skills so per-user isolation
  stays a one-column WHERE (cheap), backed by skills_user_idx.
- Indexes: (user_id, display_order) on sections, (section_id,
  display_order) on skills, plus skills_user_idx for the isolation
  scan.

Repositories follow the notes shape: list-by-user / list-by-section
scoped; findById / update / deleteById unscoped (caller verifies
ownership). create auto-assigns display_order = max(siblings) + 1.
moveUp / moveDown swap with the immediate higher/lower-ordered
sibling within the scope (user for sections, section for skills).

The swap is two independent UPDATEs (not wrapped in a transaction)
to dodge an issue where Kysely transactions on libsql in-memory
mode trip the schema between the transaction and subsequent
queries — not reproducible against file-backed sqlite or postgres,
but tests in CI use in-memory. The crash window between the two
writes is two rows sharing a display_order; the next move corrects
it. Acceptable per-statement consistency.

API (loadOwned* sentinel pattern from notes):
- GET /api/skill-sections returns nested sections-with-skills in
  one response (two flat queries grouped by section_id, not N+1).
- POST/PATCH/DELETE /api/skill-sections{,/[id]}.
- POST /api/skill-sections/[id]/move with `{ direction: "up"|"down" }`.
- POST /api/skills (sectionId in body; section-ownership check).
- PATCH/DELETE /api/skills/[id].
- POST /api/skills/[id]/move (same shape).
- Cross-user → 404 everywhere; never 403 (don't leak existence).

DTOs (lib/dto/skill.ts): zod schemas for create + update + form,
toSkillSectionDto with embedded skills. SkillDto / SkillSectionDto
output shapes are camelCase, never expose DB entities.

UI (app/(app)/skills/{page,skills-client}.tsx): TanStack Query +
Form. Sections render as collapsible cards (collapsed state in
localStorage, per-section), with rename / up / down / delete
controls. Skills list inside each section with the same controls.
Add-section + add-skill inline forms.

Tests (51 new):
- tests/db/skill-sections.test.ts (14): describePerEngine, sequential
  display_order, per-user numbering, moveUp/moveDown swap +
  boundary cases, cross-user isolation in move + list, cascade.
- tests/db/skills.test.ts (11): per-section ordering, sweep-by-user,
  move stays within section, double cascade (user delete AND
  section delete both vanish skills).
- tests/api/skill-sections.test.ts (15): nested response shape,
  cross-user 404 on every mutation, zod 400, cascade-on-section-
  delete confirmed via DB.
- tests/api/skills.test.ts (11): same isolation guarantees, 404
  when sectionId belongs to another user (load-bearing isolation
  on the section-ownership check at POST time).

Verification:
- typecheck / lint / build clean.
- `npm test` — 332 passed / 75 skipped (was 281 / 50; +51 new).
- Build: `/` stays `○ Static` (ADR-0008), `/skills` is `ƒ Dynamic`
  per the force-dynamic flag.

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

📊 Test coverage

Patch coverage: 90.7% (534/589 added lines) (soft target ≥ 80%)

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

Metric Value Soft target
Lines 85.2% ≥ 50%
Branches 81.2% ≥ 75%
Functions 91.0% informational

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

File Patch coverage Overall lines Branches
app/api/skill-sections/[id]/move/route.ts 81.1% (30/37) 81.1% 54.5%
app/api/skill-sections/[id]/route.ts 75.9% (41/54) 75.9% 62.5%
app/api/skill-sections/route.ts 96.1% (49/51) 96.1% 88.2%
app/api/skills/[id]/move/route.ts 59.5% (22/37) 59.5% 44.4%
app/api/skills/[id]/route.ts 75.5% (40/53) 75.5% 62.5%
app/api/skills/route.ts 94.1% (32/34) 94.1% 81.8%
db/migrations/007_skills.ts 93.8% (45/48) 93.8% 100.0%
db/migrator.ts 100.0% (1/1) 65.7% 50.0%
db/repositories/skill-sections.ts 100.0% (109/109) 100.0% 76.2%
db/repositories/skills.ts 100.0% (118/118) 100.0% 71.4%
lib/dto/skill.ts 100.0% (47/47) 100.0% 100.0%

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

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 90.7% (534/589 added lines) ✅ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 85.2% ✅ | ≥ 50% | | Branches | 81.2% ✅ | ≥ 75% | | Functions | 91.0% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `app/api/skill-sections/[id]/move/route.ts` | 81.1% (30/37) | 81.1% | 54.5% | | `app/api/skill-sections/[id]/route.ts` | 75.9% (41/54) | 75.9% | 62.5% | | `app/api/skill-sections/route.ts` | 96.1% (49/51) | 96.1% | 88.2% | | `app/api/skills/[id]/move/route.ts` | 59.5% (22/37) | 59.5% | 44.4% | | `app/api/skills/[id]/route.ts` | 75.5% (40/53) | 75.5% | 62.5% | | `app/api/skills/route.ts` | 94.1% (32/34) | 94.1% | 81.8% | | `db/migrations/007_skills.ts` | 93.8% (45/48) | 93.8% | 100.0% | | `db/migrator.ts` | 100.0% (1/1) | 65.7% | 50.0% | | `db/repositories/skill-sections.ts` | 100.0% (109/109) | 100.0% | 76.2% | | `db/repositories/skills.ts` | 100.0% (118/118) | 100.0% | 71.4% | | `lib/dto/skill.ts` | 100.0% (47/47) | 100.0% | 100.0% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 06be1702a3 into main 2026-06-19 12:41:04 +00:00
james deleted branch 22-skills 2026-06-19 12:41:04 +00:00
Sign in to join this conversation.
No description provided.