From 8224227743e18e5f31615dd4c6fb38e1d8d12948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20L=2E?= Date: Sat, 28 Feb 2026 22:39:55 +0000 Subject: [PATCH 1/7] olm mobile app v7 --- .claude/settings.json | 5 + .eslintrc.js | 10 +- .prettierrc.js | 11 +- AUDIT.md | 732 ++++++ App.tsx | 20 +- CLAUDE.md | 113 + GPS_AUDIT.md | 148 ++ LocalDev.md | 218 ++ actions/types.js | 26 +- assets/data/categories.js | 57 - assets/data/litterkeys.js | 285 --- assets/langs/en/auth.json | 4 +- assets/langs/en/stats.json | 7 +- assets/langs/en/team.json | 2 +- assets/langs/en/welcome.json | 3 +- babel.config.js | 1 + ios/Podfile.lock | 61 +- ios/openlittermap.xcodeproj/project.pbxproj | 10 +- package-lock.json | 355 +-- package.json | 12 +- readme/BackendAPI.md | 2080 +++++++++++++++++ readme/BackendLocations.md | 758 ++++++ readme/BackendTagging.md | 458 ++++ readme/MobileAuth.md | 137 ++ readme/MobileGallery.md | 60 + readme/MobileGlobalStats.md | 41 + readme/MobileLeaderboards.md | 26 + readme/MobileMyUploads.md | 40 + readme/MobileNavigation.md | 44 + readme/MobilePermissions.md | 57 + readme/MobileSettings.md | 63 + readme/MobileTagging.md | 218 ++ readme/MobileTeams.md | 46 + readme/MobileUpload.md | 98 + readme/summary/2026-02-28.md | 126 + reducers/auth_reducer.js | 127 +- reducers/gallery_reducer.js | 93 +- reducers/images_reducer.js | 491 ++-- reducers/index.js | 10 +- reducers/leaderboards_reducer.js | 48 +- reducers/litter_reducer.js | 175 -- reducers/locations_reducer.js | 95 + reducers/my_uploads_reducer.js | 2 +- reducers/settings_reducer.js | 15 +- reducers/shared_reducer.js | 5 +- reducers/stats_reducer.js | 57 +- reducers/tags_reducer.js | 225 ++ reducers/team_reducer.js | 40 +- reducers/web_reducer.js | 66 - routes/GalleryRoutes.tsx | 24 - routes/MainRoutes.js | 4 +- routes/TabRoutes.tsx | 32 +- screens/addTag/AddTagScreen.js | 549 +++++ screens/addTag/AddTags.js | 544 ----- .../addTagComponents/LitterBottomButtons.tsx | 151 -- .../addTagComponents/LitterCategories.tsx | 133 -- .../addTag/addTagComponents/LitterImage.tsx | 124 - .../addTagComponents/LitterPickerWheels.js | 75 - screens/addTag/addTagComponents/LitterTags.js | 154 -- .../addTagComponents/LitterTagsCard.tsx | 162 -- .../addTagComponents/LitterTextInput.js | 230 -- screens/addTag/addTagComponents/Stats.tsx | 96 - screens/addTag/addTagComponents/index.ts | 7 - screens/addTag/components/CategoryBrowser.js | 335 +++ .../addTag/components/ImageProgressDots.js | 110 + screens/addTag/components/ImageViewer.js | 244 ++ screens/addTag/components/TagPills.js | 325 +++ screens/addTag/components/TagSearchBar.js | 439 ++++ screens/addTag/components/TagSuggestions.js | 120 + screens/addTag/components/categoryColors.js | 21 + screens/addTag/index.js | 2 +- screens/auth/AuthScreen.tsx | 355 +-- screens/auth/WelcomeScreen.tsx | 238 +- .../auth/authComponents/ForgotPasswordForm.js | 140 +- screens/auth/authComponents/LanguageFlags.js | 192 +- screens/auth/authComponents/SigninForm.js | 364 +-- screens/auth/authComponents/SignupForm.js | 370 +-- screens/auth/authComponents/Slides.js | 225 +- screens/auth/authComponents/StatusMessage.js | 15 +- screens/auth/authComponents/index.js | 12 +- screens/auth/index.js | 4 +- screens/camera/CameraScreen.js | 367 --- screens/components/AnimatedCircle.tsx | 84 +- .../components/textInput/CustomTextInput.tsx | 135 +- screens/gallery/AlbumScreen.js | 4 +- screens/gallery/GalleryScreen.js | 156 +- .../gallery/galleryComponents/AlbumList.js | 14 +- .../galleryComponents/AnimatedImage.js | 45 +- screens/home/HomeScreen.js | 313 ++- .../home/homeComponents/UploadImagesGrid.js | 3 +- screens/index.js | 1 + screens/leaderboards/LeaderboardsScreen.js | 5 +- screens/map/MapScreen.js | 37 - screens/permission/CameraPermissionScreen.js | 232 +- screens/permission/GalleryPermissionScreen.js | 222 +- screens/permission/index.js | 4 +- screens/profile/ProfileScreen.js | 502 ++++ screens/profile/components/DeltaBlock.js | 54 + screens/profile/components/LevelHero.js | 128 + screens/profile/components/StatsGrid.js | 155 ++ screens/profile/helpers/ordinal.js | 23 + screens/profile/helpers/xpLevels.js | 117 + screens/setting/SettingsScreen.js | 26 +- .../settingComponents/SettingsComponent.js | 2 +- screens/team/TeamDetailsScreen.js | 4 +- screens/team/TeamScreen.js | 147 +- screens/team/TopTeamsScreen.js | 10 +- screens/team/teamComponents/CreateTeamForm.js | 137 +- screens/team/teamComponents/JoinTeamForm.js | 89 +- .../team/teamComponents/LeaderboardsTab.js | 189 ++ .../team/teamComponents/LocationListCard.js | 87 + .../teamComponents/LocationsLeaderboardTab.js | 177 ++ .../teamComponents/LocationsTeamsWrapper.js | 56 + screens/team/teamComponents/MemberCard.js | 4 +- screens/team/teamComponents/TeamListCard.js | 78 +- screens/team/teamComponents/TeamTitle.js | 4 +- screens/team/teamComponents/TeamsHomeTab.js | 162 ++ screens/team/teamComponents/TopTeamsList.js | 2 +- screens/team/teamComponents/UserTeamsList.js | 38 +- screens/team/teamComponents/index.js | 3 + screens/userStats/UserStatsScreen.js | 136 +- screens/userStats/userComponents/MyUploads.js | 28 +- .../userComponents/ProgressCircleCard.js | 116 +- store/index.js | 39 +- utils/Colors.js | 16 - utils/dayjs.js | 12 + utils/formatKey.js | 11 + utils/isGeotagged.js | 19 +- utils/isTagged.js | 12 +- utils/permissions/cameraRollPermission.js | 38 +- utils/setupAxiosInterceptors.js | 31 + yarn.lock | 314 +-- 132 files changed, 13079 insertions(+), 5086 deletions(-) create mode 100644 .claude/settings.json create mode 100644 AUDIT.md create mode 100644 CLAUDE.md create mode 100644 GPS_AUDIT.md create mode 100644 LocalDev.md delete mode 100644 assets/data/categories.js delete mode 100644 assets/data/litterkeys.js create mode 100644 readme/BackendAPI.md create mode 100644 readme/BackendLocations.md create mode 100644 readme/BackendTagging.md create mode 100644 readme/MobileAuth.md create mode 100644 readme/MobileGallery.md create mode 100644 readme/MobileGlobalStats.md create mode 100644 readme/MobileLeaderboards.md create mode 100644 readme/MobileMyUploads.md create mode 100644 readme/MobileNavigation.md create mode 100644 readme/MobilePermissions.md create mode 100644 readme/MobileSettings.md create mode 100644 readme/MobileTagging.md create mode 100644 readme/MobileTeams.md create mode 100644 readme/MobileUpload.md create mode 100644 readme/summary/2026-02-28.md delete mode 100644 reducers/litter_reducer.js create mode 100644 reducers/locations_reducer.js create mode 100644 reducers/tags_reducer.js delete mode 100644 reducers/web_reducer.js delete mode 100644 routes/GalleryRoutes.tsx create mode 100644 screens/addTag/AddTagScreen.js delete mode 100644 screens/addTag/AddTags.js delete mode 100644 screens/addTag/addTagComponents/LitterBottomButtons.tsx delete mode 100644 screens/addTag/addTagComponents/LitterCategories.tsx delete mode 100644 screens/addTag/addTagComponents/LitterImage.tsx delete mode 100644 screens/addTag/addTagComponents/LitterPickerWheels.js delete mode 100644 screens/addTag/addTagComponents/LitterTags.js delete mode 100644 screens/addTag/addTagComponents/LitterTagsCard.tsx delete mode 100644 screens/addTag/addTagComponents/LitterTextInput.js delete mode 100644 screens/addTag/addTagComponents/Stats.tsx delete mode 100644 screens/addTag/addTagComponents/index.ts create mode 100644 screens/addTag/components/CategoryBrowser.js create mode 100644 screens/addTag/components/ImageProgressDots.js create mode 100644 screens/addTag/components/ImageViewer.js create mode 100644 screens/addTag/components/TagPills.js create mode 100644 screens/addTag/components/TagSearchBar.js create mode 100644 screens/addTag/components/TagSuggestions.js create mode 100644 screens/addTag/components/categoryColors.js delete mode 100644 screens/camera/CameraScreen.js delete mode 100644 screens/map/MapScreen.js create mode 100644 screens/profile/ProfileScreen.js create mode 100644 screens/profile/components/DeltaBlock.js create mode 100644 screens/profile/components/LevelHero.js create mode 100644 screens/profile/components/StatsGrid.js create mode 100644 screens/profile/helpers/ordinal.js create mode 100644 screens/profile/helpers/xpLevels.js create mode 100644 screens/team/teamComponents/LeaderboardsTab.js create mode 100644 screens/team/teamComponents/LocationListCard.js create mode 100644 screens/team/teamComponents/LocationsLeaderboardTab.js create mode 100644 screens/team/teamComponents/LocationsTeamsWrapper.js create mode 100644 screens/team/teamComponents/TeamsHomeTab.js delete mode 100644 utils/Colors.js create mode 100644 utils/dayjs.js create mode 100644 utils/formatKey.js create mode 100644 utils/setupAxiosInterceptors.js diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..d19b7e12 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "impeccable@impeccable": false + } +} diff --git a/.eslintrc.js b/.eslintrc.js index c6906509..c94fa9ba 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,9 +2,9 @@ module.exports = { root: true, extends: '@react-native', rules: { - 'comma-dangle': ['error', 'never'] - }, - "indent": ["error", 4], - "react/jsx-indent": ["error", 4], - "react/jsx-indent-props": ["error", 4] + 'comma-dangle': ['error', 'never'], + 'indent': ['error', 4], + 'react/jsx-indent': ['error', 4], + 'react/jsx-indent-props': ['error', 4] + } }; diff --git a/.prettierrc.js b/.prettierrc.js index 2b540746..c36d3d27 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,7 +1,8 @@ module.exports = { - arrowParens: 'avoid', - bracketSameLine: true, - bracketSpacing: false, - singleQuote: true, - trailingComma: 'all', + arrowParens: 'avoid', + bracketSameLine: true, + bracketSpacing: false, + singleQuote: true, + trailingComma: 'none', + tabWidth: 4, }; diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 00000000..c91ccd99 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,732 @@ +# AUDIT.md — OpenLitterMap React Native App + +**Date:** 2026-02-28 +**App Version:** 6.2.0 +**React Native Version:** 0.74.3 +**Branch:** openlittermap/v7 + +--- + +## 1. File Inventory + +### Root Config +| File | Purpose | +|------|---------| +| `package.json` | App manifest, dependencies, scripts | +| `app.json` | RN app name config ("openlittermap") | +| `index.js` | Entry point, registers App component | +| `App.tsx` | Root: Redux Provider, PersistGate, NavigationContainer, Sentry | +| `i18n.js` | i18next setup (en, de, nl, fr, es, pt, ca, et) | +| `tsconfig.json` | Extends @react-native/typescript-config | +| `babel.config.js` | @react-native/babel-preset | +| `metro.config.js` | Default metro config | +| `jest.config.js` | Preset: react-native | +| `.eslintrc.js` | @react-native config, 4-space indent | +| `.prettierrc.js` | Single quotes, no bracket spacing | +| `Gemfile` | Ruby gems for CocoaPods | + +### Actions +| File | Purpose | +|------|---------| +| `actions/types.js` | Environment config (react-native-config), endpoint selection | + +### Store +| File | Purpose | +|------|---------| +| `store/index.js` | configureStore with redux-persist (whitelist: auth, images), AsyncStorage, redux-immutable-state-invariant (dev) | + +### Reducers (12 slices) +| File | Slice Key | API Calls | Status | +|------|-----------|-----------|--------| +| `reducers/index.js` | Root combiner | 0 | OK | +| `reducers/auth_reducer.js` | `auth` | 5 | Active, bugs | +| `reducers/camera_reducer.js` | `camera` | 0 | Minimal, 3 fields | +| `reducers/gallery_reducer.js` | `gallery` | 0 | CameraRoll fetch | +| `reducers/images_reducer.js` | `images` | 4 | Active, core | +| `reducers/litter_reducer.js` | `litter` | 0 | UI state only | +| `reducers/leaderboards_reducer.js` | `leaderboard` | 1 | Active | +| `reducers/my_uploads_reducer.js` | `my_uploads_reducer` | 1 | Active | +| `reducers/settings_reducer.js` | `settings` | 4 | Active, bugs | +| `reducers/shared_reducer.js` | `shared` | 1 | Active | +| `reducers/stats_reducer.js` | `stats` | 1 | Active | +| `reducers/team_reducer.js` | `teams` | 8 | Active, bugs | +| `reducers/web_reducer.js` | `web` | 1 (unused) | Dead code | + +### Routes +| File | Purpose | +|------|---------| +| `routes/index.js` | Barrel export for MainRoutes | +| `routes/MainRoutes.js` | Root navigator, auth gating, conditional routing | +| `routes/AuthStack.js` | Welcome → Auth screen stack | +| `routes/TabRoutes.tsx` | Bottom tabs: Home, Team, Global Data, Leaderboards, User Stats | +| `routes/PermissionStack.tsx` | Camera/Gallery permission screens | +| `routes/GalleryRoutes.tsx` | Gallery → Album stack | +| `routes/TeamStack.tsx` | Team screens stack | + +### Screens +| File | Screen | Status | +|------|--------|--------| +| `screens/home/HomeScreen.js` | Main upload screen | Active, bug at line 114 | +| `screens/home/homeComponents/ActionButton.js` | FAB button | Active | +| `screens/home/homeComponents/UploadButton.js` | Upload trigger | Active | +| `screens/home/homeComponents/UploadImagesGrid.js` | Image grid | Active | +| `screens/auth/AuthScreen.tsx` | Login/signup/forgot tabs | Active | +| `screens/auth/WelcomeScreen.tsx` | Onboarding slides | Active | +| `screens/auth/authComponents/SigninForm.tsx` | Login form (Formik+Yup) | Active | +| `screens/auth/authComponents/SignupForm.tsx` | Signup form (Formik+Yup) | Active | +| `screens/auth/authComponents/ForgotPasswordForm.tsx` | Password reset form | Active | +| `screens/auth/authComponents/Slides.tsx` | Welcome slides content | Active | +| `screens/auth/authComponents/LanguageFlags.tsx` | Language picker | Active | +| `screens/auth/authComponents/StatusMessage.tsx` | Auth status display | Active | +| `screens/addTag/AddTags.js` | Tag images with swiper | Active | +| `screens/addTag/addTagComponents/LitterBottomButtons.tsx` | Tag action buttons | Bug: delete disabled | +| `screens/addTag/addTagComponents/LitterCategories.tsx` | Category picker | Active | +| `screens/addTag/addTagComponents/LitterImage.tsx` | Image display | Active | +| `screens/addTag/addTagComponents/LitterPickerWheels.tsx` | Quantity picker | Active | +| `screens/addTag/addTagComponents/LitterTags.tsx` | Tag list display | Active | +| `screens/addTag/addTagComponents/LitterTextInput.tsx` | Custom tag input | Active | +| `screens/addTag/addTagComponents/LitterTagsCard.tsx` | Tag card display | Active | +| `screens/addTag/addTagComponents/Stats.tsx` | User stats in tagger | Bug: wrong state path | +| `screens/camera/CameraScreen.js` | Camera capture | **BROKEN** | +| `screens/gallery/GalleryScreen.js` | Photo picker | Active | +| `screens/gallery/AlbumScreen.js` | Album view | Active, minor issue | +| `screens/globalData/GlobalDataScreen.js` | Global statistics | Active | +| `screens/leaderboards/LeaderboardsScreen.js` | Leaderboard | Active | +| `screens/map/MapScreen.js` | Map view | **STUB** | +| `screens/setting/SettingsScreen.js` | Settings | Active | +| `screens/setting/settingComponents/SettingsComponent.js` | Settings edit forms | Bug: template literal | +| `screens/NewUpdateScreen.js` | App update prompt | Active | +| `screens/userStats/UserStatsScreen.js` | User profile/stats | Active | +| `screens/userStats/userComponents/MyUploads.js` | Upload history | Active, uses deprecated field | +| `screens/userStats/userComponents/ProgressCircleCard.js` | XP progress | Active | +| `screens/userStats/userComponents/ShowMyUploadsButton.js` | Nav button | Active | +| `screens/team/TeamScreen.js` | Team management | Active | +| `screens/team/TeamDetailsScreen.js` | Team details | Active | +| `screens/team/TopTeamsScreen.js` | Top teams list | Active, fake loading | +| `screens/team/TeamLeaderboardScreen.js` | Team leaderboard | Active | +| `screens/permission/CameraPermissionScreen.js` | Camera permission | Active | +| `screens/permission/GalleryPermissionScreen.js` | Gallery permission | Active | + +### Shared Components +| File | Purpose | +|------|---------| +| `screens/components/index.js` | Barrel exports | +| `screens/components/Header.js` | App header bar | +| `screens/components/Button.js` | Shared button | +| `screens/components/AnimatedCircle.js` | SVG animated circle | +| `screens/components/typography/Body.tsx` | Body text | +| `screens/components/typography/Caption.tsx` | Caption text | +| `screens/components/typography/Title.tsx` | Title text | +| `screens/components/theme/colors.ts` | Theme colors | + +### Utils +| File | Purpose | +|------|---------| +| `utils/isGeotagged.js` | Check image GPS data | +| `utils/isTagged.js` | Check image has tags | +| `utils/Colors.js` | Color constants (DUPLICATE of theme/colors.ts) | +| `utils/Values.js` | REM division factor | +| `utils/permissions/index.js` | Permission barrel exports | +| `utils/permissions/cameraPermission.js` | Camera permission helpers | +| `utils/permissions/cameraRollPermission.js` | Photo library permission helpers | +| `utils/permissions/locationPermission.js` | Location permission helpers | + +### Data +| File | Purpose | +|------|---------| +| `assets/data/categories.js` | 13 litter categories | +| `assets/data/xpLevel.js` | XP level thresholds array | +| `assets/data/litterkeys.js` | Litter item keys per category | + +--- + +## 2. API Calls + +### Auth (auth_reducer.js) + +| # | Thunk | Method | URL | Payload | Response | Auth | Notes | +|---|-------|--------|-----|---------|----------|------|-------| +| 1 | `checkValidToken` | POST | `/api/validate-token` | None (token in header) | `{message: "valid"}` | Bearer | On app boot, validates stored JWT | +| 2 | `createAccount` | POST | `/api/register` | `{client_id, client_secret, grant_type: "password", username, email, password}` | User object | None | Sends Passport OAuth credentials in body | +| 3 | `fetchUser` | GET | `/api/user` | None | User object | Bearer | Fetches full user profile, sets Sentry user | +| 4 | `sendResetPasswordRequest` | POST | `/api/password/email` | `{email}` | Success message | None | | +| 5 | `userLogin` | POST | `/oauth/token` | `{client_id, client_secret, grant_type: "password", username: email, password}` | `{access_token}` | None | **Passport OAuth token endpoint** | + +### Images (images_reducer.js) + +| # | Thunk | Method | URL | Payload | Response | Auth | Notes | +|---|-------|--------|-----|---------|----------|------|-------| +| 6 | `deleteWebImage` | DELETE | `/api/photos/delete` | `params: {photoId}` | `{success: true}` | Bearer | | +| 7 | `getUntaggedImages` | GET | `/api/v2/photos/get-untagged-uploads` | None | `{photos: [...]}` | Bearer | | +| 8 | `uploadImage` | POST | `/api/photos/upload/with-or-without-tags` | FormData (photo, lat, lon, date, picked_up, model, tags?, custom_tags?) | `{success, photo_id}` | Bearer | multipart/form-data | +| 9 | `uploadTagsToWebImage` | POST | `/api/v2/add-tags-to-uploaded-image` | `{photo_id, tags, custom_tags, picked_up}` | `{success: true}` | Bearer | | + +### Settings (settings_reducer.js) + +| # | Thunk | Method | URL | Payload | Response | Auth | Notes | +|---|-------|--------|-----|---------|----------|------|-------| +| 10 | `deleteAccount` | POST | `/api/settings/delete-account/` | `{password, token}` | `{success, msg}` | Bearer | Token sent in BOTH header and body | +| 11 | `saveSettings` | POST | `/api/settings/update/` | `{key, value}` | `{success: true}` | Bearer | **BUG: missing `return` before `rejectWithValue` on line 114** | +| 12 | `saveSocialAccounts` | PATCH | `/api/settings` | `{...values}` | `{message: "success"}` | Bearer | **BUG: missing `return` before `rejectWithValue` on line 153** | +| 13 | `toggleSettingsSwitch` | POST | `/api/settings/privacy/{endpoint}` | None | `{[key]: value}` | Bearer | Endpoint mapped from ID (4-10) | + +### Teams (team_reducer.js) + +| # | Thunk | Method | URL | Payload | Response | Auth | Notes | +|---|-------|--------|-----|---------|----------|------|-------| +| 14 | `changeActiveTeam` | POST | `/api/teams/active` | `{team_id}` | `{success, team}` | Bearer | | +| 15 | `createTeam` | POST | `/api/teams/create` | `{name, identifier, team_type: 1}` | `{success, team}` | Bearer | | +| 16 | `inactivateTeam` | POST | `/api/teams/inactivate` | None | `{success}` | Bearer | | +| 17 | `leaveTeam` | POST | `/api/teams/leave` | `{team_id}` | `{activeTeam, team}` | Bearer | **BUG: fulfilled handler is empty (commented out)** | +| 18 | `getTeamMembers` | GET | `/api/teams/members` | `params: {team_id, page}` | `{result: paginated}` | Bearer | | +| 19 | `getTopTeams` | GET | `/api/teams/leaderboard` | None | Team array | Bearer | | +| 20 | `getUserTeams` | GET | `/api/teams/list` | None | `{success, teams}` | Bearer | | +| 21 | `joinTeam` | POST | `/api/teams/join` | `{identifier}` | `{success, activeTeam, team}` | Bearer | | + +### Leaderboard (leaderboards_reducer.js) + +| # | Thunk | Method | URL | Payload | Response | Auth | Notes | +|---|-------|--------|-----|---------|----------|------|-------| +| 22 | `getLeaderboardData` | GET | `/global/leaderboard?timeFilter={value}` | None | `{success, ...data}` | None | **No `/api/` prefix — inconsistent** | + +### My Uploads (my_uploads_reducer.js) + +| # | Thunk | Method | URL | Payload | Response | Auth | Notes | +|---|-------|--------|-----|---------|----------|------|-------| +| 23 | `fetchUploads` | GET | `/history/paginated` | params: loadPage, paginationAmount, filters | `{photos: paginated}` | Bearer | **No `/api/` prefix — inconsistent** | + +### Shared (shared_reducer.js) + +| # | Thunk | Method | URL | Payload | Response | Auth | Notes | +|---|-------|--------|-----|---------|----------|------|-------| +| 24 | `checkAppVersion` | GET | `/api/mobile-app-version` | None | `{ios: {version}, android: {version}}` | None | | + +### Stats (stats_reducer.js) + +| # | Thunk | Method | URL | Payload | Response | Auth | Notes | +|---|-------|--------|-----|---------|----------|------|-------| +| 25 | `getStats` | GET | `/api/global/stats-data` | None | `{total_litter, total_photos, total_users, littercoin, previousXp, nextXp}` | None | | + +### Web (web_reducer.js) + +| # | Thunk | Method | URL | Payload | Response | Auth | Notes | +|---|-------|--------|-----|---------|----------|------|-------| +| 26 | `loadMoreWebImages` | GET | `/api/v2/photos/web/load-more` | `params: {photo_id}` | Photo array | Bearer | **DEAD CODE: Never called. URL variable not imported (will ReferenceError).** | + +**Total: 26 API thunks defined (25 functional, 1 dead code)** + +--- + +## 3. Auth Mechanism + +### Current Implementation: Laravel Passport (OAuth2 Password Grant) + +**Login flow:** +1. User submits email + password +2. App POSTs to `/oauth/token` with `client_id`, `client_secret`, `grant_type: "password"`, `username`, `password` +3. Server returns `{access_token}` (OAuth2 Bearer token) +4. Token stored in AsyncStorage as key `"jwt"` +5. All subsequent requests use `Authorization: Bearer {token}` header +6. On app boot, `checkValidToken` POSTs stored token to `/api/validate-token` + +**Registration flow:** +1. App POSTs to `/api/register` with OAuth client credentials + user info +2. On success, automatically calls `userLogin` with the same email/password + +**Token persistence:** +- `redux-persist` persists the `auth` slice (including token) to AsyncStorage +- Token also explicitly stored at AsyncStorage key `"jwt"` +- On logout, `AsyncStorage.clear()` wipes everything + +**Critical Issue: Backend Migration to Sanctum** +The backend has migrated from Passport to Sanctum. This means: +- `/oauth/token` endpoint may no longer exist or behave differently +- `client_id` and `client_secret` are Passport concepts — Sanctum does not use them +- Sanctum typically uses `/login` or `/api/login` with email+password, returning a plain-text token +- The `grant_type: "password"` field is meaningless to Sanctum +- `/api/validate-token` may still work if the backend kept it, but the login/register flows need rewriting + +**What needs to change for Sanctum:** +- Remove `CLIENT_ID`, `CLIENT_SECRET`, `grant_type` from all auth requests +- Change login endpoint from `/oauth/token` to Sanctum's login route +- Change registration endpoint payload (remove OAuth fields) +- Token format may differ (Sanctum tokens are `{id}|{token}` format) +- Remove `client_id` and `client_secret` from `actions/types.js` and `.env` files + +**Other auth issues:** +- `auth_reducer.js` exports `accountCreated` and `tokenIsValid` (lines 340, 345) which are not defined in the slice reducers — these will be `undefined` at runtime +- User object stored redundantly in both Redux (persisted) and `AsyncStorage.setItem('user', ...)` — potential desync + +--- + +## 4. State Management Map + +### Redux Store Shape + +``` +store +├── auth (persisted to AsyncStorage) +│ ├── appVersion: string +│ ├── isSubmitting: boolean +│ ├── token: string | null +│ ├── user: object | null (enhanced with level, xpRequired, targetPercentage, totalTags, totalLittercoin) +│ ├── serverStatusText: string +│ └── errors: object +│ +├── camera +│ ├── lat: number +│ ├── lon: number +│ └── autoFocus: (referenced by CameraScreen but NOT in initial state — undefined) +│ +├── gallery +│ ├── photos: array (CameraRoll photos) +│ └── lastCursor: string | null +│ +├── images +│ ├── imagesArray: array (core image collection — gallery, camera, web) +│ ├── selectedImages: array (unused?) +│ ├── previousTags: array (max 10 recent tags) +│ ├── totalToUpload: number +│ ├── uploaded: number +│ ├── uploadFailed: number +│ ├── tagged: number +│ ├── taggedFailed: number +│ ├── errorMessage: string +│ └── failedCounts: { alreadyUploaded, invalidCoordinates, unknown } +│ +├── my_uploads_reducer ← NOTE: inconsistent key name (should be my_uploads or myUploads) +│ ├── uploads: array | { data, total, current_page, ... } +│ ├── loading: boolean +│ └── error: string | null +│ +├── leaderboard +│ └── paginated: { users: array } +│ +├── litter (UI state only, no API calls) +│ ├── category: string +│ ├── item: string +│ ├── quantity: number +│ ├── items: array +│ ├── previousTags: array +│ └── currentIndex: number +│ +├── shared +│ ├── appVersion: object | null +│ ├── isSelecting: boolean +│ ├── isUploading: boolean +│ ├── selected: number +│ ├── showModal: boolean +│ └── showThankYouMessages: boolean +│ +├── settings +│ ├── model: string +│ ├── settingsModalVisible: boolean +│ ├── secondSettingsModalVisible: boolean +│ ├── settingsEdit: boolean +│ ├── settingsEditProp: string +│ ├── wait: boolean +│ ├── dataToEdit: any +│ ├── deleteAccountError: string +│ ├── updateSettingsStatusMessage: string +│ └── updatingSettings: boolean +│ +├── stats +│ ├── statsErrorMessage: string | null +│ ├── totalLitter: number +│ ├── totalPhotos: number +│ ├── totalUsers: number +│ ├── totalLittercoin: number +│ ├── targetPercentage: number +│ └── litterTarget: { previousTarget, nextTarget } +│ +├── teams +│ ├── topTeams: array +│ ├── userTeams: array +│ ├── teamMembers: array +│ ├── teamsRequestStatus: string +│ ├── selectedTeam: object +│ ├── teamsFormError: string +│ ├── teamFormStatus: string | null +│ ├── successMessage: string +│ └── memberNextPage: number | null +│ +└── web (DEAD — never read by any component) + ├── count: number + └── photos: array +``` + +### Key Data Flow Issues +- `my_uploads_reducer` uses a non-standard key name in combineReducers (line 7 of index.js) +- `web` slice is completely dead — no component reads from `state.web` +- `camera` slice has only `lat`, `lon` in its reducer but `CameraScreen` tries to read `autoFocus`, `type`, `zoom` — all undefined +- `shared.isSelecting` and `shared.selected` overlap with local state in `HomeScreen` +- `images.selectedImages` defined in initial state but never populated + +--- + +## 5. Navigation Structure + +``` +NavigationContainer (App.tsx) +└── MainRoutes (Stack.Navigator, headerShown: false) + │ + ├── [token === null] AUTH_HOME → AuthStack + │ ├── WELCOME → WelcomeScreen + │ └── AUTH → AuthScreen + │ └── MaterialTopTabs + │ ├── SIGNIN → SigninForm + │ ├── SIGNUP → SignupForm + │ └── FORGOT_PASSWORD → ForgotPasswordForm + │ + └── [token !== null] + ├── APP → TabRoutes (MaterialTopTabs, bottom) + │ ├── HOME → HomeScreen + │ ├── TEAM → TeamStack + │ │ ├── TEAM_HOME → TeamScreen + │ │ ├── TOP_TEAMS → TopTeamsScreen + │ │ ├── TEAM_DETAILS → TeamDetailsScreen + │ │ └── TEAM_LEADERBOARD → TeamLeaderboardScreen + │ ├── GLOBAL → GlobalDataScreen + │ ├── LEADERBOARD → LeaderboardsScreen + │ └── USER_STATS → UserStatsScreen + │ + ├── PERMISSION → PermissionStack + │ ├── CAMERA_PERMISSION → CameraPermissionScreen + │ └── GALLERY_PERMISSION → GalleryPermissionScreen + │ + ├── ADD_TAGS → AddTags + ├── ALBUM → GalleryScreen (NOTE: route name is misleading) + ├── SETTING → SettingsScreen + ├── UPDATE → NewUpdateScreen + └── MY_UPLOADS → MyUploads +``` + +**Navigation Issues:** +- CameraScreen is defined in the codebase but NOT registered in any navigator — it is unreachable +- MapScreen is defined but NOT registered in any navigator — it is unreachable +- The `ALBUM` route renders `GalleryScreen`, not `AlbumScreen` — naming is confusing +- `GalleryRoutes.tsx` defines a stack with Gallery → Album, but this stack is not used in MainRoutes +- `PermissionStack` is only navigated to from HomeScreen and CameraScreen (which is dead) + +--- + +## 6. Dependencies + +### Production Dependencies (35 total) + +| Package | Version | Status | Notes | +|---------|---------|--------|-------| +| `@react-native-async-storage/async-storage` | ^1.23.1 | OK | | +| `@react-native-camera-roll/camera-roll` | ^7.8.1 | OK | | +| `@react-native-clipboard/clipboard` | ^1.14.2 | OK | | +| `@react-native-community/datetimepicker` | ^8.2.0 | OK | | +| `@react-native-community/masked-view` | ^0.1.11 | **DEPRECATED** | Replace with `@react-native-masked-view/masked-view` | +| `@react-native-picker/picker` | ^2.7.7 | OK | | +| `@react-navigation/material-top-tabs` | ^6.6.13 | **OUTDATED** | v7 available | +| `@react-navigation/native` | ^6.1.17 | **OUTDATED** | v7 available | +| `@react-navigation/stack` | ^6.4.0 | **OUTDATED** | v7 available | +| `@reduxjs/toolkit` | ^2.2.6 | OK | | +| `@sentry/react-native` | ^5.24.1 | **OUTDATED** | v6 available | +| `@shopify/flash-list` | ^1.7.0 | OK | Installed but only used in TeamLeaderboardScreen | +| `axios` | ^1.7.2 | OK | | +| `formik` | ^2.4.6 | OK | | +| `i18next` | ^23.11.5 | OK | | +| `immer` | ^10.1.1 | OK | Redundant — RTK includes immer | +| `lottie-react-native` | ^6.7.2 | OK | Used in WelcomeScreen | +| `moment` | ^2.30.1 | **CONSIDER REPLACING** | Large bundle. Consider `date-fns` or `dayjs` | +| `react` | 18.2.0 | **OUTDATED** | RN 0.74 supports React 18.2 but 18.3 is available | +| `react-native` | 0.74.3 | **OUTDATED** | 0.76+ available with New Architecture | +| `react-native-actions-sheet` | ^0.9.6 | **UNUSED** | Not imported anywhere in the codebase | +| `react-native-config` | ^1.5.2 | OK | | +| `react-native-device-info` | ^11.1.0 | OK | | +| `react-native-gesture-handler` | ^2.20.0 | OK | | +| `react-native-linear-gradient` | ^2.8.3 | OK | | +| `react-native-localize` | ^3.2.0 | OK | Used in i18n.js | +| `react-native-maps` | ^1.15.6 | OK | Only used in dead MapScreen | +| `react-native-page-control` | ^1.1.2 | **POSSIBLY UNUSED** | May be replaced by custom dots | +| `react-native-pager-view` | ^6.3.3 | OK | Required by material-top-tabs | +| `react-native-permissions` | ^4.1.5 | OK | | +| `react-native-reanimated` | ^3.13.0 | OK | | +| `react-native-safe-area-context` | ^4.10.7 | OK | | +| `react-native-screens` | ^3.32.0 | OK | | +| `react-native-svg` | ^15.3.0 | OK | | +| `react-native-swipe-gestures` | ^1.0.5 | OK | | +| `react-native-swiper` | ^1.6.0 | **UNMAINTAINED** | Last publish 2020; consider `react-native-pager-view` | +| `react-native-vector-icons` | ^10.1.0 | OK | | +| `react-redux` | ^9.1.2 | OK | | +| `redux-persist` | ^6.0.0 | OK | | +| `redux-thunk` | ^3.1.0 | **REDUNDANT** | RTK includes redux-thunk by default | +| `use-count-up` | ^3.0.1 | OK | Used in GlobalDataScreen | +| `yup` | ^1.4.0 | OK | | + +### Dev Dependencies (13 total) + +| Package | Version | Status | +|---------|---------|--------| +| `@babel/core` | ^7.20.0 | OK | +| `@babel/preset-env` | ^7.20.0 | OK | +| `@babel/runtime` | ^7.20.0 | OK | +| `@react-native/babel-preset` | 0.74.85 | OK (matches RN version) | +| `@react-native/eslint-config` | 0.74.85 | OK | +| `@react-native/metro-config` | 0.74.85 | OK | +| `@react-native/typescript-config` | 0.74.85 | OK | +| `@types/react` | ^18.2.6 | OK | +| `@types/react-test-renderer` | ^18.0.0 | OK | +| `babel-jest` | ^29.6.3 | OK | +| `eslint` | ^8.19.0 | OK | +| `jest` | ^29.6.3 | OK | +| `prettier` | 2.8.8 | OK | +| `react-test-renderer` | 18.2.0 | OK | +| `redux-immutable-state-invariant` | ^2.1.0 | OK (dev-only) | +| `typescript` | 5.0.4 | **OUTDATED** | 5.4+ available | + +### Unused/Dead Dependencies +1. `react-native-actions-sheet` — not imported anywhere +2. `react-native-maps` — only used in dead MapScreen (not registered in navigator) +3. `redux-thunk` — RTK includes it +4. `immer` — RTK includes it + +--- + +## 7. Bugs and Dead Code + +### Critical Bugs + +**BUG-01: Permission check always passes (HomeScreen.js:114)** +```js +if (result === 'granted' || 'limited') +``` +The string `'limited'` is always truthy. This means the gallery permission check never fails, and the app never navigates to the permission screen. Should be: +```js +if (result === 'granted' || result === 'limited') +``` + +**BUG-02: Auth uses Passport but backend now uses Sanctum** +- `userLogin` POSTs to `/oauth/token` with `client_id`, `client_secret`, `grant_type: "password"` — Sanctum does not use this endpoint or these fields +- `createAccount` sends OAuth credentials — irrelevant for Sanctum +- This is the #1 blocking issue for v7 + +**BUG-03: Missing `return` before `rejectWithValue` (settings_reducer.js:114, 153)** +In `saveSettings.fulfilled` and `saveSocialAccounts.fulfilled`, when `response.data.success` is falsy, the code calls `rejectWithValue('...')` without `return`. This means: +- The rejection is silently ignored +- The function continues and returns `undefined` +- The fulfilled case handler receives `undefined`, causing `state.updateSettingsStatusMessage = undefined` + +**BUG-04: Stats.tsx reads wrong state path (Stats.tsx)** +```tsx +const user = useSelector(state => state.user); +``` +Should be `state.auth.user`. `state.user` is always `undefined`, so the component renders nothing useful. + +**BUG-05: SettingsComponent.js broken template literal (line ~218-219)** +The delete account error display renders a literal string `t(${deleteAccountError})` instead of calling the translation function properly. + +**BUG-06: leaveTeam fulfilled handler is empty (team_reducer.js:422-434)** +When a user leaves a team, the API call succeeds but no state is updated — the team remains in `userTeams`, and `activeTeam` is not changed. All the reducer logic is commented out. + +**BUG-07: Leaderboard error handler crashes (leaderboards_reducer.js:28)** +```js +return rejectWithValue(error.response.data); +``` +If `error.response` is undefined (network error), this throws `TypeError: Cannot read property 'data' of undefined`. + +### Moderate Bugs + +**BUG-08: Exported non-existent actions (settings_reducer.js:336-337)** +`updateSettingsStatusMessage` and `startUpdatingSettings` are exported from `settingsSlice.actions` but are not defined in the reducers object. These will be `undefined`, and calling `dispatch(startUpdatingSettings())` will throw. + +**BUG-09: Exported non-existent actions (team_reducer.js:501-503)** +`teamsFormError`, `teamsRequestError`, `teamsFormSuccess` are exported but their reducer definitions are commented out. Same issue as BUG-08. + +**BUG-10: Exported non-existent actions (auth_reducer.js:340, 345)** +`accountCreated` and `tokenIsValid` are exported but not defined in the slice. Will be `undefined`. + +**BUG-11: TopTeamsScreen fake loading (TopTeamsScreen.js)** +Uses `setTimeout(() => setIsLoading(false), 3000)` instead of tracking actual API loading state. Users always wait 3 seconds regardless of API speed. + +**BUG-12: MyUploads references deprecated `result_string` (MyUploads.js:196)** +`item.result_string` is an old database column. If the backend no longer returns it, tags will never display. + +**BUG-13: web_reducer `URL` not imported (web_reducer.js:21)** +`loadMoreWebImages` thunk uses `${URL}/api/v2/photos/web/load-more` but `URL` is never imported. Will throw `ReferenceError: URL is not defined` if ever called. + +**BUG-14: web_reducer name collision (web_reducer.js:16 vs 52)** +The async thunk `loadMoreWebImages` and the reducer action `loadMoreWebImages` share the same name. The thunk export shadows the slice action export — the empty export `{}` on line 65 confirms the action is inaccessible. + +**BUG-15: changeActiveTeam.fulfilled handler reads wrong payload shape (team_reducer.js:375)** +`state.userTeams.push(action.payload.team)` — but the thunk returns `response.data.team.id` (a number), not `{team, type}`. Will push `undefined`. + +### Minor Bugs + +**BUG-16: AlbumScreen async useEffect (AlbumScreen.js:12)** +```js +useEffect(async () => { ... }, []); +``` +Passing an async function to useEffect is a React anti-pattern (returns a Promise instead of cleanup function). + +**BUG-17: LitterBottomButtons delete disabled (LitterBottomButtons.tsx:112)** +The delete button's `onPress` handler is commented out. Users see a delete button that does nothing. + +**BUG-18: Duplicate color definitions** +`utils/Colors.js` and `screens/components/theme/colors.ts` define overlapping but different color values for the same semantic names. + +**BUG-19: LitterTagsCard inconsistent translation keys (LitterTagsCard.tsx)** +Uses `${lang}.litter.categories.${category}` with language prefix, but all other components use just `litter.categories.${category}`. This will produce wrong i18n lookups. + +**BUG-20: deleteAccount sends token in both header and body (settings_reducer.js:33-38)** +```js +data: { password, token } // token in body +headers: { Authorization: `Bearer ${token}` } // token in header +``` +Redundant and potentially confusing. Sanctum migration should clean this up. + +### Dead Code + +| Item | Location | Description | +|------|----------|-------------| +| CameraScreen | `screens/camera/CameraScreen.js` | Entire file is dead. Uses old class component with `connect(mapStateToProps, actions)`. `actions` only exports constants, not action creators. RNCamera code fully commented out. Not registered in any navigator. | +| MapScreen | `screens/map/MapScreen.js` | Entire file is dead. Stub class component with empty map (all features hidden via `visibility: 'off'`). Uses `connect(null, actions)`. Not registered in any navigator. | +| web_reducer | `reducers/web_reducer.js` | Entire slice is dead. No component reads `state.web`. The thunk has a missing import (`URL`). | +| `actions/` directory | `actions/types.js` | The `actions/` pattern is legacy. No action creators exist — only type exports. Multiple files `import * as actions from '../../actions'` expecting action creators but get only constants. | +| `react-native-actions-sheet` | `package.json` | Installed dependency never imported. | +| `react-native-maps` | `package.json` | Only used in dead MapScreen. | +| `GalleryRoutes.tsx` | `routes/GalleryRoutes.tsx` | Defines Gallery→Album stack navigator but it's never used — MainRoutes uses GalleryScreen directly. | +| `images.selectedImages` | `reducers/images_reducer.js` | In initial state but never populated or read. | +| `shared.isSelecting` / `shared.selected` | `reducers/shared_reducer.js` | In state but HomeScreen uses local useState instead. | + +--- + +## 8. Build and Config + +### Environment Configuration +- Uses `react-native-config` to load `.env` files +- Required env variables: + ``` + CURRENT_ENVIRONMENT=production|local + SECRET_CLIENT= + ID_CLIENT= + OLM_ENDPOINT= + LOCAL_SECRET_CLIENT= + LOCAL_ID_CLIENT= + LOCAL_OLM_ENDPOINT= + SENTRY_DSN= (referenced in App.tsx) + ``` +- Local environment hardcodes `http://olm.test` as endpoint (line 33 of types.js), ignoring `LOCAL_OLM_ENDPOINT` +- `console.log({ CURRENT_ENVIRONMENT })` left in production code (types.js:16) +- Client credentials logged to console in non-production (types.js:37-40) — security concern for debug builds + +### Build Scripts +```json +"scripts": { + "android": "react-native run-android", + "ios": "react-native run-ios", + "lint": "eslint .", + "start": "react-native start", + "test": "jest" +} +``` +No build, release, or codepush scripts. No CI/CD config files found. + +### iOS +- `reactNativePermissionsIOS` in package.json: Camera, LocationAccuracy, LocationWhenInUse, PhotoLibrary +- CocoaPods managed via Gemfile +- `ios/Podfile.lock` has local modifications (per git status) +- `ios/openlittermap.xcodeproj/project.pbxproj` has local modifications + +### Android +- Standard React Native Android setup +- Permissions handled via `react-native-permissions` +- No `proguard-rules.pro` customizations noted + +### Tests +- Jest configured with `react-native` preset +- **No test files exist in the codebase** — `jest.config.js` is present but there are zero `*.test.*` or `*.spec.*` files + +### TypeScript +- Mixed JS/TS codebase (most files are `.js`, some components are `.tsx`) +- `tsconfig.json` extends `@react-native/typescript-config` +- No strict mode enabled +- TypeScript 5.0.4 (outdated) + +--- + +## 9. Feature State + +| Feature | Screen(s) | Reducer(s) | API Calls | Status | Rating | +|---------|-----------|------------|-----------|--------|--------| +| **User Auth (Login/Signup)** | AuthScreen, SigninForm, SignupForm | auth | 3 | Login/signup forms work with Formik+Yup validation. **BLOCKED: Passport→Sanctum migration needed.** | 3/10 | +| **Password Reset** | ForgotPasswordForm | auth | 1 | Form works, sends email. Depends on Passport auth. | 5/10 | +| **Gallery Photo Selection** | GalleryScreen, AlbumScreen | gallery, images | 0 | CameraRoll access, gesture selection, album browsing. Permission check bug (BUG-01) bypassed but gallery still works. | 7/10 | +| **Image Upload** | HomeScreen | images, shared | 2 | Core flow: select → tag → upload. Sequential upload with progress. Cancel support. Error tracking. This is the most complete feature. | 7/10 | +| **Litter Tagging** | AddTags, LitterCategories, etc. | litter, images | 0 | 13 categories, quantity picker, custom tags, previous tags recall. Swiper navigation between images. Mostly works. Stats component broken (BUG-04). Delete button disabled (BUG-17). | 6/10 | +| **Upload History** | MyUploads | my_uploads_reducer | 1 | Paginated list with filters (date, tag, custom tag). Swipe actions (copy link, open map). Uses deprecated `result_string` field (BUG-12). | 5/10 | +| **Teams** | TeamScreen, TeamDetails, TopTeams | teams | 8 | Create, join, leave, view members, leaderboard. Leave team broken (BUG-06). Fake loading in TopTeams (BUG-11). Non-existent action exports (BUG-09). | 5/10 | +| **Leaderboards** | LeaderboardsScreen | leaderboard | 1 | Time-filtered leaderboard display. Error handling crashes on network error (BUG-07). Uses non-standard URL prefix. | 6/10 | +| **Global Stats** | GlobalDataScreen | stats | 1 | Animated counters for total litter, photos, users, littercoin. Progress bar to next milestone. Clean implementation. | 8/10 | +| **User Profile** | UserStatsScreen | auth | 0 | Displays user level, XP, tag counts, settings link. Navigation to MyUploads. | 7/10 | +| **Settings** | SettingsScreen, SettingsComponent | settings, auth | 4 | Edit name/username/email, privacy toggles, social accounts, delete account. Missing returns (BUG-03). Broken error display (BUG-05). | 5/10 | +| **Camera Capture** | CameraScreen | camera | 0 | **COMPLETELY BROKEN.** Class component, RNCamera commented out, uses dead `actions` import. Not reachable via navigation. | 0/10 | +| **Map View** | MapScreen | — | 0 | **STUB.** Empty map with all features hidden. Not reachable via navigation. | 0/10 | +| **App Version Check** | NewUpdateScreen, HomeScreen | shared | 1 | Checks backend for latest version, prompts user to update. Works. | 7/10 | +| **Internationalization** | All screens | — | 0 | 8 languages (en, de, nl, fr, es, pt, ca, et). Inconsistent key usage in LitterTagsCard. | 6/10 | +| **Welcome/Onboarding** | WelcomeScreen, Slides | — | 0 | 4 slides with Lottie animations and dot pagination. Clean implementation. | 8/10 | +| **Permissions** | CameraPermission, GalleryPermission | — | 0 | Handles camera, photo library, location permissions. Android 13+ handled correctly. | 7/10 | + +--- + +## 10. Honest Assessment + +### What Works +The core workflow — pick photos from gallery, tag them with litter categories, upload to the API — is functional and reasonably well-built. The Redux Toolkit migration from the old actions/types pattern is mostly complete. The gallery selection, image tagging UI (swiper, categories, custom tags), and upload flow are the strongest parts of the app. Internationalization covers 8 languages. The onboarding flow is polished. + +### What's Broken +1. **Auth is the #1 blocker.** The entire auth flow is built for Laravel Passport (OAuth2 password grant), but the backend has moved to Sanctum. Login/signup will not work against the current backend. This affects every authenticated feature. +2. **Camera is dead.** The in-app camera was never migrated from the old class component + `react-native-camera` pattern. RNCamera code is fully commented out. The screen is not registered in any navigator. This would need a full rewrite (react-native-vision-camera is the current standard). +3. **Map is a stub.** It renders an empty MapView with all features hidden. Not registered in any navigator. + +### Prioritized Bug List + +**P0 — App Won't Function:** +1. BUG-02: Auth Passport→Sanctum mismatch (all authenticated features blocked) + +**P1 — Features Broken:** +2. BUG-03: Missing `return` before `rejectWithValue` in settings (settings silently fail) +3. BUG-04: Stats.tsx reads `state.user` instead of `state.auth.user` (component shows nothing) +4. BUG-06: leaveTeam fulfilled handler empty (team leave appears to work but state not updated) +5. BUG-07: Leaderboard error handler crashes on network error +6. BUG-12: MyUploads references deprecated `result_string` column + +**P2 — Degraded Experience:** +7. BUG-01: Permission check always passes (masks permission issues) +8. BUG-05: SettingsComponent broken error display for delete account +9. BUG-08/09/10: Exported non-existent actions (will crash if dispatched) +10. BUG-11: TopTeamsScreen hardcoded 3-second loading +11. BUG-15: changeActiveTeam pushes wrong payload shape to userTeams +12. BUG-17: LitterBottomButtons delete is disabled + +**P3 — Cleanup:** +13. BUG-13/14: web_reducer dead code with missing import and name collision +14. BUG-16: Async useEffect in AlbumScreen +15. BUG-18: Duplicate color definitions +16. BUG-19: LitterTagsCard inconsistent i18n keys +17. BUG-20: Token sent in both header and body for deleteAccount +18. Console.log of environment/credentials in types.js +19. Dead code: CameraScreen, MapScreen, web_reducer, GalleryRoutes, unused deps + +### Salvageability Verdict + +**The app is salvageable.** The core architecture (Redux Toolkit, React Navigation v6, functional components with hooks) is sound and modern enough. The gallery → tag → upload pipeline works. The main effort is: + +1. **Auth rewrite for Sanctum** (~1-2 days) — Replace Passport OAuth flow with Sanctum token auth. Remove client_id/client_secret. Update login, register, and token validation endpoints. + +2. **Fix the P1 bugs** (~1 day) — These are mostly one-line fixes (add `return`, fix state paths, add optional chaining, uncomment reducer logic). + +3. **Camera rewrite** (~2-3 days) — Full rewrite using `react-native-vision-camera`. New functional component, GPS capture, integrate with existing images_reducer. + +4. **Map implementation** (~2-3 days) — Rewrite MapScreen as functional component, show user's litter data points, integrate with navigation. + +5. **Dead code cleanup** (~half day) — Remove CameraScreen, MapScreen (old versions), web_reducer, unused deps, GalleryRoutes. + +**Estimated total to reach a shippable v7: 7-10 days of focused work.** + +The codebase does NOT need a rewrite from scratch. The reducer layer, navigation structure, and UI components are a solid foundation. Fix auth first, fix the bugs, then build the camera and map features fresh. diff --git a/App.tsx b/App.tsx index 129028e9..9499f3fa 100644 --- a/App.tsx +++ b/App.tsx @@ -3,16 +3,20 @@ import React from 'react'; import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; import { NavigationContainer } from '@react-navigation/native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { MainRoutes } from './routes'; import * as Sentry from '@sentry/react-native'; import Config from 'react-native-config'; import configureAppStore from './store'; import { IS_PRODUCTION } from './actions/types'; +import setupAxiosInterceptors from './utils/setupAxiosInterceptors'; import './i18n'; const SENTRY_DSN = Config.SENTRY_DSN; const { store, persistor } = configureAppStore(); +setupAxiosInterceptors(store); + const App = () => { if (IS_PRODUCTION) { Sentry.init({ @@ -21,13 +25,15 @@ const App = () => { } return ( - - - - - - - + + + + + + + + + ); }; diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6975bd57 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenLitterMap is a React Native mobile app (iOS & Android) for crowdsourced litter mapping. Users photograph litter, tag it by category, and upload geotagged data to the OpenLitterMap Laravel backend API. Authentication uses Laravel Sanctum token-based auth (login with email or username). + +## Common Commands + +```bash +# Install dependencies +yarn install + +# Start Metro bundler +yarn start + +# Run on iOS / Android +yarn ios +yarn android + +# Install iOS native dependencies +cd ios && bundle exec pod install && cd .. + +# Lint +yarn lint + +# Run tests +yarn test + +# Run a single test file +npx jest path/to/test.js +``` + +Runtime: **Node v22.12.0**, **npm 11.1.0** + +Package manager: **Yarn** (v3.6.4, specified in `packageManager` field). Both `yarn.lock` and `package-lock.json` exist; prefer yarn. + +## Architecture + +### State Management + +Redux Toolkit with `createSlice` and `createAsyncThunk`. The store is configured in `store/index.js` with `redux-persist` (AsyncStorage backend, `auth` and `images` slices persisted). The `images` transform only persists `imagesArray` — upload counters reset on relaunch. In dev mode, `redux-immutable-state-invariant` middleware is included. + +Reducers in `reducers/`: +- `auth_reducer` - Authentication, user profile, JWT token management +- `tags_reducer` - Tag data fetched from API, search index (objectEntries, categoriesById, entriesByCloId) +- `camera_reducer` / `gallery_reducer` / `images_reducer` - Photo capture, selection, v5 tagging (tagsV5), swiperIndex +- `shared_reducer` - Cross-feature shared state +- `settings_reducer` - User preferences +- `stats_reducer` / `leaderboards_reducer` - Statistics and rankings +- `team_reducer` - Team features +- `my_uploads_reducer` - Upload management + +API calls use **axios** with Bearer token auth, hitting endpoints on the `URL` from `actions/types.js`. + +### Navigation + +React Navigation v6 with `@react-navigation/stack` and `@react-navigation/material-top-tabs`. + +- `routes/MainRoutes.js` - Root navigator. Shows `AuthStack` when no token, otherwise the main app stack. +- `routes/AuthStack.js` - Welcome → Auth (login/signup) flow. +- `routes/TabRoutes.tsx` - Bottom tab navigator: Home, Team, Global Data, Leaderboards, User Stats. +- `routes/PermissionStack.tsx` - Camera/gallery permission request flow. +- Modal screens: AddTagScreen, Album, Settings, Update, MyUploads. + +### Screen Organization + +Each screen lives in `screens//` with a main screen component and a subdirectory for sub-components (e.g., `screens/home/homeComponents/`, `screens/addTag/components/`, `screens/userStats/userComponents/`). + +Shared/reusable components are in `screens/components/` with barrel exports via `index.ts`: +- `theme/colors.ts` - App color palette (`Colors.accent`, `Colors.error`, etc.) +- `theme/fonts.ts` - Font definitions +- `typography/` - Styled text components (Title, SubTitle, Body, Caption, StyledText) +- `Button.tsx`, `Header.js`, `AnimatedCircle.tsx`, `StatsGrid`, `IconStatsCard`, `CustomTextInput` + +### Environment Configuration + +Uses `react-native-config` to load `.env` variables. Key env vars defined in `actions/types.js`: +- `CURRENT_ENVIRONMENT` - `"production"` or `"local"` +- `SECRET_CLIENT` / `ID_CLIENT` / `OLM_ENDPOINT` - Production OAuth credentials and API URL +- `LOCAL_SECRET_CLIENT` / `LOCAL_ID_CLIENT` / `LOCAL_OLM_ENDPOINT` - Local dev equivalents +- `SENTRY_DSN` - Error tracking (only initialized in production) + +### Internationalization + +i18next with `react-i18next`. Translation files in `assets/langs/` (en, ar, de, es, fr, ie, nl, pt). Default language auto-detected from device locale via `react-native-localize`, falling back to English. Configured in `i18n.js`. + +### Litter Data Model + +Tag data is fetched from the API (`GET /api/tags/all`) and cached in AsyncStorage (7-day TTL) by `tags_reducer.js`. Per-image tags are stored as `tagsV5: [{ cloId, quantity }]` in `images_reducer`. The `cloId` (category_litter_object_id) uniquely identifies an (object, category) pair. Display names are resolved at render time from `entriesByCloId`. + +## Code Style + +- ESLint extends `@react-native` with 4-space indentation and no trailing commas +- Prettier: single quotes, no bracket spacing, arrow parens avoided, trailing commas +- Mixed JS/TS codebase (newer files tend to be TypeScript) +- Screens export via barrel files (`index.js` or `index.ts`) + +## Key Dependencies + +- `@shopify/flash-list` for performant lists +- `react-native-maps` for map display +- `formik` + `yup` for form handling/validation +- `lottie-react-native` for animations +- `react-native-permissions` for camera/location/photo library permissions (iOS permissions listed in `reactNativePermissionsIOS` in package.json) +- `@sentry/react-native` for error tracking (production only). Sentry Cocoa SDK version is overridden to 8.46.0+ via `postinstall` script for Xcode 26 compatibility +- `moment` for date formatting + +## Build Notes + +- **Xcode 26+/macOS Tahoe**: Sentry Cocoa SDK < 8.46.0 fails to compile (`std::allocator does not support const types`). The `postinstall` script in package.json patches the RNSentry podspec to use 8.46.0. After `npm install`, run `cd ios && pod update Sentry && cd ..` if the Podfile.lock still references an older version. +- After modifying native dependencies: clean Xcode build folder (Cmd+Shift+K) and rebuild diff --git a/GPS_AUDIT.md b/GPS_AUDIT.md new file mode 100644 index 00000000..da2684f0 --- /dev/null +++ b/GPS_AUDIT.md @@ -0,0 +1,148 @@ +# GPS_AUDIT.md + +GPS coordinate extraction audit for OpenLitterMap React Native app. +Traces the complete path from photo selection to upload FormData. + +**Status: All P0/P1/P2 fixes implemented. See "Resolution Status" at end of document.** + +--- + +## 1. Flow Diagram: Gallery Selection → GPS Extraction → Upload + +``` + GALLERY FLOW (CURRENT — AFTER FIXES) + ===================================== + + [1] HomeScreen.js:96 + └─ checkGalleryPermission() + └─ checkCameraRollPermission() (utils/permissions) + ├─ iOS: check PHOTO_LIBRARY + └─ Android 13+: check READ_MEDIA_IMAGES + └─ ALSO checks ACCESS_MEDIA_LOCATION ✅ FIXED + └─ Returns 'limited' if only READ_MEDIA_IMAGES granted + + [2] HomeScreen.js:115 + └─ dispatch(getPhotosFromCameraroll()) + + [3] gallery_reducer.js + └─ CameraRoll.getPhotos({ + include: ['location', 'filename', 'fileSize', 'imageSize'] + }) + └─ ALL photos loaded (not filtered by GPS) ✅ CHANGED + └─ Each photo tagged with hasGps boolean + └─ __DEV__ debug logging for GPS diagnosis ✅ ADDED + + [4] GalleryScreen.js + └─ Shows ALL photos with visual GPS indicators ✅ CHANGED + └─ Non-geotagged: dimmed + red icon, not selectable + └─ User selects geotagged photos only + └─ dispatch(addImages({ images: selectedImages, picked_up })) + + [5] images_reducer.js — addImages reducer + └─ lat: image.lat ?? null ✅ FIXED (was ?? 0) + └─ lon: image.lon ?? null ✅ FIXED (was ?? 0) + + [6] HomeScreen.js — uploadPhotos() + └─ Pre-upload filter: isGeotagged(img) check ✅ ADDED + └─ Alert if any photos skipped (no GPS) ✅ ADDED + └─ Only geotagged photos proceed to FormData + + [7] utils/isGeotagged.js ✅ REWRITTEN + └─ Rejects null, undefined, AND 0,0 coordinates + └─ WEB images always pass + + [8] images_reducer.js — uploadImage thunk + └─ POST /api/photos/upload/with-or-without-tags + └─ Only valid coordinates reach backend +``` + +--- + +## 2. Exact Line Numbers — GPS Data Read and Passed + +| Step | File | What happens | +|------|------|-------------| +| CameraRoll fetch | `gallery_reducer.js` | `include: ['location']` — requests GPS from CameraRoll | +| GPS detection | `gallery_reducer.js` | Each photo gets `hasGps` boolean, counts tracked | +| Gallery display | `GalleryScreen.js` | All photos shown, non-GPS dimmed and non-selectable | +| Gallery → images | `GalleryScreen.js` | `dispatch(addImages({ images: sortedArray }))` — only selected (geotagged) images | +| Null-safe storage | `images_reducer.js` | `lat: image.lat ?? null, lon: image.lon ?? null` — missing GPS stays null | +| Pre-upload filter | `HomeScreen.js` | `isGeotagged()` filters before upload, Alert shows skip count | +| isGeotagged check | `utils/isGeotagged.js` | Rejects null, undefined, AND 0,0 — returns false for invalid GPS | +| FormData build | `HomeScreen.js` | Only geotagged images reach FormData | +| Upload | `images_reducer.js` | POST with FormData to backend | + +--- + +## 3. What Works on iOS and Why + +iOS CameraRoll **reliably returns `node.location`** for geotagged photos because: + +1. iOS photo library natively stores GPS metadata and exposes it through the Photos framework +2. `PHOTO_LIBRARY` permission is sufficient to read location metadata on iOS +3. The `include: ['location']` parameter works correctly on iOS +4. Photos taken with Location Services enabled always have embedded GPS in EXIF + +--- + +## 4. What Was Failing on Android and How It Was Fixed + +### Problem 1: CameraRoll returns null location (FIXED) + +On Android 13+, `READ_MEDIA_IMAGES` grants photo access but NOT location metadata. `ACCESS_MEDIA_LOCATION` must be separately granted. + +**Fix**: `checkCameraRollPermission()` now checks AND requests `ACCESS_MEDIA_LOCATION` on Android 13+. Returns `'limited'` if only `READ_MEDIA_IMAGES` is granted. + +### Problem 2: `?? 0` converting null GPS to 0,0 (FIXED) + +`images_reducer.js` used `lat: image.lat ?? 0` which silently converted missing GPS to 0,0. + +**Fix**: Changed to `lat: image.lat ?? null` in both `addImages` reducer and `getUntaggedImages.fulfilled` handler. + +### Problem 3: `isGeotagged(0,0)` returning true (FIXED) + +The old `isGeotagged()` only checked `typeof img.lat === 'number'`, which passed for 0. + +**Fix**: Complete rewrite. Now rejects null, undefined, AND explicitly checks for 0,0 (Null Island). + +### Problem 4: Non-geotagged photos silently hidden (FIXED) + +Gallery previously only showed geotagged photos. Users with Android GPS issues saw an empty gallery with no explanation. + +**Fix**: Gallery now shows ALL photos. Non-geotagged photos are visually dimmed (0.4 opacity + red location-off icon) and not selectable. Warning banner explains why. Empty state shows when no geotagged photos found. + +### Problem 5: No pre-upload GPS validation (FIXED) + +Photos could reach the upload endpoint with invalid coordinates. + +**Fix**: `uploadPhotos()` now filters through `isGeotagged()` before uploading. If any photos are skipped, Alert shows count and asks for confirmation. + +--- + +## 5. Is Any EXIF Library Used? + +**NO.** There is no EXIF parsing library in the project. The app relies entirely on `CameraRoll.getPhotos()` with `include: ['location']` for GPS data. + +This means if CameraRoll doesn't return location (possible on some Android devices even with ACCESS_MEDIA_LOCATION), there is no fallback. A future enhancement could add `@lodev09/react-native-exify` for direct EXIF reading. + +--- + +## 6. Is Device Location Used as Fallback? + +**NO.** Device location is only used for the (disabled) camera feature. Device GPS coordinates are never used as a fallback for photo coordinates. + +--- + +## 7. Resolution Status + +| Priority | Fix | Status | +|----------|-----|--------| +| P0 | Change `?? 0` to `?? null` in images_reducer.js | **DONE** | +| P0 | Add 0,0 check to `isGeotagged()` | **DONE** | +| P1 | Fix `checkCameraRollPermission()` to verify ACCESS_MEDIA_LOCATION on Android 13+ | **DONE** | +| P1 | Show all photos in gallery with GPS visual indicators | **DONE** | +| P1 | Add `__DEV__` GPS debug logging | **DONE** | +| P2 | Pre-upload GPS validation with Alert | **DONE** | +| P3 | EXIF fallback library (`@lodev09/react-native-exify`) | **DEFERRED** — pending real-device testing | +| P3 | Device location fallback with user confirmation | **DEFERRED** | +| P3 | Manual map location picker | **DEFERRED** | diff --git a/LocalDev.md b/LocalDev.md new file mode 100644 index 00000000..2068bbf4 --- /dev/null +++ b/LocalDev.md @@ -0,0 +1,218 @@ +# LocalDev.md +> OpenLitterMap React Native v7.0 — Local Development Notes + +## Local Server + +- URL: `https://olm.test` (Laravel Valet, HTTPS with self-signed cert) +- HTTP redirects to HTTPS (301) +- Set via `.env`: `CURRENT_ENVIRONMENT='local'` → `http://olm.test` in `actions/types.js` + - Note: `actions/types.js` hardcodes `'http://olm.test'` but Valet forces HTTPS + +## GET /api/tags/all — Response Shape + +**Top-level keys:** +``` +{ + categories: 11 items + objects: 113 items + materials: 38 items + brands: 2,615 items + types: 17 items + category_objects: 117 items ← THIS IS THE PIVOT TABLE (cloId source) + category_object_types: 41 items +} +``` + +### categories +```json +{ "id": 1, "key": "smoking" } +{ "id": 2, "key": "alcohol" } +{ "id": 3, "key": "beverages" } +{ "id": 4, "key": "food" } +... +``` +11 total: smoking, alcohol, beverages, food, personal_care, medical, industrial, vehicles, marine, electronics, pets + +### objects +```json +{ "id": 1, "key": "butts", "categories": [{ "id": 1, "key": "smoking" }] } +{ "id": 14, "key": "can", "categories": [ + { "id": 2, "key": "alcohol" }, + { "id": 3, "key": "beverages" }, + { "id": 4, "key": "food" } +]} +``` +- Each object has a `categories` array (eager-loaded) +- **NO pivot.id on the category relationship** — cloId is NOT here +- 96 objects have categories, 17 have empty `categories: []` +- Key format: all `snake_case` (e.g., `broken_glass`, `cigarette_box`, `car_part`, `coffee_pod`) + +### category_objects — THE PIVOT TABLE (cloId) +```json +{ "id": 1, "category_id": 1, "litter_object_id": 1 } +{ "id": 13, "category_id": 2, "litter_object_id": 13 } +{ "id": 25, "category_id": 3, "litter_object_id": 13 } +``` +- `id` = the **cloId** (category_litter_object_id) +- Maps one (category, object) pair to a unique identifier +- 117 entries total + +### Multi-category objects (disambiguated by cloId) +``` +can (object_id=14): + cloId=14 → can + alcohol + cloId=26 → can + beverages + cloId=40 → can + food + +bottle (object_id=13): + cloId=13 → bottle + alcohol + cloId=25 → bottle + beverages + +battery (object_id=76): + cloId=83 → battery + electronics + cloId=84 → battery + vehicles + +broken_glass (object_id=18): + cloId=18 → broken_glass + alcohol + cloId=27 → broken_glass + beverages + +cup (object_id=22): + cloId=22 → cup + alcohol + cloId=28 → cup + beverages +``` + +### Objects with empty categories (17) +These have no `category_objects` entries either: +``` +beer_bottle, beer_can, bottletops, brokenglass, chemical, +crisp_large, crisp_small, filters, glass_jar, oil, paper, +plastic_packaging, polystyrene, receipt, sweet_wrapper, +tobacco, wine_bottle +``` +These appear to be legacy/duplicate objects (e.g., `brokenglass` duplicates `broken_glass`, `beer_can` duplicates `can + alcohol`). They should NOT appear in the mobile tagging UI. + +### materials +```json +{ "id": 1, "key": "aluminium" } +{ "id": 2, "key": "bronze" } +``` +38 total. Not used in v1 tagging. + +### brands +```json +{ "id": 118, "key": "100smoothie" } +``` +2,615 total. Not used in v1 tagging. + +### types +```json +{ "id": 1, "key": "beer", "name": "Beer" } +{ "id": 10, "key": "coffee", "name": "Coffee" } +``` +17 total. Types have a `name` field (others only have `key`). Not used in v1 tagging. + +### category_object_types +```json +{ "category_litter_object_id": 13, "litter_object_type_id": 1 } +``` +Links category_objects to types. Not used in v1 tagging. + +## cloId Resolution + +The cloId is **NOT** on the object's category pivot — it's in the separate `category_objects` array. To build the search index: + +``` +For each entry in category_objects: + cloId = entry.id + object = objects[entry.litter_object_id] + category = categories[entry.category_id] + → { cloId, objectId, objectKey, categoryId, categoryKey } +``` + +Skip any `category_objects` entry where the object or category lookup fails (shouldn't happen — no orphans found). + +Also skip objects with empty categories (17 legacy items) — they have no `category_objects` entries. + +## Web App Tagging Schema (from readme/Tags.md) + +### How the web frontend sends tags (POST /api/v3/tags) + +The web Vue frontend sends this format per tag: +```json +{ + "object": { "id": 5, "key": "butts" }, + "quantity": 3, + "picked_up": true, + "materials": [{ "id": 2, "key": "plastic" }], + "brands": [{ "id": 1, "key": "marlboro" }], + "custom_tags": ["dirty-bench"] +} +``` + +**Key detail: The web does NOT send `clo_id` or `category` in the payload.** +The backend `resolveTag()` auto-resolves the category from `object->categories()->first()`. + +### How the web uses cloId + +The web pre-resolves cloId on the *frontend* for validation only: +- `searchableTags` computed builds one entry per (object, category) pair +- Each entry has a `cloId` looked up via `getCloId(categoryId, objectId)` +- `hasUnresolvedTags` computed blocks submit if any tag lacks a resolved `cloId` +- But the cloId is **not sent in the POST payload** + +### Web search index structure +``` +Each entry has: +- id: composite "obj-{objectId}-cat-{categoryId}" +- cloId: pre-resolved from store's getCloId() +- categoryId, categoryKey +- lowerKey: precomputed key.toLowerCase() for fast search +``` + +### formatKey (web implementation) +```js +key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) +``` +All keys are snake_case → "Title Case" (e.g., `broken_glass` → "Broken Glass"). + +### XP calculation (v1 mobile — objects only, no materials/brands) +``` +XP = 5 (upload base) + + sum of quantities (1 XP per item) + + 5 if picked_up +``` +Full XP table: upload=5, object=1/item, material=2/item, brand=3/item, custom=1/item, picked_up=+5. + +### Tag types (4 total, only #1 for mobile v1) +1. **Object tag** — `{ object: {id, key}, quantity, picked_up, materials, brands, custom_tags }` +2. Custom-only tag — `{ custom: true, key: "dirty-bench", quantity, picked_up }` +3. Brand-only tag — `{ brand_only: true, brand: {id, key}, quantity, picked_up }` +4. Material-only tag — `{ material_only: true, material: {id, key}, quantity, picked_up }` + +## RESOLVED: Mobile payload format + +**Decision**: Mobile sends `category` explicitly (unlike web which omits it). +This ensures correct disambiguation for multi-category objects like bottle, can, etc. +The web relies on `object->categories()->first()` which is wrong for multi-category objects. + +Mobile POST /api/v3/tags payload: +```json +{ + "photo_id": 123, + "tags": [ + { + "object": { "id": 5, "key": "butts" }, + "category": { "id": 1, "key": "smoking" }, + "quantity": 3, + "picked_up": true, + "materials": [], + "brands": [], + "custom_tags": [] + } + ], + "picked_up": 1 +} +``` + +The `cloId` is used client-side only (for search index, per-image storage, and +validation). It is NOT sent in the POST payload. diff --git a/actions/types.js b/actions/types.js index 99d51b26..516e3335 100644 --- a/actions/types.js +++ b/actions/types.js @@ -1,44 +1,20 @@ -// Import keys to authenticate with your Laravel backend -// See https://laravel.com/docs/8.x/passport#the-passportclient-command import Config from 'react-native-config'; -// Production -const SECRET_CLIENT = Config.SECRET_CLIENT; -const ID_CLIENT = Config.ID_CLIENT; const OLM_ENDPOINT = Config.OLM_ENDPOINT; - -// Local -const LOCAL_SECRET_CLIENT = Config.LOCAL_SECRET_CLIENT; -const LOCAL_ID_CLIENT = Config.LOCAL_ID_CLIENT; const LOCAL_OLM_ENDPOINT = Config.LOCAL_OLM_ENDPOINT; const CURRENT_ENVIRONMENT = Config.CURRENT_ENVIRONMENT; -console.log({ CURRENT_ENVIRONMENT }); // change this when working locally to disable sentry export const IS_PRODUCTION = CURRENT_ENVIRONMENT === 'production'; -let CLIENT = ''; -let SECRET = ''; let ENDPOINT = ''; if (CURRENT_ENVIRONMENT === 'production') { - CLIENT = ID_CLIENT; - SECRET = SECRET_CLIENT; ENDPOINT = OLM_ENDPOINT; } else if (CURRENT_ENVIRONMENT === 'local') { - CLIENT = LOCAL_ID_CLIENT; - SECRET = LOCAL_SECRET_CLIENT; - ENDPOINT = 'http://olm.test'; // LOCAL_OLM_ENDPOINT; -} - -if (CURRENT_ENVIRONMENT !== 'production') { - console.log('CLIENT', CLIENT); - console.log('SECRET', SECRET); - console.log('ENDPOINT', ENDPOINT); + ENDPOINT = 'http://olm.test'; } -export const CLIENT_ID = CLIENT; -export const CLIENT_SECRET = SECRET; export const URL = ENDPOINT; diff --git a/assets/data/categories.js b/assets/data/categories.js deleted file mode 100644 index a0d1d4a2..00000000 --- a/assets/data/categories.js +++ /dev/null @@ -1,57 +0,0 @@ -const CATEGORIES = [ - { - title: 'smoking', - path: require('../icons/smoking2.png') - }, - { - title: 'alcohol', - path: require('../icons/beer.png') - }, - { - title: 'dogshit', - path: require('../icons/paw.png') - }, - { - title: 'coffee', - path: require('../icons/coffee_icon.png') - }, - { - title: 'food', - path: require('../icons/food_icon.png') - }, - { - title: 'softdrinks', - path: require('../icons/plastic-bottle.png') - }, - { - title: 'brands', - path: require('../icons/briefcase.png') - }, - { - title: 'sanitary', - path: require('../icons/toilet.png') - }, - { - title: 'coastal', - path: require('../icons/ocean-transportation.png') - }, - { - title: 'dumping', - path: require('../icons/dumping.png') - }, - { - title: 'industrial', - path: require('../icons/industrial.png') - }, - { - title: 'material', - path: require('../icons/package.png') - }, - { - title: 'other', - path: require('../icons/dice2.png') - } - // { title: 'art', path: require('../icons/art_icon.png') } -]; - -export default CATEGORIES; diff --git a/assets/data/litterkeys.js b/assets/data/litterkeys.js deleted file mode 100644 index 675a881c..00000000 --- a/assets/data/litterkeys.js +++ /dev/null @@ -1,285 +0,0 @@ -const LITTERKEYS = { - smoking: [ - 'butts', // Cigarette/Butts - 'lighters', // Lighters - 'cigaretteBox', // Cigarette Box - 'tobaccoPouch', // Tobacco Pouch - 'skins', // Rolling Papers - 'smoking_plastic', // Plastic Packaging - 'filters', // Filters - 'filterbox', // Filter Box - 'smokingOther', // Smoking-Other - 'vape_pen', // Vape pen - 'vape_oil' // Vape oil - ], - alcohol: [ - 'beerBottle', // Beer Bottles - 'spiritBottle', // Spirit Bottles - 'wineBottle', // Wine Bottles - 'beerCan', // Beer Cans - 'brokenGlass', // Broken Glass - 'bottleTops', // Beer bottle tops - 'paperCardAlcoholPackaging', // Paper Packaging - 'plasticAlcoholPackaging', // Plastic Packaging - 'alcoholOther', // Alcohol-Other - 'pint', // Pint Glass - 'six_pack_rings', // Six-pack rings - 'alcohol_plastic_cups' // Plastic Cups - ], - coffee: [ - 'coffeeCups', // Coffee Cups - 'coffeeLids', // Coffee Lids - 'coffeeOther' // Coffee-Other - ], - food: [ - 'sweetWrappers', // Sweet Wrappers - 'paperFoodPackaging', // Paper/Cardboard Packaging - 'plasticFoodPackaging', // Plastic Packaging - 'plasticCutlery', // Plastic Cutlery - 'crisp_small', // Crisp/Chip Packet (small) - 'crisp_large', // Crisp/Chip Packet (large) - 'styrofoam_plate', // Styrofoam Plate - 'napkins', // Napkins - 'sauce_packet', // Sauce Packet - 'glass_jar', // Glass Jar - 'glass_jar_lid', // Glass Jar Lid - 'pizza_box', // Pizza Box - 'aluminium_foil', // Aluminium Foil - 'chewing_gum', // Chewing Gum - 'foodOther' // Food-other - ], - softdrinks: [ - 'waterBottle', // Plastic Water bottle - 'fizzyDrinkBottle', // Plastic Fizzy Drink bottle - 'tinCan', // Can - 'bottleLid', // Bottle Tops - 'bottleLabel', // Bottle Labels - 'sportsDrink', // Sports Drink bottle - 'straws', // Straws - 'plastic_cups', // Plastic Cups - 'plastic_cup_tops', // Plastic Cup Tops - 'milk_bottle', // Milk Bottle - 'milk_carton', // Milk Carton - 'paper_cups', // Paper Cups - 'juice_cartons', // Juice Cartons - 'juice_bottles', // Juice Bottles - 'juice_packet', // Juice Packet - 'ice_tea_bottles', // Ice Tea Bottles - 'ice_tea_can', // Ice Tea Can - 'energy_can', // Energy Can - 'pullring', // Pull-ring - 'strawpacket', // Straw Packaging - 'styro_cup', // Styrofoam Cup - 'softDrinkOther' // Soft Drink (other) - ], - sanitary: [ - 'gloves', // Gloves - 'facemask', // Facemask - 'condoms', // Condoms - 'nappies', // Nappies - 'menstral', // Menstral - 'deodorant', // Deodorant - 'ear_swabs', // Ear Swabs - 'tooth_pick', // Tooth Pick - 'tooth_brush', // Tooth Brush - 'wetwipes', // Wet Wipes - 'sanitaryOther', // Sanitary (other) - 'hand_sanitiser' // Hand Sanitiser - ], - other: [ - 'random_litter', // Random Litter - 'bags_litter', // Bags of Litter - 'overflowing_bins', // Overflowing Bins - 'plastic', // Unidentifiable Plastic - 'automobile', // Automobile - 'tyre', // Tyre - 'traffic_cone', // Traffic Cone - 'metal', // Metal Object - 'plastic_bags', // Plastic Bags - 'election_posters', // Election Posters - 'forsale_posters', // For Sale Posters - 'cable_tie', // Cable tie - 'books', // Books - 'magazine', // Magazines - 'paper', // Paper - 'stationary', // Stationery - 'washing_up', // Washing-up Bottle - 'clothing', // Clothing - 'hair_tie', // Hair Tie - 'ear_plugs', // Ear Plugs (music) - 'elec_small', // Electric small - 'elec_large', // Electric large - 'batteries', // Batteries - 'balloons', // Balloons - 'life_buoy', // Life Buoy - 'other' // Other (other) - ], - dumping: [ - 'small', // Small - 'medium', // Medium - 'large' // Large - ], - industrial: [ - 'oil', // Oil - 'industrial_plastic', // Plastic - 'chemical', // Chemical - 'bricks', // Bricks - 'tape', // Tape - 'industrial_other' // Other - ], - coastal: [ - 'microplastics', // Microplastics - 'mediumplastics', // Mediumplastics - 'macroplastics', // Macroplastics - 'rope_small', // Rope small - 'rope_medium', // Rope medium - 'rope_large', // Rope large - 'fishing_gear_nets', // Fishing gear/nets - 'buoys', // Buoys - 'degraded_plasticbottle', // Degraded Plastic Bottle - 'degraded_plasticbag', // Degraded Plastic Bag - 'degraded_straws', // Degraded Drinking Straws - 'degraded_lighters', // Degraded Lighters - 'balloons', // Balloons - 'lego', // Lego - 'shotgun_cartridges', // Shotgun Cartridges - 'styro_small', // Styrofoam small - 'styro_medium', // Styrofoam medium - 'styro_large', // Styrofoam large - 'coastal_other' // Coastal (other - ], - brands: [ - 'aadrink', // AA Drink - 'adidas', // Adidas - 'albertheijn', //AlbertHeijn - 'aldi', // aldi - 'amazon', // Amazon - 'amstel', // Amstel - 'apple', // Apple - 'applegreen', // applegreen - 'asahi', // asahi - 'avoca', // avoca - 'bacardi', // Bacardi - 'ballygowan', // ballygowan - 'bewleys', // bewleys - 'brambles', // brambles - 'budweiser', // Budweiser - 'bulmers', // bulmers - 'bullit', // Bullit - 'burgerking', // burgerking - 'butlers', // butlers - 'cadburys', // cadburys - 'cafenero', // cafenero - 'camel', // Camel - 'caprisun', // Capri Sun - 'carlsberg', // carlsberg - 'centra', // centra - 'circlek', // circlek - 'coke', // Coca-Cola - 'coles', // coles - 'colgate', // Colgate - 'corona', // Corona - 'costa', // Costa - 'doritos', // Doritos - 'drpepper', // DrPepper - 'dunnes', // Dunnes - 'duracell', // Duracell - 'durex', // Durex - 'esquires', // Esquires - 'evian', // evian - 'fanta', // Fanta - 'fernandes', // Fernandes - 'fosters', // fosters - 'frank_and_honest', // Frank-and-Honest - 'fritolay', // Frito-Lay - 'gatorade', // Gatorade - 'gillette', // Gillette - 'goldenpower', // goldenpower - 'guinness', // guinness - 'haribo', // Haribo - 'heineken', // Heineken - 'hertog_jan', // Hertog Jan - 'insomnia', // Insomnia - 'kellogs', // Kellogs - 'kfc', // KFC - 'lavish', // Lavish - 'lego', // Lego - 'lidl', // Lidl - 'lindenvillage', // Lindenvillage - 'lipton', // Lipton - 'lolly_and_cookes', // Lolly-and-cookes - 'loreal', // Loreal - 'lucozade', // Lucozade - 'marlboro', // Marlboro - 'mars', // Mars - 'mcdonalds', // McDonalds - 'monster', // Monster - 'nero', // nero - 'nescafe', // Nescafe - 'nestle', // Nestle - 'nike', // Nike - 'obriens', // O-Briens - 'pepsi', // Pepsi - 'powerade', // Powerade - 'redbull', // Redbull - 'ribena', // Ribena - 'sainsburys', // Sainsburys - 'samsung', // Samsung - 'schutters', // Schutters - 'slammers', // Slammers - 'spa', // SPA - 'spar', // Spar - 'starbucks', // Starbucks - 'stella', // Stella - 'subway', // Subway - 'supermacs', // Supermacs - 'supervalu', // Supervalu - 'tayto', // Tayto - 'tesco', // Tesco - 'thins', // Thins - 'tim_hortons', - 'volvic', // Volvic - 'waitrose', // Waitrose - 'walkers', // Walkers - 'wendys', - 'wilde_and_greene', // Wilde-and-Greene - 'woolworths', // Woolworths - 'wrigleys' // Wrigley - ], - trashdog: [ - 'trashdog', // TrashDog - 'littercat', // LitterCat - 'duck' // LitterDuc - ], - dogshit: [ - 'poo', // Surprise! - 'poo_in_bag' // Surprise in a bag! - ], - material: [ - 'aluminium', - 'bronze', - 'carbon_fiber', - 'ceramic', - 'composite', - 'concrete', - 'copper', - 'fiberglass', - 'glass', - 'iron_or_steel', - 'latex', - 'metal', - 'nickel', - 'nylon', - 'paper', - 'plastic', - 'polyethylene', - 'polymer', - 'polypropylene', - 'polystyrene', - 'pvc', - 'rubber', - 'titanium', - 'wood' - ] -}; -export default LITTERKEYS; diff --git a/assets/langs/en/auth.json b/assets/langs/en/auth.json index f16ce86c..21df2387 100644 --- a/assets/langs/en/auth.json +++ b/assets/langs/en/auth.json @@ -11,8 +11,10 @@ "enter-email": "Please enter an email address", "enter-password": "Please enter a password", "enter-username": "Please enter a username", - "must-contain": "Must contain 1 uppercase, 1 digit, 6+ characters", + "must-contain": "Password must be at least 6 characters", "alphanumeric-username": "Must be alphanumeric, 8-20 characters, no spaces", + "email-or-username": "Email or Username", + "enter-email-or-username": "Please enter your email or username", "email-not-valid": "This is not a valid email address", "username-min-max": "Username should be between 3-20 characters", "username-equal-to-password": "Username should not be equal to password", diff --git a/assets/langs/en/stats.json b/assets/langs/en/stats.json index 2e63b1e6..24c68bae 100644 --- a/assets/langs/en/stats.json +++ b/assets/langs/en/stats.json @@ -1,8 +1,11 @@ { "global-data": "Global Data", "next-target": "Next Target\n{{count}} Litter", - "total-litter": "Total Litter", + "total-litter": "Total Tags", "total-photos": "Total Photos", "total-users": "Total Users", - "total-littercoin": "Total Littercoin" + "total-littercoin": "Total Littercoin", + "new-today": "New Today", + "new-7-days": "This Week", + "new-30-days": "This Month" } diff --git a/assets/langs/en/team.json b/assets/langs/en/team.json index 963dfec4..9113de8c 100644 --- a/assets/langs/en/team.json +++ b/assets/langs/en/team.json @@ -1,3 +1,3 @@ { - "total-members": "Total Members" + "total-members": "Total People" } diff --git a/assets/langs/en/welcome.json b/assets/langs/en/welcome.json index 005946ff..176ef551 100644 --- a/assets/langs/en/welcome.json +++ b/assets/langs/en/welcome.json @@ -3,9 +3,10 @@ "easy": "EASY!", "just-tag-and-upload": "Just tag litter and upload it", "fun": "FUN!", - "climb-leaderboards": "Climb the leaderboards at the #LitterWorldCup", + "climb-leaderboards": "Climb the leaderboards", "open": "OPEN!", "open-database": "Help us create the world's most advanced open database on litter and plastic pollution", + "open-source-dpg": "UN Digital Public Good", "get-started": "Get Started!", "already-have-account": "Already have an account? Log in" } diff --git a/babel.config.js b/babel.config.js index f7b3da3b..02c7d135 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: ['module:@react-native/babel-preset'], + plugins: ['react-native-reanimated/plugin'], }; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f374f92a..27a55a69 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1243,8 +1243,27 @@ PODS: - React-Core - RNCClipboard (1.14.2): - React-Core - - RNCMaskedView (0.1.11): - - React + - RNCMaskedView (0.3.2): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - RNCPicker (2.7.7): - React-Core - RNDateTimePicker (8.2.0): @@ -1340,11 +1359,29 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNSentry (5.24.1): + - RNSentry (5.36.0): + - DoubleConversion + - glog - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics - React-hermes - - Sentry/HybridSDK (= 8.29.1) + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Sentry/HybridSDK (= 8.46.0) + - Yoga - RNSVG (15.3.0): - React-Core - RNVectorIcons (10.1.0): @@ -1368,7 +1405,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - Sentry/HybridSDK (8.29.1) + - Sentry/HybridSDK (8.46.0) - SocketRocket (0.7.0) - Yoga (0.0.0) @@ -1437,7 +1474,7 @@ DEPENDENCIES: - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - - "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)" + - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) @@ -1585,7 +1622,7 @@ EXTERNAL SOURCES: RNCClipboard: :path: "../node_modules/@react-native-clipboard/clipboard" RNCMaskedView: - :path: "../node_modules/@react-native-community/masked-view" + :path: "../node_modules/@react-native-masked-view/masked-view" RNCPicker: :path: "../node_modules/@react-native-picker/picker" RNDateTimePicker: @@ -1677,7 +1714,7 @@ SPEC CHECKSUMS: ReactCommon: f00e436b3925a7ae44dfa294b43ef360fbd8ccc4 RNCAsyncStorage: 826b603ae9c0f88b5ac4e956801f755109fa4d5c RNCClipboard: 5e503962f0719ace8f7fdfe9c60282b526305c85 - RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489 + RNCMaskedView: da52ec927af4b4c3f3f6b5b5e816a69be62fe642 RNCPicker: b7873ba797dc586bfaf3307d737cbdc620a9ff3e RNDateTimePicker: 40ffda97d071a98a10fdca4fa97e3977102ccd14 RNDeviceInfo: b899ce37a403a4dea52b7cb85e16e49c04a5b88e @@ -1687,13 +1724,13 @@ SPEC CHECKSUMS: RNPermissions: 9611557f7289c271e442b481523a19452aefca1f RNReanimated: 9c213184c27dc4a2ed7e9ff41a4b0b9258bb54f0 RNScreens: 5aeecbb09aa7285379b6e9f3c8a3c859bb16401c - RNSentry: e9aa15bb2f3e18c822c002eea13bbd3b222ab493 + RNSentry: bd24510e679253fcfc216e5e433dab3ac03f2ed0 RNSVG: a48668fd382115bc89761ce291a81c4ca5f2fd2e RNVectorIcons: 2a2f79274248390b80684ea3c4400bd374a15c90 - Sentry: c446963245407030e17f1b926a83c6337dc99f4e + Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d - Yoga: 88480008ccacea6301ff7bf58726e27a72931c8d + Yoga: 04f1db30bb810187397fa4c37dd1868a27af229c PODFILE CHECKSUM: 7a045df7b5e0a3f9e650ce2fe341f6680cc9560d -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/ios/openlittermap.xcodeproj/project.pbxproj b/ios/openlittermap.xcodeproj/project.pbxproj index 83480a7c..8200638f 100644 --- a/ios/openlittermap.xcodeproj/project.pbxproj +++ b/ios/openlittermap.xcodeproj/project.pbxproj @@ -642,7 +642,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -715,7 +718,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/package-lock.json b/package-lock.json index b0c0801f..6ccfd3e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,20 +12,19 @@ "@react-native-camera-roll/camera-roll": "^7.8.1", "@react-native-clipboard/clipboard": "^1.14.2", "@react-native-community/datetimepicker": "^8.2.0", - "@react-native-community/masked-view": "^0.1.11", + "@react-native-masked-view/masked-view": "^0.3.2", "@react-native-picker/picker": "^2.7.7", "@react-navigation/material-top-tabs": "^6.6.13", "@react-navigation/native": "^6.1.17", "@react-navigation/stack": "^6.4.0", "@reduxjs/toolkit": "^2.2.6", - "@sentry/react-native": "^5.24.1", + "@sentry/react-native": "^5.36.0", "@shopify/flash-list": "^1.7.0", "axios": "^1.7.2", + "dayjs": "^1.11.19", "formik": "^2.4.6", "i18next": "^23.11.5", - "immer": "^10.1.1", "lottie-react-native": "^6.7.2", - "moment": "^2.30.1", "react": "18.2.0", "react-i18next": "^14.1.2", "react-native": "0.74.3", @@ -44,11 +43,9 @@ "react-native-screens": "^3.32.0", "react-native-svg": "^15.3.0", "react-native-swipe-gestures": "^1.0.5", - "react-native-swiper": "^1.6.0", "react-native-vector-icons": "^10.1.0", "react-redux": "^9.1.2", "redux-persist": "^6.0.0", - "redux-thunk": "^3.1.0", "use-count-up": "^3.0.1", "yup": "^1.4.0" }, @@ -2934,14 +2931,13 @@ } } }, - "node_modules/@react-native-community/masked-view": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@react-native-community/masked-view/-/masked-view-0.1.11.tgz", - "integrity": "sha512-rQfMIGSR/1r/SyN87+VD8xHHzDYeHaJq6elOSCAD+0iLagXkSI2pfA0LmSXP21uw5i3em7GkkRjfJ8wpqWXZNw==", - "deprecated": "Repository was moved to @react-native-masked-view/masked-view", + "node_modules/@react-native-masked-view/masked-view": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", + "integrity": "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==", "license": "MIT", "peerDependencies": { - "react": ">=16.0", + "react": ">=16", "react-native": ">=0.57" } }, @@ -3405,71 +3401,95 @@ } }, "node_modules/@sentry-internal/feedback": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.117.0.tgz", - "integrity": "sha512-4X+NnnY17W74TymgLFH7/KPTVYpEtoMMJh8HzVdCmHTOE6j32XKBeBMRaXBhmNYmEgovgyRKKf2KvtSfgw+V1Q==", + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.119.1.tgz", + "integrity": "sha512-EPyW6EKZmhKpw/OQUPRkTynXecZdYl4uhZwdZuGqnGMAzswPOgQvFrkwsOuPYvoMfXqCH7YuRqyJrox3uBOrTA==", "license": "MIT", "dependencies": { - "@sentry/core": "7.117.0", - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0" + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" }, "engines": { "node": ">=12" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.117.0.tgz", - "integrity": "sha512-7hjIhwEcoosr+BIa0AyEssB5xwvvlzUpvD5fXu4scd3I3qfX8gdnofO96a8r+LrQm3bSj+eN+4TfKEtWb7bU5A==", + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.119.1.tgz", + "integrity": "sha512-O/lrzENbMhP/UDr7LwmfOWTjD9PLNmdaCF408Wx8SDuj7Iwc+VasGfHg7fPH4Pdr4nJON6oh+UqoV4IoG05u+A==", "license": "MIT", "dependencies": { - "@sentry/core": "7.117.0", - "@sentry/replay": "7.117.0", - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0" + "@sentry/core": "7.119.1", + "@sentry/replay": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" }, "engines": { "node": ">=12" } }, "node_modules/@sentry-internal/tracing": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.117.0.tgz", - "integrity": "sha512-fAIyijNvKBZNA12IcKo+dOYDRTNrzNsdzbm3DP37vJRKVQu19ucqP4Y6InvKokffDP2HZPzFPDoGXYuXkDhUZg==", + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.119.1.tgz", + "integrity": "sha512-cI0YraPd6qBwvUA3wQdPGTy8PzAoK0NZiaTN1LM3IczdPegehWOaEG5GVTnpGnTsmBAzn1xnBXNBhgiU4dgcrQ==", "license": "MIT", "dependencies": { - "@sentry/core": "7.117.0", - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0" + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" }, "engines": { "node": ">=8" } }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.20.1.tgz", + "integrity": "sha512-4mhEwYTK00bIb5Y9UWIELVUfru587Vaeg0DQGswv4aIRHIiMKLyNqCEejaaybQ/fNChIZOKmvyqXk430YVd7Qg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/@sentry/browser": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.117.0.tgz", - "integrity": "sha512-29X9HlvDEKIaWp6XKlNPPSNND0U6P/ede5WA2nVHfs1zJLWdZ7/ijuMc0sH/CueEkqHe/7gt94hBcI7HOU/wSw==", + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.119.1.tgz", + "integrity": "sha512-aMwAnFU4iAPeLyZvqmOQaEDHt/Dkf8rpgYeJ0OEi50dmP6AjG+KIAMCXU7CYCCQDn70ITJo8QD5+KzCoZPYz0A==", "license": "MIT", "dependencies": { - "@sentry-internal/feedback": "7.117.0", - "@sentry-internal/replay-canvas": "7.117.0", - "@sentry-internal/tracing": "7.117.0", - "@sentry/core": "7.117.0", - "@sentry/integrations": "7.117.0", - "@sentry/replay": "7.117.0", - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0" + "@sentry-internal/feedback": "7.119.1", + "@sentry-internal/replay-canvas": "7.119.1", + "@sentry-internal/tracing": "7.119.1", + "@sentry/core": "7.119.1", + "@sentry/integrations": "7.119.1", + "@sentry/replay": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/integrations": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.119.1.tgz", + "integrity": "sha512-CGmLEPnaBqbUleVqrmGYjRjf5/OwjUXo57I9t0KKWViq81mWnYhaUhRZWFNoCNQHns+3+GPCOMvl0zlawt+evw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1", + "localforage": "^1.8.1" }, "engines": { "node": ">=8" } }, "node_modules/@sentry/cli": { - "version": "2.31.2", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.31.2.tgz", - "integrity": "sha512-2aKyUx6La2P+pplL8+2vO67qJ+c1C79KYWAyQBE0JIT5kvKK9JpwtdNoK1F0/2mRpwhhYPADCz3sVIRqmL8cQQ==", + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.37.0.tgz", + "integrity": "sha512-fM3V4gZRJR/s8lafc3O07hhOYRnvkySdPkvL/0e0XW0r+xRwqIAgQ5ECbsZO16A5weUiXVSf03ztDL1FcmbJCQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -3486,19 +3506,19 @@ "node": ">= 10" }, "optionalDependencies": { - "@sentry/cli-darwin": "2.31.2", - "@sentry/cli-linux-arm": "2.31.2", - "@sentry/cli-linux-arm64": "2.31.2", - "@sentry/cli-linux-i686": "2.31.2", - "@sentry/cli-linux-x64": "2.31.2", - "@sentry/cli-win32-i686": "2.31.2", - "@sentry/cli-win32-x64": "2.31.2" + "@sentry/cli-darwin": "2.37.0", + "@sentry/cli-linux-arm": "2.37.0", + "@sentry/cli-linux-arm64": "2.37.0", + "@sentry/cli-linux-i686": "2.37.0", + "@sentry/cli-linux-x64": "2.37.0", + "@sentry/cli-win32-i686": "2.37.0", + "@sentry/cli-win32-x64": "2.37.0" } }, "node_modules/@sentry/cli-darwin": { - "version": "2.31.2", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.31.2.tgz", - "integrity": "sha512-BHA/JJXj1dlnoZQdK4efRCtHRnbBfzbIZUKAze7oRR1RfNqERI84BVUQeKateD3jWSJXQfEuclIShc61KOpbKw==", + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.37.0.tgz", + "integrity": "sha512-CsusyMvO0eCPSN7H+sKHXS1pf637PWbS4rZak/7giz/z31/6qiXmeMlcL3f9lLZKtFPJmXVFO9uprn1wbBVF8A==", "license": "BSD-3-Clause", "optional": true, "os": [ @@ -3509,9 +3529,9 @@ } }, "node_modules/@sentry/cli-linux-arm": { - "version": "2.31.2", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.31.2.tgz", - "integrity": "sha512-W8k5mGYYZz/I/OxZH65YAK7dCkQAl+wbuoASGOQjUy5VDgqH0QJ8kGJufXvFPM+f3ZQGcKAnVsZ6tFqZXETBAw==", + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.37.0.tgz", + "integrity": "sha512-Dz0qH4Yt+gGUgoVsqVt72oDj4VQynRF1QB1/Sr8g76Vbi+WxWZmUh0iFwivYVwWxdQGu/OQrE0tx946HToCRyA==", "cpu": [ "arm" ], @@ -3526,9 +3546,9 @@ } }, "node_modules/@sentry/cli-linux-arm64": { - "version": "2.31.2", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.31.2.tgz", - "integrity": "sha512-FLVKkJ/rWvPy/ka7OrUdRW63a/z8HYI1Gt8Pr6rWs50hb7YJja8lM8IO10tYmcFE/tODICsnHO9HTeUg2g2d1w==", + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.37.0.tgz", + "integrity": "sha512-2vzUWHLZ3Ct5gpcIlfd/2Qsha+y9M8LXvbZE26VxzYrIkRoLAWcnClBv8m4XsHLMURYvz3J9QSZHMZHSO7kAzw==", "cpu": [ "arm64" ], @@ -3543,9 +3563,9 @@ } }, "node_modules/@sentry/cli-linux-i686": { - "version": "2.31.2", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.31.2.tgz", - "integrity": "sha512-A64QtzaPi3MYFpZ+Fwmi0mrSyXgeLJ0cWr4jdeTGrzNpeowSteKgd6tRKU+LVq0k5shKE7wdnHk+jXnoajulMA==", + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.37.0.tgz", + "integrity": "sha512-MHRLGs4t/CQE1pG+mZBQixyWL6xDZfNalCjO8GMcTTbZFm44S3XRHfYJZNVCgdtnUP7b6OHGcu1v3SWE10LcwQ==", "cpu": [ "x86", "ia32" @@ -3561,9 +3581,9 @@ } }, "node_modules/@sentry/cli-linux-x64": { - "version": "2.31.2", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.31.2.tgz", - "integrity": "sha512-YL/r+15R4mOEiU3mzn7iFQOeFEUB6KxeKGTTrtpeOGynVUGIdq4nV5rHow5JDbIzOuBS3SpOmcIMluvo1NCh0g==", + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.37.0.tgz", + "integrity": "sha512-k76ClefKZaDNJZU/H3mGeR8uAzAGPzDRG/A7grzKfBeyhP3JW09L7Nz9IQcSjCK+xr399qLhM2HFCaPWQ6dlMw==", "cpu": [ "x64" ], @@ -3578,9 +3598,9 @@ } }, "node_modules/@sentry/cli-win32-i686": { - "version": "2.31.2", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.31.2.tgz", - "integrity": "sha512-Az/2bmW+TFI059RE0mSBIxTBcoShIclz7BDebmIoCkZ+retrwAzpmBnBCDAHow+Yi43utOow+3/4idGa2OxcLw==", + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.37.0.tgz", + "integrity": "sha512-FFyi5RNYQQkEg4GkP2f3BJcgQn0F4fjFDMiWkjCkftNPXQG+HFUEtrGsWr6mnHPdFouwbYg3tEPUWNxAoypvTw==", "cpu": [ "x86", "ia32" @@ -3595,9 +3615,9 @@ } }, "node_modules/@sentry/cli-win32-x64": { - "version": "2.31.2", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.31.2.tgz", - "integrity": "sha512-XIzyRnJu539NhpFa+JYkotzVwv3NrZ/4GfHB/JWA2zReRvsk39jJG8D5HOmm0B9JA63QQT7Dt39RW8g3lkmb6w==", + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.37.0.tgz", + "integrity": "sha512-nSMj4OcfQmyL+Tu/jWCJwhKCXFsCZW1MUk6wjjQlRt9SDLfgeapaMlK1ZvT1eZv5ZH6bj3qJfefwj4U8160uOA==", "cpu": [ "x64" ], @@ -3611,57 +3631,125 @@ } }, "node_modules/@sentry/core": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.117.0.tgz", - "integrity": "sha512-1XZ4/d/DEwnfM2zBMloXDwX+W7s76lGKQMgd8bwgPJZjjEztMJ7X0uopKAGwlQcjn242q+hsCBR6C+fSuI5kvg==", + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.1.tgz", + "integrity": "sha512-YUNnH7O7paVd+UmpArWCPH4Phlb5LwrkWVqzFWqL3xPyCcTSof2RL8UmvpkTjgYJjJ+NDfq5mPFkqv3aOEn5Sw==", "license": "MIT", "dependencies": { - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0" + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" }, "engines": { "node": ">=8" } }, "node_modules/@sentry/hub": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-7.117.0.tgz", - "integrity": "sha512-pQrXnbzsRHCUsVIqz/sZ0vggnxuuHqsmyjoy2Ha1qn1Ya4QbyAWEEGoZTnZx6I/Vt3dzVvRnR3YCywatdkaFxg==", + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-7.119.0.tgz", + "integrity": "sha512-183h5B/rZosLxpB+ZYOvFdHk0rwZbKskxqKFtcyPbDAfpCUgCass41UTqyxF6aH1qLgCRxX8GcLRF7frIa/SOg==", "license": "MIT", "dependencies": { - "@sentry/core": "7.117.0", - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0" + "@sentry/core": "7.119.0", + "@sentry/types": "7.119.0", + "@sentry/utils": "7.119.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/hub/node_modules/@sentry/core": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.0.tgz", + "integrity": "sha512-CS2kUv9rAJJEjiRat6wle3JATHypB0SyD7pt4cpX5y0dN5dZ1JrF57oLHRMnga9fxRivydHz7tMTuBhSSwhzjw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.0", + "@sentry/utils": "7.119.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/hub/node_modules/@sentry/types": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.0.tgz", + "integrity": "sha512-27qQbutDBPKGbuJHROxhIWc1i0HJaGLA90tjMu11wt0E4UNxXRX+UQl4Twu68v4EV3CPvQcEpQfgsViYcXmq+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/hub/node_modules/@sentry/utils": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.0.tgz", + "integrity": "sha512-ZwyXexWn2ZIe2bBoYnXJVPc2esCSbKpdc6+0WJa8eutXfHq3FRKg4ohkfCBpfxljQGEfP1+kfin945lA21Ka+A==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.0" }, "engines": { "node": ">=8" } }, "node_modules/@sentry/integrations": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.117.0.tgz", - "integrity": "sha512-U3suSZysmU9EiQqg0ga5CxveAyNbi9IVdsapMDq5EQGNcVDvheXtULs+BOc11WYP3Kw2yWB38VDqLepfc/Fg2g==", + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.119.0.tgz", + "integrity": "sha512-OHShvtsRW0A+ZL/ZbMnMqDEtJddPasndjq+1aQXw40mN+zeP7At/V1yPZyFaURy86iX7Ucxw5BtmzuNy7hLyTA==", "license": "MIT", "dependencies": { - "@sentry/core": "7.117.0", - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0", + "@sentry/core": "7.119.0", + "@sentry/types": "7.119.0", + "@sentry/utils": "7.119.0", "localforage": "^1.8.1" }, "engines": { "node": ">=8" } }, + "node_modules/@sentry/integrations/node_modules/@sentry/core": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.0.tgz", + "integrity": "sha512-CS2kUv9rAJJEjiRat6wle3JATHypB0SyD7pt4cpX5y0dN5dZ1JrF57oLHRMnga9fxRivydHz7tMTuBhSSwhzjw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.0", + "@sentry/utils": "7.119.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/integrations/node_modules/@sentry/types": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.0.tgz", + "integrity": "sha512-27qQbutDBPKGbuJHROxhIWc1i0HJaGLA90tjMu11wt0E4UNxXRX+UQl4Twu68v4EV3CPvQcEpQfgsViYcXmq+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/integrations/node_modules/@sentry/utils": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.0.tgz", + "integrity": "sha512-ZwyXexWn2ZIe2bBoYnXJVPc2esCSbKpdc6+0WJa8eutXfHq3FRKg4ohkfCBpfxljQGEfP1+kfin945lA21Ka+A==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@sentry/react": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-7.117.0.tgz", - "integrity": "sha512-aK+yaEP2esBhaczGU96Y7wkqB4umSIlRAzobv7ER88EGHzZulRaocTpQO8HJJGDHm4D8rD+E893BHnghkoqp4Q==", + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-7.119.1.tgz", + "integrity": "sha512-Bri314LnSVm16K3JATgn3Zsq6Uj3M/nIjdUb3nggBw0BMlFWMsyFjUCfmCio5d80KJK/lUjOIxRjzu79M6jOzQ==", "license": "MIT", "dependencies": { - "@sentry/browser": "7.117.0", - "@sentry/core": "7.117.0", - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0", + "@sentry/browser": "7.119.1", + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1", "hoist-non-react-statics": "^3.3.2" }, "engines": { @@ -3672,19 +3760,20 @@ } }, "node_modules/@sentry/react-native": { - "version": "5.24.1", - "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-5.24.1.tgz", - "integrity": "sha512-+BB48ixYn+brEntbDX49V4mUivrGT+SJUzbIN7nOsd3kml619erp5bxETD4ZK6diw0Fu2LoVdnY43TICF4kleQ==", + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-5.36.0.tgz", + "integrity": "sha512-MPTN5Wb6wEplIVydh2oXOdLJYqCAWKvncN5TBPN5OG8XdCsDqF7LyH2Sz+SK2T3hMPKESl3StAMhrrNSmHDbNg==", "license": "MIT", "dependencies": { - "@sentry/browser": "7.117.0", - "@sentry/cli": "2.31.2", - "@sentry/core": "7.117.0", - "@sentry/hub": "7.117.0", - "@sentry/integrations": "7.117.0", - "@sentry/react": "7.117.0", - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0" + "@sentry/babel-plugin-component-annotate": "2.20.1", + "@sentry/browser": "7.119.1", + "@sentry/cli": "2.37.0", + "@sentry/core": "7.119.1", + "@sentry/hub": "7.119.0", + "@sentry/integrations": "7.119.0", + "@sentry/react": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" }, "bin": { "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" @@ -3701,36 +3790,36 @@ } }, "node_modules/@sentry/replay": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.117.0.tgz", - "integrity": "sha512-V4DfU+x4UsA4BsufbQ8jHYa5H0q5PYUgso2X1PR31g1fpx7yiYguSmCfz1UryM6KkH92dfTnqXapDB44kXOqzQ==", + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.119.1.tgz", + "integrity": "sha512-4da+ruMEipuAZf35Ybt2StBdV1S+oJbSVccGpnl9w6RoeQoloT4ztR6ML3UcFDTXeTPT1FnHWDCyOfST0O7XMw==", "license": "MIT", "dependencies": { - "@sentry-internal/tracing": "7.117.0", - "@sentry/core": "7.117.0", - "@sentry/types": "7.117.0", - "@sentry/utils": "7.117.0" + "@sentry-internal/tracing": "7.119.1", + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" }, "engines": { "node": ">=12" } }, "node_modules/@sentry/types": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.117.0.tgz", - "integrity": "sha512-5dtdulcUttc3F0Te7ekZmpSp/ebt/CA71ELx0uyqVGjWsSAINwskFD77sdcjqvZWek//WjiYX1+GRKlpJ1QqsA==", + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.1.tgz", + "integrity": "sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ==", "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/@sentry/utils": { - "version": "7.117.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.117.0.tgz", - "integrity": "sha512-KkcLY8643SGBiDyPvMQOubBkwVX5IPknMHInc7jYC8pDVncGp7C65Wi506bCNPpKCWspUd/0VDNWOOen51/qKA==", + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.1.tgz", + "integrity": "sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==", "license": "MIT", "dependencies": { - "@sentry/types": "7.117.0" + "@sentry/types": "7.119.1" }, "engines": { "node": ">=8" @@ -5331,7 +5420,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.11", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, "node_modules/debug": { @@ -9129,15 +9220,6 @@ "node": ">=10" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.2", "license": "MIT" @@ -10169,15 +10251,6 @@ "integrity": "sha512-Ns7Bn9H/Tyw278+5SQx9oAblDZ7JixyzeOczcBK8dipQk2pD7Djkcfnf1nB/8RErAmMLL9iXgW0QHqiII8AhKw==", "license": "MIT" }, - "node_modules/react-native-swiper": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/react-native-swiper/-/react-native-swiper-1.6.0.tgz", - "integrity": "sha512-OnkTTZi+9uZUgy0uz1I9oYDhCU3z36lZn+LFsk9FXPRelxb/KeABzvPs3r3SrHWy1aA67KGtSFj0xNK2QD0NJQ==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.5.10" - } - }, "node_modules/react-native-tab-view": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/react-native-tab-view/-/react-native-tab-view-3.5.2.tgz", diff --git a/package.json b/package.json index 70176de4..04aa0448 100644 --- a/package.json +++ b/package.json @@ -7,27 +7,27 @@ "ios": "react-native run-ios", "lint": "eslint .", "start": "react-native start", - "test": "jest" + "test": "jest", + "postinstall": "sed -i '' \"s/s.dependency 'Sentry\\/HybridSDK', '8.41.0'/s.dependency 'Sentry\\/HybridSDK', '8.46.0'/\" node_modules/@sentry/react-native/RNSentry.podspec 2>/dev/null || true" }, "dependencies": { "@react-native-async-storage/async-storage": "^1.23.1", "@react-native-camera-roll/camera-roll": "^7.8.1", "@react-native-clipboard/clipboard": "^1.14.2", "@react-native-community/datetimepicker": "^8.2.0", - "@react-native-community/masked-view": "^0.1.11", + "@react-native-masked-view/masked-view": "^0.3.2", "@react-native-picker/picker": "^2.7.7", "@react-navigation/material-top-tabs": "^6.6.13", "@react-navigation/native": "^6.1.17", "@react-navigation/stack": "^6.4.0", "@reduxjs/toolkit": "^2.2.6", - "@sentry/react-native": "^5.24.1", + "@sentry/react-native": "^5.36.0", "@shopify/flash-list": "^1.7.0", "axios": "^1.7.2", + "dayjs": "^1.11.19", "formik": "^2.4.6", "i18next": "^23.11.5", - "immer": "^10.1.1", "lottie-react-native": "^6.7.2", - "moment": "^2.30.1", "react": "18.2.0", "react-i18next": "^14.1.2", "react-native": "0.74.3", @@ -46,11 +46,9 @@ "react-native-screens": "^3.32.0", "react-native-svg": "^15.3.0", "react-native-swipe-gestures": "^1.0.5", - "react-native-swiper": "^1.6.0", "react-native-vector-icons": "^10.1.0", "react-redux": "^9.1.2", "redux-persist": "^6.0.0", - "redux-thunk": "^3.1.0", "use-count-up": "^3.0.1", "yup": "^1.4.0" }, diff --git a/readme/BackendAPI.md b/readme/BackendAPI.md new file mode 100644 index 00000000..e9c4ec8c --- /dev/null +++ b/readme/BackendAPI.md @@ -0,0 +1,2080 @@ +# OpenLitterMap API + +Base URL: `/api` + +## Authentication + +Most endpoints require a Bearer token (Sanctum) or active session. + +- **Session auth (SPA):** `POST /api/auth/login` sets a session cookie +- **Token auth (Mobile):** `POST /api/auth/token` returns a Sanctum `token` + +Include the token as: `Authorization: Bearer ` + +All `auth:sanctum` routes accept both session cookies and Bearer tokens. + +--- + +## Auth Endpoints + +### POST /api/auth/token — Mobile Token Login + +**Auth:** None (guest) +**Rate limit:** 5 attempts per minute + +**Request:** +```json +{ + "identifier": "email_or_username", + "password": "secret" +} +``` + +Backward compat: accepts `email` or `username` field if `identifier` is absent. +Priority: `identifier` > `email` > `username`. +Auto-detects email vs username via `filter_var()`. + +**Response (200):** +```json +{ + "token": "1|abcdef1234567890...", + "user": { + "id": 1, + "email": "user@example.com", + "username": "johndoe", + "name": null, + "verified": true, + "total_images": 42, + "xp": 5000, + "level": 3, + "xp_redis": 5000, + "position": 12, + "next_level": { + "level": 4, + "title": "Litter Wizard", + "xp": 5000, + "xp_into_level": 500, + "xp_for_next": 1000, + "xp_remaining": 500, + "progress_percent": 50 + } + } +} +``` + +**Error (422):** +```json +{ + "message": "The given data was invalid.", + "errors": { "identifier": ["The auth.failed message"] } +} +``` + +Previous tokens named `mobile` are revoked on each login (prevents token buildup). +Token is created with name `mobile`: `$user->createToken('mobile')`. + +--- + +### POST /api/auth/login — Session Login (SPA) + +**Auth:** None (guest) +**Rate limit:** 5 attempts per minute (disabled on localhost) + +**Request:** +```json +{ + "identifier": "email_or_username", + "password": "secret", + "remember": true +} +``` + +**Response (200):** +```json +{ + "success": true, + "user": { /* full user object */ } +} +``` + +Session is regenerated after login. `remember` sets 2-week persistent cookie. + +--- + +### POST /api/auth/logout + +**Auth:** Required (web session) +**Middleware:** `web`, `auth:web` + +**Response (200):** +```json +{ "success": true } +``` + +--- + +### POST /api/auth/register + +**Aliases:** `POST /api/register` (legacy mobile) +**Auth:** None (guest) + +**Request:** +```json +{ + "email": "user@example.com", + "password": "min8chars", + "username": "optional_3to255_alphanum" +} +``` + +| Field | Rules | +|-------|-------| +| `email` | required, valid email, max 75, unique | +| `password` | required, min 8 chars | +| `username` | optional (auto-generated if omitted), 3-255 chars, regex `/^[a-zA-Z0-9_-]+$/`, unique | + +**Response (200):** +```json +{ + "token": "1|abcdef...", + "user": { /* full user object, xp=0, level=0 */ } +} +``` + +Side effects: welcome email (`NewUserRegMail`) sent, `Registered` and `UserSignedUp` events fired, quotas initialized (images_remaining=1000, verify_remaining=5000). `name` is always set to NULL regardless of input. Auto-generated usernames use pattern `{adjective}-{noun}-{number}` (e.g. `violently-enthusiastic-bin-overlord-5432`). Token created with name `mobile`. + +--- + +### POST /api/validate-token — Check Token Validity + +**Auth:** Required (Sanctum) + +**Response (200):** +```json +{ "message": "valid" } +``` + +Returns 401 if token is invalid/expired. + +--- + +### GET /api/user — Get Authenticated User + +**Auth:** Required (Sanctum) + +Returns the full User model with `position` and `xp_redis` appended, plus `littercoin_count`. + +**Response (200):** +```json +{ + "id": 1, + "email": "user@example.com", + "username": "johndoe", + "name": null, + "xp": 5000, + "total_images": 42, + "xp_redis": 5000, + "position": 12, + "littercoin_count": 3, + "next_level": { + "level": 4, + "title": "Litter Wizard", + "xp": 5000, + "xp_into_level": 0, + "xp_for_next": 5000, + "xp_remaining": 0, + "progress_percent": 100 + } +} +``` + +### GET /api/current-user — Get Authenticated User (Legacy) + +**Auth:** Required (session `auth`) + +Returns user model with `roles` eager-loaded and `xp_redis` appended. Used by SPA. + +--- + +### POST /api/password/email — Request Password Reset + +**Auth:** None +**Rate limit:** 3 per minute + +**Request:** +```json +{ "login": "email_or_username" } +``` + +**Response (200):** Always returns same message (prevents user enumeration): +```json +{ "message": "If an account with these details exists, we will send a password reset link." } +``` + +--- + +### POST /api/password/validate-token — Validate Reset Token + +**Auth:** None + +**Request:** +```json +{ "token": "reset_token", "email": "user@example.com" } +``` + +**Response:** `{ "valid": true }` (200) or `{ "valid": false }` (422). +Token is not consumed (safe to call multiple times). Tokens expire after 60 minutes. + +--- + +### POST /api/password/reset — Complete Password Reset + +**Auth:** None + +**Request:** +```json +{ + "token": "reset_token", + "email": "user@example.com", + "password": "new_password", + "password_confirmation": "new_password" +} +``` + +`password`: min 5 chars, confirmed. + +**Response (200):** +```json +{ + "message": "Your password has been reset!", + "user": { /* full user object */ } +} +``` + +User is auto-logged in after successful reset. Token is consumed (single-use). + +--- + +### POST /api/settings/delete-account — Delete Account (GDPR) + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ "password": "current_password" } +``` + +**Response (200):** `{ "success": true }` +**Error:** `{ "success": false, "msg": "password does not match" }` + +Photos preserved as anonymous contributions (user_id set to NULL via DB CASCADE). +Cleans up: teams, metrics, Redis leaderboards, OAuth tokens, subscriptions, roles. + +--- + +## Photo Upload + +### POST /api/v3/upload — Web Photo Upload + +**Auth:** Required (Sanctum) +**Content-Type:** multipart/form-data + +**Request:** + +| Field | Type | Rules | +|-------|------|-------| +| `photo` | file | required, jpg/png/jpeg/heif/heic/webp, max 20MB, min 1x1 | + +Must contain valid EXIF with GPS coordinates and datetime. + +**Response (200):** +```json +{ "success": true, "photo_id": 12345 } +``` + +**Validation errors:** +- "Could not read EXIF data from the image." +- "The image does not contain a date. Please check your camera settings." +- "You have already uploaded this photo" +- "Sorry, no GPS on this one." +- "Error: Could not read GPS coordinates from this image..." + +Side effects: S3 upload (full + bbox thumbnail), reverse geocoding via `ResolveLocationAction`, `ImageUploaded` broadcast event. No metrics/XP processing (happens at tagging time). + +--- + +### POST /api/photos/submit — Legacy Mobile Upload (v1) + +**Auth:** Required (Sanctum) +**Content-Type:** multipart/form-data + +**Request:** + +| Field | Type | Rules | +|-------|------|-------| +| `photo` | file | required, jpg/png/jpeg/heic/heif | +| `lat` | numeric | required | +| `lon` | numeric | required | +| `date` | string/int | required, ISO 8601 or Unix timestamp | +| `model` | string | optional, defaults to 'Mobile app v2' | +| `picked_up` | bool | optional | + +Mobile sends coordinates directly (not from EXIF). + +**Response (200):** `{ "success": true, "photo_id": 12345 }` +**Error:** `{ "success": false, "msg": "error-3" | "photo-already-uploaded" | "invalid-coordinates" }` + +--- + +### POST /api/photos/submit-with-tags — Legacy Mobile Upload + Tags (v2) + +**Aliases:** `/api/photos/upload-with-tags`, `/api/photos/upload/with-or-without-tags` +**Auth:** Required (Sanctum) + +Same as `/photos/submit` plus: + +| Field | Type | Rules | +|-------|------|-------| +| `tags` | array/JSON string | optional, v4 tag format | +| `custom_tags` | array/JSON string | optional | + +**v4 tag format (legacy mobile):** +```json +[{ + "category": "smoking", + "object": "cigarette_butt", + "brand_only": false, + "brand": null, + "material_only": false, + "material": "paper", + "custom": null, + "key": "cigarette_butt" +}] +``` + +Tags are auto-converted from v4 to v5 format via `ConvertV4TagsAction`. + +--- + +### DELETE /api/photos/delete — Delete Photo (Legacy Mobile) + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ "photoId": 123 } +``` + +**Response (200):** `{ "success": true }` +**Error (403):** `{ "success": false, "msg": "Photo not found" }` (not found or not owned) + +Reverses metrics if photo was processed (`processed_at` not null). Soft-deletes photo, removes S3 files. Decrements user `xp` and `total_images` (with `max(0, ...)` guard). + +--- + +### GET /api/check-web-photos — Check for Untagged Photos (Legacy) + +**Auth:** Required (Sanctum) + +**Response (200):** +```json +{ "photos": [{ "id": 1, "filename": "photo.jpg" }] } +``` + +Returns user's untagged photos (`verified = 0`), selecting only `id` and `filename`. + +--- + +## Tags + +### GET /api/tags — Get Available Tags (Nested by Category) + +**Auth:** None (public) + +**Query params (all optional):** + +| Param | Type | Description | +|-------|------|-------------| +| `category` | string | Filter by category key (e.g. `smoking`) | +| `object` | string | Filter by object key (partial match) | +| `materials` | string | Comma-separated material keys | +| `search` | string | Prefix search across all keys | + +**Response (200):** +```json +{ + "tags": { + "smoking": { + "id": 1, + "key": "smoking", + "litter_objects": [ + { + "id": 5, + "key": "cigarette_butt", + "materials": [ + { "id": 10, "key": "paper" } + ] + } + ] + } + } +} +``` + +--- + +### GET /api/tags/all — Get All Tags (Flat Arrays) + +**Auth:** None (public) + +**Response (200):** +```json +{ + "categories": [{ "id": 1, "key": "smoking" }], + "objects": [{ "id": 5, "key": "cigarette_butt", "categories": [...] }], + "materials": [{ "id": 10, "key": "paper" }], + "brands": [{ "id": 100, "key": "marlboro" }], + "types": [{ "id": 20, "key": "aluminum", "name": "Aluminum Can" }], + "category_objects": [{ "id": 1, "category_id": 1, "litter_object_id": 5 }], + "category_object_types": [{ "category_litter_object_id": 1, "litter_object_type_id": 20 }] +} +``` + +Note: `category_object_types` has no `id` column — use composite key for dedup. + +--- + +### POST /api/v3/tags — Add Tags to Photo + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ + "photo_id": 12345, + "tags": [ + { + "category_litter_object_id": 1, + "litter_object_type_id": null, + "quantity": 1, + "picked_up": true, + "materials": [1, 2], + "brands": [4], + "custom_tags": ["found on bench"] + } + ] +} +``` + +| Field | Type | Rules | +|-------|------|-------| +| `photo_id` | int | required, must exist (not soft-deleted), owned by user | +| `tags` | array | required, min 1 | +| `tags.*.category_litter_object_id` | int | FK to `category_litter_object` | +| `tags.*.litter_object_type_id` | int/null | FK to `litter_object_types` | +| `tags.*.quantity` | int | min 1 | +| `tags.*.picked_up` | bool/null | optional | +| `tags.*.materials` | array | material IDs | +| `tags.*.brands` | array | brand IDs | +| `tags.*.custom_tags` | array | string tags | + +**Gates:** +- 403 if user doesn't own photo +- 403 if photo already verified (`verified >= 1`) + +**Response (200):** +```json +{ + "success": true, + "photoTags": [{ "id": 1, "photo_id": 12345, "category_litter_object_id": 1, ... }] +} +``` + +Category is auto-resolved from `category_litter_object_id`. Generates summary, calculates XP, triggers metrics processing via `TagsVerifiedByAdmin` event. + +--- + +### PUT /api/v3/tags — Replace All Tags on Photo + +**Auth:** Required (Sanctum) + +Same request format as POST. Key differences: +- **No verification gate** — allows re-tagging verified photos +- Deletes all existing tags + extras first +- Resets summary, XP, and verified status to 0 +- Re-runs full tag pipeline (summary + XP + metrics delta) + +--- + +### POST /api/add-tags — Legacy Mobile Tagging + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ + "photo_id": 123, + "litter": [{ /* v4 tag objects */ }], + "custom_tags": ["tag1"], + "picked_up": true +} +``` + +Also accepts `tags` field instead of `litter`. +Auto-converts v4 format to v5 via `ConvertV4TagsAction`. + +**Response:** `{ "success": true, "msg": "tags-added" }` + +--- + +## User Profile + +### GET /api/user/profile/index — Authenticated Profile + +**Auth:** Required (Sanctum) + +**Response (200):** +```json +{ + "user": { + "id": 1, + "name": "John", + "username": "johndoe", + "email": "john@example.com", + "avatar": "https://...", + "created_at": "2020-01-15T10:30:00Z", + "member_since": "January 2020", + "global_flag": "us", + "public_profile": true, + "show_name": true, + "show_username": true, + "show_name_maps": true, + "show_username_maps": true, + "previous_tags": true, + "emailsub": true + }, + "stats": { + "uploads": 100, + "litter": 450, + "xp": 5000, + "streak": 7, + "littercoin": 250, + "photo_percent": 0.5, + "tag_percent": 0.8 + }, + "level": { + "level": 3, + "title": "Litter Wizard", + "xp": 5000, + "xp_into_level": 0, + "xp_for_next": 5000, + "xp_remaining": 0, + "progress_percent": 100 + }, + "rank": { + "global_position": 42, + "global_total": 500, + "percentile": 91.6 + }, + "global_stats": { + "total_photos": 20000, + "total_litter": 56000 + }, + "achievements": { "unlocked": 15, "total": 30 }, + "locations": { "countries": 5, "states": 12, "cities": 45 }, + "team": { "id": 5, "name": "Team A" } +} +``` + +Stats from Redis with MySQL fallback. `team` is null if no active team. + +--- + +### GET /api/user/profile/{id} — Public Profile + +**Auth:** None (public) + +**Path Parameters:** + +| Parameter | Type | Description | +|-----------|------|---------------| +| `id` | int | The user's ID | + +**Response (public profile):** + +```json +{ + "public": true, + "user": { + "id": 42, + "name": "Sean", + "username": "seanlynch", + "avatar": null, + "global_flag": "ie", + "member_since": "January 2020" + }, + "stats": { + "uploads": 500, + "litter": 2000, + "xp": 15000 + }, + "level": { + "level": 7, + "title": "Trashmonster", + "xp": 15000, + "xp_into_level": 0, + "xp_for_next": 35000, + "xp_remaining": 35000, + "progress_percent": 0 + }, + "rank": { + "global_position": 5, + "global_total": 1457, + "percentile": 99.7 + }, + "achievements": { + "unlocked": 12, + "total": 50 + }, + "locations": { + "countries": 3, + "states": 8, + "cities": 15 + } +} +``` + +**Response (private profile):** + +```json +{ + "public": false +} +``` + +**Notes:** +- `name` and `username` respect the user's privacy settings (`show_name`, `show_username`). They return `null` when hidden. +- Returns `404` if the user ID does not exist. + +--- + +### GET /api/user/profile/map — User's Photo GeoJSON + +**Auth:** Required (Sanctum) + +**Query params (optional):** + +| Param | Type | Description | +|-------|------|-------------| +| `period` | string | `created_at`, `datetime`, `updated_at` | +| `start` | string | YYYY-MM-DD | +| `end` | string | YYYY-MM-DD | + +**Response:** GeoJSON FeatureCollection. Only includes `verified >= 2` (ADMIN_APPROVED). Coordinates as `[lat, lon]`. Respects `show_name_maps` and `show_username_maps` privacy settings. + +--- + +### POST /api/user/profile/download — Request Data Export + +**Auth:** Required (Sanctum) + +**Query params (optional):** + +| Param | Type | Description | +|-------|------|-------------| +| `dateField` | string | `created_at`, `datetime`, `updated_at` | +| `fromDate` | string | YYYY-MM-DD (default: 2017) | +| `toDate` | string | YYYY-MM-DD (default: now) | + +**Response:** `{ "success": true }` + +Queues CSV export, emails S3 download link when ready. + +--- + +## User Photos + +### GET /api/v3/user/photos — User's Photos (Paginated + Filterable) + +**Auth:** Required (Sanctum) + +**Query params (all optional):** + +| Param | Type | Description | +|-------|------|-------------| +| `tagged` | bool | `true` = verified >= 1, `false` = verified = 0 | +| `id` | int | Filter by photo ID | +| `id_operator` | string | Comparison operator (default `=`) | +| `tag` | string | Filter by litter object key (LIKE search) | +| `custom_tag` | string | Filter by custom tag key (LIKE search) | +| `date_from` | string | Start date (ISO) | +| `date_to` | string | End date (ISO) | + +Pagination: 8 per page, ordered by `created_at` DESC. + +**Response (200):** +```json +{ + "photos": [{ + "id": 123, + "filename": "https://...", + "datetime": "2020-01-15T10:30:00Z", + "lat": 40.7128, + "lon": -74.0060, + "model": "iPhone 12", + "remaining": true, + "team": { "id": 5, "name": "Team A" }, + "new_tags": [{ + "id": 1, + "category_litter_object_id": "smoking_cigarette", + "quantity": 3, + "picked_up": true, + "category": { "id": 1, "key": "smoking" }, + "object": { "id": 2, "key": "cigarette" }, + "extra_tags": [ + { "type": "brand", "quantity": 3, "tag": { "id": 10, "key": "marlboro" } }, + { "type": "material", "quantity": 3, "tag": { "id": 50, "key": "paper" } } + ] + }], + "summary": ["cigarette"], + "xp": 12, + "total_tags": 1 + }], + "pagination": { + "current_page": 1, + "last_page": 5, + "per_page": 8, + "total": 40 + }, + "user": { "id": 1, "name": "...", "email": "..." } +} +``` + +Tags are under the `new_tags` key (v5 format with nested category/object/extra_tags). + +--- + +### GET /api/v3/user/photos/stats — Upload Statistics + +**Auth:** Required (Sanctum) + +**Response (200):** +```json +{ + "totalPhotos": 100, + "totalTags": 450, + "leftToTag": 10, + "taggedPercentage": 90 +} +``` + +--- + +### POST /api/user/profile/photos/delete — Bulk Delete Photos + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ + "selectAll": false, + "inclIds": [123, 124, 125], + "exclIds": [], + "filters": "{}" +} +``` + +Reverses metrics, removes S3 files, soft-deletes. Only deletes user's own photos. + +--- + +### POST /api/profile/photos/delete — Delete Single Photo + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ "photoId": 123 } +``` + +**Response (200):** `{ "message": "Photo deleted successfully!" }` +**Error:** 403 if photo not owned by user. + +Reverses metrics, removes S3 files, soft-deletes, decrements user XP and total_images. + +--- + +### GET /api/user/profile/photos/index — Unverified Photos (Legacy) + +**Auth:** Required (Sanctum) + +Paginated list of user's unverified photos (`verified = 0`), 300 per page, ordered by `created_at` DESC. + +**Response (200):** +```json +{ + "paginate": { /* Laravel paginator */ }, + "count": 50 +} +``` + +--- + +### GET /api/user/profile/photos/filter — Filter User's Photos (Legacy) + +**Auth:** Required (Sanctum) + +**Query params:** `filters` (JSON string), `selectAll` (bool), `inclIds` (array), `exclIds` (array) + +**Response (200):** +```json +{ + "count": 50, + "paginate": { /* Laravel paginator */ } +} +``` + +--- + +### GET /api/user/profile/photos/previous-custom-tags — Previous Custom Tags + +**Auth:** Required (Sanctum) + +**Response (200):** +```json +["found on bench", "near park entrance"] +``` + +--- + +## Settings + +### POST /api/settings/details — Update Name/Email/Username + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ "name": "John", "email": "john@example.com", "username": "johndoe" } +``` + +| Field | Rules | +|-------|-------| +| `name` | min 3, max 25 | +| `email` | required, email, max 75, unique | +| `username` | required, min 3, max 75, unique | + +**Response:** `{ "message": "success", "email_changed": false }` + +--- + +### PATCH /api/settings/details/password — Change Password + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ + "oldpassword": "current", + "password": "new_password", + "password_confirmation": "new_password" +} +``` + +`password`: min 5, confirmed. + +**Response:** `{ "message": "success" }` or `{ "message": "fail" }` (wrong old password) + +--- + +### POST /api/settings/update — Update Setting by Key/Value + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ "key": "username", "value": "new_value" } +``` + +**Allowed keys and rules:** + +| Key | Rules | Notes | +|-----|-------|-------| +| `name` | string, min 3, max 25 | | +| `username` | string, min 3, max 75 | unique validation | +| `email` | email, max 75 | unique validation | +| `global_flag` | nullable, string, max 10 | ISO country code | +| `items_remaining` | boolean | | +| `previous_tags` | boolean | | +| `emailsub` | boolean | | +| `public_profile` | boolean | | + +Legacy mobile: `picked_up` key remaps to `items_remaining` (inverted value). + +**Response:** `{ "success": true }` or `{ "success": false, "msg": "..." }` + +--- + +### POST /api/settings/privacy/update — Update All Privacy Flags + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ + "show_name": true, + "show_username": false, + "show_name_maps": true, + "show_username_maps": false, + "show_name_createdby": true, + "show_username_createdby": false, + "prevent_others_tagging_my_photos": false +} +``` + +--- + +### Privacy Toggle Endpoints + +All POST, auth required. Each toggles a single boolean and returns the new value. + +| Endpoint | Toggles | Response key | +|----------|---------|-------------| +| `/api/settings/privacy/maps/name` | show_name_maps | `show_name_maps` | +| `/api/settings/privacy/maps/username` | show_username_maps | `show_username_maps` | +| `/api/settings/privacy/leaderboard/name` | show_name | `show_name` | +| `/api/settings/privacy/leaderboard/username` | show_username | `show_username` | +| `/api/settings/privacy/createdby/name` | show_name_createdby | `show_name_createdby` | +| `/api/settings/privacy/createdby/username` | show_username_createdby | `show_username_createdby` | +| `/api/settings/privacy/toggle-previous-tags` | previous_tags | `previous_tags` | +| `/api/settings/email/toggle` | emailsub | `sub` | + +--- + +### PATCH /api/settings — Update Social Links + +**Auth:** Required (Sanctum) + +**Request (all optional, must be valid URLs):** +```json +{ + "social_twitter": "https://twitter.com/user", + "social_facebook": "https://facebook.com/user", + "social_instagram": "https://instagram.com/user", + "social_linkedin": "https://linkedin.com/in/user", + "social_reddit": "https://reddit.com/u/user", + "social_personal": "https://example.com" +} +``` + +**Response:** `{ "message": "success" }` + +--- + +### POST /api/settings/save-flag — Set Country Flag + +**Auth:** Required (Sanctum) + +**Request:** `{ "country": "us" }` +**Response:** `{ "message": "success" }` + +--- + +### GET /api/settings/flags/countries — Available Flag Countries + +**Auth:** None (public) + +**Response:** Key-value pairs of `shortcode` -> `country name`. + +--- + +### POST /api/settings/phone/submit — Set Phone Number + +**Auth:** Required (Sanctum) + +**Request:** `{ "phonenumber": "+1234567890" }` + +--- + +### POST /api/settings/phone/remove — Remove Phone Number + +**Auth:** Required (Sanctum) + +**Response:** `{ "message": "success" }` + +--- + +## Leaderboard + +### GET /api/leaderboard + +Returns ranked users by XP. Requires authentication. + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|----------------|--------|------------|-----------------------------------------------------------------------------| +| `timeFilter` | string | `all-time` | One of: `all-time`, `today`, `yesterday`, `this-month`, `last-month`, `this-year`, `last-year` | +| `locationType` | string | — | Filter by location scope: `country`, `state`, `city`. Must be paired with `locationId`. | +| `locationId` | int | — | ID of the location to filter by. Must be paired with `locationType`. | +| `page` | int | `1` | Page number (100 results per page). | + +**Response:** + +```json +{ + "success": true, + "users": [ + { + "user_id": 42, + "public_profile": true, + "name": "Sean", + "username": "@seanlynch", + "xp": "1,234", + "global_flag": "ie", + "social": { "twitter": "https://twitter.com/..." }, + "team": "CleanCoast", + "rank": 1 + } + ], + "hasNextPage": false, + "total": 150, + "activeUsers": 450, + "totalUsers": 1000, + "currentUserRank": 42 +} +``` + +**User object fields:** + +| Field | Type | Description | +|------------------|-------------|-----------------------------------------------------------------| +| `user_id` | int | User ID. Use to link to public profile. | +| `public_profile` | bool | Whether the user's profile is publicly viewable. | +| `name` | string | Display name (empty string if user hides name on leaderboards). | +| `username` | string | `@username` (empty string if user hides username). | +| `xp` | string | Formatted XP with commas (e.g. `"1,234"`). | +| `global_flag` | string/null | ISO country code for flag display (e.g. `"ie"`, `"gb"`). | +| `social` | object/null | Social links keyed by type (`twitter`, `facebook`, `personal`). | +| `team` | string | Active team name (empty string if none or hidden). | +| `rank` | int | Position in the leaderboard (1-indexed). | + +**Time filters explained:** + +| Filter | Description | +|--------------|------------------------------------| +| `all-time` | Cumulative XP across all time | +| `today` | XP earned today (UTC) | +| `yesterday` | XP earned yesterday (UTC) | +| `this-month` | XP earned in the current month | +| `last-month` | XP earned in the previous month | +| `this-year` | XP earned in the current year | +| `last-year` | XP earned in the previous year | + +**Error responses:** + +- Missing one of `locationType`/`locationId`: `{ "success": false, "msg": "Both locationType and locationId required for location filtering" }` +- Invalid `locationType`: `{ "success": false, "msg": "Invalid locationType" }` +- Invalid `timeFilter`: `{ "success": false, "msg": "Invalid time filter" }` + +Filters on `xp > 0`. Users with `public_profile=true` have clickable profiles at `/profile/{user_id}`. + +--- + +## Achievements + +### GET /api/achievements + +**Auth:** Required (Sanctum) + +**Response (200):** +```json +{ + "overview": { + "uploads": { + "progress": 42, + "next_threshold": 50, + "percentage": 84, + "unlocked": [ + { "id": 1, "threshold": 10, "metadata": { "name": "First Steps", "icon": "rocket" } } + ], + "next": { "id": 2, "threshold": 50, "percentage": 84 } + }, + "streak": { "..." : "..." }, + "total_categories": { "..." : "..." }, + "total_objects": { "..." : "..." } + }, + "categories": [{ + "id": 1, + "key": "smoking", + "name": "Smoking", + "achievement": { "..." : "..." }, + "objects": [{ + "id": 1, + "key": "cigarette", + "name": "Cigarette", + "achievement": { "..." : "..." } + }] + }], + "summary": { "total": 150, "unlocked": 42, "percentage": 28 } +} +``` + +Hierarchical: overview > categories > objects. Sorted by progress (highest first). + +--- + +## Global Map + +### GET /api/points — Map Points (GeoJSON) + +**Auth:** None (public) + +**Query params:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `bbox` | object | required | `{left, bottom, right, top}` bounding box | +| `zoom` | int | required | Zoom level (0-22) | +| `page` | int | 1 | Page number | +| `per_page` | int | 1000 | Results per page (max 500) | +| `categories` | array | — | Category keys to filter | +| `litter_objects` | array | — | Object keys to filter | +| `materials` | array | — | Material keys to filter | +| `brands` | array | — | Brand keys to filter | +| `custom_tags` | array | — | Custom tag keys to filter | +| `from` | string | — | Start date (YYYY-MM-DD) | +| `to` | string | — | End date (YYYY-MM-DD) | +| `year` | int | — | Filter by year (overrides from/to) | +| `username` | string | — | Filter by username | + +**Response (200):** GeoJSON FeatureCollection +```json +{ + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-74.006, 40.713] }, + "properties": { + "id": 123, + "datetime": "2025-02-28T10:30:00Z", + "verified": 2, + "picked_up": false, + "summary": { "..." : "..." }, + "filename": "photo.jpg", + "username": "johndoe", + "name": "John", + "team": "Team A", + "social": null + } + }], + "page": 1, + "last_page": 10, + "per_page": 1000, + "total": 9500, + "has_more_pages": true, + "meta": { + "bbox": [-74.006, 40.713, -74.005, 40.714], + "zoom": 12, + "generated_at": "2025-02-28T16:30:00Z" + } +} +``` + +Only `is_public = true` photos. Masks identity for safeguarded teams. `filename` shown only if `verified >= 2`. Caches non-username-filtered requests for 2 minutes. + +--- + +### GET /api/points/{id} — Single Photo Point + +**Auth:** None (public) + +Returns single photo data. Used as fallback when photo isn't in current GeoJSON page. + +**Response (200):** +```json +{ + "id": 123, + "lat": 40.7128, + "lon": -74.0060, + "datetime": "2025-02-28T10:30:00Z", + "verified": 2, + "filename": "photo.jpg", + "username": "johndoe", + "name": "John", + "social": null, + "flag": "ie", + "team": "Team A", + "summary": { ... } +} +``` + +Identity fields (`username`, `name`, `social`, `flag`) are `null` when team has safeguarding enabled. Privacy settings (`show_name_maps`, `show_username_maps`) are respected. + +--- + +### GET /api/points/stats — Map Stats for Viewport + +**Auth:** None (public) + +Same query params as `/api/points`. + +**Response (200):** +```json +{ + "data": { + "photos": 150, + "tags": 500, + "categories": 8, + "objects": 25, + "brands": 12 + }, + "meta": { + "bbox": [-74.006, 40.713, -74.005, 40.714], + "zoom": 12, + "categories": null, + "litter_objects": null, + "materials": null, + "brands": null, + "custom_tags": null, + "from": null, + "to": null, + "username": null, + "year": null, + "generated_at": "2025-02-28T16:30:00Z", + "cached": false + } +} +``` + +--- + +### GET /api/clusters — Map Clusters (GeoJSON) + +**Auth:** None (public) + +**Query params:** + +| Param | Type | Description | +|-------|------|-------------| +| `zoom` | numeric | Snapped to nearest configured zoom level | +| `bbox` | array/string | `bbox[left]`, `bbox[bottom]`, `bbox[right]`, `bbox[top]` — or comma-separated string `-180,-90,180,90` | +| `lat`, `lon` | numeric | Optional, creates bbox if no bbox provided | + +**Response:** GeoJSON FeatureCollection with cluster points containing `properties.count`. + +Supports ETag-based caching (`If-None-Match` header returns 304 if unchanged). Response includes `Cache-Control`, `ETag`, and `X-Cluster-Zoom` headers. + +--- + +### GET /api/clusters/zoom-levels — Available Cluster Zoom Levels + +**Auth:** None (public) + +**Response (200):** +```json +{ + "zoom_levels": [2, 4, 6, 8, 10, 12], + "global_zooms": [2, 4, 6], + "tile_zooms": [8, 10, 12] +} +``` + +--- + +### GET /api/global/stats-data — Global Statistics (Deprecated) + +**Auth:** None (public) + +**Response:** +```json +{ + "total_litter": 500000, + "total_photos": 50000, + "previousXp": 250000, + "nextXp": 500000, + "littercoin": 1000, + "total_users": 10000 +} +``` + +--- + +## Locations + +### GET /api/locations/global — Global Stats + Country List + +**Auth:** None (public) + +--- + +### GET /api/locations/{type} — List Locations by Type + +**Auth:** None (public) +**Types:** `country`, `state`, `city` + +**Query params (optional):** + +| Param | Type | Description | +|-------|------|-------------| +| `period` | string | `today`, `yesterday`, `this_month`, `last_month`, `this_year` | +| `year` | int | Custom year (2015-current) | +| `month` | int | Custom month 1-12 (requires year) | + +**Response (200):** +```json +{ + "stats": { + "photos": 50000, + "tags": 150000, + "xp": 2500000, + "contributors": 5000, + "countries": 110, + "total_users": 10000 + }, + "activity": { + "today": { "photos": 150, "tags": 500, "xp": 15000 }, + "this_month": { "photos": 3000, "tags": 10000, "xp": 300000 } + }, + "locations": [{ + "id": 1, + "name": "United States", + "shortcode": "US", + "photos": 20000, + "tags": 60000, + "xp": 1000000, + "contributors": 2000, + "pct_tags": 40.0, + "pct_photos": 40.0, + "avg_tags_per_person": 30.0, + "avg_photos_per_person": 10.0, + "created_at": "2015-01-01T00:00:00Z", + "created_by": "John Doe", + "last_updated_at": "2025-02-28T10:30:00Z", + "last_updated_by": "Jane Smith" + }], + "location_type": "country", + "breadcrumbs": [{ "name": "World", "type": "global", "id": null }] +} +``` + +Response keys are `locations` and `location_type` (not `children`/`children_type`). + +--- + +### GET /api/locations/{type}/{id} — Location Detail + Children + +Same query params as index. Returns `location`, `stats`, `meta`, `activity`, `locations` (children), `location_type`, `breadcrumbs`. + +--- + +### GET /api/locations/{type}/{id}/categories — Location Category Breakdown + +### GET /api/locations/{type}/{id}/timeseries — Location Time Series + +### GET /api/locations/{type}/{id}/leaderboard — Location Leaderboard + +### Location Tag Endpoints (all under `/api/locations/{type}/{id}/tags/`) + +| Endpoint | Description | +|----------|-------------| +| `/top` | Top tags at this location | +| `/summary` | Tag summary | +| `/by-category` | Tags grouped by category | +| `/cleanup` | Cleanup data | +| `/trending` | Trending tags | + +--- + +### Legacy: GET /api/v1/locations/{type}/{id} + +Same as above, constrained to `country|state|city` types and numeric IDs. + +--- + +## Teams + +### GET /api/teams/types — Team Types (Public, no auth) + +**Response:** `{ "success": true, "types": [{ "id": 1, "team": "School" }, ...] }` + +Returns team types ordered by `id` descending. + +--- + +### POST /api/teams/create — Create Team + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ "name": "Team Name", "description": "...", "type_id": 1 } +``` + +--- + +### POST /api/teams/join — Join Team + +**Auth:** Required (Sanctum) + +**Request:** `{ "identifier": "team_code" }` +**Response:** `{ "success": true, "team": { ... }, "activeTeam": { ... } }` +**Error:** `{ "success": false, "msg": "already-joined" }` + +--- + +### POST /api/teams/leave — Leave Team + +**Auth:** Required (Sanctum) + +**Request:** `{ "team_id": 1 }` +**Response:** `{ "success": true, "team": { ... }, "activeTeam": { ... } }` +**Errors (403):** `not-a-member`, `you-are-last-member` + +User cannot be the only member. + +--- + +### POST /api/teams/active — Set Active Team + +**Auth:** Required (Sanctum) + +**Request:** `{ "team_id": 1 }` +**Response:** `{ "success": true, "team": { ... } }` +**Errors:** `{ "success": false, "message": "team-not-found" | "not-a-member" }` + +--- + +### POST /api/teams/inactivate — Deactivate All Teams + +**Auth:** Required (Sanctum) + +Sets active team to null. + +--- + +### PATCH /api/teams/update/{team} — Update Team + +**Auth:** Required (team leader only) + +**Response:** `{ "success": true, "team": { ... } }` +**Error (403):** `{ "success": false, "message": "member-not-allowed" }` + +--- + +### GET /api/teams/joined — User's Joined Teams + +**Auth:** Required (Sanctum) + +**Response:** Raw array of team objects (user's teams collection). + +--- + +### GET /api/teams/list — List User's Teams + +**Auth:** Required (Sanctum) + +**Response:** `{ "success": true, "teams": [ ... ] }` + +--- + +### GET /api/teams/members?team_id=X — Team Members + +**Auth:** Required (Sanctum) + +**Response:** +```json +{ + "success": true, + "total_members": 25, + "result": [{ "id": 123, "name": "Student 1", ... }] +} +``` + +School teams apply safeguarding: deterministic pseudonyms ("Student 1", "Student 2", etc.). + +--- + +### GET /api/teams/data?team_id=X&period=all — Team Dashboard Data + +**Auth:** Required (Sanctum) + +| Param | Type | Description | +|-------|------|-------------| +| `team_id` | int | required (0 = all user's teams) | +| `period` | string | `today`, `week`, `month`, `year`, `all` (default) | + +**Response:** +```json +{ + "photos_count": 150, + "litter_count": 500, + "members_count": 25, + "verification": { + "unverified": 10, + "verified": 20, + "admin_approved": 50, + "bbox_applied": 30, + "bbox_verified": 25, + "ai_ready": 15 + } +} +``` + +--- + +### GET /api/teams/leaderboard — Teams Leaderboard + +**Auth:** Required (Sanctum) + +Teams ranked by total litter. Only teams with `leaderboards=true` shown. + +--- + +### POST /api/teams/leaderboard/visibility — Toggle Leaderboard Visibility + +**Auth:** Required (team leader) + +**Request:** `{ "team_id": 1 }` +**Response:** `{ "success": true, "visible": true }` (where `visible` = team's `leaderboards` field) +**Error (403):** `{ "success": false, "message": "member-not-allowed" }` + +--- + +### POST /api/teams/settings — Update Team Privacy + +**Auth:** Required (Sanctum) + +**Request:** +```json +{ + "team_id": 1, + "settings": { + "show_name_maps": true, + "show_username_maps": true, + "show_name_leaderboards": false, + "show_username_leaderboards": false + } +} +``` + +Use `"all": true` instead of `team_id` to apply to all user's teams. + +**Response:** `{ "success": true }` +**Error (403):** `{ "message": "Not a member of this team." }` + +--- + +### GET /api/teams/photos?team_id=X&status=pending — Team Photos + +**Auth:** Required (team member) + +| Param | Type | Description | +|-------|------|-------------| +| `team_id` | int | required | +| `status` | string | `pending`, `approved`, `all` (default) | +| `page` | int | page number | + +**Response:** +```json +{ + "success": true, + "photos": { + "data": [{ + "id": 123, + "filename": "photo.jpg", + "is_public": false, + "verified": 1, + "team_approved_at": null, + "photoTags": [{ ... }], + "user": { "id": 123, "name": "Student Name" } + }], + "total": 50, + "current_page": 1 + }, + "stats": { "total": 150, "pending": 50, "approved": 100 } +} +``` + +--- + +### GET /api/teams/photos/{photo} — Single Team Photo + +**Auth:** Required (team member) + +**Response:** `{ "success": true, "photo": { ... } }` +**Errors:** `{ "success": false, "message": "not-a-team-photo" }` (404), `{ "success": false, "message": "not-a-member" }` (403) + +--- + +### PATCH /api/teams/photos/{photo}/tags — Update Team Photo Tags + +**Auth:** Required (team leader / `manage school team` permission) + +Deletes existing tags, recreates with new data, regenerates summary + XP. + +--- + +### POST /api/teams/photos/approve — Approve Photos + +**Auth:** Required (team leader / `manage school team` permission) + +**Request:** +```json +{ "team_id": 1, "photo_ids": [123, 124] } +``` +Or: `{ "team_id": 1, "approve_all": true }` + +**Response:** `{ "success": true, "approved_count": 3, "message": "3 photos approved and published." }` + +Idempotent (WHERE `is_public = 0`). Sets `is_public=true`, fires `TagsVerifiedByAdmin` for metrics. + +--- + +### POST /api/teams/photos/revoke — Revoke Photo Approval + +**Auth:** Required (team leader / `manage school team` permission) + +**Request:** +```json +{ "team_id": 1, "photo_ids": [123, 124] } +``` +Or: `{ "team_id": 1, "revoke_all": true }` + +**Response:** `{ "success": true, "revoked_count": 2, "message": "2 photos revoked." }` + +Idempotent (WHERE `is_public = true`). Reverses metrics, sets `is_public=false`, `verified=1`. + +--- + +### DELETE /api/teams/photos/{photo}?team_id=X — Delete Team Photo + +**Auth:** Required (team leader / `manage school team` permission) + +**Response:** +```json +{ + "success": true, + "message": "Photo deleted.", + "stats": { "total": 149, "pending": 49, "approved": 100 } +} +``` + +Reverses metrics, removes S3 files, soft-deletes. + +--- + +### GET /api/teams/photos/map?team_id=X — Team Photo Map Points + +**Auth:** Required (team member) + +Returns max 5000 points with `id`, `lat`, `lng`, `tags`, `verified`, `is_public`, `date`. + +--- + +### GET /api/teams/clusters/{team} — Team Clusters (GeoJSON) + +**Auth:** Required (Sanctum) + +**Query params:** `zoom`, `bbox` + +--- + +### GET /api/teams/points/{team} — Team Points (GeoJSON) + +**Auth:** Required (team member) + +**Query params:** `bbox`, `layers` + +--- + +### POST /api/teams/download — Download Team Data + +**Auth:** Required (Sanctum) + +**Request:** `{ "team_id": 1 }` +**Response:** `{ "success": true }` +**Error:** `{ "success": false, "message": "not-a-member" }` + +Queues background export job. + +--- + +## Community & Map Data + +### GET /api/community/stats — Community Statistics + +**Auth:** None (public) + +**Response (200):** +```json +{ + "photosPerMonth": 3000, + "litterTagsPerMonth": 10000, + "usersPerMonth": 50, + "statsByMonth": { + "photosByMonth": [100, 200, 300], + "usersByMonth": [10, 20, 30], + "periods": ["Jan 2024", "Feb 2024", "Mar 2024"] + } +} +``` + +--- + +### GET /api/mobile-app-version — Mobile App Version + +**Auth:** None (public) + +**Response (200):** +```json +{ + "ios": { + "url": "https://apps.apple.com/us/app/openlittermap/id1475982147", + "version": "6.1.0" + }, + "android": { + "url": "https://play.google.com/store/apps/details?id=com.geotech.openlittermap", + "version": "6.1.0" + } +} +``` + +--- + +### GET /api/history/paginated — Paginated Tagging History + +**Auth:** Optional (changes filter behavior) + +**Query params:** + +| Param | Type | Description | +|-------|------|-------------| +| `loadPage` | int | Page number (default: 1) | +| `filterCountry` | string | Country filter or `'all'` (default) | +| `filterDateFrom` | string | Start date filter | +| `filterDateTo` | string | End date filter | +| `filterTag` | string | Search in summary JSON | +| `filterCustomTag` | string | Search custom tags | +| `paginationAmount` | int | Results per page | + +**Response (200):** `{ "success": true, "photos": { /* paginated */ } }` + +If authenticated: shows user's own photos. If unauthenticated: shows `verified >= 2` and `is_public = true` photos only. + +--- + +### GET /api/countries/names — Country Name List + +**Auth:** None (public) + +**Response (200):** +```json +{ + "success": true, + "countries": [ + { "id": 1, "country": "United States", "shortcode": "US", "manual_verify": true } + ] +} +``` + +Only returns countries with `manual_verify = true` (or shortcode `pr`). + +--- + +### GET /api/global/points — Global Map Points (Legacy) + +**Auth:** None (public) + +GeoJSON endpoint for the global map. Filters: `is_public = true`. Supports `bbox`, `layers`, `fromDate`, `toDate`, `year`, `username` query params. + +--- + +### GET /api/global/art-data — Litter Art Data (Deprecated) + +**Auth:** None (public) + +Returns GeoJSON of photos with `art_id != null`, `verified >= 2`, `is_public = true`. + +--- + +### GET /api/global/search/custom-tags — Search Custom Tags + +**Auth:** None (public) + +**Query params:** `search` (required, string prefix) + +**Response (200):** +```json +{ "success": true, "tags": ["plastic wrapper", "plastic bottle"] } +``` + +Returns top 20 matching custom tags ordered by frequency. + +--- + +### GET /api/tags-search — Display Tags on Map + +**Auth:** None (public) + +**Query params:** `custom_tag`, `custom_tags` (comma-separated), `brand` + +Returns GeoJSON FeatureCollection of matching photos (`is_public = true`, max 5000). + +--- + +### POST /api/download — Download Location Data + +**Auth:** Optional + +**Request:** +```json +{ "locationType": "city", "locationId": 42, "email": "user@example.com" } +``` + +`email` is optional if authenticated (uses auth user's email). Queues CSV export job, emails download link. + +**Response:** `{ "success": true }` or `{ "success": false }` + +--- + +## Cleanups + +### POST /api/cleanups/create — Create Cleanup Event + +**Auth:** Required + +**Request:** +```json +{ + "name": "Beach Cleanup", + "date": "2025-03-15", + "lat": 40.7128, + "lon": -74.0060, + "time": "10:00 AM", + "description": "Annual beach cleanup event", + "invite_link": "beach-cleanup-2025" +} +``` + +| Field | Rules | +|-------|-------| +| `name` | required, min 5 | +| `date` | required | +| `lat` | required | +| `lon` | required | +| `time` | required, min 3 | +| `description` | required, min 5 | +| `invite_link` | required, unique, min 1 | + +**Response:** `{ "success": true, "cleanup": { ... } }` + +Creator is automatically joined. + +--- + +### GET /api/cleanups/get-cleanups — Get All Cleanups (GeoJSON) + +**Auth:** None (public) + +**Response:** `{ "success": true, "geojson": { /* GeoJSON FeatureCollection */ } }` + +--- + +### POST /api/cleanups/{inviteLink}/join — Join Cleanup + +**Auth:** Optional (returns error message if unauthenticated) + +**Response:** `{ "success": true, "cleanup": { ... } }` +**Errors:** `{ "success": false, "msg": "unauthenticated" | "already joined" | "cleanup not found" }` + +--- + +### POST /api/cleanups/{inviteLink}/leave — Leave Cleanup + +**Auth:** Required + +**Response:** `{ "success": true }` +**Errors:** `{ "success": false, "msg": "not found" | "cannot leave" | "already left" }` + +Creator cannot leave their own cleanup. + +--- + +### GET /api/city — Get City Map Data (Legacy) + +**Auth:** None (public) + +**Query params:** `city` (required, URL-decoded name), `min` / `max` (dates, format `d-m-Y`), `hex` (optional, default 100) + +**Response (200):** +```json +{ + "center_map": [40.7128, -74.0060], + "map_zoom": 13, + "litterGeojson": { "type": "FeatureCollection", "features": [...] }, + "hex": 100 +} +``` + +Filters: `is_public = true`, `verified > 0`. + +--- + +### POST /api/littercoin/merchants — Become a Merchant + +**Auth:** None specified in route + +Registers interest as a Littercoin merchant. + +--- + +### GET /api/locations/world-cup — World Cup Data + +**Auth:** None (public) + +Returns location data for the World Cup leaderboard. + +--- + +### POST /api/settings/toggle — Toggle Items Remaining + +**Auth:** Required (Sanctum) + +Toggles the `items_remaining` boolean for the user. + +**Response:** `{ "message": "success", "value": true }` + +--- + +### POST /api/profile/photos/remaining/{id} — Toggle Photo Remaining Flag + +**Auth:** Required (Sanctum) + +Toggles the `remaining` field on a specific photo. + +**Response:** `{ "success": true }` + +--- + +### POST /api/user/profile/photos/tags/bulkTag — Bulk Tag Photos (Deprecated) + +**Auth:** Required (Sanctum) +**Status:** 410 Gone + +**Response:** `{ "message": "Use POST /api/v3/tags for tagging" }` + +--- + +### POST /api/profile/upload-profile-photo — Upload Profile Photo (Stub) + +**Auth:** Required (Sanctum) +**Status:** 501 Not Implemented + +Not yet implemented. + +--- + +## Admin Endpoints + +Admin endpoints under `/api/admin/` require the `admin` middleware. These are internal and not documented for mobile use. Includes: photo queue, verification, tag management, merchant approval. + +--- + +## Bounding Box Endpoints + +Bbox endpoints under `/api/bbox/` require the `can_bbox` middleware. Used for bounding box annotation workflow. Includes: index, create, skip, update tags, verify. + +--- + +## Legacy Mobile Endpoints (v2) + +These remain for backward compatibility with older app versions: + +### GET /api/v2/photos/get-untagged-uploads — Untagged Photos (Deprecated) + +**Auth:** Required (Sanctum) + +**Response:** `{ "count": 5, "photos": [{ "id": 1, "filename": "...", "remaining": 1, "platform": "web" }] }` + +Returns first 100 untagged photos (`verification = 0`). `photos` is `null` if count is 0. + +--- + +### GET /api/v2/photos/web/load-more — Load More Untagged (Deprecated) + +**Auth:** Required (Sanctum) + +**Query param:** `photo_id` (int, load photos after this ID) + +Returns up to 10 photos with `id` and `filename` only. + +--- + +### Other Legacy Endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /api/v2/photos/web/index` | Alias for get-untagged-uploads | +| `POST /api/v2/add-tags-to-uploaded-image` | Add v4 tags (same as `/api/add-tags`) | +| `POST /api/upload` | Upload photo (legacy alias for v3/upload) | + +--- + +## Reference + +### Verification Pipeline + +| Value | Status | Meaning | +|-------|--------|---------| +| 0 | UNVERIFIED | Uploaded, no tags | +| 1 | VERIFIED | Tagged (school students land here, awaiting teacher approval) | +| 2 | ADMIN_APPROVED | Verified by admin/trusted user OR teacher-approved | +| 3 | BBOX_APPLIED | Bounding boxes drawn | +| 4 | BBOX_VERIFIED | Bounding boxes verified | +| 5 | AI_READY | Ready for OpenLitterAI training | + +### Level System + +| XP Threshold | Level | Title | +|-------------|-------|-------| +| 0 | 0 | Complete Noob | +| 100 | 1 | Still A Noob | +| 500 | 2 | Post-Noob | +| 1,000 | 3 | Litter Wizard | +| 5,000 | 4 | Trash Warrior | +| 10,000 | 5 | Early Guardian | +| 15,000 | 6 | Trashmonster | +| 50,000 | 7 | Force of Nature | +| 100,000 | 8 | Planet Protector | +| 200,000 | 9 | Galactic Garbagething | +| 500,000 | 10 | Interplanetary | +| 1,000,000 | 11 | SuperIntelligent LitterMaster | + +### XP Scoring + +| Action | XP per unit | +|--------|------------| +| Upload | 5 | +| Object tag | 1 (special overrides exist) | +| Brand | 3 | +| Material | 2 | +| Custom tag | 1 | + +Brands use their own `quantity`. Materials and custom_tags use parent tag's `quantity`. + +### Error Response Patterns + +Controllers use two error field names inconsistently: +- `msg` — Used by: auth endpoints, team join, legacy photo endpoints +- `message` — Used by: team member/settings endpoints, team photos, newer endpoints + +Both return `success: false` with the error string. + +### Auth Architecture + +- **SPA (web):** Session-based via `auth:web` + Sanctum cookie +- **Mobile:** Stateless Sanctum tokens via `Authorization: Bearer {token}` +- **Dual guard:** Most routes use `auth:sanctum` (supports both session and token) +- Token login revokes previous tokens (prevents buildup) +- Registration returns both session + token (immediate use from either client) diff --git a/readme/BackendLocations.md b/readme/BackendLocations.md new file mode 100644 index 00000000..0e3f5ad0 --- /dev/null +++ b/readme/BackendLocations.md @@ -0,0 +1,758 @@ +# OpenLitterMap v5 — Locations System + +## Overview + +This document covers the v5 locations architecture: what's changing, what's being deprecated, the new database schema, Redis design, and the re-engineered upload flow. + +The core principle: **locations tables store identity only; all aggregates live in the `metrics` table and Redis.** + +--- + +## Current State (v4) — Problems + +### 1. Redundant data on `photos` table + +The photo record stores location data in two ways simultaneously: + +| Foreign Keys (keep) | String Columns (deprecate) | +|---|---| +| `country_id` | `country`, `country_code` | +| `state_id` | `county` (actually state name) | +| `city_id` | `city`, `display_name`, `location`, `road` | + +The string columns are denormalized copies of data that already exists on the location tables. They made sense before we had proper foreign keys but now they just drift and waste space. + +### 2. Legacy category counters on `cities` table + +The `cities` table has 16+ `total_*` columns: + +``` +total_smoking, total_cigaretteButts, total_food, total_softdrinks, +total_plasticBottles, total_alcohol, total_coffee, total_drugs, +total_dumping, total_industrial, total_needles, total_sanitary, +total_other, total_coastal, total_pathways, total_art, total_dogshit +``` + +These are completely replaced by the `metrics` table time-series which tracks tags by category, object, material, and brand at all location levels with full time-series granularity. + +### 3. Legacy columns on all location tables + +| Column | Tables | Status | +|---|---|---| +| `manual_verify` | countries, states, cities | Deprecated — no longer used | +| `littercoin_paid` | countries, states, cities | Deprecated — Littercoin tracked elsewhere | +| `countrynameb` | countries | Deprecated — unused alternate name | +| `statenameb` | states | Deprecated — unused alternate name | +| `user_id_last_uploaded` | countries, states, cities | Deprecated — derivable from photos table | + +### 4. `UpdateLeaderboardsForLocationAction` is deprecated + +Already marked `@deprecated` but still called from the upload controller. It writes to old Redis key patterns: + +``` +xp.country.{id} # old format +leaderboard:country:{id}:total # old format +leaderboard:country:{id}:{year}:{month}:{day} # old format +``` + +The `MetricsService` + `RedisMetricsCollector` now handles all of this via the unified metrics pipeline. + +### 5. Events overlap with MetricsService + +| Event | What it does | v5 status | +|---|---|---| +| `ImageUploaded` | Updates total_contributors_redis, broadcasts to map | **Keep** — real-time broadcast still needed | +| `IncrementPhotoMonth` | Increments month counters per location | **Remove** — metrics table handles time-series | +| `NewCountryAdded` | Notifies Twitter/Slack | **Keep** — notification, not metrics | +| `NewStateAdded` | Notifies Twitter/Slack | **Keep** — notification, not metrics | +| `NewCityAdded` | Notifies Twitter/Slack | **Keep** — notification, not metrics | + +### 6. UploadHelper error handling + +Falls back to sentinel records (`error_country`, `error_state`, `error_city`). This means: + +- Bad geocode results silently create photos attached to error locations +- These pollute metrics and leaderboards +- No way to distinguish "geocode failed" from "geocode returned unexpected format" + +### 7. Upload controller does too much + +The `__invoke` method handles: image processing → S3 upload → bbox upload → GPS extraction → reverse geocoding → location resolution → photo creation → XP/leaderboards → 5 different events. This needs to be broken into focused steps. + +--- + +## v5 Target Schema + +### `countries` table + +```sql +-- Keep +id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY +country VARCHAR(255) NOT NULL -- Display name +shortcode VARCHAR(2) NOT NULL UNIQUE -- ISO 3166-1 alpha-2 +created_by BIGINT UNSIGNED NULLABLE -- User who first triggered creation +created_at TIMESTAMP +updated_at TIMESTAMP + +-- Deprecate (migration to drop) +manual_verify -- unused +littercoin_paid -- tracked elsewhere +countrynameb -- unused alternate name +user_id_last_uploaded -- derivable from photos +``` + +### `states` table + +```sql +-- Keep +id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY +state VARCHAR(255) NOT NULL +country_id BIGINT UNSIGNED NOT NULL -- FK → countries +created_by BIGINT UNSIGNED NULLABLE +created_at TIMESTAMP +updated_at TIMESTAMP + +UNIQUE KEY (country_id, state) + +-- Deprecate (migration to drop) +statenameb -- unused alternate name +manual_verify -- unused +littercoin_paid -- tracked elsewhere +user_id_last_uploaded -- derivable from photos +``` + +### `cities` table + +```sql +-- Keep +id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY +city VARCHAR(255) NOT NULL +country_id BIGINT UNSIGNED NOT NULL -- FK → countries +state_id BIGINT UNSIGNED NOT NULL -- FK → states +created_by BIGINT UNSIGNED NULLABLE +created_at TIMESTAMP +updated_at TIMESTAMP + +UNIQUE KEY (country_id, state_id, city) + +-- Deprecate (migration to drop) — ALL total_* columns +total_smoking, total_cigaretteButts, total_food, total_softdrinks, +total_plasticBottles, total_alcohol, total_coffee, total_drugs, +total_dumping, total_industrial, total_needles, total_sanitary, +total_other, total_coastal, total_pathways, total_art, total_dogshit, +manual_verify, littercoin_paid, user_id_last_uploaded +``` + +### `photos` table — columns to deprecate + +```sql +-- Deprecate (migration to drop) +country -- redundant, use country_id → countries.country +country_code -- redundant, use country_id → countries.shortcode +county -- confusingly named (it's state), use state_id → states.state +city -- redundant, use city_id → cities.city +display_name -- full OSM address string, move to address_array JSON +location -- first element of address array, derivable +road -- second element of address array, derivable + +-- Keep +country_id, state_id, city_id -- foreign keys +address_array -- raw OSM response (JSON), source of truth for display_name/location/road +lat, lon, geohash -- coordinates +``` + +### `metrics` table (already exists in v5) + +This is the single source of truth for all aggregates. See `MetricsService` for full schema. + +```sql +-- Composite unique key +(timescale, location_type, location_id, user_id, year, month, week, bucket_date) + +-- Additive counters +uploads, tags, brands, materials, custom_tags, litter, xp +``` + +**LocationType enum:** +- `0` = Global +- `1` = Country +- `2` = State +- `3` = City + +**Timescales:** +- `0` = All-time +- `1` = Daily +- `2` = Weekly (ISO) +- `3` = Monthly +- `4` = Yearly + +--- + +## v5 Location Models + +### Country model (cleaned) + +```php +class Country extends Location +{ + protected $fillable = [ + 'country', + 'shortcode', + 'created_by', + ]; + + protected $appends = [ + 'total_litter_redis', + 'total_photos_redis', + 'total_contributors_redis', + 'litter_data', + 'brands_data', + 'objects_data', + 'materials_data', + 'recent_activity', + 'total_xp', + 'ppm', + 'updatedAtDiffForHumans', + 'total_ppm', + ]; + + public function getRouteKeyName(): string + { + return 'country'; + } + + public function states() + { + return $this->hasMany(State::class); + } + + public function cities() + { + return $this->hasMany(City::class); + } +} +``` + +### State model (cleaned) + +```php +class State extends Location +{ + protected $fillable = [ + 'state', + 'country_id', + 'created_by', + ]; + + // Same $appends as Country + + public function country() + { + return $this->belongsTo(Country::class); + } + + public function cities() + { + return $this->hasMany(City::class); + } +} +``` + +### City model (cleaned) + +```php +class City extends Location +{ + protected $fillable = [ + 'city', + 'country_id', + 'state_id', + 'created_by', + ]; + + // Same $appends as Country + + public function country() + { + return $this->belongsTo(Country::class); + } + + public function state() + { + return $this->belongsTo(State::class); + } +} +``` + +### Location base model `$appends` + +All aggregate data (`total_litter_redis`, `total_photos_redis`, etc.) should be computed from Redis, which is populated by `RedisMetricsCollector` and rebuildable from the `metrics` table. The base `Location` model provides these accessors. + +--- + +## Redis Key Design (v5) + +Redis is a **derived cache** — rebuildable from the `metrics` table at any time. + +### Scope prefixes (from `LocationType` enum) + +``` +global → LocationType::Global +country:{id} → LocationType::Country +state:{id} → LocationType::State +city:{id} → LocationType::City +``` + +### Key patterns managed by `RedisMetricsCollector` + +All keys use `RedisKeys::*` builders (single source of truth). See `app/Services/Redis/RedisKeys.php`. + +``` +# Aggregate counters (HINCRBY on hash keys) +{scope}:stats → HASH { uploads, tags, litter, xp, brands, materials, custom_tags } + +# Contributors (PFADD on HyperLogLog) +{scope}:hll → HyperLogLog of user IDs (~0.81% error, O(1) reads) + +# Per-tag counters +{scope}:obj → HASH { object_id: count } +{scope}:mat → HASH { material_id: count } +{scope}:brand → HASH { brand_id: count } +{scope}:cat → HASH { category_id: count } + +# Tag rankings (ZINCRBY on sorted sets) +{scope}:rank:objects → ZSET { object_id: count } +{scope}:rank:materials → ZSET { material_id: count } +{scope}:rank:brands → ZSET { brand_id: count } + +# Leaderboards (ZINCRBY on sorted sets) +{scope}:lb:xp → ZSET { user_id: xp } + +# Per-user +{u:$userId}:stats → HASH { uploads, xp, litter } +{u:$userId}:bitmap → BITMAP (activity streak tracking) +``` + +### Deprecated Redis keys (to remove) + +``` +# Old leaderboard format — replaced by {scope}:lb:xp ZSETs +xp.country.{id} +xp.country.{id}.state.{id} +xp.country.{id}.state.{id}.city.{id} +leaderboard:country:{id}:total +leaderboard:state:{id}:total +leaderboard:city:{id}:total +leaderboard:country:{id}:{year}:{month}:{day} +leaderboard:state:{id}:{year}:{month}:{day} +leaderboard:city:{id}:{year}:{month}:{day} +leaderboard:country:{id}:{year}:{month} +leaderboard:state:{id}:{year}:{month} +leaderboard:city:{id}:{year}:{month} +leaderboard:country:{id}:{year} +leaderboard:state:{id}:{year} +leaderboard:city:{id}:{year} + +# Old location format — replaced by {scope}:stats, {scope}:hll +country:*:user_ids +state:*:user_ids +city:*:user_ids +``` + +--- + +## Migrations + +### Migration 1: Drop deprecated columns from locations + +```php +Schema::table('countries', function (Blueprint $table) { + $table->dropColumn([ + 'manual_verify', + 'littercoin_paid', + 'countrynameb', + 'user_id_last_uploaded', + ]); +}); + +Schema::table('states', function (Blueprint $table) { + $table->dropColumn([ + 'statenameb', + 'manual_verify', + 'littercoin_paid', + 'user_id_last_uploaded', + ]); +}); + +Schema::table('cities', function (Blueprint $table) { + $table->dropColumn([ + 'total_smoking', + 'total_cigaretteButts', + 'total_food', + 'total_softdrinks', + 'total_plasticBottles', + 'total_alcohol', + 'total_coffee', + 'total_drugs', + 'total_dumping', + 'total_industrial', + 'total_needles', + 'total_sanitary', + 'total_other', + 'total_coastal', + 'total_pathways', + 'total_art', + 'total_dogshit', + 'manual_verify', + 'littercoin_paid', + 'user_id_last_uploaded', + ]); +}); +``` + +### Migration 2: Drop deprecated columns from photos + +```php +Schema::table('photos', function (Blueprint $table) { + $table->dropColumn([ + 'country', + 'country_code', + 'county', // actually state name + 'city', // string duplicate of city_id + 'display_name', // derivable from address_array + 'location', // derivable from address_array + 'road', // derivable from address_array + ]); +}); +``` + +**Important:** Run Migration 2 only after confirming no code reads these columns. During transition, you can mark them as nullable/deprecated first, then drop in a follow-up migration. + +--- + +## Re-engineered Upload Flow + +### Current flow (v4) + +``` +UploadPhotoController::__invoke() +├── MakeImageAction::run() → image + EXIF +├── UploadPhotoAction::run() × 2 → S3 + bbox +├── getCoordinatesFromPhoto() → lat/lon +├── ReverseGeocodeLocationAction::run() → OSM address +├── UploadHelper::getCountry/State/City → firstOrCreate locations +├── Photo::create() → 20+ columns including string locations +├── event(ImageUploaded) → broadcast + contributor counts +├── UpdateLeaderboardsForLocationAction → deprecated Redis writes +├── event(NewCountryAdded) → notification +├── event(NewStateAdded) → notification +├── event(NewCityAdded) → notification +└── event(IncrementPhotoMonth) → deprecated month counters +``` + +### New flow (v5) + +``` +UploadPhotoController::__invoke() +├── MakeImageAction::run() → image + EXIF +├── UploadPhotoAction::run() × 2 → S3 + bbox +├── getCoordinatesFromPhoto() → lat/lon +├── ResolveLocationAction::run() → country, state, city (replaces UploadHelper) +├── Photo::create() → slim columns (FKs only, no string duplication) +├── MetricsService::processPhoto() → MySQL metrics + Redis (replaces leaderboards action) +├── event(ImageUploaded) → broadcast to real-time map +├── event(NewCountryAdded) → notification (if wasRecentlyCreated) +├── event(NewStateAdded) → notification (if wasRecentlyCreated) +└── event(NewCityAdded) → notification (if wasRecentlyCreated) +``` + +**Removed:** +- `UpdateLeaderboardsForLocationAction` — replaced by `MetricsService` +- `IncrementPhotoMonth` event — replaced by `metrics` table time-series +- String location columns from `Photo::create()` +- `UploadHelper` class — replaced by `ResolveLocationAction` + +### New `ResolveLocationAction` + +Replaces `UploadHelper` with cleaner error handling: + +```php +namespace App\Actions\Locations; + +use App\Models\Location\{Country, State, City}; + +class ResolveLocationAction +{ + /** + * Resolve lat/lon to Country, State, City. + * + * @throws \App\Exceptions\GeocodingException + */ + public function run(float $lat, float $lon): LocationResult + { + $revGeoCode = app(ReverseGeocodeLocationAction::class)->run($lat, $lon); + $address = $revGeoCode['address']; + + $country = $this->resolveCountry($address); + $state = $this->resolveState($country, $address); + $city = $this->resolveCity($country, $state, $address); + + return new LocationResult( + country: $country, + state: $state, + city: $city, + addressArray: $address, + displayName: $revGeoCode['display_name'], + ); + } + + private function resolveCountry(array $address): Country + { + $code = $address['country_code'] ?? null; + + if (!$code) { + throw new \App\Exceptions\GeocodingException('No country_code in geocode response'); + } + + return Country::firstOrCreate( + ['shortcode' => strtoupper($code)], + ['country' => $address['country'] ?? '', 'created_by' => auth()->id()] + ); + } + + private function resolveState(Country $country, array $address): State + { + $name = $this->lookup($address, ['state', 'county', 'region', 'state_district']); + + if (!$name) { + throw new \App\Exceptions\GeocodingException('No state found in geocode response'); + } + + return State::firstOrCreate( + ['state' => $name, 'country_id' => $country->id], + ['created_by' => auth()->id()] + ); + } + + private function resolveCity(Country $country, State $state, array $address): City + { + $name = $this->lookup($address, ['city', 'town', 'city_district', 'village', 'hamlet', 'locality', 'county']); + + if (!$name) { + throw new \App\Exceptions\GeocodingException('No city found in geocode response'); + } + + return City::firstOrCreate( + ['country_id' => $country->id, 'state_id' => $state->id, 'city' => $name], + ['created_by' => auth()->id()] + ); + } + + private function lookup(array $address, array $keys): ?string + { + foreach ($keys as $key) { + if (!empty($address[$key])) { + return $address[$key]; + } + } + return null; + } +} +``` + +### New `LocationResult` DTO + +```php +namespace App\Actions\Locations; + +use App\Models\Location\{Country, State, City}; + +class LocationResult +{ + public function __construct( + public readonly Country $country, + public readonly State $state, + public readonly City $city, + public readonly array $addressArray, + public readonly string $displayName, + ) {} +} +``` + +### New `UploadPhotoController` (v5) + +```php +namespace App\Http\Controllers\Uploads; + +use Geohash\GeoHash; +use App\Models\Photo; +use App\Events\{ImageUploaded, NewCityAdded, NewCountryAdded, NewStateAdded}; +use App\Actions\Photos\{MakeImageAction, UploadPhotoAction}; +use App\Actions\Locations\ResolveLocationAction; +use App\Services\Metrics\MetricsService; +use App\Http\Controllers\Controller; +use App\Http\Requests\UploadPhotoRequest; +use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Auth; + +class UploadPhotoController extends Controller +{ + public function __construct( + private MakeImageAction $makeImageAction, + private UploadPhotoAction $uploadPhotoAction, + private ResolveLocationAction $resolveLocationAction, + private MetricsService $metricsService, + ) {} + + public function __invoke(UploadPhotoRequest $request): JsonResponse + { + $user = Auth::user(); + $file = $request->file('photo'); + + // 1. Process image & extract EXIF + $imageAndExif = $this->makeImageAction->run($file); + $image = $imageAndExif['image']; + $exif = $imageAndExif['exif']; + $dateTime = getDateTimeForPhoto($exif); + + // 2. Upload full image + bbox thumbnail + $imageName = $this->uploadPhotoAction->run($image, $dateTime, $file->hashName()); + $bboxImageName = $this->uploadPhotoAction->run( + $this->makeImageAction->run($file, true)['image'], + $dateTime, + $file->hashName(), + 'bbox' + ); + + // 3. Resolve location from GPS coordinates + $coordinates = getCoordinatesFromPhoto($exif); + $lat = $coordinates[0]; + $lon = $coordinates[1]; + + $location = $this->resolveLocationAction->run($lat, $lon); + + // 4. Create photo (slim — no string location duplication) + $photo = Photo::create([ + 'user_id' => $user->id, + 'filename' => $imageName, + 'datetime' => $dateTime, + 'remaining' => !$user->picked_up, + 'lat' => $lat, + 'lon' => $lon, + 'model' => $exif['Model'] ?? 'Unknown', + 'country_id' => $location->country->id, + 'state_id' => $location->state->id, + 'city_id' => $location->city->id, + 'platform' => 'web', + 'geohash' => (new GeoHash())->encode($lat, $lon), + 'team_id' => $user->active_team, + 'five_hundred_square_filepath' => $bboxImageName, + 'address_array' => json_encode($location->addressArray), + ]); + + // 5. Broadcast to real-time map + event(new ImageUploaded($user, $photo, $location->country, $location->state, $location->city)); + + // 6. Notify on new locations + if ($location->country->wasRecentlyCreated) { + event(new NewCountryAdded($location->country->country, $location->country->shortcode, now())); + } + if ($location->state->wasRecentlyCreated) { + event(new NewStateAdded($location->state->state, $location->country->country, now())); + } + if ($location->city->wasRecentlyCreated) { + event(new NewCityAdded( + $location->city->city, $location->state->state, $location->country->country, + now(), $location->city->id, $lat, $lon, $photo->id + )); + } + + // 7. MetricsService processes after tags are added (not here) + // Tags are added in a separate step. MetricsService::processPhoto() + // is called when the user submits tags, not at upload time. + // At upload time, the photo has 0 tags and 0 XP. + + return response()->json(['success' => true]); + } +} +``` + +--- + +## When MetricsService Runs + +Important distinction: **photo upload ≠ photo tagging**. + +1. **Upload** — the controller above creates the photo with coordinates, image, and location FKs. No tags yet. +2. **Tagging** — the user adds tags (litter categories, materials, brands) in a separate request. This is when `MetricsService::processPhoto()` should run, because that's when tags, XP, and litter counts exist. + +If tags are submitted at upload time (e.g. pre-tagged uploads), then `MetricsService::processPhoto()` can be called at the end of the upload controller. But for the typical web flow where tagging is separate, the metrics call belongs in the tagging controller. + +--- + +## Files to Delete / Deprecate + +| File | Action | Reason | +|---|---|---| +| `App\Helpers\Post\UploadHelper` | **Delete** | Replaced by `ResolveLocationAction` | +| `App\Actions\Locations\UpdateLeaderboardsForLocationAction` | **Delete** | Already `@deprecated`, replaced by `MetricsService` | +| `App\Actions\Locations\UpdateLeaderboardsXpAction` | **Delete** | Called only by the above | +| `App\Events\Photo\IncrementPhotoMonth` | **Delete** | Replaced by `metrics` table time-series | + +--- + +## Migration Checklist + +1. **Create** `ResolveLocationAction` + `LocationResult` DTO +2. **Create** `GeocodingException` for proper error handling +3. **Update** `UploadPhotoController` to v5 flow +4. **Update** `ImageUploaded` event if it references deprecated photo columns +5. **Verify** no code reads the deprecated photo string columns (`country`, `county`, `city`, etc.) +6. **Run** Migration 1: drop deprecated location columns +7. **Run** Migration 2: drop deprecated photo columns +8. **Delete** `UploadHelper`, `UpdateLeaderboardsForLocationAction`, `IncrementPhotoMonth` +9. **Clean up** old Redis keys (run a one-off script to delete the deprecated key patterns) +10. **Update** `$fillable` on Country, State, City models +11. **Remove** `$appends` entries that reference deleted columns (if any) + +--- + +## API Endpoints (Location Data) + +### LocationController (v1) — Browsing UI + +`app/Http/Controllers/Location/LocationController.php` serves the hierarchical location browsing. + +| Endpoint | Description | +|---|---| +| `GET /api/v1/locations` | Global view: list of countries with stats | +| `GET /api/v1/locations/{type}/{id}` | Drill into country/state/city | + +**Response format:** +```json +{ + "stats": { "countries": 120, "uploads": 50000, "tags": 120000, ... }, + "locations": [ ... ], + "location_type": "country", + "breadcrumbs": [ { "label": "World", "type": null, "id": null } ], + "activity": [ ... ] +} +``` + +Key naming: `locations` (not `children`) and `location_type` (not `children_type`). Pinia store `useLocationsStore` reads these keys. + +Time filtering: `?period=today|yesterday|this_month|last_month|this_year` or `?year=2024`. Mutually exclusive. + +### Legacy endpoints (Redis-backed) + +All location aggregate data is served from Redis (fast) with MySQL metrics table as the source of truth (rebuildable). + +| Endpoint | Source | Notes | +|---|---|---| +| `GET /api/countries` | DB + Redis appends | List with aggregates from Redis | +| `GET /api/countries/{country}` | DB + Redis appends | Single country with full data | +| `GET /api/countries/{country}/states` | DB + Redis appends | States within country | +| `GET /api/states/{state}/cities` | DB + Redis appends | Cities within state | +| `GET /api/leaderboard` | Redis sorted sets | `{scope}:lb:xp` (see `readme/Leaderboards.md`) | + +The `$appends` on location models (`total_litter_redis`, `total_photos_redis`, etc.) read directly from Redis hashes, making these endpoints fast without any MySQL aggregate queries. diff --git a/readme/BackendTagging.md b/readme/BackendTagging.md new file mode 100644 index 00000000..4398f1c5 --- /dev/null +++ b/readme/BackendTagging.md @@ -0,0 +1,458 @@ +# OpenLitterMap v5 Tagging System + +## Overview + +OpenLitterMap v5 introduces a flexible, hierarchical tagging system that allows precise classification of litter items. Each photo can have multiple tags organized by categories, objects, and their properties (materials, brands, and custom attributes). + +## Core Concepts + +### Tag Hierarchy + +``` +Photo +├── PhotoTag (Primary tagged item) +│ ├── Category (e.g., "smoking", "food", "softdrinks") +│ ├── LitterObject (e.g., "butts", "wrapper", "bottle") +│ ├── Quantity (How many of this item) +│ └── PhotoTagExtraTags (Additional properties) +│ ├── Materials (e.g., "plastic", "glass", "aluminium") +│ ├── Brands (e.g., "coca-cola", "marlboro", "mcdonalds") +│ └── CustomTags (User-defined tags) +``` + +### Database Structure + +``` +photos +├── id +├── user_id +├── summary (JSON) - Cached tag structure +├── xp (INT) - Calculated experience points +├── total_tags (INT) - Total item count +├── total_brands (INT) - Total brand count +├── processed_at (TIMESTAMP) - When metrics were processed +├── processed_fp (VARCHAR) - Fingerprint for idempotency +├── processed_tags (TEXT) - Cached tags for metrics +├── processed_xp (INT UNSIGNED) - XP value at last metrics processing +└── migrated_at (TIMESTAMP) - v5 migration timestamp + +photo_tags +├── id +├── photo_id +├── category_id +├── litter_object_id +├── custom_tag_primary_id (for custom-only tags) +├── quantity +└── picked_up (BOOLEAN) + +photo_tag_extra_tags +├── photo_tag_id +├── tag_type (material|brand|custom_tag) +├── tag_type_id +├── quantity +└── index +``` + +## Photo Summary Structure + +Each photo maintains a `summary` JSON field with this structure: + +```json +{ + "tags": { + "2": { + "15": { + "quantity": 5, + "materials": { + "3": 5, + "7": 5 + }, + "brands": { + "12": 3, + "18": 2 + }, + "custom_tags": {} + } + } + }, + "totals": { + "total_tags": 10, + "total_objects": 5, + "by_category": { + "2": 5 + }, + "materials": 10, + "brands": 5, + "custom_tags": 0 + }, + "keys": { + "categories": {"2": "smoking"}, + "objects": {"15": "butts"}, + "materials": {"3": "plastic", "7": "paper"}, + "brands": {"12": "marlboro", "18": "camel"}, + "custom_tags": {} + } +} +``` + +**Key meanings:** +- `"2"` = Category ID (smoking) +- `"15"` = Object ID (butts) +- `"3"` = Material ID (plastic) with quantity 5 +- `"7"` = Material ID (paper) with quantity 5 +- `"12"` = Brand ID (marlboro) with quantity 3 +- `"18"` = Brand ID (camel) with quantity 2 + +## XP (Experience Points) System + +XP rewards users for tagging litter: + +| Action | XP Value | +|------------------|-------------| +| Upload | 5 | +| Standard Object | 1 per item | +| Material | 2 per item | +| Brand | 3 per item | +| Custom Tag | 1 per item | +| Picked Up | +5 bonus | +| Special Objects: | | +| - Small item | 10 per item | +| - Medium item | 25 per item | +| - Large item | 50 per item | +| - Bags of Litter | 10 per item | + +### XP Calculation Details + +`AddTagsToPhotoAction::calculateXp()` uses `XpScore` enum multipliers: + +- **Upload base:** always 5 XP per photo +- **Object:** `quantity × objectXp` (default 1; special objects override: small=10, medium=25, large=50, bagsLitter=10) +- **Brand extra tags:** `brand.quantity × 3` (brands have their own independent quantity) +- **Material extra tags:** `parentTag.quantity × 2` (materials use parent tag's quantity — set membership) +- **Custom tag extra tags:** `parentTag.quantity × 1` (same as materials — set membership) + +### XP Calculation Example + +``` +Photo with: +- 3 cigarette butts (qty=3) +- 2 materials (plastic, paper) +- 1 brand (marlboro, brandQty=2) +- 1 custom tag + +XP = 5 (upload base) + + 3 × 1 (3 objects at 1 XP each) + + 2 × (3 × 2) (2 materials × parentQty × materialXP) + + 2 × 3 (brand: brandQty × brandXP) + + 3 × 1 (custom tag: parentQty × customXP) + = 5 + 3 + 12 + 6 + 3 = 29 XP +``` + +## Brand-Object Relationships + +### NOTE: Brands are deferred — doing them later. + +### Discovery Process +```bash +# Step 1: Discover 1-to-1 relationships +php artisan olm:define-brand-relationships + +# Step 2: Create relationships for remaining brands (≥10% threshold) +php artisan olm:auto-create-brand-relationships --apply +``` + +### How Brands Attach During Migration +1. **Pivot lookup**: Check taggables table for existing relationships +2. **Quantity matching**: Match brands to objects with same quantity +3. **Fallback**: Unmatched brands create brands-only PhotoTag + +### Database Structure +``` +taggables +├── category_litter_object_id // Links to pivot table +├── taggable_type // 'App\Models\Litter\Tags\BrandList' +├── taggable_id // Brand ID from brandslist +└── quantity // Occurrence count +``` + +``` +brandslist table: +├── id // Primary key +├── key // Brand key/slug (e.g., "coca-cola", "marlboro") +├── crowdsourced // Boolean +└── is_custom // Boolean +``` + +## Tag Migration from v4 to v5 + +### Old Format (v4) +```php +[ + 'smoking' => [ + 'butts' => 5, + 'cigaretteBox' => 1 + ], + 'brands' => [ + 'marlboro' => 3, + 'camel' => 2 + ] +] +``` + +### New Format (v5) +```php +PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => 2, // smoking + 'litter_object_id' => 15, // butts + 'quantity' => 5, + 'picked_up' => true +]); + +// Attach brands as extra tags +$photoTag->attachExtraTags([ + ['id' => 12, 'quantity' => 3], // marlboro + ['id' => 18, 'quantity' => 2], // camel +], 'brand', 0); +``` + +## Special Cases + +### 1. Brands-Only Photos +When a photo only has brands without specific objects: + +```php +PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => $brandsCategoryId, + 'quantity' => $totalBrandQuantity, + 'picked_up' => !$photo->remaining +]); +``` + +### 2. Custom Tags Only +For photos with only custom tags: + +```php +PhotoTag::create([ + 'photo_id' => $photo->id, + 'custom_tag_primary_id' => $customTag->id, + 'quantity' => $quantity, + 'picked_up' => !$photo->remaining +]); +``` + +### 3. Deprecated Tag Mapping +Old tags are automatically mapped to new equivalents: + +| Old Tag | New Object | Materials Added | +|------------------------|----------------|-----------------| +| `beerBottle` | `beer_bottle` | `[glass]` | +| `beerCan` | `beer_can` | `[aluminium]` | +| `coffeeCups` | `cup` | `[paper]` | +| `plasticFoodPackaging` | `packaging` | `[plastic]` | +| `waterBottle` | `water_bottle` | `[plastic]` | + +**Note**: Materials are automatically added based on the deprecated tag mappings. For example, `beerBottle` automatically adds `glass` material to the object. + +Full mapping in `ClassifyTagsService::normalizeDeprecatedTag()`. + +### 4. Unknown Tags +Unknown tags are automatically created as new objects: + +```php +$created = LitterObject::firstOrCreate( + ['key' => 'mystery_item'], + ['crowdsourced' => true] +); +``` + +### 5. Multiple Brands per Object +A single object can have multiple brands attached: +- Example: `butts` object with both `marlboro` and `camel` brands +- Stored in `photo_tag_extra_tags` with `tag_type='brand'` + +### 6. Multiple Objects per Brand +Brands can validly attach to multiple objects: +- Example: `mcdonalds` → `cup`, `packaging`, `lid`, `wrapper` +- Relationships defined in `taggables` table + +## Validation Rules + +- Quantities must be positive integers +- Category-Object relationships must be valid +- Materials/Brands must be attached to objects +- Custom tags can be standalone or attached +- XP calculation uses enum-defined values +- Fingerprinting prevents duplicate processing + +## API Response Format + +```json +{ + "photo_id": 12345, + "tags": { + "smoking": { + "butts": { + "quantity": 5, + "materials": ["plastic", "paper"], + "brands": ["marlboro", "camel"] + } + } + }, + "metrics": { + "total_items": 5, + "total_brands": 2, + "xp_earned": 30 + }, + "location": { + "country": "Ireland", + "state": "Munster", + "city": "Cork" + } +} +``` + +## Web Frontend Replace/Edit Tags (PUT /api/v3/tags) + +The `/tag?photo=` URL loads a specific photo for editing. If the photo already has tags, AddTags.vue enters **edit mode** and uses `PUT /api/v3/tags` to replace all existing tags. + +### Flow + +1. **Load photo:** `GET_SINGLE_PHOTO(id)` calls `/api/v3/user/photos?id=X&id_operator==&per_page=1` — filters by authenticated user (ownership enforced server-side) +2. **Convert existing tags:** `convertExistingTags(photo)` transforms `new_tags` API format back into the frontend's tag format (handles object, brand-only, material-only, custom-only) +3. **User edits tags** — same UI as normal tagging (search, add, remove, quantity, materials/brands) +4. **Submit:** `REPLACE_TAGS({ photoId, tags })` calls `PUT /api/v3/tags` + +### Backend (`PhotoTagsController::update()`) + +```php +// 1. Delete old tags + extras +$photo->photoTags()->each(function ($tag) { + $tag->extraTags()->delete(); + $tag->delete(); +}); + +// 2. Reset summary, XP, verification +$photo->update(['summary' => null, 'xp' => 0, 'verified' => 0]); + +// 3. Re-add tags (generates new summary, XP, fires TagsVerifiedByAdmin) +$this->addTagsToPhotoAction->run(Auth::id(), $photo->id, $tags); +``` + +**MetricsService delta handling:** When `TagsVerifiedByAdmin` fires, `ProcessPhotoMetrics` → `MetricsService::processPhoto()` detects the photo was previously processed (has `processed_at`). It calls `doUpdate()` which calculates deltas between old `processed_tags` and the new summary, then applies positive/negative adjustments to all MySQL + Redis metrics. + +### Security + +- `ReplacePhotoTagsRequest` checks `$photo->user_id === $this->user()->id` — returns 403 for non-owners +- `GET_SINGLE_PHOTO` calls `/api/v3/user/photos` which filters by `Auth::user()->id` — cannot load another user's photo +- Both `PhotoTagsRequest` (POST) and `ReplacePhotoTagsRequest` (PUT) enforce ownership + +### Frontend files + +| File | Change for edit mode | +|---|---| +| `AddTags.vue` | Reads `route.query.photo`, loads specific photo, `isEditMode` ref, `convertExistingTags()`, uses `REPLACE_TAGS` on submit | +| `TaggingHeader.vue` | `isEditMode` prop — hides Skip/Pagination, shows "Editing" badge, "Update" button | +| `Uploads.vue` | Navigates to `/tag?photo=` on photo click and "Tag this photo" link | +| `stores/photos/requests.js` | `GET_SINGLE_PHOTO()`, `REPLACE_TAGS()` actions | + +### Test file + +`tests/Feature/Tags/ReplacePhotoTagsTest.php` — 5 tests (replace tags, already-tagged photos, ownership, auth, extra tags cleanup) + +--- + +## Web Frontend Tagging (POST /api/v3/tags) + +The Vue frontend (`/tag` route → `AddTags.vue`) sends tags via `POST /api/v3/tags` to `PhotoTagsController` → `AddTagsToPhotoAction` (v5). The frontend sends 4 distinct tag types: + +### 1. Object tag (with optional materials/brands/custom tags) +```json +{ + "object": { "id": 5, "key": "butts" }, + "quantity": 3, + "picked_up": true, + "materials": [{ "id": 2, "key": "plastic" }], + "brands": [{ "id": 1, "key": "marlboro" }], + "custom_tags": ["dirty-bench"] +} +``` +**Backend:** `resolveTag()` looks up object, auto-resolves category from `object->categories()->first()`. Category need NOT be sent. + +### 2. Custom-only tag +```json +{ "custom": true, "key": "dirty-bench", "quantity": 1, "picked_up": null } +``` +**Backend:** `$tag['custom']` is boolean true (flag), `$tag['key']` is the actual tag name. Creates `CustomTagNew` record via `$tag['key']`. + +### 3. Brand-only tag +```json +{ "brand_only": true, "brand": { "id": 1, "key": "coca-cola" }, "quantity": 1, "picked_up": null } +``` +**Backend:** Creates PhotoTag with `category_id=null`, `litter_object_id=null`, attaches brand as extra tag. + +### 4. Material-only tag +```json +{ "material_only": true, "material": { "id": 2, "key": "plastic" }, "quantity": 1, "picked_up": null } +``` +**Backend:** Same pattern as brand-only — PhotoTag with null FKs, material as extra tag. + +### Frontend files + +| File | Purpose | +|---|---| +| `resources/js/views/General/Tagging/v2/AddTags.vue` | Main tagging page — search index, tag selection, submit | +| `resources/js/views/General/Tagging/v2/components/UnifiedTagSearch.vue` | Debounced tag search combobox with grouped results | +| `resources/js/views/General/Tagging/v2/components/TagCard.vue` | Tag card with type pills, category display, formatKey | +| `resources/js/views/General/Tagging/v2/components/ActiveTagsList.vue` | Container for active tags | +| `resources/js/views/General/Tagging/v2/components/TaggingHeader.vue` | Header: XP bar, level title, pagination, unresolved warning | +| `resources/js/views/General/Tagging/v2/components/PhotoViewer.vue` | Photo display with zoom | +| `resources/js/stores/photos/requests.js` | `UPLOAD_TAGS()` → POST, `REPLACE_TAGS()` → PUT, `GET_SINGLE_PHOTO()` | +| `resources/js/stores/tags/requests.js` | `GET_ALL_TAGS()` → GET /api/tags/all | + +### Tag data loading +`GET /api/tags/all` returns flat arrays: `{ categories, objects, materials, brands, types, category_objects, category_object_types }`. Objects include their categories via eager load: `LitterObject::with(['categories:id,key'])`. `category_object_types` only returns `category_litter_object_id` and `litter_object_type_id` (no `id` column). + +### Frontend search index (category disambiguation) + +`AddTags.vue` builds a `searchableTags` computed that generates **one entry per (object, category) pair** instead of one per object. This prevents data corruption when the same object exists in multiple categories (e.g., "bottle" exists in alcohol, beverages, and food). + +Each entry has: +- `id`: composite `obj-{objectId}-cat-{categoryId}` for deduplication +- `cloId`: pre-resolved `category_litter_object_id` from the store's `getCloId(categoryId, objectId)` +- `categoryId`, `categoryKey`: the specific category for this entry +- `lowerKey`: precomputed `key.toLowerCase()` for fast search filtering + +**Type entries** are also generated from `categoryObjectTypes` with composite id `type-{cloId}-{typeId}`. Type search results show the parent object and category as context. + +### Display formatting + +`formatKey(key)` converts `snake_case` keys to `Title Case`: `key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())`. Used in search results, tag cards, detail badges, and recent tags. + +Tag cards show `"Bottle · Alcohol"` format (object + category). Type pills replace the old `