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, a user can voluntarily post one of their own visit photos to that patch's public community board — from the photo lightbox on a patch's "My Visit" tab or on an Album place page (built 2026-10-11, shipped in v1.1.106; it replaces publishing to the patch's public gallery, PatchPhoto). That is opt-in and per photo, and does not touch the private album photo — it uploads a fresh, re-encoded copy whose file carries no metadata, but the photo's location goes with the post and is public.

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:876-895) with no revokedAt and an unexpired expiresAt. Access is granted by an admin from the Cloud Album admin panel (source: 'admin_grant'). There is no referral path to album access any more: the referral ladder's album grant was removed 2026-10-25 (ReferralQualificationService writes no AlbumAccess; see referral-and-invite.md), and source: 'referral' rows written earlier are left as they were.

Since 2026-10-25 the album gate is an entitlement resolver (the pure resolveEntitlements function in the backend's entitlement rules) built for a paid tier that is not live yet. AlbumAccessService.hasAccess (backend/src/album/album-access.service.ts:25-27) returns EntitlementsService.forUser(...).album, always ANDed with cloud_album. Today it grants album access only through admin grants, exactly as before; no other path to access exists in the product yet.

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 resolves each asset through lookupVisitPhotoAsset (mobile/src/hooks/visitPhotoLookup.ts): first the platform asset reference (nextAssetRef, a ph:// or content:// URI, confirmed by MediaAsset.getShape()), and only if that fails expo-media-library's getAssetInfoAsync as a fallback; a ref is pruned only when both agree it is gone. No network call is made to show these photos. A photo removed from the camera roll is pruned from the album automatically (purgeVisitPhoto).

The Places redesign (flag album_places_redesign)

All of it reads the same buildAlbum / groupAlbum data as today's album (mobile/src/domain/album.ts); only the drawing changes. Pure view logic is in mobile/src/domain/albumPlacesView.ts (groupCover, rowAccent, placesSubtitle, coverKicker, sectionPreview) and mobile/src/domain/albumMap.ts.

Cloud backup pipeline (access granted)

  1. syncCloudAlbum() (mobile/src/services/album/albumSyncRunner.ts:177-238) 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 GET /api/album/access says album and backup — runs one pass of runAlbumSync. Access alone does not upload: an account with the album whose backup is off queues nothing, and the server answers 409 ALBUM_BACKUP_OFF to an upload intent. The device keeps both answers (cloudAlbumHasAccess from album, cloudAlbumBackupOn from backup; it never reads the legacy hasAccess) and gates uploads on selectAlbumUploading (mobile/src/domain/store.ts), not on access. With the album but backup off, no pass runs, but photos deleted in the lightbox are still sent to the server for removal (albumSyncRunner.ts:203-210).
    • Backup turned off during a pass stops the pass: the device re-checks before every batch, and a 409 ALBUM_BACKUP_OFF from the server ends it at once, with no more photos read or encoded, no photo marked failed and no error shown (mobile/src/services/album/albumSync.ts:347-349).
  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

Posting a visit photo to a patch's board (My Visit tab, Album place page)

Built 2026-10-11, shipped in v1.1.106. This replaced publishing to the patch's public gallery (below), and is a separate feature from the private album/cloud-backup pipeline, sharing only the source photo. Full mechanics live in community-and-moderation; what matters for the album:

The retired public gallery (PatchPhoto) — store builds before 1.1.106 only

In store builds before 1.1.106, the My Visit tab's lightbox offers "Add to gallery", which copies a visit photo into the patch's public PatchPhoto gallery via POST /patches/:patchId/photos (backend/src/patch-photos/patch-photos.controller.ts:32-59, re-encoded by the same EXIF-stripping uploadResized, with no location sent), dedupes by sourceHash on (patchId, userId, sourceHash), and unpublishes with DELETE /photos/:photoId (backend/src/patch-photos/patch-photos.service.ts:159-179). The new build deletes that client path (useGalleryPublish, publishedPhotos) and no longer shows the gallery on the new patch screen; the rows, their S3 objects and the endpoints stay, for older builds and the previous patch screen (flag new_patch_detail_screen off).

Data model

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

AlbumShare (backend/prisma/schema.prisma:1034-1062, table album_shares):

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

PatchPhoto (backend/prisma/schema.prisma:531, table patch_photos, out of primary scope; the target of the retired publish flow above, still written by store builds before 1.1.106) — 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:5-15). Returns { hasAccess, album, backup, source } from EntitlementsService.stateFor (album-access.controller.ts:55-65): album is whether the account has the cloud album; backup is whether this account's photos may be uploaded right now (the album plus the account's own backup choice); hasAccess is whether this device may back up now (album and backup together), the one field older app builds read, so a build that predates the backup choice never uploads for an account that has not chosen. source can now carry values other than the grant sources. Fixed 2026-10-25: until then it read album_access directly and ignored cloud_album — it told a granted account "you have the album" (and the app started syncing) while the flag was off and every write the guard re-checked was refused. The service's own comment claimed the endpoint honoured the flag; it did not.
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. With backup off it instead deletes the caller's uploaded objects that no ready row points at (uploads that finished after backup was turned off) and answers 409 ALBUM_BACKUP_OFF (backend/src/album/album-sync.service.ts:266-276).
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.
PUT /api/album/backup Jwt + Throttle 30/hr per account Switch cloud backup on or off for the account ({ enabled }). While it is off, POST /api/album/photos/intent and commit answer 409 ALBUM_BACKUP_OFF.
GET /api/album/copies Jwt + Throttle 240/hr per account (no AlbumAccess) { count }: how many backed-up photos the caller holds (ready, one per image). Readable without the album so the app can offer "Remove backed-up copies" to anyone who still has copies.
DELETE /api/album/photos Jwt + Throttle 30/hr per account (no AlbumAccess) Remove all of the caller's backed-up copies: every object the rows name AND everything stored under the account's own storage prefix, so an upload that landed after its row was gone is removed too (AlbumDataService.deleteAll, backend/src/album/album-data.service.ts:87-101). Refused with 409 ALBUM_BACKUP_ON while backup is on (backend/src/album/album-access.controller.ts:115-127).
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 /post-images then POST /patches/:patchId/posts Jwt (community module) Post visit photos to the patch's board (since v1.1.106): one upload per photo, then one post with images, imageCoords (public) and imageSourceHashes (author-only). Distinct guard stack from the album.
POST /patches/:patchId/photos Jwt (separate module) Publish a PatchPhoto — the retired public-gallery path, still called by store builds before 1.1.106 and the previous patch screen.
GET /patches/:patchId/photos Optional Jwt Public gallery list (same — not read by the new patch screen).
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