Scout — Full Product Context → feature documentation

Profile photos

Members can set a profile photo on the Edit profile screen. It shows next to their posts and comments, on the leaderboard, in the app drawer and on their public profile page. Every photo is checked automatically before it is accepted.

Summary

A member can set a profile photo on the Edit profile screen (scout://edit-profile, board ticket 1c035f64, 2026-09-28). The photo replaces the brass initial on these surfaces (and only these): their own Profile screen and app drawer, the bylines on their community posts and comments, the comment composer, the @-mention picker, the leaderboard (rows, podium and the pinned "You" row), the blocked-accounts list, and their public web profile page (/u/:slug) and its share card. Every upload is checked by an automated moderation model before it is accepted; if the check cannot run, the photo is refused rather than accepted unchecked.

Before this there was no photo anywhere: every in-app avatar was an initial on a brass disc, drawn by three separate BrassAvatar copies, and the public page showed a generated pattern.

Status

Shipped, unconditional, members only. No feature flag gates it. Guests cannot set a photo. It needs OPENAI_API_KEY on the server — see "Configuration and flags": without it no one can set a photo.

User-facing surfaces

How it works

  1. Pick. pickImages({ source, allowsEditing: true }) (mobile/src/lib/image-pick.ts) after the library/camera permission check; the image is compressed to JPEG on the device. It is staged in screen state.
  2. Save. accountApi.save sends one multipart PATCH /api/account/profile (photo, and handle if it changed) through the FormData-aware client (mobile/src/api/client.ts).
  3. Moderate, then store. ProfileEditService.save (backend/src/account/profile-edit.service.ts):
    • runs ImageModerationService.check (backend/src/moderation/image-moderation.service.ts, OpenAI omni-moderation-latest) on the raw bytes before anything is uploaded, so a rejected image never reaches the public bucket. Flagged → 422 "That photo can't be used. Try a different one." Cannot decide (no key, network or API error, an empty answer) → 503 "Couldn't check that photo. Try again." — fail-closed;
    • ImageUploadService.uploadResized(..., 'avatars', { maxEdge: 512, square: true }) cover-crops to a 512×512 webp (sharp attention crop) and writes a public S3 object under avatars/;
    • writes Profile.avatarUrl (and the handle) in one row update. If that write fails — e.g. the handle was taken in a race — the object this request just uploaded is deleted; after a successful write, the photo it replaced is deleted.
  4. Show. The app reads its own photo from GET /api/account/profile (useMyProfile), not the JWT, which cannot carry it. The byline DTOs behind the surfaces above carry avatarUrl beside the name (the notification, patch-photo and admin payloads do not — see "does NOT do"), from one shared lookup, profileIdentityMap (backend/src/prisma/profile-names.ts). Save writes the result into the myProfile cache and invalidates the byline queries (community boards, leaderboard, messages) so the new photo appears at once.
  5. Remove. removePhoto=true sets avatarUrl to null (the initial returns) and deletes the object.
  6. Public page. GET /u/:slug uses the photo in place of the generated avatar. The share card embeds it as a PNG data URI clipped to a circle (backend/src/public-profile/avatar-embed.ts, og-card.ts), with a 3 s fetch timeout; a slow, missing or undecodable photo falls back to the generated avatar rather than breaking the card.

Data model

API surface

All JwtAuthGuard; a guest gets 403 on writes.

Method & path Throttle Purpose
GET /api/account/profile — { id, handle, avatarUrl, email, isAnonymous }
PATCH /api/account/profile 10/min Multipart: optional photo (jpeg/png/webp/heic, ≤10 MB), handle, removePhoto=true. Errors: 400 handle-invalid, 409 handle-taken, 422 photo-rejected, 503 moderation-unavailable

Byline payloads that now carry avatarUrl: community posts and comments, message comments, leaderboard entries, blocked accounts.

Key files

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions