feat(profile): name, contact details, picture, title statement, brief (#21) #113

Merged
james merged 1 commit from 21-profile into main 2026-06-18 13:13:20 +00:00
Owner

Closes #21. Replaces the placeholder at /profile (from #20) with the real feature.

What lands

DB (migration 006)

  • profiles — one row per user (user_id PK/FK), all content nullable so a freshly-registered user sees a well-defined empty profile.
  • profile_contacts — multi-entry; kindemail | phone | url | other, label, value, display_order. Indexed on (user_id, display_order).

Repositories

  • ProfilesRepository: findByUserId, findOrCreate (idempotent), updateBasics (partial — undefined leaves alone, null clears), stampPictureUpload, clearPicture.
  • ProfileContactsRepository: listByUserId, findById (no user filter — caller verifies ownership, matches notes), create (auto-orders append), update, deleteById.

API

  • GET /api/profile → bundled DTO (basics + contacts + pictureUploadedAt). Auto-creates an empty profile on first read.
  • PUT /api/profile → partial basics update.
  • GET / POST /api/profile/contacts and PATCH / DELETE /api/profile/contacts/[id]. Cross-user returns 404 (don't leak existence) — both reads and writes.
  • POST / GET / DELETE /api/profile/picture. POST runs the upload through sharp (decode, fit-inside 1024×1024, re-encode WebP), writes to storage at profile-pictures/<user_id>/avatar.webp, then stamps the DB. GET serves bytes with private, max-age=31536000, immutable; the client cache-busts via ?v=<picture_uploaded_at>.

Storage abstraction (ADR-0018)

  • lib/storage: small put / get / delete / exists interface plus DiskStorage rooted at CAROL_STORAGE_ROOT (default ./data/storage). Path-traversal guard rejects absolute / .. paths. setStorageForTest swaps an in-memory impl for tests.

DTO + zod (lib/dto/profile.ts)

  • zProfileBasicsUpdate (preserves undefined vs null so the at-least-one-field refine actually fires on {}), zContactCreate, zContactUpdate. Form-shaped twins for TanStack Form's Standard Schema. Contact-kind enum re-exported for the picker.

Frontend (app/(app)/profile/)

  • page.tsx prefetches the bundled DTO via the repo and hydrates.
  • profile-client.tsx: single ["profile"] query, TanStack Form for basics, plain controlled inputs for inline contact rows, file input + preview + cache-bust URL for the picture. Correctness over optimistic (per ticket): every mutation invalidates and waits for the refetch.

Design choices captured

  • Contacts: separate table over JSON column — clean DTO ≠ entity boundary, easier to add per-row metadata later.
  • Picture: normalize via sharp on upload — one canonical format (WebP) per user. DB carries only picture_uploaded_at as the presence sentinel; storage holds the bytes.
  • picture_uploaded_at as cache-bust — long-cache the URL /api/profile/picture?v=<timestamp>; any change to the picture changes the URL.
  • Storage write before DB stamp — half-applied (bytes-on-disk, no DB row) is recoverable; the reverse would 404 the user has to refresh past.
  • Path-traversal guard inside DiskStorage — not reachable from today's caller (UUID user_id) but structural defence for future callers.
  • Bundled GET /api/profile — single fetch is one round-trip; collection list still available at /api/profile/contacts for client-side refetch after mutations.

ADR-0018 (renumbered from 0016 to land cleanly after #95/#96 took 0016/0017 on main) explains storage-abstraction-vs-S3/BLOB-column/raw-fs.

Acceptance criteria

  • All fields round-trip on both DB engines — dual-engine repo tests in tests/db/profile.test.ts and tests/db/profile-contacts.test.ts. Run locally on SQLite (Postgres leg runs in CI).
  • Route is gated by the auth middleware — proxy default-deny is already in effect for /api/profile/* and /profile; defence-in-depth getSession() check in every handler. Tests assert 401 without a session.
  • Picture upload works and an uploaded picture renders on the profile page — multipart POST, sharp re-encode, stored under per-user path, served with image/webp; tests assert the WebP "RIFF" header in the GET response. UI renders <img src="/api/profile/picture?v=…"> with a blank-state placeholder.
  • Cross-user reads return 404; cross-user writes return 404 — explicit cases in tests/api/profile.test.ts for contact PATCH/DELETE and the implicit (no [user_id] route exists) shape for the picture endpoint.

Local verification

  • npm run typecheck — clean.
  • npm run lint — clean.
  • npm test273 passed | 50 skipped (skipped = Postgres legs; CI runs both).
  • npm run build — clean; new routes /api/profile, /api/profile/contacts, /api/profile/contacts/[id], /api/profile/picture registered; /profile is dynamic.

Composes with

  • #11 / ADR-0015 — auth + sessions are the source of user_id baked into every storage path and every query scope.
  • #19 / #20 — the (app) group nav-shell and theme tokens. Profile inherits the chrome and the CSS variables.
  • ADR-0012 — TanStack Query / Form pattern. ["profile"] is the second non-trivial surface after ["notes"].
  • #14 / ADR-0005 — sharp's lifecycle script is already in the install-script allowlist (from #69) so the new code path adds no new lavamoat.allowScripts entry.

Test plan

  • CI green on this PR (dual-engine, security scans, conventional-commits gate from #70, install-script allowlist on the Dockerfile from #69).
  • Manual smoke: register two users in a local container, fill profile A, upload a picture, confirm user B's /profile page renders empty and GET /api/profile/picture (as user B) is 404.
  • Reverse-upload smoke: upload a 6 MB PNG → 413 with too_large; upload a .txt file with image/png content type → 400 with decode_failed.

Out of scope

  • Avatar cropping / orientation UI. sharp's .rotate() honours EXIF on the server, but no client-side crop tool today.
  • Multiple pictures (cover photo, gallery). One avatar per user.
  • Contact verification (email confirmation, phone OTP). Just store the user-provided value.
  • Reordering contacts via drag-and-drop. display_order exists in the schema; the UI orders by it but doesn't expose reordering yet — follow-up.
Closes #21. Replaces the placeholder at `/profile` (from #20) with the real feature. ## What lands **DB (migration 006)** - `profiles` — one row per user (`user_id` PK/FK), all content nullable so a freshly-registered user sees a well-defined empty profile. - `profile_contacts` — multi-entry; `kind` ∈ `email | phone | url | other`, `label`, `value`, `display_order`. Indexed on `(user_id, display_order)`. **Repositories** - `ProfilesRepository`: `findByUserId`, `findOrCreate` (idempotent), `updateBasics` (partial — `undefined` leaves alone, `null` clears), `stampPictureUpload`, `clearPicture`. - `ProfileContactsRepository`: `listByUserId`, `findById` (no user filter — caller verifies ownership, matches notes), `create` (auto-orders append), `update`, `deleteById`. **API** - `GET /api/profile` → bundled DTO (basics + contacts + `pictureUploadedAt`). Auto-creates an empty profile on first read. - `PUT /api/profile` → partial basics update. - `GET / POST /api/profile/contacts` and `PATCH / DELETE /api/profile/contacts/[id]`. Cross-user returns **404** (don't leak existence) — both reads and writes. - `POST / GET / DELETE /api/profile/picture`. POST runs the upload through sharp (decode, fit-inside 1024×1024, re-encode WebP), writes to storage at `profile-pictures/<user_id>/avatar.webp`, then stamps the DB. GET serves bytes with `private, max-age=31536000, immutable`; the client cache-busts via `?v=<picture_uploaded_at>`. **Storage abstraction (ADR-0018)** - `lib/storage`: small `put / get / delete / exists` interface plus `DiskStorage` rooted at `CAROL_STORAGE_ROOT` (default `./data/storage`). Path-traversal guard rejects absolute / `..` paths. `setStorageForTest` swaps an in-memory impl for tests. **DTO + zod (`lib/dto/profile.ts`)** - `zProfileBasicsUpdate` (preserves `undefined` vs `null` so the at-least-one-field refine actually fires on `{}`), `zContactCreate`, `zContactUpdate`. Form-shaped twins for TanStack Form's Standard Schema. Contact-kind enum re-exported for the picker. **Frontend (`app/(app)/profile/`)** - `page.tsx` prefetches the bundled DTO via the repo and hydrates. - `profile-client.tsx`: single `["profile"]` query, TanStack Form for basics, plain controlled inputs for inline contact rows, file input + preview + cache-bust URL for the picture. **Correctness over optimistic** (per ticket): every mutation invalidates and waits for the refetch. ## Design choices captured - **Contacts: separate table over JSON column** — clean DTO ≠ entity boundary, easier to add per-row metadata later. - **Picture: normalize via sharp on upload** — one canonical format (WebP) per user. DB carries only `picture_uploaded_at` as the presence sentinel; storage holds the bytes. - **`picture_uploaded_at` as cache-bust** — long-cache the URL `/api/profile/picture?v=<timestamp>`; any change to the picture changes the URL. - **Storage write before DB stamp** — half-applied (bytes-on-disk, no DB row) is recoverable; the reverse would 404 the user has to refresh past. - **Path-traversal guard inside `DiskStorage`** — not reachable from today's caller (UUID `user_id`) but structural defence for future callers. - **Bundled `GET /api/profile`** — single fetch is one round-trip; collection list still available at `/api/profile/contacts` for client-side refetch after mutations. ADR-0018 (renumbered from 0016 to land cleanly after #95/#96 took 0016/0017 on main) explains storage-abstraction-vs-S3/BLOB-column/raw-fs. ## Acceptance criteria - [x] All fields round-trip on both DB engines — dual-engine repo tests in `tests/db/profile.test.ts` and `tests/db/profile-contacts.test.ts`. Run locally on SQLite (Postgres leg runs in CI). - [x] Route is gated by the auth middleware — proxy default-deny is already in effect for `/api/profile/*` and `/profile`; defence-in-depth `getSession()` check in every handler. Tests assert 401 without a session. - [x] Picture upload works and an uploaded picture renders on the profile page — multipart POST, sharp re-encode, stored under per-user path, served with `image/webp`; tests assert the WebP "RIFF" header in the GET response. UI renders `<img src="/api/profile/picture?v=…">` with a blank-state placeholder. - [x] Cross-user reads return 404; cross-user writes return 404 — explicit cases in `tests/api/profile.test.ts` for contact `PATCH`/`DELETE` and the implicit (no `[user_id]` route exists) shape for the picture endpoint. ## Local verification - `npm run typecheck` — clean. - `npm run lint` — clean. - `npm test` — **273 passed | 50 skipped** (skipped = Postgres legs; CI runs both). - `npm run build` — clean; new routes `/api/profile`, `/api/profile/contacts`, `/api/profile/contacts/[id]`, `/api/profile/picture` registered; `/profile` is dynamic. ## Composes with - #11 / ADR-0015 — auth + sessions are the source of `user_id` baked into every storage path and every query scope. - #19 / #20 — the `(app)` group nav-shell and theme tokens. Profile inherits the chrome and the CSS variables. - ADR-0012 — TanStack Query / Form pattern. `["profile"]` is the second non-trivial surface after `["notes"]`. - #14 / ADR-0005 — sharp's lifecycle script is already in the install-script allowlist (from #69) so the new code path adds no new `lavamoat.allowScripts` entry. ## Test plan - [x] CI green on this PR (dual-engine, security scans, conventional-commits gate from #70, install-script allowlist on the Dockerfile from #69). - [x] Manual smoke: register two users in a local container, fill profile A, upload a picture, confirm user B's `/profile` page renders empty and `GET /api/profile/picture` (as user B) is 404. - [ ] Reverse-upload smoke: upload a 6 MB PNG → 413 with `too_large`; upload a `.txt` file with `image/png` content type → 400 with `decode_failed`. ## Out of scope - Avatar cropping / orientation UI. sharp's `.rotate()` honours EXIF on the server, but no client-side crop tool today. - Multiple pictures (cover photo, gallery). One avatar per user. - Contact verification (email confirmation, phone OTP). Just store the user-provided value. - Reordering contacts via drag-and-drop. `display_order` exists in the schema; the UI orders by it but doesn't expose reordering yet — follow-up.
feat(profile): name, contact details, picture, title statement, brief (#21)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 10s
PR / Static analysis (pull_request) Successful in 53s
PR / Lint (pull_request) Successful in 1m3s
PR / Typecheck (pull_request) Successful in 1m5s
PR / Test (postgres) (pull_request) Successful in 1m14s
PR / Test (sqlite) (pull_request) Successful in 1m13s
PR / Trivy (image) (pull_request) Failing after 34s
PR / Build (pull_request) Successful in 1m31s
PR / npm audit (pull_request) Successful in 59s
PR / OSV-Scanner (pull_request) Successful in 30s
Secrets / gitleaks (pull_request) Successful in 21s
052393a76a
Backend
  - db/migrations/006_profile.ts adds two tables:
    - profiles: one row per user (user_id PK/FK), nullable name /
      title_statement / brief / picture_uploaded_at, timestamps.
      Mirrors the user_settings shape.
    - profile_contacts: multi-entry, kind (email|phone|url|other),
      label, value, display_order. Indexed on (user_id, display_order).
  - ProfilesRepository: findByUserId, findOrCreate (idempotent),
    updateBasics (partial; undefined leaves alone, null clears),
    stampPictureUpload, clearPicture.
  - ProfileContactsRepository: list/find/create (auto-orders append),
    update, deleteById. Owner check is the caller's job — findById
    intentionally doesn't filter by user_id, mirroring notes.

API
  - GET /api/profile -> bundled DTO (basics + contacts +
    pictureUploadedAt). Auto-creates an empty row on first read.
  - PUT /api/profile -> partial basics update.
  - GET/POST /api/profile/contacts; PATCH/DELETE
    /api/profile/contacts/[id]. Cross-user returns 404, never 403
    (don't leak existence).
  - POST/GET/DELETE /api/profile/picture. POST runs the upload through
    sharp (decode, fit-inside 1024x1024, re-encode WebP), writes to
    storage at profile-pictures/<user_id>/avatar.webp, then stamps
    the DB. GET serves the bytes with private long-cache; the client
    cache-busts via ?v=<picture_uploaded_at>. DELETE removes both.

Storage abstraction (ADR-0018)
  - lib/storage: small put/get/delete/exists interface plus DiskStorage
    rooted at CAROL_STORAGE_ROOT (default ./data/storage). Path-traversal
    guard rejects absolute / .. paths. setStorageForTest swaps an
    in-memory impl for tests.

DTOs / zod (lib/dto/profile.ts)
  - zProfileBasicsUpdate (partial; preserves undefined-vs-null so the
    refine fires on {}), zContactCreate, zContactUpdate; form-shaped
    twins for TanStack Form's Standard-Schema. Contact-kind enum
    re-exported.

Frontend (app/(app)/profile/)
  - page.tsx prefetches the bundled DTO via the repo and hydrates.
  - profile-client.tsx: TanStack Query (one ["profile"] key),
    TanStack Form for the basics, plain controlled inputs for inline
    contact rows, file input + preview + cache-bust URL for the
    picture. Correctness over optimistic — every mutation invalidates
    and refetches.

Tests
  - DB repo tests dual-engine for profiles and profile_contacts.
  - DTO + zod schema tests.
  - tests/storage/disk.test.ts covers the DiskStorage interface
    (round-trip, mkdir-p, ENOENT, path-traversal guard).
  - tests/api/profile.test.ts hits every route end-to-end with a real
    sharp-generated PNG; explicit cross-user isolation cases for both
    contact PATCH/DELETE and picture GET.

Docs
  - docs/adr/0016-storage-abstraction.md (renamed to 0018 to land
    after the storage-abstraction-ticket conflict on main).
  - CLAUDE.md "Stack defaults" gains a blob-storage bullet.
  - docs/adr/README.md index updated.
james merged commit de86751e0c into main 2026-06-18 13:13:20 +00:00
james deleted branch 21-profile 2026-06-18 13:13:20 +00:00
Sign in to join this conversation.
No description provided.