Skill v1.0.1
currentAutomated scan100/100+3 new
version: "1.0.1" 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 remappingapp/Http/Controllers/API/DeleteAccountController.php— Account deletion with Redis cleanup across all location scopesapp/Http/Controllers/User/UserPhotoController.php— Bulk tag, bulk delete, filter, previous custom tagsapp/Http/Controllers/User/Photos/UsersUploadsController.php— Paginated photo listing with v5 tag structureapp/Services/LevelService.php— XP-threshold based levels (12 levels, config-driven viaconfig/levels.php)app/Services/Redis/RedisMetricsCollector.php—getUserMetrics()returns uploads/xp/litter/streak from Redisapp/Services/Redis/RedisKeys.php— Cluster-safe key generation for all scopesresources/js/stores/profile.js— Pinia store: FETCH_PROFILE() from/api/user/profile/indexresources/js/stores/settings.js— Pinia store: UPDATE_SETTING, TOGGLE_PRIVACY, DELETE_ACCOUNTresources/js/views/Profile/Profile.vue— Tab container (Dashboard, Photos, Settings) with query-param routingresources/js/views/Profile/components/ProfileDashboard.vue— Level card, stats grid, rank, achievements, locations, teamresources/js/views/Profile/components/ProfilePhotos.vue— Upload count, links to /uploads, /upload, /tagresources/js/views/Profile/components/ProfileSettings.vue— Account fields, preference toggles, privacy toggles, delete accountresources/js/views/Profile/components/SettingsField.vue— Inline-editable text field with save/cancelresources/js/views/Profile/components/SettingsToggle.vue— Toggle switch componenttests/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, usessummaryJSON notresult_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
- All profile routes use `auth:sanctum`. Not
auth:api. UseactingAs($user)in tests (no guard argument). - Redis-first with MySQL fallback.
RedisMetricsCollector::getUserMetrics()returns Redis data; ProfileController falls back to MySQL.resolveUserStats()batches the DB fallback into a singleselectRaw('COUNT(*), SUM(total_tags)')query. Litter stat falls back toPhoto::sum('total_tags')(not deprecatedusers.total_litter). - Rank from Redis ZSET with MySQL fallback.
ZREVRANKon{g}:lb:xp; if false, count users with more XP viaUser::where('xp', '>', $xp)->count() + 1. - Level updated on profile view. If
$user->level != calculated,$user->save()syncs it. - 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.
- Legacy key remapping.
items_remaining→picked_upwith inverted boolean (backward compat for old mobile clients). - Photos preserved on account deletion. User hard-deleted, photos remain (public contribution to map).
- 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.
- Privacy toggles are boolean columns.
show_name_maps,show_username_maps,show_name_createdby,show_username_createdbyon User model. Controllers toggle and return new value. - Photo deletion reverses metrics.
MetricsService::deletePhoto()called for processed photos before soft-delete. Decrementsuser.xpanduser.total_images. - GeoJSON uses `summary` JSON, not `result_string`.
ProfileController@geojsonreturnsproperties.summary(v5 JSON array), notproperties.result_string(deprecated v4 string). Frontendpopup.jsexpectssummary.
Routes
SPA routes — auth:sanctum (session cookies + Sanctum tokens)
# Profile dashboardGET /api/user/profile/index → ProfileController@indexGET /api/user/profile/map → ProfileController@geojsonGET /api/user/profile/download → ProfileController@download# Photo management (SPA)GET /api/user/profile/photos/index → UserPhotoController@indexGET /api/user/profile/photos/filter → UserPhotoController@filterPOST /api/user/profile/photos/tags/bulkTag → UserPhotoController@bulkTagPOST /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@updatePOST /api/settings/delete-account → DeleteAccountController# Privacy toggles (SPA)POST /api/settings/privacy/maps/name → ApiSettingsController@mapsNamePOST /api/settings/privacy/maps/username → ApiSettingsController@mapsUsernamePOST /api/settings/privacy/leaderboard/name → ApiSettingsController@leaderboardNamePOST /api/settings/privacy/leaderboard/username → ApiSettingsController@leaderboardUsernamePOST /api/settings/privacy/createdby/name → ApiSettingsController@createdByNamePOST /api/settings/privacy/createdby/username → ApiSettingsController@createdByUsernamePOST /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@detailsPATCH /api/settings/details/password → UsersController@changePasswordPOST /api/settings/privacy/update → UsersController@togglePrivacyPOST /api/settings/phone/submit → UsersController@phonePOST /api/settings/phone/remove → UsersController@removePhonePOST /api/settings/toggle → UsersController@togglePresencePOST /api/settings/email/toggle → EmailSubController@toggleEmailSubGET /api/settings/flags/countries → SettingsController@getCountriesPOST /api/settings/save-flag → SettingsController@saveFlagPATCH /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@indexGET /api/v3/user/photos/stats → UsersUploadsController@statsPATCH /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
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
$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
$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 — NoobLevel 2: 100 XP — Litter PickerLevel 3: 1000 XP — Litter WizardLevel 4: 5000 XP — Trash WarriorLevel 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
// 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 ZSETsforeach ($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
// Profile.vue uses query params for tab persistenceconst route = useRoute();const router = useRouter();const activeTab = computed(() => route.query.tab || 'dashboard');function switchTab(tab) {router.replace({ query: { tab } });}
Settings store sync pattern
// settings.js syncs back to userStore after updateasync 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 useauth: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. UseactingAs($user)with no guard argument. Using'api'guard in test +auth:sanctumon route = 401. - Uploading via `/api/photos/submit` in web-guard tests. That route uses
auth:api(Passport). If your test usesactingAs($user)(web guard), the upload silently fails. UsePhoto::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
falsefor 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 inALLOWED_SETTINGS. The whitelist check prevents privilege escalation. - Not reversing metrics on photo deletion.
MetricsService::deletePhoto()must run for processed photos (those withprocessed_at) before soft-delete. - Forgetting inverted boolean for `picked_up`. Mobile sends
picked_up=falsemeaningitems_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.PhotosControllerhad this bug (fixed).