Scout — Full Product Context → feature documentation

The Album and Cloud Photo Backup

'The Album' is the screen where a Scout user sees every photo they've attached to a visited patch, organized by place, city, state, and campaign.

Summary

"The Album" is the screen where a Scout user sees every photo they've attached to a visited patch, organized by place, city, state, and campaign. It exists in two layers that are easy to conflate but behave very differently:

  1. The local album — always on for every user. Photos live in the device's own camera roll; Scout stores only a pointer (a MediaLibrary asset id) and shows the photo by reading it straight off the device. Nothing is uploaded.
  2. Cloud backup — an account-gated add-on. When (and only when) an account has been granted cloud-album access, matched photos are re-encoded on the device and uploaded to private S3 object storage, and the app can mint shareable links to them.

The old product brief's claim — "Photos stay on the device; what is stored is a reference, never a file" — is true only for an account without cloud-album access, and false the moment access is granted. The codebase itself documents this exact ambiguity and treats it as a compliance-sensitive fact: mobile/src/domain/cloudAlbumCopy.ts:1-13 says outright that the app's own in-app privacy copy "VARIES" and is "TRUE for every account without cloud album access and FALSE for one with it."

Separately, from the "My Visit" tab on a patch's detail screen, a user can voluntarily copy one of their own visit photos into that patch's public community gallery (PatchPhoto, a different model from the album). That is opt-in, reversible, and does not touch the private album photo or its metadata — it uploads a fresh, EXIF-stripped copy.

Status (shipped / beta-badged / flagged off)

Even when cloud_album is on, an individual account only gets upload/share access if a row exists in AlbumAccess (backend/prisma/schema.prisma:655-674) with no revokedAt and an unexpired expiresAt. Access is granted either by an admin from the Cloud Album admin panel (source: 'admin_grant') or automatically by the referral-reward system (source: 'referral', wired in backend/src/referral/referral-qualification.service.ts — referral mechanics are out of scope for this document).

User-facing surfaces

How it works (end-to-end)

Local album (no cloud access)

  1. Photos matched to a patch visit are recorded as lightweight references (VisitPhotoRef: a MediaLibrary assetId + optional takenAt) — populated by the camera-roll import/unlock pipeline, which is out of scope for this document.
  2. buildAlbum (mobile/src/domain/album.ts:114-190) joins those refs against the patch catalog, groups them into state → city → place, and is pure/offline — "ZERO MediaLibrary calls" per its own header (mobile/src/domain/album.ts:8).
  3. Display resolves each asset id to a live device URI at render time via useAlbumPhotoUris (mobile/src/hooks/useAlbumPhotoUris.ts), which calls expo-media-library's getAssetInfoAsync directly against the OS photo library. No network call is made to show these photos. A photo removed from the camera roll is pruned from the album automatically (purgeVisitPhoto).

Cloud backup pipeline (access granted)

  1. syncCloudAlbum() (mobile/src/services/album/albumSyncRunner.ts:169-204) runs on app open, on foreground, and after an import. It first mirrors every (asset, patch) pair the device knows about into a local upload queue, then — if access is confirmed live via GET /api/album/access — runs one pass of runAlbumSync.
  2. For each queued photo, the device resolves the asset (MediaLibrary.getAssetInfoAsync, with shouldDownloadFromNetwork: true so an iCloud-optimized/offloaded original is fetched first — mobile/src/services/album/albumSyncRunner.ts:63-70), reads its EXIF/GPS/timestamp, and computes a SHA-256 of the original file bytes (contentHash).
  3. The device calls POST /api/album/photos/intent (backend/src/album/album-sync.service.ts:52, batches of up to 25 photos, MAX_INTENT_BATCH). The server checks for an existing row matching either sourceAssetId (same device) or contentHash (same photo, new device/asset id after a restore); if the object is already on S3 under any patch, it reuses those keys and skips a re-upload. Otherwise it reserves a pending row and returns presigned S3 PUT URLs for a master and a thumb object.
  4. The device re-encodes the photo client-side (mobile/src/lib/albumEncode.ts): the master is resized so its long edge is 1600px and saved as WebP quality 0.82; the thumb is derived from the already-resized master (not the original) and resized again to a 400px long edge, WebP quality 0.75. Neither output file carries EXIF — WebP re-encoding via expo-image-manipulator discards it, and the app deliberately does not write it back (mobile/src/lib/albumEncode.ts:14-19).
  5. The two derivatives are PUT directly from the phone to S3 using the presigned URLs — the bytes never pass through the Scout backend (mobile/src/services/album/albumSyncRunner.ts:98 comment: "the bytes never pass through our server").
  6. The device calls POST /api/album/photos/commit. The server does an S3 HEAD on both objects to confirm they actually landed (backend/src/album/album-sync.service.ts:227-296), generates a blurhash placeholder from the thumb, and only then flips the row(s) — every row sharing that sourceAssetId, since one photo can be filed under a place, its city, and its state simultaneously — to status: 'ready'.
  7. The original EXIF dictionary, GPS coordinates, capture time, and camera make/model are sent to the server separately in the intent call and stored as structured columns/JSON on the AlbumPhoto row (exifJson, latitude, longitude, takenAt, cameraMake, cameraModel) — this is the durable, queryable metadata record. It is never embedded in the uploaded image file (mobile/src/lib/albumMetadata.ts:1-13).

