Skip to content

feat: profile photo upload - #117

Merged
renv39 merged 3 commits into
mainfrom
Ren/profile-photo-upload
Jul 27, 2026
Merged

feat: profile photo upload#117
renv39 merged 3 commits into
mainfrom
Ren/profile-photo-upload

Conversation

@renv39

@renv39 renv39 commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Wires up the Change Photo button on /profile, which was a stub with no onClick and no file input, and shows the resulting photo in the navbar avatar.

There was nowhere to store a photo — no column on user, nothing in the profile DTO — so this spans backend and frontend.

Behaviour

  • Change Photo opens a file picker; the image is compressed, uploaded to R2 under a new avatars/ prefix, and persisted immediately.
  • Remove appears only when a photo is set and restores the initials.
  • The navbar account-menu avatar shows the photo and updates live, without a reload, on any page.
  • Failures roll back to the previous photo and report on their own error line.

Notable decisions

The photo saves on selection, not with the Save button. handleSave already chains two requests behind a single pass/fail message; folding a third failure point in would make a failed upload indistinguishable from a failed name save. A preview that waits for Save also leaves the avatar showing something the database does not have.

The stored value is an object key; the API returns a resolved URL. This matches how items and claims expose images, and makes toUserProfileDto async. The validator therefore accepts only an avatars/<uuid>.<ext> key and rejects absolute URLs — a presigned GET expires within the hour, so a client echoing a fetched value back must fail loudly rather than persist a URL that rots.

The photo lives in a small external store rather than component state. Threading it as a prop would touch every page rendering the navbar and still not update live. clearAuthSession resets it so a photo cannot survive into the next account on the same browser.

purpose on the presigned-URL route is optional and defaults to report, so every existing caller is unchanged.

Migration

20260725120000_add_user_profile_photo — one nullable VARCHAR(500), no data rewritten. Already applied to the shared Neon dev database via migrate deploy. Existing code never selects the column, so nothing else is affected.

Verification

Backend 173 tests, frontend 85; typecheck, lint and build clean in both packages. Each of the three commits typechecks standalone.

Driven in headless Chromium against the real API and real R2: upload → avatar renders and decodes → persists across reload → navbar updates without a reload → visible on other pages → Remove reverts everything. Both validator guards confirmed live (400 on an echoed URL, 400 on a reports/ key).

Two bugs surfaced while writing the tests and are fixed here: 'constructor' in KEY_PREFIX_BY_PURPOSE returned true via the prototype chain, and the module-level store was silently satisfying later test assertions.

Known gap

Replacing a photo orphans the previous R2 object — nothing deletes it. The existing report-photo flow has the same gap, so it is left as-is rather than widening scope here. Worth a follow-up ticket.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added profile photo upload, preview, removal, and validation in Profile Settings.
    • Profile photos now appear in the navigation avatar and persist across sessions.
    • Added support for avatar-specific image uploads alongside report uploads.
    • User profile responses now include the resolved profile photo URL.
  • Documentation

    • Updated audit event documentation for profile photo changes and upload purposes.
  • Tests

    • Added coverage for photo uploads, removal, validation, persistence failures, and avatar display.

renv39 added 3 commits July 25, 2026 03:44
Adds the persistence the profile page needs to change a user's photo:

- `profile_photo_url` on `user`, nullable, holding the R2 object key.
- `PATCH /api/users/me/photo` sets or clears it. No requireRole — every
  role has a profile photo, unlike the student-only notification route.
- `POST /api/uploads/presigned-url` takes an optional
  `purpose: 'report' | 'avatar'` selecting the key prefix, so avatars can
  be given their own lifecycle rules later. Omitting it keeps the
  existing `reports/` behaviour, so current callers are unaffected.

The profile DTO returns a *resolved* URL (public or presigned GET), like
items and claims do, which makes `toUserProfileDto` async. The validator
therefore accepts only an `avatars/<uuid>.<ext>` key and rejects absolute
URLs: a presigned GET expires within the hour, so a client echoing a
fetched value back must fail loudly rather than persist a URL that rots.

