feat(profile): name, contact details, picture, title statement, brief (#21) #113
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!113
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "21-profile"
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?
Closes #21. Replaces the placeholder at
/profile(from #20) with the real feature.What lands
DB (migration 006)
profiles— one row per user (user_idPK/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 —undefinedleaves alone,nullclears),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/contactsandPATCH / 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 atprofile-pictures/<user_id>/avatar.webp, then stamps the DB. GET serves bytes withprivate, max-age=31536000, immutable; the client cache-busts via?v=<picture_uploaded_at>.Storage abstraction (ADR-0018)
lib/storage: smallput / get / delete / existsinterface plusDiskStoragerooted atCAROL_STORAGE_ROOT(default./data/storage). Path-traversal guard rejects absolute /..paths.setStorageForTestswaps an in-memory impl for tests.DTO + zod (
lib/dto/profile.ts)zProfileBasicsUpdate(preservesundefinedvsnullso 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.tsxprefetches 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
picture_uploaded_atas the presence sentinel; storage holds the bytes.picture_uploaded_atas cache-bust — long-cache the URL/api/profile/picture?v=<timestamp>; any change to the picture changes the URL.DiskStorage— not reachable from today's caller (UUIDuser_id) but structural defence for future callers.GET /api/profile— single fetch is one round-trip; collection list still available at/api/profile/contactsfor 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
tests/db/profile.test.tsandtests/db/profile-contacts.test.ts. Run locally on SQLite (Postgres leg runs in CI)./api/profile/*and/profile; defence-in-depthgetSession()check in every handler. Tests assert 401 without a session.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.tests/api/profile.test.tsfor contactPATCH/DELETEand 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/pictureregistered;/profileis dynamic.Composes with
user_idbaked into every storage path and every query scope.(app)group nav-shell and theme tokens. Profile inherits the chrome and the CSS variables.["profile"]is the second non-trivial surface after["notes"].lavamoat.allowScriptsentry.Test plan
/profilepage renders empty andGET /api/profile/picture(as user B) is 404.too_large; upload a.txtfile withimage/pngcontent type → 400 withdecode_failed.Out of scope
.rotate()honours EXIF on the server, but no client-side crop tool today.display_orderexists in the schema; the UI orders by it but doesn't expose reordering yet — follow-up.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.