feat(api+client): people feature with relatives, met-through, organizations, and notes (#27) #268

Merged
james merged 4 commits from 27-people-feature into main 2026-06-24 02:16:09 +00:00
Owner

Summary

The People feature (#27) — the richest single feature in the spec — lands end to end across the data layer, the API, the typed client, the universal client UI, and i18n.

This PR also ships a minimal Organizations skeleton (option A from the scope decision below) so the linked-form person_organizations relationship works today; #28 fleshes out the full Organizations surface (description, links, joined date, key people) later.

Organizations scope decision: option A — minimal skeleton

The ticket calls out three reasonable paths:

  • A. Ship a minimal organizations table + CRUD now; #28 expands.
  • B. Stub-only person→org form in this PR; document #28 as a blocker.
  • C. Build full #28 inside this PR.

Picked A because:

  • The ticket's acceptance criterion "both forms supported" is unmet under B.
  • C doubles the PR's surface — Organizations as designed in #28 is its own ticket with its own design + ADR work.
  • A matches the smallest schema that makes the linked-form semantics work today (FK on person_organizations.organization_id, cascade behaviour, cross-user 404 when referencing an organization that belongs to someone else). #28 then adds columns (description, website, joined date) and the Organizations drill-down screen without touching the link table.

The skeleton ships as just id, user_id, name, display_order, created_at, updated_at, with GET /api/organizations (paginated) and POST /api/organizations (create). Inline create on the detail screen lets a user mint a tracked org without leaving the Person.

Schema (migration 016 + 017)

users
  └── organizations          (id, user_id, name, display_order)
  └── people                 (id, user_id, name, gender?, pronouns?, display_order)
       ├── person_contacts          (kind, value, label?, display_order)
       ├── person_organizations     (organization_id? OR stub_name? + role?)   CHECK exactly-one-of
       ├── person_relatives         (related_person_id, relationship_type)     CHECK no-self, UNIQUE quadruple
       ├── person_met_through       (through_person_id)                        CHECK no-self, UNIQUE triple
       └── person_notes             (date, text)                               indexed (user_id, person_id, date DESC)

Every child carries a denormalised user_id so cross-user isolation stays a one-column filter on every leaf. Cascades are DDL-level (ON DELETE CASCADE); deleting a Person removes their contacts, organisation links, relatives (both directions — self-referential FKs cascade through related_person_id too), met-through, and notes. Deleting a User cascades through the whole tree.

The KYSELY_TABLES drop list in apps/api/tests/db/_engines.ts was updated with the new tables in dependency order (children before parents).

Routes (registered + drift-checked)

Method Path Notes
GET /api/organizations cursor-paginated
POST /api/organizations create
GET /api/people cursor-paginated; summary carries primary contact + primary org
POST /api/people basics only
GET /api/people/{id} nested detail tree
PATCH /api/people/{id} basics
DELETE /api/people/{id} cascade-deletes everything
POST /api/people/{id}/contacts mirrors profile-contacts
PATCH / DELETE /api/people/{id}/contacts/{contactId} namespaced under person to avoid /api/contacts collision
POST /api/people/{id}/organizations discriminated union {kind:"linked"|"stub"}
PATCH / DELETE /api/people/{id}/organizations/{entryId} PATCH swaps form
POST /api/people/{id}/relatives enum on relationshipType
DELETE /api/people/{id}/relatives/{entryId} no PATCH — change of type is delete + recreate
POST / DELETE /api/people/{id}/met-through(/{entryId})
POST /api/people/{id}/notes
PATCH / DELETE /api/people/{id}/notes/{noteId}

Cross-reference inputs (organizationId, relatedPersonId, throughPersonId) are verified to belong to the authenticated user before insert — cross-user attempts return 404, never 403, never "exists but unowned".

openapi.json regenerated; coverage check shows 85 (path, method) pairs registered, drift check green.

Decisions worth flagging

  • Relationship-type allowlist: an enum spouse | child | parent | sibling | other rather than free-form text. "Extensible" per the spec, but a closed list keeps the picker UX simple and the data clean. Adding a type is a one-line zod-enum change + an i18n key.
  • person_organizations PATCH: a discriminated union — kind: "linked" swaps to a tracked org (clears stub_name), kind: "stub" swaps to free-form (clears organization_id), no kind means "edit role/displayOrder only" (validated to keep organizationId and stubName out of the body).
  • Cascade through related_person_id: deleting a Person removes every row where they're the referenced relative or met-through too, not just where they're the owning person. Both engines honour the self-referential FK cascade; the dual-engine test exercises it.
  • Multi-org per person: yes, multiple person_organizations rows per Person are allowed. The list summary picks the first (lowest display_order) as "primary".
  • Picker UX: cross-reference inputs render as inline chip-pickers over the user's existing people / orgs. Simple enough to ship; can level up to the design system's combobox once it lands.
  • Inline "create new org" on the detail screen — saves a round-trip to a separate Organizations screen when the user is adding their first linked org from a Person.

Test plan

  • Repo tests for every table (create / update / delete / listing) and cross-user isolation (a row owned by B never resolves under A's queries)
  • HTTP-layer tests for every route — happy paths + 404 on cross-user attempts + discriminated-union validation on person_organizations + same-user constraint on relative / met-through / organization refs
  • Cascade behaviour end-to-end (deleting a Person removes their contacts, organization links, relatives, met-through, notes)
  • CHECK constraints (no-self on relatives + met-through; exactly-one-of on person_organizations) exercised at the DB layer
  • UNIQUE constraints (duplicate relative / met-through entries) rejected by the DB layer and surfaced as 400 from the API
  • pnpm -F @carol/api typecheck / lint / test / openapi:check / openapi:coverage
  • pnpm -F @carol/api-client typecheck / lint / test / check
  • pnpm -F @carol/client typecheck / lint / test / export:web

Postgres leg of the matrix skipped locally (TEST_POSTGRES_URL unset); CI will run it on PR.

Closes #27.

## Summary The People feature (#27) — the richest single feature in the spec — lands end to end across the data layer, the API, the typed client, the universal client UI, and i18n. This PR also ships a **minimal Organizations skeleton** (option **A** from the scope decision below) so the linked-form `person_organizations` relationship works today; #28 fleshes out the full Organizations surface (description, links, joined date, key people) later. ## Organizations scope decision: option A — minimal skeleton The ticket calls out three reasonable paths: - **A.** Ship a minimal `organizations` table + CRUD now; #28 expands. - **B.** Stub-only person→org form in this PR; document #28 as a blocker. - **C.** Build full #28 inside this PR. Picked **A** because: - The ticket's acceptance criterion "both forms supported" is unmet under **B**. - **C** doubles the PR's surface — Organizations as designed in #28 is its own ticket with its own design + ADR work. - **A** matches the smallest schema that makes the linked-form *semantics* work today (FK on `person_organizations.organization_id`, cascade behaviour, cross-user 404 when referencing an organization that belongs to someone else). #28 then adds columns (description, website, joined date) and the Organizations drill-down screen without touching the link table. The skeleton ships as just `id`, `user_id`, `name`, `display_order`, `created_at`, `updated_at`, with `GET /api/organizations` (paginated) and `POST /api/organizations` (create). Inline create on the detail screen lets a user mint a tracked org without leaving the Person. ## Schema (migration 016 + 017) ``` users └── organizations (id, user_id, name, display_order) └── people (id, user_id, name, gender?, pronouns?, display_order) ├── person_contacts (kind, value, label?, display_order) ├── person_organizations (organization_id? OR stub_name? + role?) CHECK exactly-one-of ├── person_relatives (related_person_id, relationship_type) CHECK no-self, UNIQUE quadruple ├── person_met_through (through_person_id) CHECK no-self, UNIQUE triple └── person_notes (date, text) indexed (user_id, person_id, date DESC) ``` Every child carries a denormalised `user_id` so cross-user isolation stays a one-column filter on every leaf. Cascades are DDL-level (`ON DELETE CASCADE`); deleting a Person removes their contacts, organisation links, relatives (both directions — self-referential FKs cascade through `related_person_id` too), met-through, and notes. Deleting a User cascades through the whole tree. The `KYSELY_TABLES` drop list in `apps/api/tests/db/_engines.ts` was updated with the new tables in dependency order (children before parents). ## Routes (registered + drift-checked) | Method | Path | Notes | |---|---|---| | GET | `/api/organizations` | cursor-paginated | | POST | `/api/organizations` | create | | GET | `/api/people` | cursor-paginated; summary carries primary contact + primary org | | POST | `/api/people` | basics only | | GET | `/api/people/{id}` | nested detail tree | | PATCH | `/api/people/{id}` | basics | | DELETE | `/api/people/{id}` | cascade-deletes everything | | POST | `/api/people/{id}/contacts` | mirrors profile-contacts | | PATCH / DELETE | `/api/people/{id}/contacts/{contactId}` | namespaced under person to avoid /api/contacts collision | | POST | `/api/people/{id}/organizations` | discriminated union `{kind:"linked"\|"stub"}` | | PATCH / DELETE | `/api/people/{id}/organizations/{entryId}` | PATCH swaps form | | POST | `/api/people/{id}/relatives` | enum on `relationshipType` | | DELETE | `/api/people/{id}/relatives/{entryId}` | no PATCH — change of type is delete + recreate | | POST / DELETE | `/api/people/{id}/met-through(/{entryId})` | | | POST | `/api/people/{id}/notes` | | | PATCH / DELETE | `/api/people/{id}/notes/{noteId}` | | Cross-reference inputs (`organizationId`, `relatedPersonId`, `throughPersonId`) are verified to belong to the authenticated user before insert — cross-user attempts return **404**, never 403, never "exists but unowned". `openapi.json` regenerated; coverage check shows **85 (path, method) pairs registered**, drift check green. ## Decisions worth flagging - **Relationship-type allowlist**: an enum `spouse | child | parent | sibling | other` rather than free-form text. "Extensible" per the spec, but a closed list keeps the picker UX simple and the data clean. Adding a type is a one-line zod-enum change + an i18n key. - **`person_organizations` PATCH**: a discriminated union — `kind: "linked"` swaps to a tracked org (clears stub_name), `kind: "stub"` swaps to free-form (clears organization_id), no `kind` means "edit role/displayOrder only" (validated to keep `organizationId` and `stubName` out of the body). - **Cascade through `related_person_id`**: deleting a Person removes every row where they're the *referenced* relative or met-through too, not just where they're the owning person. Both engines honour the self-referential FK cascade; the dual-engine test exercises it. - **Multi-org per person**: yes, multiple `person_organizations` rows per Person are allowed. The list summary picks the first (lowest display_order) as "primary". - **Picker UX**: cross-reference inputs render as inline chip-pickers over the user's existing people / orgs. Simple enough to ship; can level up to the design system's combobox once it lands. - **Inline "create new org"** on the detail screen — saves a round-trip to a separate Organizations screen when the user is adding their first linked org from a Person. ## Test plan - [x] Repo tests for every table (create / update / delete / listing) and cross-user isolation (a row owned by B never resolves under A's queries) - [x] HTTP-layer tests for every route — happy paths + 404 on cross-user attempts + discriminated-union validation on `person_organizations` + same-user constraint on relative / met-through / organization refs - [x] Cascade behaviour end-to-end (deleting a Person removes their contacts, organization links, relatives, met-through, notes) - [x] CHECK constraints (no-self on relatives + met-through; exactly-one-of on person_organizations) exercised at the DB layer - [x] UNIQUE constraints (duplicate relative / met-through entries) rejected by the DB layer and surfaced as 400 from the API - [x] `pnpm -F @carol/api typecheck / lint / test / openapi:check / openapi:coverage` - [x] `pnpm -F @carol/api-client typecheck / lint / test / check` - [x] `pnpm -F @carol/client typecheck / lint / test / export:web` Postgres leg of the matrix skipped locally (`TEST_POSTGRES_URL` unset); CI will run it on PR. Closes #27.
Six new tables for the People feature plus a minimal Organizations
skeleton:

- organizations (#28 skeleton): name + display_order. Full surface
  lands when #28 is fleshed out.
- people: name + gender + pronouns + display_order.
- person_contacts: kind/value/label/display_order, mirroring the
  profile_contacts shape.
- person_organizations: discriminated linked-vs-stub via a CHECK
  constraint enforcing (organization_id IS NULL) <> (stub_name IS NULL).
- person_relatives: self-referential, CHECK prevents self-reference,
  UNIQUE prevents duplicates.
- person_met_through: self-referential, same constraints.
- person_notes: dated free-form per-person notes (distinct from
  /api/notes).

Children carry a denormalised user_id so cross-user isolation stays a
one-column filter on every leaf. KYSELY_TABLES updated with the new
drop order (children before parents) so postgres teardown stays clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
zod DTOs for the full People surface (basics + contacts +
organizations + relatives + met-through + notes) plus a minimal
Organizations skeleton. Person-organization is a discriminated union
on { kind: "linked" | "stub" } that maps to the
"exactly one of (organization_id, stub_name)" CHECK constraint.

Routes:

- GET/POST /api/organizations (cursor-paginated)
- GET/POST /api/people (cursor-paginated, summary with primary
  contact + primary org)
- GET/PATCH/DELETE /api/people/{id} (nested detail tree)
- POST /api/people/{id}/contacts +
  PATCH/DELETE /api/people/{id}/contacts/{contactId}
- POST/PATCH/DELETE /api/people/{id}/organizations(/{entryId})
- POST /api/people/{id}/relatives +
  DELETE /api/people/{id}/relatives/{entryId} (no PATCH —
  relationship type changes via delete + recreate)
- POST/DELETE /api/people/{id}/met-through(/{entryId})
- POST/PATCH/DELETE /api/people/{id}/notes(/{noteId})

Cross-reference validation: every relatedPersonId / throughPersonId
/ organizationId is verified to belong to the authenticated user
BEFORE insert; cross-user attempts return 404, never 403, per the
"don't leak existence" convention. A shared `loadOwnedPerson` helper
collapses the auth + ownership preamble that every sub-resource
needs.

openapi.json regenerated; coverage check passes (85 routes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds:

- `useOrganizations`, `useCreateOrganization` — minimal CRUD for the
  #28 skeleton.
- `usePeople`, `usePerson`, `useCreatePerson`, `useUpdatePerson`,
  `useDeletePerson` — list summary, detail tree, and basics CRUD.
- One hook pair per sub-resource (contacts, organizations, relatives,
  met-through, notes). Each mutation invalidates both the detail tree
  AND the list — the summary card carries the primary contact + org
  name, so a sub-resource write may shift the summary.

The generated client picked up the new operations from openapi.json.
`@carol/api-client check` passes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(client+i18n): network screen + person detail (#27)
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 18s
PR / OSV-Scanner (pull_request) Successful in 22s
PR / Static analysis (pull_request) Successful in 49s
PR / Trivy (image) (pull_request) Successful in 1m45s
PR / Package age policy (soft) (pull_request) Successful in 11s
PR / pnpm audit (pull_request) Successful in 2m57s
PR / Lint (pull_request) Successful in 3m11s
Secrets / gitleaks (pull_request) Successful in 29s
PR / OpenAPI (pull_request) Successful in 3m19s
PR / Test (postgres) (pull_request) Successful in 3m27s
PR / Typecheck (pull_request) Successful in 3m37s
PR / Test (sqlite) (pull_request) Successful in 3m37s
PR / Build (pull_request) Successful in 3m46s
PR / Coverage (soft) (pull_request) Successful in 3m6s
PR / Client (web export smoke) (pull_request) Successful in 4m9s
f4d0f213d0
Replaces the /network placeholder with a real list + create form and
adds /network/[id] — the per-person detail tree with seven sections:

- Basics (page-level edit toggle following the Profile pattern)
- Contacts (kind picker, value, optional label)
- Organisations (linked vs stub toggle; inline "create tracked" on
  empty)
- Relatives (other-person picker + relationship enum)
- Met through (other-person picker)
- Notes timeline (date + text, sorted DESC)

Cross-reference inputs (relative, met-through, organisation) render as
horizontal chip pickers over the user's existing people / orgs so
nothing escapes the per-user silo. The People list summary already
carries the primary contact + primary org name so the rows render
without per-person drill-down chatter.

i18n catalog gets the `network.*` namespace fleshed out — basics,
contacts, organisations, relatives, met-through, person-notes,
plus the per-section copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

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

Metric Value Soft target
Lines 81.7% ≥ 50%
Branches 72.9% ⚠️ ≥ 75%
Functions 90.7% informational

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

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 81.7% ✅ | ≥ 50% | | Branches | 72.9% ⚠️ | ≥ 75% | | Functions | 90.7% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james force-pushed 27-people-feature from f4d0f213d0
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 18s
PR / OSV-Scanner (pull_request) Successful in 22s
PR / Static analysis (pull_request) Successful in 49s
PR / Trivy (image) (pull_request) Successful in 1m45s
PR / Package age policy (soft) (pull_request) Successful in 11s
PR / pnpm audit (pull_request) Successful in 2m57s
PR / Lint (pull_request) Successful in 3m11s
Secrets / gitleaks (pull_request) Successful in 29s
PR / OpenAPI (pull_request) Successful in 3m19s
PR / Test (postgres) (pull_request) Successful in 3m27s
PR / Typecheck (pull_request) Successful in 3m37s
PR / Test (sqlite) (pull_request) Successful in 3m37s
PR / Build (pull_request) Successful in 3m46s
PR / Coverage (soft) (pull_request) Successful in 3m6s
PR / Client (web export smoke) (pull_request) Successful in 4m9s
to ed338865bb
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 17s
PR / OSV-Scanner (pull_request) Successful in 17s
PR / Static analysis (pull_request) Successful in 47s
PR / Trivy (image) (pull_request) Successful in 1m57s
PR / Package age policy (soft) (pull_request) Successful in 13s
Secrets / gitleaks (pull_request) Successful in 17s
PR / Client (web export smoke) (pull_request) Successful in 7m7s
PR / pnpm audit (pull_request) Successful in 7m29s
PR / Test (sqlite) (pull_request) Successful in 7m29s
PR / OpenAPI (pull_request) Successful in 7m40s
PR / Lint (pull_request) Successful in 7m49s
PR / Typecheck (pull_request) Successful in 8m10s
PR / Coverage (soft) (pull_request) Successful in 7m40s
PR / Build (pull_request) Successful in 9m11s
PR / Test (postgres) (pull_request) Failing after 12m27s
2026-06-24 02:13:48 +00:00
Compare
james merged commit 837c9e90e6 into main 2026-06-24 02:16:09 +00:00
james deleted branch 27-people-feature 2026-06-24 02:16:10 +00:00
Sign in to join this conversation.
No description provided.