User Profile & Settings

June 7, 2026 · View on GitHub

Overview

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).


Frontend

Routes

PathComponentBehavior
/profileProfile.vueTab container with ?tab= query param (dashboard, photos, settings)
/profile/:idPublicProfile.vuePublic profile view (no auth required). Shows stats if public_profile=true, "private" empty state otherwise.
/settingsRedirects to /profile?tab=settings

Components

ComponentPurpose
views/Profile/Profile.vueTab 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.vueLevel card, stats grid, rank, achievements, locations, global stats
views/Profile/components/ProfilePhotos.vueUpload count, links to /uploads, /upload, /tag
views/Profile/components/ProfileSettings.vueAccount fields, preference toggles, privacy toggles, account deletion. Reads entirely from userStore.user (no profileStore dependency)
views/Profile/components/SettingsField.vueReusable inline-editable text field
views/Profile/components/SettingsToggle.vueReusable toggle switch
views/Profile/PublicProfile.vuePublic profile page (level, stats, rank, achievements, locations)

Pinia Stores

StoreActions
stores/profile.jsFETCH_PROFILE() — fetches /api/user/profile/index
stores/settings.jsUPDATE_SETTING(key, value), TOGGLE_PRIVACY(endpoint), DELETE_ACCOUNT(password)
  • Admin links (Admin - Queue, Admin - Redis) gated by isAdmin computed (checks userStore.admin + user.roles array)
  • /settings link removed (accessed via Profile page)
  • Profile link points to /profile

Backend API

Public Profile Endpoint (no auth)

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.

Profile Endpoints (auth:sanctum group)

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)

Settings Endpoints (auth:api group — mobile)

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)

Privacy Toggle Endpoints (auth:api — individual toggles)

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

General Setting Update (auth:api)

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).

Account Deletion (auth:api)

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.


ProfileController@index Response

{
    "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.

FieldSource
statsResolvesUserProfile trait — resolveUserStatsLight() (metrics table + Redis HGETALL, no streak). SPA index() adds streak via resolveUserStats().
levelLevelService::getUserLevel($xp) — pure PHP, zero queries
rankgetGlobalRank() — Redis ZREVRANK on {g}:lb:xp, fallback to users.xp count
locationsSPA only — cached 5 min, Photo::where(user_id) distinct country/state/city counts (keyed by photo count for auto-invalidation)
achievementsRemoved — uncached DB query, frontend shows "Coming Soon" placeholder
global_statsRemoved — 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).


Level System

Config-driven 12-level threshold system in config/levels.php.

  • Thresholds: Flat XP values (0, 100, 1000, 5000, ... 1,000,000)
  • Service: LevelService::getUserLevel(int $xp) returns level info array
  • Titles: From "Noob" (level 1) to "SuperIntelligent LitterMaster" (level 11)

Users Table — Settings/Profile Columns

Identity

ColumnTypeDefaultPurpose
namevarchar(255)Display name
usernamevarchar(255)Unique handle
emailvarchar(255)Login credential
avatarvarchar(255)default.jpgProfile image
public_profiletinyint0Allow others to see profile

Privacy (6 global toggles)

ColumnTypeDefaultPurpose
show_nametinyint0Show name on leaderboards
show_usernametinyint0Show username on leaderboards
show_name_mapstinyint0Show name on maps
show_username_mapstinyint0Show username on maps
show_name_createdbytinyint0Show name in location "Created By"
show_username_createdbytinyint0Show username in location "Created By"
prevent_others_tagging_my_photostinyint0Opt out of admin tagging

Preferences

ColumnTypeDefaultPurpose
picked_uptinyint1Default "picked up" state (true = litter was picked up)
public_photosbooleantrueDefault visibility for new photo uploads
previous_tagsint0Show previous tags when tagging
emailsubint unsigned1Email subscription
global_flagvarchar(255)nullCountry flag for leaderboard
active_teamint unsignednullFK to active team
settingsjsonnullSocial 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.


Test Coverage

FileTestsCovers
tests/Feature/User/ProfileIndexTest.php4Response structure, auth required, location counts, rank total = full user count
tests/Feature/User/PublicProfileTest.php4Public profile data, private returns public: false, privacy settings respected, 404 for nonexistent
tests/Feature/User/ProfileGeojsonTest.php1Only verified >= ADMIN_APPROVED photos returned
tests/Feature/User/SettingsProfileTest.php10Mass assignment blocked, allowed updates, key remapping, public_profile, old routes 404, validation, duplicate email
tests/Feature/User/DeleteAccountTest.php4Redis cleanup (keys + rankings), location-scoped cleanup, photo preservation, wrong password rejection
tests/Unit/Services/LevelServiceTest.php7Level 1-3 boundaries, partial progress, high XP, max level cap at 12, all keys present

Bugs Fixed (Session 13-14)

#BugFix
1Mass assignment vulnerability in ApiSettingsController@updateWhitelist of 8 allowed keys with per-key validation
2updateSecurity wrote to non-existent first_name/user_name columnsMethod and route removed
3Old destroy had no cleanupRoute removed (use DeleteAccountController instead)
4Profile photo upload brokenDisabled with 501
5removePhone set '' instead of nullFixed to null
6No Redis cleanup on account deletionAdded cleanupRedis() method
7geojson() used verified = 2Changed to >= ADMIN_APPROVED->value
8UserPhotoController@index queried verification = 0Changed to verified = UNVERIFIED->value
9No /profile or /settings Vue routesAdded routes, built Profile.vue
10Admin links shown to all usersGated by isAdmin role check
11Profile routes used auth:api (Passport only)Changed to auth:sanctum (session + token)

Bugs Fixed (Session 15 — User Journey Audit)

#BugFix
1Uploads page double-fetched on mountRemoved onMounted from Uploads.vue (UploadsHeader already handles initial fetch)
2UploadsPagination lost filters on page changeFixed fetchPhotosOnly(page) — no longer passes perPage as filters arg
3No empty state on Uploads pageAdded "You haven't uploaded any photos yet" message with Upload link
4Login modal caused full page reloadChanged <a href="/signup"> to <router-link> with modal close
5Tag submission failures were silentAdded toast.error() in AddTags.vue catch block
6XP calculation ignored enum multipliersRewrote AddTagsToPhotoAction::calculateXp() to use XpScore enum