--- name: profile-system description: ProfileController, ApiSettingsController, DeleteAccountController, user stats (Redis + MySQL fallback), LevelService, privacy toggles, account deletion with Redis cleanup, and the Profile Vue page. --- # Profile System User profile dashboard, settings management, privacy controls, and account deletion. Stats aggregated from Redis with MySQL fallback for pre-v5 users. Fully audited for v5 — all controllers, stores, tests confirmed correct. 35 tests across 7 files. No user tag editing (admin-only). User photo delete flows call MetricsService::deletePhoto() before soft delete. ## Key Files - `app/Http/Controllers/User/ProfileController.php` — Dashboard data (index), GeoJSON (geojson), CSV export (download) - `app/Http/Controllers/ApiSettingsController.php` — Privacy toggles, whitelisted setting updates, legacy key remapping - `app/Http/Controllers/API/DeleteAccountController.php` — Account deletion with Redis cleanup across all location scopes - `app/Http/Controllers/User/UserPhotoController.php` — Bulk tag, bulk delete, filter, previous custom tags - `app/Http/Controllers/User/Photos/UsersUploadsController.php` — Paginated photo listing with v5 tag structure - `app/Services/LevelService.php` — XP-threshold based levels (12 levels, config-driven via `config/levels.php`) - `app/Services/Redis/RedisMetricsCollector.php` — `getUserMetrics()` returns uploads/xp/litter/streak from Redis - `app/Services/Redis/RedisKeys.php` — Cluster-safe key generation for all scopes - `resources/js/stores/profile.js` — Pinia store: FETCH_PROFILE() from `/api/user/profile/index` - `resources/js/stores/settings.js` — Pinia store: UPDATE_SETTING, TOGGLE_PRIVACY, DELETE_ACCOUNT - `resources/js/views/Profile/Profile.vue` — Tab container (Dashboard, Photos, Settings) with query-param routing - `resources/js/views/Profile/components/ProfileDashboard.vue` — Level card, stats grid, rank, achievements, locations, team - `resources/js/views/Profile/components/ProfilePhotos.vue` — Upload count, links to /uploads, /upload, /tag - `resources/js/views/Profile/components/ProfileSettings.vue` — Account fields, preference toggles, privacy toggles, delete account - `resources/js/views/Profile/components/SettingsField.vue` — Inline-editable text field with save/cancel - `resources/js/views/Profile/components/SettingsToggle.vue` — Toggle switch component - `tests/Feature/User/ProfileIndexTest.php` — 4 tests (structure, auth, location counts, rank total) - `tests/Feature/User/PublicProfileTest.php` — 4 tests (public profile data, private returns, privacy settings, 404) - `tests/Feature/User/ProfileGeojsonTest.php` — 1 test (geojson returns only admin-approved photos, uses `summary` JSON not `result_string`) - `tests/Feature/User/SettingsProfileTest.php` — 10 tests (whitelist, validation, legacy remapping, old routes) - `tests/Feature/User/DeleteAccountTest.php` — 4 tests (Redis cleanup, photo preservation, password validation) - `tests/Feature/User/UserPhotoBulkDeleteTest.php` — 5 tests (soft-delete, counters, ownership, metrics, selectAll) - `tests/Feature/Photos/WebDeletePhotoTest.php` — 5 tests (single delete, ownership, counters, metrics reversal) ## Invariants 1. **All profile routes use `auth:sanctum`.** Not `auth:api`. Use `actingAs($user)` in tests (no guard argument). 2. **Redis-first with MySQL fallback.** `RedisMetricsCollector::getUserMetrics()` returns Redis data; ProfileController falls back to MySQL. `resolveUserStats()` batches the DB fallback into a single `selectRaw('COUNT(*), SUM(total_tags)')` query. Litter stat falls back to `Photo::sum('total_tags')` (not deprecated `users.total_litter`). 3. **Rank from Redis ZSET with MySQL fallback.** `ZREVRANK` on `{g}:lb:xp`; if false, count users with more XP via `User::where('xp', '>', $xp)->count() + 1`. 4. **Level updated on profile view.** If `$user->level != calculated`, `$user->save()` syncs it. 5. **Settings whitelist enforced server-side.** `ApiSettingsController::ALLOWED_SETTINGS = ['name', 'username', 'email', 'global_flag', 'picked_up', 'previous_tags', 'emailsub', 'public_profile']`. Any other key returns 422. 5a. **`users.public_photos` controls upload visibility default.** Boolean, default `true`. New uploads inherit this value unless overridden by an explicit request param. School team uploads always override to `false` regardless. Updated via the settings system. Own-user photo queries (e.g. `GET /api/v3/user/photos`) include all photos regardless of `is_public` — the user sees their own private photos. 6. **Legacy key remapping.** `items_remaining` → `picked_up` with inverted boolean (backward compat for old mobile clients). 7. **Photos preserved on account deletion.** User hard-deleted, photos remain (public contribution to map). 8. **Redis cleanup is comprehensive.** Removes user from XP and contributor rankings for every location scope (global, country, state, city), plus user stats hash, tags hash, and streak bitmap. 9. **Privacy toggles are boolean columns.** `show_name_maps`, `show_username_maps`, `show_name_createdby`, `show_username_createdby` on User model. Controllers toggle and return new value. 10. **Photo deletion reverses metrics.** `MetricsService::deletePhoto()` called for processed photos before soft-delete. Decrements `user.xp` and `user.total_images`. 11. **GeoJSON uses `summary` JSON, not `result_string`.** `ProfileController@geojson` returns `properties.summary` (v5 JSON array), not `properties.result_string` (deprecated v4 string). Frontend `popup.js` expects `summary`. ## Routes ### SPA routes — `auth:sanctum` (session cookies + Sanctum tokens) ``` # Profile dashboard GET /api/user/profile/index → ProfileController@index GET /api/user/profile/map → ProfileController@geojson GET /api/user/profile/download → ProfileController@download # Photo management (SPA) GET /api/user/profile/photos/index → UserPhotoController@index GET /api/user/profile/photos/filter → UserPhotoController@filter POST /api/user/profile/photos/tags/bulkTag → UserPhotoController@bulkTag POST /api/user/profile/photos/delete → UserPhotoController@destroy # Single-photo delete (SPA legacy route, also auth:sanctum) POST /api/profile/photos/delete → PhotosController@deleteImage # Settings (SPA — new endpoints) POST /api/settings/update → ApiSettingsController@update POST /api/settings/delete-account → DeleteAccountController # Privacy toggles (SPA) POST /api/settings/privacy/maps/name → ApiSettingsController@mapsName POST /api/settings/privacy/maps/username → ApiSettingsController@mapsUsername POST /api/settings/privacy/leaderboard/name → ApiSettingsController@leaderboardName POST /api/settings/privacy/leaderboard/username → ApiSettingsController@leaderboardUsername 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 ``` ### Legacy mobile routes — `auth:api` (Passport tokens) ``` # Settings (mobile — legacy endpoints, separate from SPA) 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 # Photo delete (mobile) DELETE /api/photos/delete → ApiPhotosController@deleteImage ``` ### v3 routes — `auth:api,web` (both guards) ``` GET /api/v3/user/photos → UsersUploadsController@index GET /api/v3/user/photos/stats → UsersUploadsController@stats PATCH /api/v3/photos/{id}/visibility → PhotoVisibilityController (or inline) Owner only. Blocked for school team photos (403). Toggles is_public per-photo. Triggers dirty tile marking via PhotoObserver. ``` ## Patterns ### ProfileController@index response ```php return [ 'user' => [id, name, username, avatar, created_at, member_since, global_flag, public_profile], 'stats' => [uploads, litter, xp, streak, littercoin, photo_percent, tag_percent], 'level' => [level, title, xp_into_level, xp_for_next, xp_remaining, progress_percent], 'rank' => [global_position, global_total, percentile], 'achievements' => [unlocked, total], 'locations' => [countries, states, cities], 'global_stats' => [total_photos, total_tags], 'team' => [id, name] | null, ]; ``` ### Caching - **Global stats** cached 5 min (`profile:global_stats`) — from metrics aggregate row (user_id=0) - **Location counts** cached 5 min (`profile:{userId}:locations:{photoCount}`) — keyed by photo count for auto-invalidation on upload - **Public profile location counts** cached 5 min (`profile:{id}:public_locations:{photoCount}`) - **Rank** uses Redis ZREVRANK (O(log n)), MySQL fallback only when user not in ZSET - **User count** cached 1 hour (`users:count`) - **Achievements count** cached 1 hour (`achievements:count`) ### Redis + MySQL fallback pattern ```php $metrics = RedisMetricsCollector::getUserMetrics($userId); // resolveUserStats() uses metrics table first, falls back to Redis, then DB $metricsRow = DB::table('metrics')->where(...)->first(['uploads', 'tags', 'xp']); $uploads = (int) ($metricsRow->uploads ?? 0) ?: $redisMetrics['uploads'] ?: Photo::count(); $xp = (int) ($metricsRow->xp ?? 0) ?: $redisMetrics['xp'] ?: (int) $user->xp; ``` ### Rank calculation ```php $globalXpKey = RedisKeys::xpRanking(RedisKeys::global()); $rank = Redis::zRevRank($globalXpKey, (string) $userId); if ($rank !== false) { $globalPosition = $rank + 1; // 0-indexed → 1-indexed } else { $globalPosition = User::where('xp', '>', $xp)->count() + 1; } ``` ### Level progression (config-driven thresholds) ``` Level 1: 0 XP — Noob Level 2: 100 XP — Litter Picker Level 3: 1000 XP — Litter Wizard Level 4: 5000 XP — Trash Warrior Level 5: 10000 XP — Early Guardian ... Level 11: 1000000 XP — SuperIntelligent LitterMaster ``` Config: `config/levels.php`. Service: `LevelService::getUserLevel($xp)` returns level info array. User model `next_level` accessor calls LevelService. ### Account deletion Redis cleanup ```php // Determine all location scopes from user's photos $photos = Photo::where('user_id', $userId)->get(); $scopes = [RedisKeys::global()]; foreach ($photos as $photo) { if ($photo->country_id) $scopes[] = RedisKeys::country($photo->country_id); if ($photo->state_id) $scopes[] = RedisKeys::state($photo->state_id); if ($photo->city_id) $scopes[] = RedisKeys::city($photo->city_id); } $scopes = array_unique($scopes); // Remove from all ranking ZSETs foreach ($scopes as $scope) { Redis::zRem(RedisKeys::xpRanking($scope), (string) $userId); Redis::zRem(RedisKeys::contributorRanking($scope), (string) $userId); } // Delete user-specific keys $userScope = RedisKeys::user($userId); Redis::del(RedisKeys::stats($userScope)); Redis::del("{$userScope}:tags"); Redis::del(RedisKeys::userBitmap($userId)); ``` ### Frontend tab routing ```javascript // Profile.vue uses query params for tab persistence const route = useRoute(); const router = useRouter(); const activeTab = computed(() => route.query.tab || 'dashboard'); function switchTab(tab) { router.replace({ query: { tab } }); } ``` ### Settings store sync pattern ```javascript // settings.js syncs back to userStore after update async UPDATE_SETTING(key, value) { const { data } = await axios.post('/api/settings/update', { key, value }); if (data.success) { const userStore = useUserStore(); if (userStore.user[key] !== undefined) { userStore.user[key] = value; } } } ``` ## Common Mistakes - **Mixing up SPA vs mobile auth guards.** SPA profile/settings routes use `auth:sanctum` (session cookies). Legacy mobile routes use `auth:api` (Passport tokens). They are separate route groups — don't merge them. Sanctum does NOT validate Passport tokens. - **Using `Auth::guard('api')->user()` in SPA controllers.** Use `Auth::user()` — Sanctum resolves the user from session or token automatically. `Auth::guard('api')` returns null for session-authenticated SPA users. - **Using `actingAs($user, 'api')` in tests for SPA routes.** SPA routes use `auth:sanctum`. Use `actingAs($user)` with no guard argument. Using `'api'` guard in test + `auth:sanctum` on route = 401. - **Uploading via `/api/photos/submit` in web-guard tests.** That route uses `auth:api` (Passport). If your test uses `actingAs($user)` (web guard), the upload silently fails. Use `Photo::factory()` to create test photos instead. - **Not falling back to MySQL for pre-v5 users.** Redis stats hash is empty for users who uploaded before v5. Always check `$metrics['uploads'] ?: (int) $user->total_images`. - **Comparing level with wrong XP.** Levels are threshold-based (not cumulative): 0, 100, 1000, 5000, etc. Use `LevelService::getUserLevel()`, don't calculate manually. - **Forgetting `ZREVRANK` returns `false` not `null`.** PHP Redis returns `false` for missing members. Check `$rank !== false`. - **Hard-deleting photos on account deletion.** Photos are public contributions and must be preserved. Only the User record is hard-deleted. - **Allowing mass assignment of protected fields.** `is_admin`, `verification_required`, etc. are NOT in `ALLOWED_SETTINGS`. The whitelist check prevents privilege escalation. - **Not reversing metrics on photo deletion.** `MetricsService::deletePhoto()` must run for processed photos (those with `processed_at`) before soft-delete. - **Forgetting inverted boolean for `picked_up`.** Mobile sends `picked_up=false` meaning `items_remaining=true`. The controller inverts the value. - **Adding constructor middleware to controllers in `auth:sanctum` route groups.** Route group handles auth — constructor `$this->middleware('auth')` is redundant and can conflict. `PhotosController` had this bug (fixed).