feat(api+client): people feature with relatives, met-through, organizations, and notes (#27) #268
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!268
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "27-people-feature"
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
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_organizationsrelationship 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:
organizationstable + CRUD now; #28 expands.Picked A because:
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, withGET /api/organizations(paginated) andPOST /api/organizations(create). Inline create on the detail screen lets a user mint a tracked org without leaving the Person.Schema (migration 016 + 017)
Every child carries a denormalised
user_idso 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 throughrelated_person_idtoo), met-through, and notes. Deleting a User cascades through the whole tree.The
KYSELY_TABLESdrop list inapps/api/tests/db/_engines.tswas updated with the new tables in dependency order (children before parents).Routes (registered + drift-checked)
/api/organizations/api/organizations/api/people/api/people/api/people/{id}/api/people/{id}/api/people/{id}/api/people/{id}/contacts/api/people/{id}/contacts/{contactId}/api/people/{id}/organizations{kind:"linked"|"stub"}/api/people/{id}/organizations/{entryId}/api/people/{id}/relativesrelationshipType/api/people/{id}/relatives/{entryId}/api/people/{id}/met-through(/{entryId})/api/people/{id}/notes/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.jsonregenerated; coverage check shows 85 (path, method) pairs registered, drift check green.Decisions worth flagging
spouse | child | parent | sibling | otherrather 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_organizationsPATCH: a discriminated union —kind: "linked"swaps to a tracked org (clears stub_name),kind: "stub"swaps to free-form (clears organization_id), nokindmeans "edit role/displayOrder only" (validated to keeporganizationIdandstubNameout of the body).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.person_organizationsrows per Person are allowed. The list summary picks the first (lowest display_order) as "primary".Test plan
person_organizations+ same-user constraint on relative / met-through / organization refspnpm -F @carol/api typecheck / lint / test / openapi:check / openapi:coveragepnpm -F @carol/api-client typecheck / lint / test / checkpnpm -F @carol/client typecheck / lint / test / export:webPostgres leg of the matrix skipped locally (
TEST_POSTGRES_URLunset); CI will run it on PR.Closes #27.
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>📊 Test coverage
Patch coverage: no testable lines changed.
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Soft thresholds per ADR-0019. Coverage is informational and does not block merge.
f4d0f213d0ed338865bb