Profile photo and job title — design
Figma alignment batch 2, project C. Audit row 2.5 (user chip, node 77:4065 in frame 123:7183). Row 1.10 (organization logo) is out of scope.
Decisions
Section titled “Decisions”J1. The job title is the member’s current primary seat. GET /me gains job_title: the
title of the position orgstruct.PrimaryPositionOf returns for today. A member with no primary
seat gets null, and the chip falls back to the organization name, which is what it shows
today. No new column: the org chart already holds the title (§4.7, one source of truth).
P1. A photo is an ordinary document. It is uploaded through the existing
POST /documents → PUT → POST /documents/{id}/complete flow (presigned, verified by size,
digest and magic bytes), so there is no second storage path. image/webp joins the accepted
document types for every document, receipts included (magic bytes via
http.DetectContentType; no thumbnail is rendered for it).
P2. One pointer column. organization_member.photo_document_id, with a composite
(org_id, photo_document_id) foreign key to document. A member has at most one photo; the
history of changes is the audit log. Removing a photo clears the pointer; the document itself
stays, as every document does (R31).
P3. Only for yourself. PUT /me/photo {document_id} and DELETE /me/photo act on the
caller’s own membership, read from the claim, and carry no permission (listed in
api/public_routes.txt, like /me). The document must be the caller’s own upload, verified,
an image (JPEG, PNG or WebP), at most 2 MiB, and not already evidence for a record. Each set
and removal writes an audit row (organization_member, photo_set / photo_removed).
P4. A binder, member_photo. Registering it means a document in use as a photo cannot
also be attached as a receipt (the one-record rule), and any member of the organization may
open a current photo through GET /documents/{id}.
P5. A stable link, shown where names are shown. GET /me and GET /org/members carry
photo_url = /v1/org/members/{id}/photo?v={document}. That route answers 302 (no-store)
to a freshly presigned link, so the link never expires; v changes with the photo. Same
organization only (explicit org_id predicate and RLS); no photo or another organization is
404. It carries no permission, like the directory. An <img> cannot send the bearer token,
so the web fetches the link with it and shows a blob; initials when that fails.
W1. Settings → Profile. A new first tab: the current photo (or initials), “Upload photo”,
“Remove photo”, and a hint “JPG, PNG or WebP, up to 2 MB”. Type and size are checked in the
browser first. No cropping: the avatar is object-cover in a circle.
W2. The chip. 28 px avatar, name, and the job title (or organization name) on the second line, per the Figma node.
Not done
Section titled “Not done”- Organization logo (row 1.10).
- Server-side resizing or re-encoding.
- A member without
documents.document.upload(seeded on every filing role) cannot upload; the Upload button is disabled with a reason.