Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesProgress PhotosProgress Photos — Behavior

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

InvariantEnforcement
Uploaded MIME must start with image/createForSelf checks meta.mimeType.startsWith('image/')
Each photo belongs to exactly one membershipFK + service binds membershipId = membership.id
Members can only see their own; staff can see members in their orgrequireAccess in service
Members can only delete photos they uploaded OR that are pinned to their membershipdeleteOwn checks both uploadedBy and membershipId
Photo binaries are never publicly addressableAll URLs are 1h presigned
bodyweight_kg (optional inline measurement) does NOT insert a body_metrics rowIntentional — body-metrics is the canonical record; this field is a convenience snapshot for the photo

Golden paths

Member uploads a photo (mobile)

  1. Member selects photo from camera/library. Client requests POST /uploads/presign{ uploadId, putUrl, r2Key }.
  2. Client PUTs the binary to putUrl.
  3. Client calls POST /organizations/:orgId/members/me/progress-photos with { uploadId, recordedAt, notes?, bodyweightKg? }.
  4. Service: looks up staged upload, validates MIME, runs sharp to generate a thumb and probe (width, height), inserts the row, marks the upload consumed.
  5. UI fetches GET /members/me/progress-photos for refreshed timeline.
  1. Coach opens the member detail page → Photos tab.
  2. GET /members/:membershipId/progress-photos with optional ?from/?to/?cursor/?limit.
  3. Each row in the response has presigned url + thumbnailUrl (TTL 1h).

Member deletes own photo

  1. Member taps Delete in the photo viewer.
  2. DELETE /members/me/progress-photos/:photoId → soft delete.
  3. R2 binary remains for the time being (no janitor wired).

Edge cases

ScenarioBehavior
Upload PUT succeeds but createForSelf never calledStaged 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 imageException bubbles; row not inserted; upload remains staged
bodyweight_kg provided with a value that conflicts with a same-day weight metricBoth rows coexist; no reconciliation
Cursor refers to a deleted rowfindFirst returns null; cursor effectively becomes “page 1”
Member queries with from > toReturns empty array (no validation error)
Two staff in the same org view the same photo simultaneouslyBoth get fresh presigned URLs (Redis cache hit on the second call)

Side effects

ActionWritesReads R2Writes R2
Createprogress_photos insert; uploads mark consumedOriginal (for sharp thumb)Thumbnail PNG
List— (presigns only)
Deleteprogress_photos update (deleted_at)— (binary remains)

No events emitted today.

Permissions

ActionAuth
Member self-uploadActive org member
Member list ownSelf
Staff list a member’sStaff in same org
Delete ownThe uploader OR the membership owner — both checked