The audit entry records only whether the photo was set or cleared — the
object key and URL are both prohibited audit detail values.
The button was a stub — no onClick, no file input. It now opens a file
picker, uploads the image to R2 under the avatar prefix, and persists the
key. A Remove button appears only when a photo is set and restores the
initials fallback.

The photo saves on selection rather than with the Save button. handleSave
already chains two requests behind a single pass/fail message, so folding
a third failure point into it would make a failed upload indistinguishable
from a failed name save; and a preview that waits for Save leaves the
avatar showing something the database does not have. Upload failures roll
back to the previous photo and report on their own error line.

The photo lives in a small external store rather than local state so the
navbar avatar can track it, including the optimistic preview and rollback.
Threading it as a prop would touch every page that renders the navbar and
still not update live.
The account menu always rendered the mdiAccountCircle icon. It now shows
the user's photo when one is set, reading it from the shared store so it
updates the moment the profile page changes it — no reload, and no prop
threaded through the pages that render the navbar.

clearAuthSession resets the store, so a photo cannot survive into the
next account signed in on the same browser.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds profile photo storage and validation, purpose-aware avatar uploads, resolved profile-photo responses, shared client photo state, profile settings controls, and navigation avatar rendering with integration tests.

Changes

Profile photo lifecycle

Layer / File(s) Summary
Avatar storage and upload purposes
backend/prisma/..., backend/src/routes/uploads.ts, backend/src/utils/auditEvents.ts, backend/tests/uploads.integration.test.ts
Adds the nullable profile photo column, supports report and avatar upload purposes, routes avatar keys under avatars/, and records the resolved purpose in audit details.
Profile photo persistence and resolved responses
backend/src/routes/users.ts, backend/src/validators/users.ts, backend/tests/users.integration.test.ts
Validates avatar object keys, persists or clears profile photos, resolves stored keys to image URLs, and tests authentication, authorization, validation, and audit behavior.
Client photo state and persistence flow
foundit-ui/types/users.ts, foundit-ui/utils/profilePhotoStore.ts, foundit-ui/hooks/*, foundit-ui/lib/api/users.ts, foundit-ui/utils/handleImageUpload.ts, foundit-ui/utils/auth.ts, foundit-ui/tests/hooks/*
Adds shared profile-photo state, session loading, avatar upload/removal handlers, API persistence, upload-purpose typing, rollback behavior, and session cleanup.
Profile and navigation avatar rendering
foundit-ui/app/profile/page.tsx, foundit-ui/components/Navbar.tsx, foundit-ui/tests/components/Navbar.test.tsx
Adds photo selection, upload status, removal, fallback initials, and profile-photo rendering in the navigation dropdown.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  actor User
  participant ProfilePage
  participant useProfileForm
  participant UploadsAPI
  participant UsersAPI
  participant ProfilePhotoStore
  User->>ProfilePage: select profile photo
  ProfilePage->>useProfileForm: handlePhotoSelected(file)
  useProfileForm->>UploadsAPI: request avatar presigned upload
  useProfileForm->>UploadsAPI: upload image
  useProfileForm->>UsersAPI: persist avatar object key
  UsersAPI-->>useProfileForm: resolved profilePhotoUrl
  useProfileForm->>ProfilePhotoStore: update shared photo
  ProfilePhotoStore-->>ProfilePage: render uploaded photo
Loading

Possibly related PRs

  • 86unj/Foundit#72: Earlier profile settings and form/API connections used by this photo-management flow.
  • 86unj/Foundit#75: Earlier /api/users/me profile implementation extended here with resolved photo URLs.
  • 86unj/Foundit#114: Audit-event registry changes extended here for profile-photo and upload-purpose details.

Suggested reviewers: 86unj, hnam10, humbeatbox

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main change: adding profile photo upload support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Ren/profile-photo-upload

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
backend/src/routes/users.ts (1)

425-477: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Delete the old avatar object on photo update/clear.

loadActiveUserProfile() fetches the previous profilePhotoUrl, but PATCH /me/photo only updates the key and never removes the prior R2 object. Add a best-effort DeleteObjectCommand for existing.profile_photo_url when it is present and different from profilePhotoUrl after a successful transaction, without making request failures depend on the delete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/users.ts` around lines 425 - 477, After the successful
transaction in the PATCH /me/photo handler, compare existing.profile_photo_url
with the new profilePhotoUrl and issue a best-effort DeleteObjectCommand for the
old object when it is present and changed, including when the new value is null.
Perform deletion after the database update and swallow/log deletion failures so
they do not affect the response; reuse the existing R2/S3 client and object-key
extraction utilities.
foundit-ui/hooks/useProfileForm.ts (1)

52-92: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Duplicate /api/users/me fetch on profile-page mount.

useProfilePhoto() (line 58, enabled defaults true) fires its own fetch to /api/users/me on mount if the photo hasn't loaded yet, while loadProfile() (lines 79-92) independently fetches the same endpoint and already calls setProfilePhoto(data.profilePhotoUrl ?? null). On a fresh session, the profile page issues two concurrent requests for the same data.

Since this hook already seeds the shared store itself, disable the redundant fetch:

⚡ Proposed fix
-  const photoUrl = useProfilePhoto();
+  // loadProfile() below already fetches the full profile (including the
+  // photo) and seeds the store, so skip the hook's own fetch here.
+  const photoUrl = useProfilePhoto(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@foundit-ui/hooks/useProfileForm.ts` around lines 52 - 92, Disable the initial
fetch performed by useProfilePhoto in useProfileForm, while retaining the
existing loadProfile request and its setProfilePhoto(data.profilePhotoUrl ??
null) store update. Pass the hook’s enabled option as false so the profile page
makes only the single /api/users/me request.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@foundit-ui/app/profile/page.tsx`:
- Around line 241-262: Update handlePhotoRemove to set photoStatus to 'removing'
instead of 'uploading'. In the profile photo buttons, keep the Change Photo
loading state and “Uploading...” label limited to photoStatus === 'uploading',
and disable both buttons whenever photoStatus !== 'idle'.
- Around line 197-206: Update useProfilePhoto/profilePhotoStore to proactively
refresh profilePhotoUrl periodically while it is set, especially after the
server is ready, so signed URLs do not expire during a session. In
foundit-ui/app/profile/page.tsx (lines 197-206) and
foundit-ui/components/Navbar.tsx (lines 143-153), add onError handling to
replace failed image loads with the existing initials/icon placeholder.

---

Outside diff comments:
In `@backend/src/routes/users.ts`:
- Around line 425-477: After the successful transaction in the PATCH /me/photo
handler, compare existing.profile_photo_url with the new profilePhotoUrl and
issue a best-effort DeleteObjectCommand for the old object when it is present
and changed, including when the new value is null. Perform deletion after the
database update and swallow/log deletion failures so they do not affect the
response; reuse the existing R2/S3 client and object-key extraction utilities.

In `@foundit-ui/hooks/useProfileForm.ts`:
- Around line 52-92: Disable the initial fetch performed by useProfilePhoto in
useProfileForm, while retaining the existing loadProfile request and its
setProfilePhoto(data.profilePhotoUrl ?? null) store update. Pass the hook’s
enabled option as false so the profile page makes only the single /api/users/me
request.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c0b96c9a-860f-41d5-b45f-f50d78cbb077

📥 Commits

Reviewing files that changed from the base of the PR and between f400142 and 9f7ac8b.

📒 Files selected for processing (20)
  • backend/docs/audit-events.md
  • backend/prisma/migrations/20260725120000_add_user_profile_photo/migration.sql
  • backend/prisma/schema.prisma
  • backend/src/routes/uploads.ts
  • backend/src/routes/users.ts
  • backend/src/utils/auditEvents.ts
  • backend/src/validators/users.ts
  • backend/tests/uploads.integration.test.ts
  • backend/tests/users.integration.test.ts
  • foundit-ui/app/profile/page.tsx
  • foundit-ui/components/Navbar.tsx
  • foundit-ui/hooks/useProfileForm.ts
  • foundit-ui/hooks/useProfilePhoto.ts
  • foundit-ui/lib/api/users.ts
  • foundit-ui/tests/components/Navbar.test.tsx
  • foundit-ui/tests/hooks/useProfileForm.test.ts
  • foundit-ui/types/users.ts
  • foundit-ui/utils/auth.ts
  • foundit-ui/utils/handleImageUpload.ts
  • foundit-ui/utils/profilePhotoStore.ts

Comment on lines +197 to +206
{photoUrl ? (
<Image
src={photoUrl}
alt=""
w="80px"
h="80px"
rounded="full"
objectFit="cover"
flexShrink={0}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching relevant names:\n'
git ls-files | rg '(^|/)(page\.tsx|Navbar\.tsx|users\.ts|routes\.ts|profilePhotoStore|useProfilePhoto)' || true

printf '\nProfile page Image/photoUrl usages:\n'
rg -n "photoUrl|setProfilePhoto|ProfilePhoto|onError|initials|avatar|Image" foundit-ui/app/profile foundit-ui/components/Navbar.tsx foundit-ui -g '*.tsx' -g '*.ts' || true

printf '\nRelevant snippets:\n'
for f in foundit-ui/app/profile/page.tsx foundit-ui/components/Navbar.tsx; do
  echo "--- $f ---"
  wc -l "$f"
  ast-grep outline "$f" --view compact || true
done

echo "--- users doc/snippet ---"
f=$(fd -a 'users\.ts$' . | grep 'foundit-ui/lib/api/users.ts' | head -n1 || true)
if [ -n "${f:-}" ]; then
  wc -l "$f"
  sed -n '1,180p' "$f"
else
  fd 'users\.ts$' .
fi

Repository: 86unj/Foundit

Length of output: 30690


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- backend users routes profile photo references ---'
rg -n "profilePhoto|avatars|profile photo|profile photo|photo" backend/src/routes users.ts backend/src/routes/users.ts backend/src/routes/admin/users.ts || true

echo '--- full users.ts ---'
sed -n '1,120p' foundit-ui/lib/api/users

echo '--- profile page relevant sections ---'
sed -n '160,240p' foundit-ui/app/profile/page.tsx

echo '--- navbar relevant sections ---'
sed -n '110,160p' foundit-ui/components/Navbar.tsx

echo '--- profile photo store/hook ---'
sed -n '1,90p' foundit-ui/utils/profilePhotoStore.ts
sed -n '1,75p' foundit-ui/hooks/useProfilePhoto.ts

echo '--- auth clear/clearProfilePhoto references ---'
sed -n '1,50p' foundit-ui/utils/auth.ts

echo '--- tests around fallback/loading/expiration relevant assertions ---'
rg -n "expired|expires|onError|clearProfilePhoto|photoUrl|avatar|initials|account" foundit-ui/tests foundit-ui -g '*.tsx' -g '*.ts' || true

Repository: 86unj/Foundit

Length of output: 6731


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- frontend files ---'
sed -n '1,120p' foundit-ui/lib/api/users.ts
sed -n '160,240p' foundit-ui/app/profile/page.tsx
sed -n '110,160p' foundit-ui/components/Navbar.tsx
sed -n '1,90p' foundit-ui/utils/profilePhotoStore.ts
sed -n '1,75p' foundit-ui/hooks/useProfilePhoto.ts
sed -n '1,50p' foundit-ui/utils/auth.ts

echo '--- backend users/uploads relevant snippets ---'
sed -n '1,80p' backend/src/routes/users.ts
sed -n '360,470p' backend/src/routes/users.ts
sed -n '1,120p' backend/src/routes/uploads.ts
rg -n "EXPIRES|expires|Expires|PRESIGN|Presigned|duration|ttl|ttlSeconds|profilePhotoUrl|avatar" backend/src -g '*.ts' || true

echo '--- frontend tests/profile photo relevant ---'
sed -n '60,105p' foundit-ui/tests/components/Navbar.test.tsx
sed -n '1,180p' foundit-ui/tests/hooks/useProfileForm.test.ts

Repository: 86unj/Foundit

Length of output: 32754


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- backend imageUrl resolver ---'
sed -n '1,80p' backend/src/utils/imageUrl.ts

echo '--- imports/constants that determine signed/base URL behavior ---'
rg -n "R2_PUBLIC|PUBLIC_BASE|AWS|S3_PUBLIC|baseUrl|publicBase|bucket|resolveImageUrl|getSignedUrl|expiresIn" backend/src -g '*.ts' || true

Repository: 86unj/Foundit

Length of output: 4496


Refresh expired profile photo URLs and add fallback UI coverage.

When R2_PUBLIC_BASE_URL is unset, profilePhotoUrl is a signed R2 GET URL with a 1-hour lifetime, but foundit-ui loads and caches it once per session in useProfilePhoto/profilePhotoStore. After the server is ready, refresh the URL periodically/proactively while profilePhotoUrl is set, and add an onError fallback to both foundit-ui/app/profile/page.tsx and foundit-ui/components/Navbar.tsx so failed photo loads fall back to the initials/icon placeholder.

📍 Affects 2 files
  • foundit-ui/app/profile/page.tsx#L197-L206 (this comment)
  • foundit-ui/components/Navbar.tsx#L143-L153
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@foundit-ui/app/profile/page.tsx` around lines 197 - 206, Update
useProfilePhoto/profilePhotoStore to proactively refresh profilePhotoUrl
periodically while it is set, especially after the server is ready, so signed
URLs do not expire during a session. In foundit-ui/app/profile/page.tsx (lines
197-206) and foundit-ui/components/Navbar.tsx (lines 143-153), add onError
handling to replace failed image loads with the existing initials/icon
placeholder.

Comment on lines +241 to +262
<Button
variant="outline"
size="sm"
borderColor="gray.300"
loading={photoStatus === 'uploading'}
loadingText="Uploading..."
onClick={() => photoInputRef.current?.click()}
>
Change Photo
</Button>

{photoUrl && (
<Button
variant="ghost"
size="sm"
color="red.600"
disabled={photoStatus === 'uploading'}
onClick={handlePhotoRemove}
>
Remove
</Button>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Change Photo" shows "Uploading..." while removing a photo.

Both handlePhotoSelected and handlePhotoRemove set the shared photoStatus to 'uploading', so clicking "Remove" also puts the "Change Photo" button into its loading state with the label "Uploading...", which is misleading since nothing is being uploaded.

🔧 Proposed fix (distinguish busy reasons)
-  const [photoStatus, setPhotoStatus] = useState<'idle' | 'uploading'>('idle');
+  const [photoStatus, setPhotoStatus] = useState<
+    'idle' | 'uploading' | 'removing'
+  >('idle');

Then set 'removing' in handlePhotoRemove and gate the "Change Photo" button's loading/loadingText on photoStatus === 'uploading' only, while using photoStatus !== 'idle' for disabling both buttons.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@foundit-ui/app/profile/page.tsx` around lines 241 - 262, Update
handlePhotoRemove to set photoStatus to 'removing' instead of 'uploading'. In
the profile photo buttons, keep the Change Photo loading state and
“Uploading...” label limited to photoStatus === 'uploading', and disable both
buttons whenever photoStatus !== 'idle'.

@renv39
renv39 merged commit 7b9b1e8 into main Jul 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant