The profile and settings system provides user dashboard stats, photo management, account settings, and privacy controls. The frontend is a single /profile page with tab navigation (Dashboard, Photos, Settings).
Auth guard: auth:sanctum (supports both session cookies from SPA and API tokens from mobile).
| Path | Component | Behavior |
|---|---|---|
/profile |
Profile.vue |
Tab container with ?tab= query param (dashboard, photos, settings) |
/profile/:id |
PublicProfile.vue |
Public profile view (no auth required). Shows stats if public_profile=true, "private" empty state otherwise. |
/settings |
— | Redirects to /profile?tab=settings |
| Component | Purpose |
|---|---|
views/Profile/Profile.vue |
Tab container, lazy-loads FETCH_PROFILE() only for dashboard/photos tabs. Settings tab renders immediately from userStore.user (populated by lightweight /refresh on app load) |
views/Profile/components/ProfileDashboard.vue |
Level card, stats grid, rank, achievements, locations, global stats |
views/Profile/components/ProfilePhotos.vue |
Upload count, links to /uploads, /upload, /tag |
views/Profile/components/ProfileSettings.vue |
Account fields, preference toggles, privacy toggles, account deletion. Reads entirely from userStore.user (no profileStore dependency) |
views/Profile/components/SettingsField.vue |
Reusable inline-editable text field |
views/Profile/components/SettingsToggle.vue |
Reusable toggle switch |
views/Profile/PublicProfile.vue |
Public profile page (level, stats, rank, achievements, locations) |
| Store | Actions |
|---|---|
stores/profile.js |
FETCH_PROFILE() — fetches /api/user/profile/index |
stores/settings.js |
UPDATE_SETTING(key, value), TOGGLE_PRIVACY(endpoint), DELETE_ACCOUNT(password) |
- Admin links (
Admin - Queue,Admin - Redis) gated byisAdmincomputed (checksuserStore.admin+user.rolesarray) /settingslink removed (accessed via Profile page)- Profile link points to
/profile
GET /api/user/profile/{id} ProfileController@show
Returns { public: true, user, stats, level, rank, achievements, locations } if public_profile=true. Returns { public: false } if private. Respects show_name/show_username privacy settings. Returns 404 for nonexistent users.
GET /api/user/profile/index ProfileController@index (full profile — stats, rank, streak, locations, percentages; no achievements/global_stats)
GET /api/user/profile/refresh ProfileController@refresh (lightweight — user fields, XP, level only; used by REFRESH_USER on app load)
GET /api/user/profile/map ProfileController@geojson
GET /api/user/profile/download ProfileController@download
GET /api/user/profile/photos/index UserPhotoController@index
GET /api/user/profile/photos/filter UserPhotoController@filter
GET /api/user/profile/photos/previous-custom-tags UserPhotoController@previousCustomTags
POST /api/user/profile/photos/tags/bulkTag UserPhotoController@bulkTag
POST /api/user/profile/photos/delete UserPhotoController@destroy
POST /api/profile/photos/remaining/{id} PhotosController@remaining
POST /api/profile/photos/delete PhotosController@deleteImage
POST /api/profile/upload-profile-photo UsersController@uploadProfilePhoto (disabled — 501)
POST /api/settings/details UsersController@details
PATCH /api/settings/details/password UsersController@changePassword
POST /api/settings/privacy/update UsersController@togglePrivacy
POST /api/settings/phone/submit UsersController@phone
POST /api/settings/phone/remove UsersController@removePhone
POST /api/settings/toggle UsersController@togglePresence
POST /api/settings/email/toggle EmailSubController@toggleEmailSub
GET /api/settings/flags/countries SettingsController@getCountries
POST /api/settings/save-flag SettingsController@saveFlag
PATCH /api/settings SettingsController@update (social links)
POST /api/settings/privacy/maps/name ApiSettingsController@mapsName
POST /api/settings/privacy/maps/username ApiSettingsController@mapsUsername
POST /api/settings/privacy/leaderboard/name ApiSettingsController@leaderboardName (also syncs team_user pivot)
POST /api/settings/privacy/leaderboard/username ApiSettingsController@leaderboardUsername (also syncs team_user pivot)
POST /api/settings/privacy/createdby/name ApiSettingsController@createdByName
POST /api/settings/privacy/createdby/username ApiSettingsController@createdByUsername
POST /api/settings/privacy/toggle-previous-tags ApiSettingsController@togglePreviousTags
POST /api/settings/update ApiSettingsController@update
Whitelist-validated key/value endpoint. Allowed keys: name, username, email, global_flag, picked_up, previous_tags, emailsub, public_profile. Unique checks on email and username. Legacy mobile: items_remaining key remaps to picked_up (inverted boolean).
POST /api/settings/delete-account DeleteAccountController
Password-confirmed. Cleans up: AdminVerificationLog, cleanups, location ownership, roles, OAuth tokens, payments (reassigned), subscriptions, team_user, teams. Redis cleanup: user stats hash, tags hash, bitmap, XP/contributor ZSETs across all location scopes. Photos preserved.
{
"user": { "id", "name", "username", "avatar", "created_at", "global_flag", "public_profile" },
"stats": { "uploads", "litter", "xp", "streak" },
"level": { "level", "title", "xp", "xp_into_level", "xp_for_next", "xp_remaining", "progress_percent" },
"rank": { "global_position", "global_total", "percentile" },
"global_stats": { "total_photos", "total_tags" },
"achievements": { "unlocked", "total" },
"locations": { "countries", "states", "cities" }
}Photo visibility scope: Own-user queries (index(), geojson(), location counts) include ALL of the user's photos, including private ones (is_public = false). The public profile show() endpoint only counts and exposes public photos.
| Field | Source |
|---|---|
stats |
ResolvesUserProfile trait — resolveUserStatsLight() (metrics table + Redis HGETALL, no streak). SPA index() adds streak via resolveUserStats(). |
level |
LevelService::getUserLevel($xp) — pure PHP, zero queries |
rank |
getGlobalRank() — Redis ZREVRANK on {g}:lb:xp, fallback to users.xp count |
locations |
SPA only — cached 5 min, Photo::where(user_id) distinct country/state/city counts (keyed by photo count for auto-invalidation) |
achievements |
Removed — uncached DB query, frontend shows "Coming Soon" placeholder |
global_stats |
Removed — unused by frontend |
Performance: Mobile auth response ~100ms (lean). SPA profile/index ~100ms warm, ~690ms cold cache (location COUNT DISTINCT is the cold-cache bottleneck at ~550ms).
Config-driven 12-level threshold system in config/levels.php.
- Thresholds: Flat XP values (0, 100, 500, 1000, 5000, ... 1,000,000)
- Service:
LevelService::getUserLevel(int $xp)returns level info array - Titles: From "Complete Noob" (level 1) to "SuperIntelligent LitterMaster" (level 12)
| Column | Type | Default | Purpose |
|---|---|---|---|
name |
varchar(255) | — | Display name |
username |
varchar(255) | — | Unique handle |
email |
varchar(255) | — | Login credential |
avatar |
varchar(255) | default.jpg |
Profile image |
public_profile |
tinyint | 0 | Allow others to see profile |
| Column | Type | Default | Purpose |
|---|---|---|---|
show_name |
tinyint | 0 | Show name on leaderboards |
show_username |
tinyint | 0 | Show username on leaderboards |
show_name_maps |
tinyint | 0 | Show name on maps |
show_username_maps |
tinyint | 0 | Show username on maps |
show_name_createdby |
tinyint | 0 | Show name in location "Created By" |
show_username_createdby |
tinyint | 0 | Show username in location "Created By" |
prevent_others_tagging_my_photos |
tinyint | 0 | Opt out of admin tagging |
| Column | Type | Default | Purpose |
|---|---|---|---|
picked_up |
tinyint | 1 | Default "picked up" state (true = litter was picked up) |
public_photos |
boolean | true |
Default visibility for new photo uploads |
previous_tags |
int | 0 | Show previous tags when tagging |
emailsub |
int unsigned | 1 | Email subscription |
global_flag |
varchar(255) | null | Country flag for leaderboard |
active_team |
int unsigned | null | FK to active team |
settings |
json | null | Social links JSON bag |
social_twitter, social_facebook, social_instagram, social_linkedin, social_reddit, social_personal
Accessed via $user->setting('key') and $user->settings(['key' => 'value']). Exposed as social_links appended attribute.
| File | Tests | Covers |
|---|---|---|
tests/Feature/User/ProfileIndexTest.php |
4 | Response structure, auth required, location counts, rank total = full user count |
tests/Feature/User/PublicProfileTest.php |
4 | Public profile data, private returns public: false, privacy settings respected, 404 for nonexistent |
tests/Feature/User/ProfileGeojsonTest.php |
1 | Only verified >= ADMIN_APPROVED photos returned |
tests/Feature/User/SettingsProfileTest.php |
10 | Mass assignment blocked, allowed updates, key remapping, public_profile, old routes 404, validation, duplicate email |
tests/Feature/User/DeleteAccountTest.php |
4 | Redis cleanup (keys + rankings), location-scoped cleanup, photo preservation, wrong password rejection |
tests/Unit/Services/LevelServiceTest.php |
7 | Level 1-3 boundaries, partial progress, high XP, max level cap at 12, all keys present |
| # | Bug | Fix |
|---|---|---|
| 1 | Mass assignment vulnerability in ApiSettingsController@update |
Whitelist of 8 allowed keys with per-key validation |
| 2 | updateSecurity wrote to non-existent first_name/user_name columns |
Method and route removed |
| 3 | Old destroy had no cleanup |
Route removed (use DeleteAccountController instead) |
| 4 | Profile photo upload broken | Disabled with 501 |
| 5 | removePhone set '' instead of null |
Fixed to null |
| 6 | No Redis cleanup on account deletion | Added cleanupRedis() method |
| 7 | geojson() used verified = 2 |
Changed to >= ADMIN_APPROVED->value |
| 8 | UserPhotoController@index queried verification = 0 |
Changed to verified = UNVERIFIED->value |
| 9 | No /profile or /settings Vue routes |
Added routes, built Profile.vue |
| 10 | Admin links shown to all users | Gated by isAdmin role check |
| 11 | Profile routes used auth:api (Passport only) |
Changed to auth:sanctum (session + token) |
| # | Bug | Fix |
|---|---|---|
| 1 | Uploads page double-fetched on mount | Removed onMounted from Uploads.vue (UploadsHeader already handles initial fetch) |
| 2 | UploadsPagination lost filters on page change | Fixed fetchPhotosOnly(page) — no longer passes perPage as filters arg |
| 3 | No empty state on Uploads page | Added "You haven't uploaded any photos yet" message with Upload link |
| 4 | Login modal caused full page reload | Changed <a href="/signup"> to <router-link> with modal close |
| 5 | Tag submission failures were silent | Added toast.error() in AddTags.vue catch block |
| 6 | XP calculation ignored enum multipliers | Rewrote AddTagsToPhotoAction::calculateXp() to use XpScore enum |