Progress Photos — Behavior
State machine
upload presign ──> binary in R2 (staged) ──> createForSelf ──┐
│
▼
progress_photos row (active)
│
├── (no edit path today)
│
└── deleteOwn ──> soft-deleted (deleted_at set)No coach-edit, no shared / unshared, no archive. The two states are active (deleted_at IS NULL) and soft-deleted.
Invariants
| Invariant | Enforcement |
|---|---|
Uploaded MIME must start with image/ | createForSelf checks meta.mimeType.startsWith('image/') |
| Each photo belongs to exactly one membership | FK + service binds membershipId = membership.id |
| Members can only see their own; staff can see members in their org | requireAccess in service |
| Members can only delete photos they uploaded OR that are pinned to their membership | deleteOwn checks both uploadedBy and membershipId |
| Photo binaries are never publicly addressable | All URLs are 1h presigned |
bodyweight_kg (optional inline measurement) does NOT insert a body_metrics row | Intentional — body-metrics is the canonical record; this field is a convenience snapshot for the photo |
Golden paths
Member uploads a photo (mobile)
- Member selects photo from camera/library. Client requests
POST /uploads/presign→{ uploadId, putUrl, r2Key }. - Client PUTs the binary to
putUrl. - Client calls
POST /organizations/:orgId/members/me/progress-photoswith{ uploadId, recordedAt, notes?, bodyweightKg? }. - Service: looks up staged upload, validates MIME, runs
sharpto generate a thumb and probe(width, height), inserts the row, marks the upload consumed. - UI fetches
GET /members/me/progress-photosfor refreshed timeline.
Coach reviews a member’s gallery
- Coach opens the member detail page → Photos tab.
GET /members/:membershipId/progress-photoswith optional?from/?to/?cursor/?limit.- Each row in the response has presigned
url+thumbnailUrl(TTL 1h).
Member deletes own photo
- Member taps Delete in the photo viewer.
DELETE /members/me/progress-photos/:photoId→ soft delete.- R2 binary remains for the time being (no janitor wired).
Edge cases
| Scenario | Behavior |
|---|---|
Upload PUT succeeds but createForSelf never called | Staged upload eventually expires (handled by uploads module’s janitor logic) |
| Non-image MIME (e.g. PDF) | 400 — Progress photo must be an image |
| Sharp crash on a corrupt image | Exception bubbles; row not inserted; upload remains staged |
bodyweight_kg provided with a value that conflicts with a same-day weight metric | Both rows coexist; no reconciliation |
| Cursor refers to a deleted row | findFirst returns null; cursor effectively becomes “page 1” |
Member queries with from > to | Returns empty array (no validation error) |
| Two staff in the same org view the same photo simultaneously | Both get fresh presigned URLs (Redis cache hit on the second call) |
Side effects
| Action | Writes | Reads R2 | Writes R2 |
|---|---|---|---|
| Create | progress_photos insert; uploads mark consumed | Original (for sharp thumb) | Thumbnail PNG |
| List | — | — | — (presigns only) |
| Delete | progress_photos update (deleted_at) | — | — (binary remains) |
No events emitted today.
Permissions
| Action | Auth |
|---|---|
| Member self-upload | Active org member |
| Member list own | Self |
| Staff list a member’s | Staff in same org |
| Delete own | The uploader OR the membership owner — both checked |