feat(api+client): organizations feature with links and key people (#28) #269

Merged
james merged 4 commits from 28-organizations-feature into main 2026-06-24 12:16:41 +00:00
Owner

Summary

Expands the minimal Organizations skeleton from #27 into the full #28 surface. Adds description + date_joined columns to the existing organizations table, two child tables (organization_links, organization_key_people), the matching CRUD routes + DTOs + TanStack hooks, and a new Organization detail screen at /network/orgs/[id]. The /network screen restructures into People / Organizations tabs.

Schema diff

Migration 019_organizations_expand.ts:

  • organizations — add description TEXT NULL, date_joined TEXT NULL (ISO YYYY-MM-DD).
  • organization_links — new table: id, user_id (FK users), organization_id (FK organizations CASCADE), url, label?, display_order, created_at. Index (user_id, organization_id, display_order).
  • organization_key_people — new table: id, user_id (FK users), organization_id (FK organizations CASCADE), person_id (FK people ON DELETE CASCADE — implements the acceptance criterion), role?, display_order, created_at. UNIQUE (user_id, organization_id, person_id). Indexes on (user_id, organization_id, display_order) + (person_id) (for the reverse cascade lookup).
  • apps/api/tests/db/_engines.ts KYSELY_TABLES updated in dependency order (#259 trap — children before parents).

Route table

Method Path Notes
GET /api/organizations Cursor-paginated; summary DTO with linkCount + keyPeopleCount.
POST /api/organizations Now accepts description, dateJoined.
GET /api/organizations/{id} Detail tree (links + key people, denormalised personName).
PATCH /api/organizations/{id} Basics. Returns refreshed detail tree.
DELETE /api/organizations/{id} Cascades.
POST /api/organizations/{id}/links URL validated as http(s) via z.url() + refine.
PATCH /api/organizations/{id}/links/{linkId}
DELETE /api/organizations/{id}/links/{linkId}
POST /api/organizations/{id}/key-people Verifies the Person belongs to the authenticated user; 409 key_person_exists on UNIQUE pre-check.
PATCH /api/organizations/{id}/key-people/{entryId} role / displayOrder.
DELETE /api/organizations/{id}/key-people/{entryId}

All routes use getAuthIdentity(req) (session OR bearer) and return 404 on cross-user. OpenAPI coverage: 98 (path, method) pairs registered; drift gate green.

Network UX decision (Option A vs B)

Picked A. Segmented control on /network with People + Organizations tabs, pure client-state, no sub-routes — mirrors the Experience screen's tab pattern (Education / Jobs / Contracts). Lighter footprint than sub-routes, keeps the existing "add a person" CTA on the primary /network landing, and matches what the rest of the app does. Option B (/network/people + /network/organizations) would force every Network nav click to land on a redirect.

The Organization detail screen lives at a sub-route — /network/orgs/[id] — so it can have its own page-level Edit toggle, mirroring the /network/[id] Person detail screen and the #266 Profile pattern.

The Person detail screen's organisation list now taps through to /network/orgs/[id] for linked-form rows (the existing organizationId from #27). Stub-form rows stay non-interactive — they don't reference a tracked Organization.

Decisions worth flagging

  • Link URL validation strictness. I went with the same gate as Projects.link (#265): z.url() plus a refine requiring http(s)://. This rejects javascript: and other surprises. The client UI also runs the regex before submitting, so the typical "oops I typed example.com" never round-trips a 400.
  • Key-people cascade direction. Per the ticket's acceptance criterion, deleting a Person removes their organization_key_people row on every Organization — DDL-level ON DELETE CASCADE on person_id. Considered "leave as orphan with person_id NULL" but rejected: that'd leak a tombstone row through the API, and the Organization screen would render personName: "?" indefinitely with no recovery path.
  • Picker excludes already-added Persons. Cheaper UX than letting the user pick someone and surfacing the 409. The 409 is still wired through as a fallback in case of a tab-switching race.

Test plan

  • Migration applies cleanly on both engines (SQLite always; Postgres via _engines.ts matrix when TEST_POSTGRES_URL is set).
  • Repo tests: organizations (10 SQLite, mirror Postgres), organization_links (6), organization_key_people (6) — includes the UNIQUE constraint check and the Person-delete cascade.
  • HTTP-layer tests: 29 cases covering happy paths, 401, 404 cross-user on every endpoint, 400 URL validation (non-URL + javascript:), 409 key_person_exists, and the end-to-end "delete a Person → key-people row is gone, Organization survives" cascade through the API.
  • pnpm -F @carol/api typecheck / lint / test / openapi:check / openapi:coverage — all green.
  • pnpm -F @carol/api-client typecheck / lint / test / check — generated schema regenerated; drift gate green.
  • pnpm -F @carol/client typecheck / lint / test / export:web — all green; new /network/orgs/[id] route exported.

Surprises

  • The existing zOrganizationDto had become a backwards-compat re-export of the new summary shape; the named OpenAPI component OrganizationDto now points at the same z schema as OrganizationSummaryDto. Both names appear in the spec so future codegen consumers can move at their own pace.
  • The loadOwnedOrganization helper mirrors loadOwnedPerson line-for-line — the People pattern translated cleanly. No surprises in the sub-resource routes.

Sibling: #27 (People). Spec: idea.md → Network → Organizations.

Closes #28.

## Summary Expands the minimal Organizations skeleton from #27 into the full #28 surface. Adds `description` + `date_joined` columns to the existing `organizations` table, two child tables (`organization_links`, `organization_key_people`), the matching CRUD routes + DTOs + TanStack hooks, and a new Organization detail screen at `/network/orgs/[id]`. The `/network` screen restructures into People / Organizations tabs. ## Schema diff Migration `019_organizations_expand.ts`: - `organizations` — add `description TEXT NULL`, `date_joined TEXT NULL` (ISO `YYYY-MM-DD`). - `organization_links` — new table: `id`, `user_id` (FK users), `organization_id` (FK organizations CASCADE), `url`, `label?`, `display_order`, `created_at`. Index `(user_id, organization_id, display_order)`. - `organization_key_people` — new table: `id`, `user_id` (FK users), `organization_id` (FK organizations CASCADE), `person_id` (**FK people ON DELETE CASCADE** — implements the acceptance criterion), `role?`, `display_order`, `created_at`. UNIQUE `(user_id, organization_id, person_id)`. Indexes on `(user_id, organization_id, display_order)` + `(person_id)` (for the reverse cascade lookup). - `apps/api/tests/db/_engines.ts` `KYSELY_TABLES` updated in dependency order (#259 trap — children before parents). ## Route table | Method | Path | Notes | | ------ | ---- | ----- | | GET | `/api/organizations` | Cursor-paginated; summary DTO with `linkCount` + `keyPeopleCount`. | | POST | `/api/organizations` | Now accepts `description`, `dateJoined`. | | GET | `/api/organizations/{id}` | Detail tree (links + key people, denormalised `personName`). | | PATCH | `/api/organizations/{id}` | Basics. Returns refreshed detail tree. | | DELETE | `/api/organizations/{id}` | Cascades. | | POST | `/api/organizations/{id}/links` | URL validated as http(s) via `z.url()` + refine. | | PATCH | `/api/organizations/{id}/links/{linkId}` | | | DELETE | `/api/organizations/{id}/links/{linkId}` | | | POST | `/api/organizations/{id}/key-people` | Verifies the Person belongs to the authenticated user; 409 `key_person_exists` on UNIQUE pre-check. | | PATCH | `/api/organizations/{id}/key-people/{entryId}` | role / displayOrder. | | DELETE | `/api/organizations/{id}/key-people/{entryId}` | | All routes use `getAuthIdentity(req)` (session OR bearer) and return 404 on cross-user. OpenAPI coverage: 98 (path, method) pairs registered; drift gate green. ## Network UX decision (Option A vs B) **Picked A.** Segmented control on `/network` with People + Organizations tabs, pure client-state, no sub-routes — mirrors the Experience screen's tab pattern (Education / Jobs / Contracts). Lighter footprint than sub-routes, keeps the existing "add a person" CTA on the primary `/network` landing, and matches what the rest of the app does. Option B (`/network/people` + `/network/organizations`) would force every Network nav click to land on a redirect. The Organization detail screen lives at a sub-route — `/network/orgs/[id]` — so it can have its own page-level Edit toggle, mirroring the `/network/[id]` Person detail screen and the #266 Profile pattern. ## People-detail cross-link The Person detail screen's organisation list now taps through to `/network/orgs/[id]` for linked-form rows (the existing `organizationId` from #27). Stub-form rows stay non-interactive — they don't reference a tracked Organization. ## Decisions worth flagging - **Link URL validation strictness.** I went with the same gate as Projects.link (#265): `z.url()` plus a refine requiring `http(s)://`. This rejects `javascript:` and other surprises. The client UI also runs the regex before submitting, so the typical "oops I typed `example.com`" never round-trips a 400. - **Key-people cascade direction.** Per the ticket's acceptance criterion, deleting a Person removes their `organization_key_people` row on every Organization — DDL-level `ON DELETE CASCADE` on `person_id`. Considered "leave as orphan with `person_id` NULL" but rejected: that'd leak a tombstone row through the API, and the Organization screen would render `personName: "?"` indefinitely with no recovery path. - **Picker excludes already-added Persons.** Cheaper UX than letting the user pick someone and surfacing the 409. The 409 is still wired through as a fallback in case of a tab-switching race. ## Test plan - [x] Migration applies cleanly on both engines (SQLite always; Postgres via `_engines.ts` matrix when `TEST_POSTGRES_URL` is set). - [x] Repo tests: organizations (10 SQLite, mirror Postgres), organization_links (6), organization_key_people (6) — includes the UNIQUE constraint check and the Person-delete cascade. - [x] HTTP-layer tests: 29 cases covering happy paths, 401, 404 cross-user on every endpoint, 400 URL validation (non-URL + `javascript:`), 409 `key_person_exists`, and the end-to-end "delete a Person → key-people row is gone, Organization survives" cascade through the API. - [x] `pnpm -F @carol/api typecheck / lint / test / openapi:check / openapi:coverage` — all green. - [x] `pnpm -F @carol/api-client typecheck / lint / test / check` — generated schema regenerated; drift gate green. - [x] `pnpm -F @carol/client typecheck / lint / test / export:web` — all green; new `/network/orgs/[id]` route exported. ## Surprises - The existing `zOrganizationDto` had become a backwards-compat re-export of the new summary shape; the named OpenAPI component `OrganizationDto` now points at the same z schema as `OrganizationSummaryDto`. Both names appear in the spec so future codegen consumers can move at their own pace. - The `loadOwnedOrganization` helper mirrors `loadOwnedPerson` line-for-line — the People pattern translated cleanly. No surprises in the sub-resource routes. ## Links Sibling: #27 (People). Spec: `idea.md` → Network → Organizations. Closes #28.
Migration 019 adds `description` + `date_joined` to the existing
`organizations` table (both nullable, mirroring #27's voice) plus two
child tables:

- `organization_links` (id, user_id, organization_id, url, label?,
  display_order, created_at) — per-org URL list. URL format is
  enforced at the API layer; DB column is plain text.
- `organization_key_people` (id, user_id, organization_id, person_id,
  role?, display_order, created_at) — references into the per-user
  People list. UNIQUE on (user, org, person) prevents the same Person
  from appearing twice on a given Organization. ON DELETE CASCADE on
  `person_id` implements #28's acceptance criterion: deleting a
  Person removes their key-people reference everywhere.

`user_id` is denormalised onto both children for the one-column
isolation check pattern shared with Jobs (010) and People (018).

KYSELY_TABLES drops list updated in dependency order to keep the
Postgres engine clean between runs (#259 trap).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extends the minimal /api/organizations skeleton from #27 into the full
#28 surface. Mirrors the People (#27) sub-resource layout:

- GET /api/organizations now returns the summary DTO with linkCount +
  keyPeopleCount so the list renders without per-row drill-downs.
- POST /api/organizations accepts the new description + dateJoined
  fields.
- GET /api/organizations/{id} returns the nested detail tree (links +
  key-people, with denormalised personName).
- PATCH /api/organizations/{id} updates basics.
- DELETE /api/organizations/{id} cascades to links + key-people +
  person_organizations (DDL-level).

Sub-resources mirror the per-person pattern:

- /links — POST, PATCH, DELETE. URL validation via z.url() + http(s)
  refine matches the Projects link pattern from #265.
- /key-people — POST (verifies the Person belongs to the
  authenticated user; 409 key_person_exists on UNIQUE violation
  pre-empted by findExisting), PATCH (role / display_order), DELETE.

All routes use getAuthIdentity and return 404 on cross-user. zod
DTOs route per-field errors through the existing zodErrorMap (#212).
The OpenAPI spec drift gate stays green; 98 (path, method) pairs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Regenerates src/generated/schema.d.ts against the expanded
/api/organizations spec and adds the matching TanStack Query hooks:

- useOrganization(id) — detail tree.
- useUpdateOrganization / useDeleteOrganization.
- useCreateOrganizationLink / useUpdateOrganizationLink /
  useDeleteOrganizationLink.
- useAddOrganizationKeyPerson / useUpdateOrganizationKeyPerson /
  useRemoveOrganizationKeyPerson.

Sub-resource mutations invalidate BOTH keys.organizations.detail(id)
and keys.organizations.all because the list-row summary counts
(linkCount, keyPeopleCount) shift on every sub-resource write.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(client+i18n): network tabs and organization detail screen (#28)
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 45s
PR / OSV-Scanner (pull_request) Successful in 24s
PR / Static analysis (pull_request) Successful in 1m13s
PR / Trivy (image) (pull_request) Successful in 1m41s
PR / Package age policy (soft) (pull_request) Successful in 13s
Secrets / gitleaks (pull_request) Successful in 17s
PR / OpenAPI (pull_request) Successful in 6m27s
PR / Test (sqlite) (pull_request) Successful in 6m54s
PR / Build (pull_request) Successful in 7m5s
PR / Coverage (soft) (pull_request) Successful in 6m34s
PR / Lint (pull_request) Successful in 8m54s
PR / Client (web export smoke) (pull_request) Successful in 9m0s
PR / Typecheck (pull_request) Successful in 9m24s
PR / pnpm audit (pull_request) Successful in 9m33s
PR / Test (postgres) (pull_request) Successful in 9m43s
97f64f1924
UX decision: Option A from the ticket — segmented control on /network
with People + Organizations tabs (mirrors Experience's tab pattern).
One screen, pure client-state, no sub-routes. The People tab keeps
its existing inline "add a person" CTA; the Organizations tab adds
its own inline create form.

Cards on the Organizations list show name, description (truncated),
plus the link count + key-people count from the new summary DTO.

New screen at /network/orgs/[id] mirrors the Person detail layout:
- Basics card with page-level Edit toggle (name, description, date
  joined) — the #266 Profile pattern.
- Links card with label/URL list + inline add form. Client-side
  http(s) gate keeps the 400 from round-tripping for obvious typos;
  the server still validates.
- Key-people card with Person picker + optional role. Already-added
  Persons are filtered out of the picker; the API's 409
  key_person_exists falls through to a friendly message just in case
  of a race.

People detail screen: linked-form organizations now tap through to
/network/orgs/[id]. Stub-form rows stay non-interactive.

i18n: extends the `network.*` namespace with `tabs.*`,
`organizationsScreen.*` (linkCount / keyPeopleCount plural keys,
loading / loadFailed), and a new `organizationDetail.*` subtree.
en.json is the source of truth; es.json falls back per-key as before.

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.6% ≥ 50%
Branches 72.8% ⚠️ ≥ 75%
Functions 91.1% 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.6% ✅ | ≥ 50% | | Branches | 72.8% ⚠️ | ≥ 75% | | Functions | 91.1% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 5ba43bdc6e into main 2026-06-24 12:16:41 +00:00
james deleted branch 28-organizations-feature 2026-06-24 12:16:41 +00:00
Sign in to join this conversation.
No description provided.