Restore on a fresh login / new phone

GET /api/album/photos/library (backend/src/album/album-sync.service.ts:307-360, added in commit 3244965d, "Fix cloud album restore on fresh login (#187)") returns one signed-URL render row per distinct photo (deduped by sourceAssetId, attributed to the most specific non-city/non-state patch). useCloudAlbumPhotos (mobile/src/hooks/useCloudAlbumPhotos.ts) fetches this on the Album screen and merges it with whatever local visit-photo refs exist, so a user who reinstalls the app or logs in on a new device sees their previously backed-up photos immediately via signed S3 URLs — even before any camera-roll matching has run again on the new device. contentHash is what then lets a re-import of the same photos on the new device recognize them as already uploaded rather than re-uploading (backend/src/album/album-sync.service.ts:181-186 and the "content-hash dedupe" test block).

Sharing

Publishing a photo to a patch's public gallery (My Visit tab)

This is a separate feature from the private album/cloud-backup pipeline, sharing only the source photo:

Data model

AlbumPhoto (backend/prisma/schema.prisma:715-755, table album_photos) — one private cloud-backup row per (user, sourceAssetId, patch):

AlbumShare (backend/prisma/schema.prisma:759-783, table album_shares):

AlbumAccess (backend/prisma/schema.prisma:655-674, table album_access) — one row per user, userId is the primary key:

PatchPhoto (backend/prisma/schema.prisma:405-431, table patch_photos, out of primary scope but the target of the publish flow above) — public gallery photo: patchId, userId, url (public S3), blurhash, sourceHash (nullable, dedupe), unique on (patchId, userId, sourceHash).

API surface

All routes below require JwtAuthGuard (401 for an unauthenticated caller) unless noted. Every write route additionally stacks AlbumAccessGuard (403 ALBUM_ACCESS_REQUIRED for an authenticated account without live access) — the guard never trusts the client's own belief about its access (backend/src/album/album-sync.controller.ts:9-14).

Route Guard Purpose
GET /api/album/access Jwt only Advisory read of the caller's own access state; cached client-side in MMKV, never treated as authorization for a write (backend/src/album/album-access.controller.ts:9-13).
POST /api/album/photos/intent Jwt + AlbumAccess + Throttle 240/hr Reserve up to 25 rows, return presigned upload URLs or report duplicates.
POST /api/album/photos/commit Jwt + AlbumAccess Verify S3 HEAD, flip rows to ready, generate blurhash.
GET /api/album/photos/sync-state Jwt + AlbumAccess Cheap {count, checksum} for reconciliation.
GET /api/album/photos/library Jwt + AlbumAccess Signed display URLs for restore/fresh-install hydration.
GET /api/album/photos/manifest Jwt + AlbumAccess Every ready row's ids, for queue reconciliation and share-id backfill.
POST /api/album/photos/remove Jwt + AlbumAccess Explicit user delete of specific (asset, patch) rows.
POST /api/album/shares Jwt + AlbumAccess Create/reuse a share link.
GET /api/album/shares Jwt + AlbumAccess List the caller's own shares.
POST /api/album/shares/:id/revoke Jwt + AlbumAccess Revoke a share the caller owns.
GET /a/:token None Public share page HTML. Access-gated indirectly via AlbumAccessService.hasAccess(owner).
GET /a/:token/card.png None Public Open Graph card image (title + count only, no photo).
POST /patches/:patchId/photos Jwt (separate module) Publish a PatchPhoto — the public-gallery path, distinct guard stack from the album.
GET /patches/:patchId/photos Optional Jwt Public gallery list.
DELETE /photos/:photoId Jwt Unpublish (owner-only).

Admin-only (not app-facing): GET/POST /admin/api/album-access/:userId (grant/revoke/stats), POST /admin/api/album-access/:userId/data (permanent delete), backed by backend/src/admin/api/album-access-api.service.ts and rendered by backend/admin-ui/src/components/CloudAlbumPanel.tsx.

Key files

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Backend (backend/src/album/__tests__/ and colocated *.spec.ts):

Mobile:

Open questions