diff --git a/.ai/skills/v5-migration/SKILL.md b/.ai/skills/v5-migration/SKILL.md index 0f6ed1fad..387eb84ee 100644 --- a/.ai/skills/v5-migration/SKILL.md +++ b/.ai/skills/v5-migration/SKILL.md @@ -118,6 +118,22 @@ public function handle(): int } ``` +## Post-Migration Fix: Orphaned Tags (2026-04-04) + +The v5 migration left 189,518 `photo_tags` rows with `category_litter_object_id = NULL` because `DEPRECATED_TAG_MAP` mapped v4 keys to composite names (e.g. `beerCan` → `beer_can`) instead of decomposing into object + type (e.g. `alcohol.can` + type `beer`). The fallback created runtime `litter_objects` (crowdsourced=1) without CLO relationships. + +**Fix commands:** +- `php artisan olm:fix-orphaned-tags` — Remaps 71 orphan LOs to canonical CLO/LO/type targets. 74 mapping entries (3 multi-category splits). Batched, transacted, idempotent. Supports `--apply`, `--verify-only`, `--log`. +- `php artisan olm:regenerate-summaries --orphan-fix` — Regenerates stale summaries for affected photos. Chunked via `chunkById`, resumable (skips photos with non-null `clo_id` in summary), `Photo::withoutEvents()`. +- `php artisan olm:reprocess-metrics --from-file=` — Delta-based MetricsService reprocess for ~1,041 photos with XP changes (special object bonus corrections). + +**Key files:** +- `app/Console/Commands/tmp/v5/Migration/FixOrphanedTags.php` +- `app/Console/Commands/tmp/v5/Migration/RegenerateSummaries.php` +- `app/Console/Commands/tmp/v5/Migration/ReprocessPhotoMetrics.php` +- `readme/TagsCleanupPostMigration.md` — Full mapping tables and judgment calls +- `readme/changelog/production-orphan-fix-runbook.md` — Production execution steps + ## Common Mistakes - **Removing `migrated_at` check.** This is the idempotency guard. Without it, photos get double-migrated. diff --git a/.env.example b/.env.example index a026b2c72..df33f456f 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,8 @@ TWITTER_API_CONSUMER_SECRET= TWITTER_API_ACCESS_TOKEN= TWITTER_API_ACCESS_SECRET= +# BROWSERSHOT_CHROME_PATH=/snap/bin/chromium + LOCATION_API_KEY= BACKUP_NAME=OpenLitterMap_Backup diff --git a/CLAUDE.md b/CLAUDE.md index 2aea85bd3..e54da94f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ Built by a single developer over 17 years. - Replace tags (`PUT /api/v3/tags`) accepts empty `tags: []` to clear all tags from a photo - `TagsConfig` provides helper methods: `buildObjectMap()`, `buildObjectMaps()`, `allMaterialKeys()`, `allTypeKeys()` — use these instead of hardcoding lists - Legacy v1/v2 mobile endpoints removed (2026-03-01) — mobile uses v3 endpoints with CLO format only -- `Photo` model uses `SoftDeletes` — `$photo->delete()` soft-deletes, `Photo::public()` auto-excludes +- `Photo` model has `SoftDeletes` trait but all delete endpoints use `forceDelete()` for hard deletion. Cascading FKs on `photo_tags` (→ `photo_tag_extras`) handle relationship cleanup - Locations API uses `locations`/`location_type` keys (not `children`/`children_type`) - `UsersUploadsController` returns tags under key `'new_tags'` (frontend reads `photo.new_tags`) - Untagged photo filter uses `whereNull('summary')` — summary is set by `GeneratePhotoSummaryService` when tags are added, regardless of verification status @@ -163,7 +163,7 @@ Built by a single developer over 17 years. - Admin roles: `superadmin` (all access), `admin` (photo review), `helper` (tag editing only) — Spatie Permission on `web` guard - Photo deletion must reverse metrics first: call `MetricsService::deletePhoto()` BEFORE `$photo->delete()` - Consistent API field naming: all list/leaderboard endpoints use `total_tags`, `total_images`, `total_members`, `created_at`, `updated_at` — never `total_litter`, `tags`, `photos`, `contributors` -- `GET /api/global/stats-data` is public (no auth) — returns `total_tags`, `total_images`, `total_users`, `new_users_today`, `new_users_last_7_days`, `new_users_last_30_days`, `new_tags_today`, `new_tags_last_7_days`, `new_tags_last_30_days`, `new_photos_today`, `new_photos_last_7_days`, `new_photos_last_30_days` from metrics table + users table +- `GET /api/global/stats-data` is public (no auth) — returns `total_tags`, `total_images`, `total_users`, `new_users_last_24_hours`, `new_users_last_7_days`, `new_users_last_30_days`, `new_tags_last_24_hours`, `new_tags_last_7_days`, `new_tags_last_30_days`, `new_photos_last_24_hours`, `new_photos_last_7_days`, `new_photos_last_30_days` from metrics table + users table. Also returns legacy `*_today` aliases (`new_users_today`, `new_tags_today`, `new_photos_today`) for pre-v5.7 mobile compat ## Level System XP-threshold based levels defined in `config/levels.php`. `LevelService::getUserLevel($xp)` returns level info. @@ -213,6 +213,7 @@ Fully deployed. 1010+ tests passing. Facilitator queue (3-panel admin-like UI fo - `readme/Terms.md` — Terms & Conditions source content (Vue component renders this) - `readme/Privacy.md` — Privacy Policy source content with GDPR legal basis (Vue component renders this) - `readme/Twitter.md` — Automated Twitter/X commands (schedule, data sources, tweet format, Browsershot config) +- `readme/TagSuggestions.md` — Quick tags sync API (mobile presets, bulk-replace, cross-device sync) ## Daily Changelog After every change in a session, append a one-line entry to `readme/changelog/YYYY-MM-DD.md` (create the file if it doesn't exist for today's date). Group entries by session. This is the running record of all work done each day. diff --git a/app/Actions/QuickTags/SyncQuickTagsAction.php b/app/Actions/QuickTags/SyncQuickTagsAction.php new file mode 100644 index 000000000..76bbba304 --- /dev/null +++ b/app/Actions/QuickTags/SyncQuickTagsAction.php @@ -0,0 +1,50 @@ +id)->delete(); + + $now = now(); + $rows = []; + + foreach ($tags as $index => $tag) { + $rows[] = [ + 'user_id' => $user->id, + 'clo_id' => $tag['clo_id'], + 'type_id' => $tag['type_id'] ?? null, + 'custom_name' => $tag['custom_name'] ?? null, + 'quantity' => $tag['quantity'] ?? 1, + 'picked_up' => $tag['picked_up'] ?? null, + 'materials' => json_encode($tag['materials'] ?? []), + 'brands' => json_encode($tag['brands'] ?? []), + 'sort_order' => $index, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (!empty($rows)) { + UserQuickTag::insert($rows); + } + + return $user->quickTags()->get(); + }); + } +} diff --git a/app/Actions/Tags/AddTagsToPhotoAction.php b/app/Actions/Tags/AddTagsToPhotoAction.php index c19867425..ba31004eb 100644 --- a/app/Actions/Tags/AddTagsToPhotoAction.php +++ b/app/Actions/Tags/AddTagsToPhotoAction.php @@ -170,20 +170,6 @@ protected function createTagFromClo(int $userId, int $photoId, array $tag): Phot } } - // Validate "other" object requires at least one extra tag - $object = LitterObject::find($clo->litter_object_id); - if ($object && $object->key === 'other') { - $hasMaterials = ! empty($tag['materials']); - $hasBrands = ! empty($tag['brands']); - $hasCustomTags = ! empty($tag['custom_tags']); - - if (! $hasMaterials && ! $hasBrands && ! $hasCustomTags) { - throw ValidationException::withMessages([ - 'tags' => ['Object "other" requires at least one material, brand, or custom tag.'], - ]); - } - } - // Create the PhotoTag $photoTag = PhotoTag::create([ 'photo_id' => $photoId, diff --git a/app/Console/Commands/Twitter/AnnualImpactReportTweet.php b/app/Console/Commands/Twitter/AnnualImpactReportTweet.php new file mode 100644 index 000000000..9425b7105 --- /dev/null +++ b/app/Console/Commands/Twitter/AnnualImpactReportTweet.php @@ -0,0 +1,58 @@ +environment('production') && ! app()->runningUnitTests()) { + $this->info('Skipping — not production environment.'); + return self::SUCCESS; + } + + $lastYear = now()->subYear()->year; + + $url = "https://openlittermap.com/impact/annual/{$lastYear}"; + $dir = public_path("images/reports/annual/{$lastYear}"); + + @mkdir($dir, 0755, true); + + $path = "{$dir}/impact-report.png"; + + try { + Browsershot::url($url) + ->windowSize(1200, 800) + ->fullPage() + ->waitUntilNetworkIdle() + ->setChromePath(config('services.browsershot.chrome_path')) + ->save($path); + } catch (\Throwable $e) { + $this->error("Browsershot failed: {$e->getMessage()}"); + return self::FAILURE; + } + + $this->info("Image saved to {$path}"); + + $msg = "Annual Impact Report for {$lastYear}." + . " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; + + Twitter::sendTweetWithImage($msg, $path); + + $this->info('Tweet sent'); + + @unlink($path); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Twitter/MonthlyImpactReportTweet.php b/app/Console/Commands/Twitter/MonthlyImpactReportTweet.php index f3ad61ff7..ca0f3b405 100644 --- a/app/Console/Commands/Twitter/MonthlyImpactReportTweet.php +++ b/app/Console/Commands/Twitter/MonthlyImpactReportTweet.php @@ -28,9 +28,7 @@ public function handle(): int $url = "https://openlittermap.com/impact/monthly/{$year}/{$month}"; $dir = public_path("images/reports/monthly/{$year}/{$month}"); - if (! file_exists($dir)) { - mkdir($dir, 0755, true); - } + @mkdir($dir, 0755, true); $path = "{$dir}/impact-report.png"; @@ -39,7 +37,7 @@ public function handle(): int ->windowSize(1200, 800) ->fullPage() ->waitUntilNetworkIdle() - ->setChromePath('/snap/bin/chromium') + ->setChromePath(config('services.browsershot.chrome_path')) ->save($path); } catch (\Throwable $e) { $this->error("Browsershot failed: {$e->getMessage()}"); diff --git a/app/Console/Commands/Twitter/WeeklyImpactReportTweet.php b/app/Console/Commands/Twitter/WeeklyImpactReportTweet.php index eb5f41656..4cb914a57 100644 --- a/app/Console/Commands/Twitter/WeeklyImpactReportTweet.php +++ b/app/Console/Commands/Twitter/WeeklyImpactReportTweet.php @@ -29,16 +29,15 @@ public function handle(): int $url = "https://openlittermap.com/impact/weekly/{$isoYear}/{$isoWeek}"; $dir = public_path("images/reports/weekly/{$isoYear}/{$isoWeek}"); - if (! file_exists($dir)) { - mkdir($dir, 0755, true); - } + @mkdir($dir, 0755, true); $path = "{$dir}/impact-report.png"; try { Browsershot::url($url) ->windowSize(1200, 800) - ->setChromePath('/snap/bin/chromium') + ->waitUntilNetworkIdle() + ->setChromePath(config('services.browsershot.chrome_path')) ->save($path); } catch (\Throwable $e) { $this->error("Browsershot failed: {$e->getMessage()}"); diff --git a/app/Console/Commands/tmp/v5/Migration/FixOrphanedTags.php b/app/Console/Commands/tmp/v5/Migration/FixOrphanedTags.php new file mode 100644 index 000000000..54b61c23d --- /dev/null +++ b/app/Console/Commands/tmp/v5/Migration/FixOrphanedTags.php @@ -0,0 +1,391 @@ + */ + private array $affectedPhotoIds = []; + + public function handle(): int + { + $this->apply = $this->option('apply'); + $this->batchSize = (int) $this->option('batch'); + + $this->openLog(); + + if ($this->option('verify-only')) { + return $this->runVerification(); + } + + $mode = $this->apply ? '🔴 LIVE MODE' : '🟢 DRY-RUN MODE'; + $this->log("=== Fix Orphaned Photo Tags ({$mode}) ==="); + $this->log(''); + + // Pre-flight: check for existing non-NULL type_ids on orphaned rows + $existingTypes = DB::table('photo_tags') + ->whereNull('category_litter_object_id') + ->whereNotNull('litter_object_id') + ->whereNotNull('litter_object_type_id') + ->count(); + + $this->log("Pre-flight: orphaned rows with existing litter_object_type_id = {$existingTypes}"); + if ($existingTypes > 0) { + $this->log("WARNING: {$existingTypes} orphaned rows already have a type_id set. These will NOT be overwritten.", 'warn'); + } + $this->log(''); + + $mappings = $this->buildMappings(); + + foreach ($mappings as $mapping) { + $this->processMapping($mapping); + } + + $this->log(''); + $this->log('=== SUMMARY ==='); + $this->table( + ['Orphan Key', 'Category Filter', 'Expected', 'Actual', 'Match?'], + collect($this->results)->map(fn ($r) => [ + $r['key'], + $r['category_filter'] ?? '—', + $r['expected'], + $r['actual'], + $r['expected'] === $r['actual'] ? '✓' : '✗ MISMATCH', + ])->toArray() + ); + + $mismatches = collect($this->results)->filter(fn ($r) => $r['expected'] !== $r['actual']); + if ($mismatches->isNotEmpty()) { + $this->log("⚠ {$mismatches->count()} mapping(s) had count mismatches — review above.", 'warn'); + } + + $this->log(''); + $this->log("Total expected: {$this->totalExpected}"); + $this->log("Total " . ($this->apply ? 'updated' : 'would update') . ": {$this->totalUpdated}"); + + $uniquePhotos = count(array_unique($this->affectedPhotoIds)); + $this->log("Distinct affected photos: {$uniquePhotos}"); + + // Post-flight verification + if ($this->apply) { + $this->log(''); + $this->runVerification(); + } + + $this->closeLog(); + + return 0; + } + + private function runVerification(): int + { + $this->log('=== VERIFICATION ==='); + + $orphanedWithLo = DB::table('photo_tags') + ->whereNull('category_litter_object_id') + ->whereNotNull('litter_object_id') + ->count(); + $this->log("Orphaned photo_tags (NULL CLO + non-NULL LO): {$orphanedWithLo}" . ($orphanedWithLo === 0 ? ' ✓' : ' ✗')); + + $extraTagOnly = DB::table('photo_tags') + ->whereNull('category_litter_object_id') + ->whereNull('litter_object_id') + ->count(); + $this->log("Extra-tag-only (NULL CLO + NULL LO, expected ~24,628): {$extraTagOnly}"); + + $spotCheck = DB::table('photo_tags') + ->join('litter_objects', 'photo_tags.litter_object_id', '=', 'litter_objects.id') + ->whereNull('photo_tags.category_litter_object_id') + ->whereIn('litter_objects.key', ['energy_can', 'beer_can', 'water_bottle', 'soda_can']) + ->selectRaw('litter_objects.`key`, COUNT(*) as remaining') + ->groupBy('litter_objects.key') + ->get(); + + if ($spotCheck->isEmpty()) { + $this->log('Spot check (energy_can, beer_can, water_bottle, soda_can): 0 remaining ✓'); + } else { + foreach ($spotCheck as $row) { + $this->log("Spot check: {$row->key} still has {$row->remaining} orphaned rows ✗", 'warn'); + } + } + + $this->closeLog(); + + return 0; + } + + private function processMapping(array $mapping): void + { + $label = $mapping['label']; + $orphanLoId = $mapping['orphan_lo_id']; + $targetCloId = $mapping['target_clo_id']; + $targetLoId = $mapping['target_lo_id']; + $targetCategoryId = $mapping['target_category_id']; + $typeId = $mapping['type_id'] ?? null; + $categoryFilter = $mapping['category_filter'] ?? null; + + $query = DB::table('photo_tags') + ->where('litter_object_id', $orphanLoId) + ->whereNull('category_litter_object_id'); + + if ($categoryFilter !== null) { + $query->where('category_id', $categoryFilter); + } + + $count = $query->count(); + + // Sample up to 5 affected photo_ids for spot-checking + $samplePhotoIds = (clone $query)->limit(5)->pluck('photo_id'); + if ($samplePhotoIds->isNotEmpty()) { + $this->log(" Sample photo_ids: " . $samplePhotoIds->join(', ')); + } + + // Collect all affected photo_ids for the summary count + $allPhotoIds = (clone $query)->pluck('photo_id')->unique()->values()->toArray(); + $this->affectedPhotoIds = array_merge($this->affectedPhotoIds, $allPhotoIds); + + $updateData = [ + 'category_litter_object_id' => $targetCloId, + 'litter_object_id' => $targetLoId, + 'category_id' => $targetCategoryId, + ]; + + $categoryLabel = $categoryFilter !== null ? " (cat={$categoryFilter})" : ''; + $typeLabel = $typeId !== null ? " +type={$typeId}" : ''; + + if ($this->apply && $count > 0) { + $updated = $this->executeBatched($orphanLoId, $categoryFilter, $updateData, $typeId); + } else { + $updated = $count; + } + + $this->results[] = [ + 'key' => $label, + 'category_filter' => $categoryFilter, + 'expected' => $count, + 'actual' => $updated, + ]; + + $this->totalExpected += $count; + $this->totalUpdated += $updated; + + $action = $this->apply ? 'Updated' : 'Would update'; + $this->log(" {$action} {$updated}/{$count} — {$label}{$categoryLabel}{$typeLabel} → CLO {$targetCloId}, LO {$targetLoId}"); + } + + private function executeBatched(int $orphanLoId, ?int $categoryFilter, array $updateData, ?int $typeId): int + { + $totalUpdated = 0; + + DB::transaction(function () use ($orphanLoId, $categoryFilter, $updateData, $typeId, &$totalUpdated) { + while (true) { + $query = DB::table('photo_tags') + ->where('litter_object_id', $orphanLoId) + ->whereNull('category_litter_object_id'); + + if ($categoryFilter !== null) { + $query->where('category_id', $categoryFilter); + } + + $ids = $query->limit($this->batchSize)->pluck('id'); + + if ($ids->isEmpty()) { + break; + } + + // Main update: CLO, LO, category + DB::table('photo_tags') + ->whereIn('id', $ids) + ->update($updateData); + + // Type update: only set on rows that don't already have a type_id + if ($typeId !== null) { + DB::table('photo_tags') + ->whereIn('id', $ids) + ->whereNull('litter_object_type_id') + ->update(['litter_object_type_id' => $typeId]); + } + + $totalUpdated += $ids->count(); + $this->log(" Batch: {$ids->count()} rows (running total: {$totalUpdated})"); + } + }); + + return $totalUpdated; + } + + /** + * Build the complete mapping array from the diagnostic. + * + * Each entry: orphan LO ID → target CLO ID, canonical LO ID, category ID, optional type ID. + * Multi-category orphans have separate entries with category_filter. + */ + private function buildMappings(): array + { + return [ + // ── Alcohol ── + ['label' => 'beer_can', 'orphan_lo_id' => 137, 'target_clo_id' => 5, 'target_lo_id' => 5, 'target_category_id' => 2, 'type_id' => 1], + ['label' => 'beer_bottle', 'orphan_lo_id' => 138, 'target_clo_id' => 2, 'target_lo_id' => 2, 'target_category_id' => 2, 'type_id' => 1], + ['label' => 'spirits_bottle', 'orphan_lo_id' => 144, 'target_clo_id' => 2, 'target_lo_id' => 2, 'target_category_id' => 2, 'type_id' => 3], + ['label' => 'wine_bottle', 'orphan_lo_id' => 139, 'target_clo_id' => 2, 'target_lo_id' => 2, 'target_category_id' => 2, 'type_id' => 2], + ['label' => 'bottletops', 'orphan_lo_id' => 146, 'target_clo_id' => 4, 'target_lo_id' => 4, 'target_category_id' => 2], + + // ── Alcohol / Softdrinks split: brokenglass ── + ['label' => 'brokenglass', 'orphan_lo_id' => 164, 'target_clo_id' => 3, 'target_lo_id' => 3, 'target_category_id' => 2, 'category_filter' => 2], + ['label' => 'brokenglass', 'orphan_lo_id' => 164, 'target_clo_id' => 151, 'target_lo_id' => 3, 'target_category_id' => 16, 'category_filter' => 16], + + // ── Softdrinks ── + ['label' => 'energy_can', 'orphan_lo_id' => 156, 'target_clo_id' => 152, 'target_lo_id' => 5, 'target_category_id' => 16, 'type_id' => 26], + ['label' => 'water_bottle', 'orphan_lo_id' => 140, 'target_clo_id' => 149, 'target_lo_id' => 2, 'target_category_id' => 16, 'type_id' => 23], + ['label' => 'soda_can', 'orphan_lo_id' => 142, 'target_clo_id' => 152, 'target_lo_id' => 5, 'target_category_id' => 16, 'type_id' => 24], + ['label' => 'fizzy_bottle', 'orphan_lo_id' => 145, 'target_clo_id' => 149, 'target_lo_id' => 2, 'target_category_id' => 16, 'type_id' => 24], + ['label' => 'sports_bottle', 'orphan_lo_id' => 143, 'target_clo_id' => 149, 'target_lo_id' => 2, 'target_category_id' => 16, 'type_id' => 27], + ['label' => 'juice_carton', 'orphan_lo_id' => 154, 'target_clo_id' => 153, 'target_lo_id' => 124, 'target_category_id' => 16, 'type_id' => 25], + ['label' => 'juice_bottle', 'orphan_lo_id' => 155, 'target_clo_id' => 149, 'target_lo_id' => 2, 'target_category_id' => 16, 'type_id' => 25], + ['label' => 'straw_packaging', 'orphan_lo_id' => 172, 'target_clo_id' => 162, 'target_lo_id' => 126, 'target_category_id' => 16], + ['label' => 'milk_bottle', 'orphan_lo_id' => 153, 'target_clo_id' => 149, 'target_lo_id' => 2, 'target_category_id' => 16, 'type_id' => 29], + ['label' => 'iceTea_bottle', 'orphan_lo_id' => 186, 'target_clo_id' => 149, 'target_lo_id' => 2, 'target_category_id' => 16, 'type_id' => 28], + ['label' => 'milk_carton', 'orphan_lo_id' => 151, 'target_clo_id' => 153, 'target_lo_id' => 124, 'target_category_id' => 16, 'type_id' => 29], + ['label' => 'pullRing', 'orphan_lo_id' => 175, 'target_clo_id' => 160, 'target_lo_id' => 10, 'target_category_id' => 16], + ['label' => 'icedTea_can', 'orphan_lo_id' => 194, 'target_clo_id' => 152, 'target_lo_id' => 5, 'target_category_id' => 16, 'type_id' => 31], + + // ── Softdrinks / Marine split: straws ── + ['label' => 'straws', 'orphan_lo_id' => 150, 'target_clo_id' => 161, 'target_lo_id' => 25, 'target_category_id' => 16, 'category_filter' => 16], + ['label' => 'straws', 'orphan_lo_id' => 150, 'target_clo_id' => 93, 'target_lo_id' => 1, 'target_category_id' => 10, 'category_filter' => 10], + + // ── Smoking ── + ['label' => 'cigarette_box', 'orphan_lo_id' => 141, 'target_clo_id' => 140, 'target_lo_id' => 37, 'target_category_id' => 15, 'type_id' => 13], + ['label' => 'rollingPapers', 'orphan_lo_id' => 148, 'target_clo_id' => 144, 'target_lo_id' => 120, 'target_category_id' => 15], + ['label' => 'filters', 'orphan_lo_id' => 167, 'target_clo_id' => 146, 'target_lo_id' => 122, 'target_category_id' => 15], + ['label' => 'vapePen', 'orphan_lo_id' => 190, 'target_clo_id' => 147, 'target_lo_id' => 123, 'target_category_id' => 15, 'type_id' => 17], + ['label' => 'tobaccopouch', 'orphan_lo_id' => 162, 'target_clo_id' => 145, 'target_lo_id' => 121, 'target_category_id' => 15, 'type_id' => 15], + ['label' => 'vapeOil', 'orphan_lo_id' => 189, 'target_clo_id' => 147, 'target_lo_id' => 123, 'target_category_id' => 15, 'type_id' => 22], + + // ── Sanitary → Medical (category change) ── + ['label' => 'facemask', 'orphan_lo_id' => 183, 'target_clo_id' => 95, 'target_lo_id' => 79, 'target_category_id' => 11], + ['label' => 'gloves', 'orphan_lo_id' => 80, 'target_clo_id' => 96, 'target_lo_id' => 80, 'target_category_id' => 11], + ['label' => 'sanitiser', 'orphan_lo_id' => 85, 'target_clo_id' => 101, 'target_lo_id' => 85, 'target_category_id' => 11], + + // ── Sanitary (same category) ── + ['label' => 'wetwipes', 'orphan_lo_id' => 179, 'target_clo_id' => 125, 'target_lo_id' => 104, 'target_category_id' => 14], + ['label' => 'earSwabs', 'orphan_lo_id' => 166, 'target_clo_id' => 127, 'target_lo_id' => 106, 'target_category_id' => 14], + ['label' => 'condoms', 'orphan_lo_id' => 158, 'target_clo_id' => 136, 'target_lo_id' => 115, 'target_category_id' => 14], + ['label' => 'menstrual', 'orphan_lo_id' => 182, 'target_clo_id' => 133, 'target_lo_id' => 112, 'target_category_id' => 14], + ['label' => 'toothpick', 'orphan_lo_id' => 168, 'target_clo_id' => 138, 'target_lo_id' => 1, 'target_category_id' => 14], + ['label' => 'hair_tie', 'orphan_lo_id' => 160, 'target_clo_id' => 138, 'target_lo_id' => 1, 'target_category_id' => 14], + ['label' => 'ear_plugs', 'orphan_lo_id' => 161, 'target_clo_id' => 138, 'target_lo_id' => 1, 'target_category_id' => 14], + + // ── Food ── + ['label' => 'crisp_small', 'orphan_lo_id' => 157, 'target_clo_id' => 49, 'target_lo_id' => 40, 'target_category_id' => 8], + ['label' => 'crisp_large', 'orphan_lo_id' => 163, 'target_clo_id' => 49, 'target_lo_id' => 40, 'target_category_id' => 8], + ['label' => 'glass_jar', 'orphan_lo_id' => 159, 'target_clo_id' => 52, 'target_lo_id' => 43, 'target_category_id' => 8], + + // ── Other → correct category (category changes) ── + ['label' => 'dump', 'orphan_lo_id' => 165, 'target_clo_id' => 34, 'target_lo_id' => 28, 'target_category_id' => 6], + ['label' => 'dogshit', 'orphan_lo_id' => 102, 'target_clo_id' => 122, 'target_lo_id' => 102, 'target_category_id' => 13], + ['label' => 'dogshit_in_bag', 'orphan_lo_id' => 103, 'target_clo_id' => 123, 'target_lo_id' => 103, 'target_category_id' => 13], + ['label' => 'batteries', 'orphan_lo_id' => 181, 'target_clo_id' => 36, 'target_lo_id' => 29, 'target_category_id' => 7], + ['label' => 'tyre', 'orphan_lo_id' => 135, 'target_clo_id' => 173, 'target_lo_id' => 135, 'target_category_id' => 17], + ['label' => 'life_buoy', 'orphan_lo_id' => 184, 'target_clo_id' => 78, 'target_lo_id' => 63, 'target_category_id' => 10], + + // ── Other (same category) ── + ['label' => 'randomLitter', 'orphan_lo_id' => 170, 'target_clo_id' => 121, 'target_lo_id' => 1, 'target_category_id' => 12], + ['label' => 'plasticBags', 'orphan_lo_id' => 149, 'target_clo_id' => 111, 'target_lo_id' => 92, 'target_category_id' => 12], + ['label' => 'bagsLitter', 'orphan_lo_id' => 176, 'target_clo_id' => 106, 'target_lo_id' => 15, 'target_category_id' => 12], + ['label' => 'cableTie', 'orphan_lo_id' => 187, 'target_clo_id' => 114, 'target_lo_id' => 95, 'target_category_id' => 12], + ['label' => 'overflowingBins', 'orphan_lo_id' => 188, 'target_clo_id' => 107, 'target_lo_id' => 19, 'target_category_id' => 12], + ['label' => 'trafficCone', 'orphan_lo_id' => 171, 'target_clo_id' => 109, 'target_lo_id' => 90, 'target_category_id' => 12], + ['label' => 'posters', 'orphan_lo_id' => 177, 'target_clo_id' => 113, 'target_lo_id' => 94, 'target_category_id' => 12], + ['label' => 'washingUp', 'orphan_lo_id' => 152, 'target_clo_id' => 121, 'target_lo_id' => 1, 'target_category_id' => 12], + ['label' => 'magazine', 'orphan_lo_id' => 191, 'target_clo_id' => 121, 'target_lo_id' => 1, 'target_category_id' => 12], + ['label' => 'books', 'orphan_lo_id' => 192, 'target_clo_id' => 121, 'target_lo_id' => 1, 'target_category_id' => 12], + ['label' => 'lego', 'orphan_lo_id' => 197, 'target_clo_id' => 121, 'target_lo_id' => 1, 'target_category_id' => 12], + ['label' => 'automobile', 'orphan_lo_id' => 180, 'target_clo_id' => 167, 'target_lo_id' => 129, 'target_category_id' => 17], + ['label' => 'elec_small', 'orphan_lo_id' => 174, 'target_clo_id' => 43, 'target_lo_id' => 1, 'target_category_id' => 7], + ['label' => 'elec_large', 'orphan_lo_id' => 169, 'target_clo_id' => 43, 'target_lo_id' => 1, 'target_category_id' => 7], + + // ── Other / Marine split: balloons ── + ['label' => 'balloons', 'orphan_lo_id' => 178, 'target_clo_id' => 115, 'target_lo_id' => 96, 'target_category_id' => 12, 'category_filter' => 12], + ['label' => 'balloons', 'orphan_lo_id' => 178, 'target_clo_id' => 93, 'target_lo_id' => 1, 'target_category_id' => 10, 'category_filter' => 10], + + // ── Marine ── + ['label' => 'mediumplastics', 'orphan_lo_id' => 147, 'target_clo_id' => 85, 'target_lo_id' => 70, 'target_category_id' => 10], + ['label' => 'bag (marine)', 'orphan_lo_id' => 36, 'target_clo_id' => 93, 'target_lo_id' => 1, 'target_category_id' => 10], + ['label' => 'bottle (marine)', 'orphan_lo_id' => 2, 'target_clo_id' => 93, 'target_lo_id' => 1, 'target_category_id' => 10, 'category_filter' => 10], + ['label' => 'fishing_nets', 'orphan_lo_id' => 173, 'target_clo_id' => 84, 'target_lo_id' => 69, 'target_category_id' => 10], + ['label' => 'lighters (marine)', 'orphan_lo_id' => 119, 'target_clo_id' => 142, 'target_lo_id' => 119, 'target_category_id' => 15, 'category_filter' => 10], + ['label' => 'shotgun_cartridges', 'orphan_lo_id' => 193, 'target_clo_id' => 91, 'target_lo_id' => 76, 'target_category_id' => 10], + ['label' => 'buoys', 'orphan_lo_id' => 196, 'target_clo_id' => 78, 'target_lo_id' => 63, 'target_category_id' => 10], + + // ── Industrial ── + ['label' => 'plastic (industrial)', 'orphan_lo_id' => 89, 'target_clo_id' => 108, 'target_lo_id' => 89, 'target_category_id' => 12, 'category_filter' => 9], + ['label' => 'oil', 'orphan_lo_id' => 198, 'target_clo_id' => 67, 'target_lo_id' => 54, 'target_category_id' => 9], + ['label' => 'chemical', 'orphan_lo_id' => 195, 'target_clo_id' => 69, 'target_lo_id' => 56, 'target_category_id' => 9], + + // ── Art ── + ['label' => 'item (art)', 'orphan_lo_id' => 185, 'target_clo_id' => 16, 'target_lo_id' => 1, 'target_category_id' => 3], + ]; + } + + private function log(string $message, string $level = 'info'): void + { + match ($level) { + 'warn' => $this->warn($message), + 'error' => $this->error($message), + default => $message === '' ? $this->newLine() : $this->info($message), + }; + + if ($this->logFile) { + fwrite($this->logFile, '[' . now()->toDateTimeString() . "] {$message}\n"); + } + } + + private function openLog(): void + { + $path = $this->option('log'); + + if (! $path) { + return; + } + + $dir = dirname($path); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $this->logFile = fopen($path, 'a'); + + if ($this->logFile) { + $this->info("Logging to: {$path}"); + fwrite($this->logFile, "\n=== " . now()->toDateTimeString() . " ===\n"); + } + } + + private function closeLog(): void + { + if ($this->logFile) { + fclose($this->logFile); + $this->logFile = null; + } + } +} diff --git a/app/Console/Commands/tmp/v5/Migration/RegenerateSummaries.php b/app/Console/Commands/tmp/v5/Migration/RegenerateSummaries.php new file mode 100644 index 000000000..9dbdf00d6 --- /dev/null +++ b/app/Console/Commands/tmp/v5/Migration/RegenerateSummaries.php @@ -0,0 +1,347 @@ +openLog(); + $this->openChangedIdsFile(); + + $this->limit = (int) $this->option('limit'); + $batchSize = (int) $this->option('batch'); + $dryRun = $this->option('dry-run'); + $mode = $dryRun ? 'DRY-RUN' : 'LIVE'; + + $this->log("=== Regenerate Summaries ({$mode}) ==="); + + // --photo-ids and --from-file: small sets, load into memory + if ($ids = $this->option('photo-ids')) { + $photoIds = collect(explode(',', $ids))->map(fn ($id) => (int) trim($id))->filter(); + $this->log("Source: --photo-ids ({$photoIds->count()} photos)"); + + return $this->processCollection($photoIds, $batchSize, $summaryService, $dryRun); + } + + if ($file = $this->option('from-file')) { + if (! file_exists($file)) { + $this->log("File not found: {$file}", 'error'); + + return 1; + } + $photoIds = collect(file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) + ->map(fn ($id) => (int) trim($id)) + ->filter(); + $this->log("Source: --from-file ({$photoIds->count()} photos)"); + + return $this->processCollection($photoIds, $batchSize, $summaryService, $dryRun); + } + + if ($this->option('orphan-fix')) { + return $this->processOrphanFix($batchSize, $summaryService, $dryRun); + } + + $this->log('ERROR: Specify --photo-ids, --from-file, or --orphan-fix', 'error'); + + return 1; + } + + /** + * Process a known collection of photo IDs (small sets from --photo-ids or --from-file). + */ + private function processCollection($photoIds, int $batchSize, GeneratePhotoSummaryService $summaryService, bool $dryRun): int + { + $photoIds = $this->limit > 0 ? $photoIds->take($this->limit) : $photoIds; + $this->log("Photos to process: {$photoIds->count()}"); + $this->log(''); + + $photoIds->chunk($batchSize)->each(function ($chunk) use ($summaryService, $dryRun) { + $photos = Photo::withTrashed()->whereIn('id', $chunk)->get(); + + foreach ($photos as $photo) { + if ($this->limitReached()) { + return false; + } + $this->processPhoto($photo, $summaryService, $dryRun); + } + + $this->logProgress(); + }); + + return $this->finish(); + } + + /** + * Chunked, resumable processing for orphan-fix affected photos. + * Uses DB-level chunking (never loads all IDs into memory). + * Resumable: skips photos whose summary already has clo_id set on first tag. + */ + private function processOrphanFix(int $batchSize, GeneratePhotoSummaryService $summaryService, bool $dryRun): int + { + $total = DB::table('photos') + ->whereNotNull('migrated_at') + ->whereNotNull('summary') + ->count(); + + $this->log("Source: --orphan-fix (scanning {$total} migrated photos, skipping already-regenerated)"); + $this->log(''); + + $stopped = false; + + DB::table('photos') + ->select('id', 'summary') + ->whereNotNull('migrated_at') + ->whereNotNull('summary') + ->orderBy('id') + ->chunkById($batchSize, function ($rows) use ($summaryService, $dryRun, &$stopped) { + if ($stopped) { + return false; + } + + // Filter to photos with stale summaries (clo_id is null on first tag) + $staleIds = []; + foreach ($rows as $row) { + if ($this->hasStaleSummary($row->summary)) { + $staleIds[] = $row->id; + } else { + $this->skipped++; + } + } + + if (empty($staleIds)) { + return true; + } + + $photos = Photo::withTrashed()->whereIn('id', $staleIds)->get(); + + foreach ($photos as $photo) { + if ($this->limitReached()) { + $stopped = true; + + return false; + } + $this->processPhoto($photo, $summaryService, $dryRun); + } + + $this->logProgress(); + }); + + return $this->finish(); + } + + /** + * Check if a photo's summary has a stale first tag (clo_id is null). + * Already-regenerated photos will have clo_id set. + */ + private function hasStaleSummary(string $summaryJson): bool + { + $summary = json_decode($summaryJson, true); + + if (! $summary || empty($summary['tags'])) { + return false; + } + + // If any tag has a null clo_id, the summary is stale + foreach ($summary['tags'] as $tag) { + if (! isset($tag['clo_id']) || $tag['clo_id'] === null) { + // Extra-tag-only entries (no object) legitimately have null clo_id. + // Only stale if the tag has an object_id (orphan fix changed these). + if (isset($tag['object_id']) && $tag['object_id'] > 0) { + return true; + } + } + } + + return false; + } + + private function processPhoto(Photo $photo, GeneratePhotoSummaryService $summaryService, bool $dryRun): void + { + try { + $oldSummary = $photo->summary; + $oldXp = $photo->xp; + + if ($dryRun) { + $tagCount = $photo->photoTags()->count(); + $this->processed++; + $this->log(" Photo {$photo->id}: {$tagCount} tags, would regenerate"); + $this->changed++; + + return; + } + + // Pure summary write — no events, no observers, no MetricsService + Photo::withoutEvents(function () use ($photo, $summaryService) { + $summaryService->run($photo); + }); + + $photo->refresh(); + $this->processed++; + + $summaryChanged = json_encode($oldSummary) !== json_encode($photo->summary); + $xpChanged = $oldXp !== $photo->xp; + + if ($summaryChanged || $xpChanged) { + $this->changed++; + $this->writeChangedId($photo->id); + $xpDelta = $photo->xp - $oldXp; + if ($xpDelta !== 0) { + $this->log(" Photo {$photo->id}: summary updated, xp: {$oldXp}→{$photo->xp}"); + } + } else { + $this->unchanged++; + } + } catch (\Throwable $e) { + $this->errors++; + $this->log(" Photo {$photo->id}: ERROR — {$e->getMessage()}", 'error'); + } + } + + private function limitReached(): bool + { + return $this->limit > 0 && $this->processed >= $this->limit; + } + + private function logProgress(): void + { + $total = $this->processed + $this->skipped; + $msg = " [{$total}] {$this->processed} processed ({$this->changed} changed, {$this->unchanged} unchanged), {$this->skipped} skipped, {$this->errors} errors"; + + // Overwrite line on terminal for compact progress + $this->output->write("\r\033[K" . $msg); + $this->newLine(); + + if ($this->logFile) { + fwrite($this->logFile, '[' . now()->toDateTimeString() . "] {$msg}\n"); + } + } + + private function finish(): int + { + $this->log(''); + $this->log('=== SUMMARY ==='); + $this->log("Processed: {$this->processed}"); + $this->log("Changed: {$this->changed}"); + $this->log("Unchanged: {$this->unchanged}"); + $this->log("Skipped (already regenerated): {$this->skipped}"); + $this->log("Errors: {$this->errors}"); + + if ($this->changedIdsFile) { + $path = $this->option('changed-ids'); + $this->log("Changed photo IDs written to: {$path}"); + } + + $this->closeChangedIdsFile(); + $this->closeLog(); + + return 0; + } + + private function log(string $message, string $level = 'info'): void + { + match ($level) { + 'warn' => $this->warn($message), + 'error' => $this->error($message), + default => $message === '' ? $this->newLine() : $this->info($message), + }; + + if ($this->logFile) { + fwrite($this->logFile, '[' . now()->toDateTimeString() . "] {$message}\n"); + } + } + + private function openLog(): void + { + $path = $this->option('log'); + + if (! $path) { + return; + } + + $dir = dirname($path); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $this->logFile = fopen($path, 'a'); + + if ($this->logFile) { + $this->info("Logging to: {$path}"); + fwrite($this->logFile, "\n=== " . now()->toDateTimeString() . " ===\n"); + } + } + + private function closeLog(): void + { + if ($this->logFile) { + fclose($this->logFile); + $this->logFile = null; + } + } + + private function openChangedIdsFile(): void + { + $path = $this->option('changed-ids'); + + if (! $path) { + return; + } + + $dir = dirname($path); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $this->changedIdsFile = fopen($path, 'a'); + + if ($this->changedIdsFile) { + $this->info("Writing changed photo IDs to: {$path}"); + } + } + + private function writeChangedId(int $photoId): void + { + if ($this->changedIdsFile) { + fwrite($this->changedIdsFile, $photoId . "\n"); + } + } + + private function closeChangedIdsFile(): void + { + if ($this->changedIdsFile) { + fclose($this->changedIdsFile); + $this->changedIdsFile = null; + } + } +} diff --git a/app/Console/Commands/tmp/v5/Migration/ReprocessPhotoMetrics.php b/app/Console/Commands/tmp/v5/Migration/ReprocessPhotoMetrics.php new file mode 100644 index 000000000..5bde23313 --- /dev/null +++ b/app/Console/Commands/tmp/v5/Migration/ReprocessPhotoMetrics.php @@ -0,0 +1,165 @@ +openLog(); + + $photoIds = $this->resolvePhotoIds(); + + if ($photoIds === null || $photoIds->isEmpty()) { + $this->log('ERROR: Specify --photo-ids or --from-file', 'error'); + + return 1; + } + + $batchSize = (int) $this->option('batch'); + $dryRun = $this->option('dry-run'); + $mode = $dryRun ? 'DRY-RUN' : 'LIVE'; + + $this->log("=== Reprocess Photo Metrics ({$mode}) ==="); + $this->log("Photos: {$photoIds->count()}"); + $this->log(''); + + $photoIds->chunk($batchSize)->each(function ($chunk) use ($metricsService, $dryRun) { + $photos = Photo::withTrashed()->whereIn('id', $chunk)->get(); + + foreach ($photos as $photo) { + $this->processPhoto($photo, $metricsService, $dryRun); + } + + $this->log(" Progress: {$this->processed} processed, {$this->changed} changed, {$this->skipped} skipped, {$this->errors} errors"); + }); + + $this->log(''); + $this->log('=== SUMMARY ==='); + $this->log("Processed: {$this->processed}"); + $this->log("Changed (delta applied): {$this->changed}"); + $this->log("Skipped (no delta): {$this->skipped}"); + $this->log("Errors: {$this->errors}"); + + $this->closeLog(); + + return 0; + } + + private function processPhoto(Photo $photo, MetricsService $metricsService, bool $dryRun): void + { + try { + $oldProcessedXp = $photo->processed_xp; + $oldProcessedFp = $photo->processed_fp; + $currentXp = $photo->xp; + + if ($dryRun) { + $this->processed++; + $xpDelta = $currentXp - ($oldProcessedXp ?? 0); + $this->log(" Photo {$photo->id}: xp delta would be {$xpDelta} ({$oldProcessedXp}→{$currentXp})"); + $this->changed++; + + return; + } + + $metricsService->processPhoto($photo); + $photo->refresh(); + + $this->processed++; + + if ($photo->processed_xp !== $oldProcessedXp || $photo->processed_fp !== $oldProcessedFp) { + $this->changed++; + $this->log(" Photo {$photo->id}: metrics updated, processed_xp: {$oldProcessedXp}→{$photo->processed_xp}"); + } else { + $this->skipped++; + } + } catch (\Throwable $e) { + $this->errors++; + $this->log(" Photo {$photo->id}: ERROR — {$e->getMessage()}", 'error'); + } + } + + private function resolvePhotoIds() + { + if ($ids = $this->option('photo-ids')) { + return collect(explode(',', $ids))->map(fn ($id) => (int) trim($id))->filter(); + } + + if ($file = $this->option('from-file')) { + if (! file_exists($file)) { + $this->log("File not found: {$file}", 'error'); + + return null; + } + + return collect(file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) + ->map(fn ($id) => (int) trim($id)) + ->filter(); + } + + return null; + } + + private function log(string $message, string $level = 'info'): void + { + match ($level) { + 'warn' => $this->warn($message), + 'error' => $this->error($message), + default => $message === '' ? $this->newLine() : $this->info($message), + }; + + if ($this->logFile) { + fwrite($this->logFile, '[' . now()->toDateTimeString() . "] {$message}\n"); + } + } + + private function openLog(): void + { + $path = $this->option('log'); + + if (! $path) { + return; + } + + $dir = dirname($path); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $this->logFile = fopen($path, 'a'); + + if ($this->logFile) { + $this->info("Logging to: {$path}"); + fwrite($this->logFile, "\n=== " . now()->toDateTimeString() . " ===\n"); + } + } + + private function closeLog(): void + { + if ($this->logFile) { + fclose($this->logFile); + $this->logFile = null; + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e74707448..132a6848f 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -17,6 +17,7 @@ protected function schedule (Schedule $schedule): void $schedule->command('twitter:changelog')->dailyAt('07:00'); $schedule->command('twitter:weekly-impact-report-tweet')->weeklyOn(1, '06:30'); $schedule->command('twitter:monthly-impact-report-tweet')->monthlyOn(1, '06:30'); + $schedule->command('twitter:annual-impact-report-tweet')->yearlyOn(1, 1, '06:30'); } /** diff --git a/app/Http/Controllers/API/GlobalStatsController.php b/app/Http/Controllers/API/GlobalStatsController.php index 2aac8b122..a1467b5b6 100644 --- a/app/Http/Controllers/API/GlobalStatsController.php +++ b/app/Http/Controllers/API/GlobalStatsController.php @@ -27,7 +27,7 @@ public function index(): JsonResponse ->first(['uploads', 'tags']); $now = now('UTC'); - $today = $now->toDateString(); + $oneDayAgo = $now->copy()->subHours(24)->toDateString(); $sevenDaysAgo = $now->copy()->subDays(7)->startOfDay()->toDateString(); $thirtyDaysAgo = $now->copy()->subDays(30)->startOfDay()->toDateString(); @@ -38,29 +38,46 @@ public function index(): JsonResponse ->where('user_id', 0) ->where('bucket_date', '>=', $thirtyDaysAgo) ->selectRaw( - 'SUM(CASE WHEN bucket_date = ? THEN tags ELSE 0 END) as new_tags_today, + 'SUM(CASE WHEN bucket_date >= ? THEN tags ELSE 0 END) as new_tags_24h, SUM(CASE WHEN bucket_date >= ? THEN tags ELSE 0 END) as new_tags_7d, SUM(tags) as new_tags_30d, - SUM(CASE WHEN bucket_date = ? THEN uploads ELSE 0 END) as new_photos_today, + SUM(CASE WHEN bucket_date >= ? THEN uploads ELSE 0 END) as new_photos_24h, SUM(CASE WHEN bucket_date >= ? THEN uploads ELSE 0 END) as new_photos_7d, SUM(uploads) as new_photos_30d', - [$today, $sevenDaysAgo, $today, $sevenDaysAgo] + [$oneDayAgo, $sevenDaysAgo, $oneDayAgo, $sevenDaysAgo] ) ->first(); + $usersLast24h = User::where('created_at', '>=', $now->copy()->subHours(24))->count(); + $usersLast7d = User::where('created_at', '>=', $now->copy()->subDays(7)->startOfDay())->count(); + $usersLast30d = User::where('created_at', '>=', $now->copy()->subDays(30)->startOfDay())->count(); + $tagsLast24h = (int) ($growth->new_tags_24h ?? 0); + $tagsLast7d = (int) ($growth->new_tags_7d ?? 0); + $tagsLast30d = (int) ($growth->new_tags_30d ?? 0); + $photosLast24h = (int) ($growth->new_photos_24h ?? 0); + $photosLast7d = (int) ($growth->new_photos_7d ?? 0); + $photosLast30d = (int) ($growth->new_photos_30d ?? 0); + return [ 'total_tags' => (int) ($row->tags ?? 0), 'total_images' => (int) ($row->uploads ?? 0), 'total_users' => User::count(), - 'new_users_today' => User::whereDate('created_at', $today)->count(), - 'new_users_last_7_days' => User::where('created_at', '>=', $now->copy()->subDays(7)->startOfDay())->count(), - 'new_users_last_30_days' => User::where('created_at', '>=', $now->copy()->subDays(30)->startOfDay())->count(), - 'new_tags_today' => (int) ($growth->new_tags_today ?? 0), - 'new_tags_last_7_days' => (int) ($growth->new_tags_7d ?? 0), - 'new_tags_last_30_days' => (int) ($growth->new_tags_30d ?? 0), - 'new_photos_today' => (int) ($growth->new_photos_today ?? 0), - 'new_photos_last_7_days' => (int) ($growth->new_photos_7d ?? 0), - 'new_photos_last_30_days' => (int) ($growth->new_photos_30d ?? 0), + + // New keys (v5.7+) + 'new_users_last_24_hours' => $usersLast24h, + 'new_users_last_7_days' => $usersLast7d, + 'new_users_last_30_days' => $usersLast30d, + 'new_tags_last_24_hours' => $tagsLast24h, + 'new_tags_last_7_days' => $tagsLast7d, + 'new_tags_last_30_days' => $tagsLast30d, + 'new_photos_last_24_hours' => $photosLast24h, + 'new_photos_last_7_days' => $photosLast7d, + 'new_photos_last_30_days' => $photosLast30d, + + // Legacy keys (pre-v5.7 mobile compat) + 'new_users_today' => $usersLast24h, + 'new_tags_today' => $tagsLast24h, + 'new_photos_today' => $photosLast24h, ]; }); diff --git a/app/Http/Controllers/API/MobileAppVersionController.php b/app/Http/Controllers/API/MobileAppVersionController.php index bd2f15f99..b2691dac7 100644 --- a/app/Http/Controllers/API/MobileAppVersionController.php +++ b/app/Http/Controllers/API/MobileAppVersionController.php @@ -11,11 +11,11 @@ public function __invoke (): array return [ 'ios' => [ 'url' => 'https://apps.apple.com/us/app/openlittermap/id1475982147', - 'version' => '7.1.0' + 'version' => '7.5.0' ], 'android' => [ 'url' => 'https://play.google.com/store/apps/details?id=com.geotech.openlittermap', - 'version' => '7.1.0' + 'version' => '7.5.0' ] ]; } diff --git a/app/Http/Controllers/API/QuickTagsController.php b/app/Http/Controllers/API/QuickTagsController.php new file mode 100644 index 000000000..a48d2fb4e --- /dev/null +++ b/app/Http/Controllers/API/QuickTagsController.php @@ -0,0 +1,147 @@ +user()->quickTags()->get(); + + return response()->json([ + 'success' => true, + 'tags' => $tags, + ]); + } + + /** + * PUT /api/user/quick-tags + */ + public function update(SyncQuickTagsRequest $request, SyncQuickTagsAction $action): JsonResponse + { + $tags = $action->run( + $request->user(), + $request->validated('tags') + ); + + return response()->json([ + 'success' => true, + 'tags' => $tags, + ]); + } + + /** + * GET /api/v3/user/top-tags?limit=20 + * + * Returns the user's most-tagged items grouped by CLO + type, + * with the dominant brand (>50%) included when applicable. + */ + public function topTags(Request $request): JsonResponse + { + $limit = min((int) ($request->query('limit', 20)), 30); + $userId = $request->user()->id; + + // Get top CLO + type combos by quantity + $rows = DB::table('photo_tags as pt') + ->join('photos as p', 'p.id', '=', 'pt.photo_id') + ->join('category_litter_object as clo', 'clo.id', '=', 'pt.category_litter_object_id') + ->join('categories as c', 'c.id', '=', 'clo.category_id') + ->join('litter_objects as lo', 'lo.id', '=', 'clo.litter_object_id') + ->leftJoin('litter_object_types as lot', 'lot.id', '=', 'pt.litter_object_type_id') + ->where('p.user_id', $userId) + ->whereNotNull('pt.category_litter_object_id') + ->select( + 'pt.category_litter_object_id as clo_id', + 'c.key as category_key', + 'lo.key as object_key', + 'pt.litter_object_type_id as type_id', + 'lot.key as type_key', + DB::raw('SUM(pt.quantity) as total') + ) + ->groupBy('pt.category_litter_object_id', 'c.key', 'lo.key', 'pt.litter_object_type_id', 'lot.key') + ->havingRaw('SUM(pt.quantity) >= 3') + ->orderByDesc('total') + ->limit($limit) + ->get(); + + if ($rows->isEmpty()) { + return response()->json(['success' => true, 'tags' => []]); + } + + // Find dominant brand (>50%) for each CLO + type combo + $cloTypeKeys = $rows->map(fn ($r) => $r->clo_id . ':' . ($r->type_id ?? 'null'))->toArray(); + + $brandRows = DB::table('photo_tag_extra_tags as ptet') + ->join('photo_tags as pt', 'pt.id', '=', 'ptet.photo_tag_id') + ->join('photos as p', 'p.id', '=', 'pt.photo_id') + ->join('brandslist as bl', 'bl.id', '=', 'ptet.tag_type_id') + ->where('ptet.tag_type', 'brand') + ->where('p.user_id', $userId) + ->whereNotNull('pt.category_litter_object_id') + ->select( + 'pt.category_litter_object_id as clo_id', + 'pt.litter_object_type_id as type_id', + 'ptet.tag_type_id as brand_id', + 'bl.key as brand_key', + DB::raw('SUM(ptet.quantity) as brand_total') + ) + ->groupBy('pt.category_litter_object_id', 'pt.litter_object_type_id', 'ptet.tag_type_id', 'bl.key') + ->orderByDesc('brand_total') + ->get(); + + // Build lookup: "clo_id:type_id" → best brand (only if >50%) + $brandLookup = []; + $groupTotals = $rows->keyBy(fn ($r) => $r->clo_id . ':' . ($r->type_id ?? 'null')) + ->map(fn ($r) => (int) $r->total); + + foreach ($brandRows as $br) { + $key = $br->clo_id . ':' . ($br->type_id ?? 'null'); + + if (! isset($groupTotals[$key])) { + continue; + } + + // Only include if this brand accounts for >50% of the group + if (((int) $br->brand_total / $groupTotals[$key]) > 0.5) { + if (! isset($brandLookup[$key]) || (int) $br->brand_total > $brandLookup[$key]['brand_total']) { + $brandLookup[$key] = [ + 'brand_id' => (int) $br->brand_id, + 'brand_key' => $br->brand_key, + 'brand_total' => (int) $br->brand_total, + ]; + } + } + } + + $tags = $rows->map(function ($row) use ($brandLookup) { + $key = $row->clo_id . ':' . ($row->type_id ?? 'null'); + $brand = $brandLookup[$key] ?? null; + + return [ + 'clo_id' => (int) $row->clo_id, + 'category_key' => $row->category_key, + 'object_key' => $row->object_key, + 'type_id' => $row->type_id ? (int) $row->type_id : null, + 'type_key' => $row->type_key, + 'brand_id' => $brand ? $brand['brand_id'] : null, + 'brand_key' => $brand ? $brand['brand_key'] : null, + 'total' => (int) $row->total, + ]; + })->values(); + + return response()->json([ + 'success' => true, + 'tags' => $tags, + ]); + } +} diff --git a/app/Http/Controllers/PhotosController.php b/app/Http/Controllers/PhotosController.php index 1d53c487c..84943e9c5 100644 --- a/app/Http/Controllers/PhotosController.php +++ b/app/Http/Controllers/PhotosController.php @@ -17,7 +17,8 @@ public function __construct( } /** - * Delete a photo — reverses metrics, removes S3 files, soft-deletes the row. + * Delete a photo — reverses metrics, removes S3 files, hard-deletes the row. + * Cascading FKs on photo_tags (→ photo_tag_extras) handle relationship cleanup. */ public function deleteImage(Request $request) { @@ -28,7 +29,7 @@ public function deleteImage(Request $request) abort(403); } - // Reverse metrics before soft delete (if photo was processed) + // Reverse metrics before delete (if photo was processed) // MetricsService::deletePhoto() reverses both upload XP and tag XP // from MySQL metrics, Redis, and users.xp if ($photo->processed_at !== null) { @@ -38,8 +39,8 @@ public function deleteImage(Request $request) // Delete S3 files $this->deletePhotoAction->run($photo); - // Soft delete - $photo->delete(); + // Hard delete — cascading FKs clean up photo_tags and extras + $photo->forceDelete(); return response()->json(['message' => 'Photo deleted successfully!']); } diff --git a/app/Http/Controllers/Reports/GenerateImpactReportController.php b/app/Http/Controllers/Reports/GenerateImpactReportController.php index a71358563..5f4eee68c 100644 --- a/app/Http/Controllers/Reports/GenerateImpactReportController.php +++ b/app/Http/Controllers/Reports/GenerateImpactReportController.php @@ -6,6 +6,7 @@ use Carbon\Carbon; use App\Enums\LocationType; +use App\Enums\Timescale; use App\Models\Users\User; use Illuminate\Contracts\View\View; use Illuminate\Support\Number; @@ -21,7 +22,7 @@ class GenerateImpactReportController extends Controller */ public function __invoke(string $period = 'weekly', $year = null, $monthOrWeek = null): View { - $period = in_array($period, ['weekly', 'monthly'], true) ? $period : 'weekly'; + $period = in_array($period, ['weekly', 'monthly', 'annual'], true) ? $period : 'weekly'; [$start, $endExclusive, $label] = $this->resolveDateRange($period, $year, $monthOrWeek); @@ -30,7 +31,9 @@ public function __invoke(string $period = 'weekly', $year = null, $monthOrWeek = } $cacheKey = "impact_report:{$period}:{$label}"; - $cacheTtl = $endExclusive->copy()->endOfDay(); + $cacheTtl = $endExclusive->isPast() + ? now()->addDays(30) + : $endExclusive->copy()->endOfDay(); $report = Cache::remember( $cacheKey, @@ -47,7 +50,11 @@ public function __invoke(string $period = 'weekly', $year = null, $monthOrWeek = private function buildReport(string $period, Carbon $start, Carbon $endExclusive): array { - $dateFormat = $period === 'monthly' ? 'F Y' : 'D jS M Y'; + $dateFormat = match ($period) { + 'annual' => 'Y', + 'monthly' => 'F Y', + default => 'D jS M Y', + }; $endInclusive = $endExclusive->copy()->subSecond(); $periodRow = $this->getMetricsRow($period, $start); @@ -93,15 +100,18 @@ private function getMetricsRow(string $period, ?Carbon $start = null): ?object ->where('user_id', 0); if ($period === 'all_time') { - $query->where('timescale', 0); + $query->where('timescale', Timescale::AllTime->value); } elseif ($period === 'weekly') { - $query->where('timescale', 2) + $query->where('timescale', Timescale::Weekly->value) ->where('year', (int) $start->format('o')) ->where('week', (int) $start->format('W')); - } else { - $query->where('timescale', 3) + } elseif ($period === 'monthly') { + $query->where('timescale', Timescale::Monthly->value) ->where('year', $start->year) ->where('month', $start->month); + } elseif ($period === 'annual') { + $query->where('timescale', Timescale::Yearly->value) + ->where('year', $start->year); } return $query->first(['uploads', 'tags', 'brands', 'litter', 'xp']); @@ -178,72 +188,27 @@ private function getTopObjects(Carbon $start, Carbon $endExclusive): array } /* ------------------------------------------------------------------ - * Top 10 brands - * - * OLD SCHEMA (v4): The `brands` table has one column per brand - * (marlboro, coke, heineken, etc.) with quantity values. - * Photos link via photos.brands_id → brands.id - * - * TODO: Once v5 migration is complete, switch to: - * photo_tag_extra_tags WHERE tag_type='brand' - * JOIN brandslist ON brandslist.id = tag_type_id + * Top 10 brands (via photo_tag_extra_tags) * ------------------------------------------------------------------ */ private function getTopBrands(Carbon $start, Carbon $endExclusive): array { - $brandRows = DB::table('brands') - ->join('photos', 'photos.brands_id', '=', 'brands.id') - ->where('photos.created_at', '>=', $start) - ->where('photos.created_at', '<', $endExclusive) - ->whereNotNull('photos.brands_id') - ->select('brands.*') - ->get(); - - if ($brandRows->isEmpty()) { - return []; - } - - $brandColumns = \App\Models\Litter\Categories\Brand::types(); - $totals = []; - - foreach ($brandRows as $row) { - foreach ($brandColumns as $col) { - if (!empty($row->$col)) { - $totals[$col] = ($totals[$col] ?? 0) + (int) $row->$col; - } - } - } - - arsort($totals); - - return collect(array_slice($totals, 0, 10, true)) - ->mapWithKeys(fn ($total, $key) => [self::formatKey($key) => $total]) + return DB::table('photo_tag_extra_tags as ptet') + ->join('photo_tags as pt', 'pt.id', '=', 'ptet.photo_tag_id') + ->join('photos as p', 'p.id', '=', 'pt.photo_id') + ->join('brandslist as bl', 'bl.id', '=', 'ptet.tag_type_id') + ->where('ptet.tag_type', 'brand') + ->where('p.created_at', '>=', $start) + ->where('p.created_at', '<', $endExclusive) + ->select('bl.key', DB::raw('SUM(ptet.quantity) as total')) + ->groupBy('bl.key') + ->orderByDesc('total') + ->limit(10) + ->pluck('total', 'key') + ->mapWithKeys(fn ($total, $key) => [self::formatKey($key) => (int) $total]) ->toArray(); } - /* ------------------------------------------------------------------ - * Top 10 brands (single query via photo_tag_extra_tags) - * ------------------------------------------------------------------ */ - - // v5 version -// private function getTopBrands(Carbon $start, Carbon $endExclusive): array -// { -// return DB::table('photo_tag_extra_tags as ptet') -// ->join('photo_tags as pt', 'pt.id', '=', 'ptet.photo_tag_id') -// ->join('photos as p', 'p.id', '=', 'pt.photo_id') -// ->join('brandslist as bl', 'bl.id', '=', 'ptet.tag_type_id') -// ->where('ptet.tag_type', 'brand') -// ->where('p.created_at', '>=', $start) -// ->where('p.created_at', '<', $endExclusive) -// ->select('bl.key', DB::raw('SUM(ptet.quantity) as total')) -// ->groupBy('bl.key') -// ->orderByDesc('total') -// ->limit(10) -// ->pluck('total', 'key') -// ->mapWithKeys(fn ($total, $key) => [self::formatKey($key) => (int) $total]) -// ->toArray(); -// } - /** * beer_bottle → Beer Bottle, coca-cola → Coca Cola */ @@ -258,6 +223,14 @@ private static function formatKey(string $key): string private function resolveDateRange(string $period, $year, $monthOrWeek): array { + if ($period === 'annual') { + $yearInt = $year !== null ? (int) $year : now()->subYear()->year; + $start = Carbon::createFromDate($yearInt, 1, 1)->startOfDay(); + $endExclusive = $start->copy()->addYear(); + + return [$start, $endExclusive, (string) $yearInt]; + } + $hasParams = $year !== null && $monthOrWeek !== null; if ($period === 'weekly') { diff --git a/app/Http/Controllers/Teams/ParticipantPhotoController.php b/app/Http/Controllers/Teams/ParticipantPhotoController.php index ba261d32a..ffff24b9b 100644 --- a/app/Http/Controllers/Teams/ParticipantPhotoController.php +++ b/app/Http/Controllers/Teams/ParticipantPhotoController.php @@ -60,10 +60,10 @@ public function destroy(Request $request, Photo $photo, DeletePhotoAction $delet ], 422); } - // Delete S3 files and soft-delete the photo + // Delete S3 files and hard-delete the photo // No metrics reversal needed — school photos not processed until approval $deletePhotoAction->run($photo); - $photo->delete(); + $photo->forceDelete(); return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/Teams/TeamPhotosController.php b/app/Http/Controllers/Teams/TeamPhotosController.php index d9f33fc08..0e512e167 100644 --- a/app/Http/Controllers/Teams/TeamPhotosController.php +++ b/app/Http/Controllers/Teams/TeamPhotosController.php @@ -491,7 +491,7 @@ public function destroy(Request $request, Photo $photo): JsonResponse return response()->json(['success' => false, 'message' => 'unauthorized'], 403); } - // Reverse metrics before soft delete (if photo was processed) + // Reverse metrics before delete (if photo was processed) // MetricsService::deletePhoto() reverses both upload XP and tag XP // from MySQL metrics, Redis, and users.xp if ($photo->processed_at !== null) { @@ -501,8 +501,8 @@ public function destroy(Request $request, Photo $photo): JsonResponse // Delete S3 files app(DeletePhotoAction::class)->run($photo); - // Soft delete - $photo->delete(); + // Hard delete — cascading FKs clean up photo_tags and extras + $photo->forceDelete(); return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/User/UserPhotoController.php b/app/Http/Controllers/User/UserPhotoController.php index ae64d8489..17ace03d7 100644 --- a/app/Http/Controllers/User/UserPhotoController.php +++ b/app/Http/Controllers/User/UserPhotoController.php @@ -30,8 +30,8 @@ public function bulkTag(Request $request): JsonResponse /** * Bulk delete user's own photos. * - * Reverses metrics, removes S3 files, soft-deletes each photo, - * and decrements user counters. + * Reverses metrics, removes S3 files, hard-deletes each photo. + * Cascading FKs on photo_tags (→ photo_tag_extras) handle relationship cleanup. */ public function destroy(Request $request): array { @@ -51,7 +51,7 @@ public function destroy(Request $request): array continue; } - // Reverse metrics before soft delete (if photo was processed) + // Reverse metrics before delete (if photo was processed) // MetricsService::deletePhoto() reverses both upload XP and tag XP // from MySQL metrics, Redis, and users.xp if ($photo->processed_at !== null) { @@ -61,8 +61,8 @@ public function destroy(Request $request): array // Delete S3 files $deletePhotoAction->run($photo); - // Soft delete - $photo->delete(); + // Hard delete — cascading FKs clean up photo_tags and extras + $photo->forceDelete(); $deleted++; } catch (\Exception $e) { diff --git a/app/Http/Requests/Api/SyncQuickTagsRequest.php b/app/Http/Requests/Api/SyncQuickTagsRequest.php new file mode 100644 index 000000000..1604f90b1 --- /dev/null +++ b/app/Http/Requests/Api/SyncQuickTagsRequest.php @@ -0,0 +1,40 @@ +user() !== null; + } + + public function rules(): array + { + $maxQty = $this->user()->is_trusted ? 100 : 10; + + return [ + 'tags' => 'present|array|max:30', + 'tags.*.clo_id' => 'required|integer|exists:category_litter_object,id', + 'tags.*.type_id' => 'nullable|integer|exists:litter_object_types,id', + 'tags.*.custom_name' => 'nullable|string|max:60', + 'tags.*.quantity' => "required|integer|min:1|max:{$maxQty}", + 'tags.*.picked_up' => 'nullable|boolean', + 'tags.*.materials' => 'present|array', + 'tags.*.materials.*' => 'integer|exists:materials,id', + 'tags.*.brands' => 'present|array', + 'tags.*.brands.*.id' => 'required|integer|exists:brandslist,id', + 'tags.*.brands.*.quantity' => "required|integer|min:1|max:{$maxQty}", + ]; + } + + public function messages(): array + { + return [ + 'tags.max' => 'Maximum 30 quick tags allowed.', + 'tags.*.clo_id.exists' => 'One or more tag IDs are no longer valid. Please remove stale tags and try again.', + ]; + } +} diff --git a/app/Mail/EmailUpdate.php b/app/Mail/EmailUpdate.php index ee3ee29a6..3facf60e9 100644 --- a/app/Mail/EmailUpdate.php +++ b/app/Mail/EmailUpdate.php @@ -20,7 +20,7 @@ public function __construct($user) public function build(): static { return $this->from('info@openlittermap.com', 'Seán @ OpenLitterMap') - ->subject("OpenLitterMap v5 is now online \xF0\x9F\x9A\x80") - ->view('emails.update26'); + ->subject('Update 27 - Mobile app updates & more!') + ->view('emails.update27'); } } diff --git a/app/Models/Users/User.php b/app/Models/Users/User.php index 39ceb144d..c0950f3f5 100644 --- a/app/Models/Users/User.php +++ b/app/Models/Users/User.php @@ -408,6 +408,11 @@ public function achievements() ); } + public function quickTags(): HasMany + { + return $this->hasMany(UserQuickTag::class)->orderBy('sort_order'); + } + /** * @deprecated */ diff --git a/app/Models/Users/UserQuickTag.php b/app/Models/Users/UserQuickTag.php new file mode 100644 index 000000000..446d8815e --- /dev/null +++ b/app/Models/Users/UserQuickTag.php @@ -0,0 +1,41 @@ + 'integer', + 'type_id' => 'integer', + 'quantity' => 'integer', + 'picked_up' => 'boolean', + 'materials' => 'array', + 'brands' => 'array', + 'sort_order' => 'integer', + ]; + } + + protected $hidden = ['user_id', 'created_at', 'updated_at']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Traits/ResolvesUserProfile.php b/app/Traits/ResolvesUserProfile.php index fddc74dc8..af394cc73 100644 --- a/app/Traits/ResolvesUserProfile.php +++ b/app/Traits/ResolvesUserProfile.php @@ -196,15 +196,26 @@ protected function calculateStreak(string $userScope, int $userId): int /** * Get the user's global rank position from Redis ZREVRANK. - * Falls back to users.xp count if user is not in the Redis ZSET. + * Falls back to users.xp count if Redis ZSET is empty or incomplete. */ protected function getGlobalRank(int $userId, int $fallbackXp): int { $globalXpKey = RedisKeys::xpRanking(RedisKeys::global()); - $rank = Redis::zRevRank($globalXpKey, (string) $userId); - if ($rank !== false) { - return $rank + 1; + try { + $rank = Redis::zRevRank($globalXpKey, (string) $userId); + + if ($rank !== false && $rank !== null) { + // Verify ZSET is reasonably complete before trusting the rank + $zsetSize = (int) Redis::zCard($globalXpKey); + $totalUsers = (int) Cache::remember('users:count', 3600, fn () => User::count()); + + if ($zsetSize >= max(1, $totalUsers * 0.5)) { + return $rank + 1; + } + } + } catch (\Exception $e) { + // Redis unavailable — fall through to MySQL } return User::where('xp', '>', $fallbackXp)->count() + 1; diff --git a/config/services.php b/config/services.php index 7030ac457..514136d11 100644 --- a/config/services.php +++ b/config/services.php @@ -58,4 +58,8 @@ 'access_secret' => env('TWITTER_API_ACCESS_SECRET'), ], + 'browsershot' => [ + 'chrome_path' => env('BROWSERSHOT_CHROME_PATH', '/snap/bin/chromium'), + ], + ]; diff --git a/database/migrations/2026_03_29_000001_add_cascade_delete_to_photo_foreign_keys.php b/database/migrations/2026_03_29_000001_add_cascade_delete_to_photo_foreign_keys.php new file mode 100644 index 000000000..0cabc7916 --- /dev/null +++ b/database/migrations/2026_03_29_000001_add_cascade_delete_to_photo_foreign_keys.php @@ -0,0 +1,85 @@ +dropForeign(['photo_id']); + $table->foreign('photo_id')->references('id')->on('photos')->onDelete('set null'); + }); + + // custom_tags: drop old FK (no cascade), re-add with cascade + Schema::table('custom_tags', function (Blueprint $table) { + $table->dropForeign(['photo_id']); + $table->foreign('photo_id')->references('id')->on('photos')->onDelete('cascade'); + }); + + // littercoins: drop old FK (no cascade), re-add with set null (column is nullable) + Schema::table('littercoins', function (Blueprint $table) { + $table->dropForeign(['photo_id']); + $table->foreign('photo_id')->references('id')->on('photos')->onDelete('set null'); + }); + + // annotations: fix column type (bigint → int unsigned to match photos.id), + // clean orphaned rows, then add FK + Schema::table('annotations', function (Blueprint $table) { + $table->unsignedInteger('photo_id')->change(); + }); + DB::statement('DELETE FROM annotations WHERE photo_id NOT IN (SELECT id FROM photos)'); + Schema::table('annotations', function (Blueprint $table) { + $table->foreign('photo_id')->references('id')->on('photos')->onDelete('cascade'); + }); + + // admin_verification_logs: clean orphaned rows, then add FK + DB::statement('DELETE FROM admin_verification_logs WHERE photo_id NOT IN (SELECT id FROM photos)'); + Schema::table('admin_verification_logs', function (Blueprint $table) { + $table->foreign('photo_id')->references('id')->on('photos')->onDelete('cascade'); + }); + } + + public function down(): void + { + // admin_verification_logs: remove FK (didn't exist before) + Schema::table('admin_verification_logs', function (Blueprint $table) { + $table->dropForeign(['photo_id']); + }); + + // annotations: remove FK and restore original bigint type + Schema::table('annotations', function (Blueprint $table) { + $table->dropForeign(['photo_id']); + }); + Schema::table('annotations', function (Blueprint $table) { + $table->unsignedBigInteger('photo_id')->change(); + }); + + // badges: restore original FK without cascade + Schema::table('badges', function (Blueprint $table) { + $table->dropForeign(['photo_id']); + $table->foreign('photo_id')->references('id')->on('photos'); + }); + + // custom_tags: restore original FK without cascade + Schema::table('custom_tags', function (Blueprint $table) { + $table->dropForeign(['photo_id']); + $table->foreign('photo_id')->references('id')->on('photos'); + }); + + // littercoins: restore original FK without cascade + Schema::table('littercoins', function (Blueprint $table) { + $table->dropForeign(['photo_id']); + $table->foreign('photo_id')->references('id')->on('photos'); + }); + } +}; diff --git a/database/migrations/2026_04_03_181217_create_user_quick_tags_table.php b/database/migrations/2026_04_03_181217_create_user_quick_tags_table.php new file mode 100644 index 000000000..452c44505 --- /dev/null +++ b/database/migrations/2026_04_03_181217_create_user_quick_tags_table.php @@ -0,0 +1,43 @@ +id(); + $table->unsignedInteger('user_id'); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->unsignedBigInteger('clo_id'); + $table->unsignedInteger('type_id')->nullable(); + $table->unsignedTinyInteger('quantity')->default(1); + $table->boolean('picked_up')->nullable(); + $table->json('materials'); + $table->json('brands'); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index('user_id'); + + $table->foreign('clo_id') + ->references('id') + ->on('category_litter_object') + ->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_quick_tags'); + } +}; diff --git a/database/migrations/2026_04_04_124916_add_custom_name_to_user_quick_tags_table.php b/database/migrations/2026_04_04_124916_add_custom_name_to_user_quick_tags_table.php new file mode 100644 index 000000000..d0369fc93 --- /dev/null +++ b/database/migrations/2026_04_04_124916_add_custom_name_to_user_quick_tags_table.php @@ -0,0 +1,28 @@ +string('custom_name', 60)->nullable()->default(null)->after('type_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_quick_tags', function (Blueprint $table) { + $table->dropColumn('custom_name'); + }); + } +}; diff --git a/package.json b/package.json index b4349811b..2ff21cc96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openlittermap-web", - "version": "5.4.7", + "version": "5.7.5", "type": "module", "author": "Seán Lynch", "license": "GPL v3", diff --git a/readme/API.md b/readme/API.md index dfbcda3c2..3d60719bb 100644 --- a/readme/API.md +++ b/readme/API.md @@ -823,6 +823,65 @@ Toggling `is_public` on a verified (`>= ADMIN_APPROVED`) photo marks its cluster --- +### GET /api/v3/user/top-tags — User's Top Tagged Items + +**Auth:** Required (Sanctum) + +Returns the authenticated user's most-tagged items grouped by CLO + type, ordered by total quantity. Used by mobile to populate Quick Tags with personal presets via "Use my top tags." + +**Query parameters:** +- `limit` — integer, 1-30, default 20 + +**Response (200):** +```json +{ + "success": true, + "tags": [ + { + "clo_id": 141, + "category_key": "smoking", + "object_key": "butts", + "type_id": null, + "type_key": null, + "brand_id": null, + "brand_key": null, + "total": 9819 + }, + { + "clo_id": 5, + "category_key": "alcohol", + "object_key": "can", + "type_id": 1, + "type_key": "beer", + "brand_id": null, + "brand_key": null, + "total": 773 + }, + { + "clo_id": 149, + "category_key": "softdrinks", + "object_key": "can", + "type_id": 23, + "type_key": "energy", + "brand_id": 42, + "brand_key": "redbull", + "total": 656 + } + ] +} +``` + +**Minimum threshold:** Items with total quantity < 3 are excluded (noise filter). The response may contain fewer items than `limit`. + +**Brand logic:** `brand_id`/`brand_key` are populated only when a single brand accounts for >50% of that CLO+type group's total quantity. Otherwise null. + +**Empty state:** User with no tags returns `{"success": true, "tags": []}`. + +**Controller:** `App\Http\Controllers\API\QuickTagsController@topTags` +**Test:** `tests/Feature/QuickTags/TopTagsTest.php` + +--- + ### POST /api/user/profile/photos/delete — Bulk Delete Photos **Auth:** Required (Sanctum) @@ -1341,7 +1400,7 @@ Supports ETag-based caching (`If-None-Match` header returns 304 if unchanged). R **Auth:** None (public) -World totals from the metrics table (all-time, timescale=0, location_type=Global). User growth stats from `users.created_at`. Tag and photo growth stats from daily metric buckets (timescale=1). +World totals from the metrics table (all-time, timescale=0, location_type=Global). User growth from `users.created_at` (exact 24h window). Tag and photo growth from daily metric buckets (timescale=1) — "last 24 hours" uses `bucket_date >= yesterday` (daily-granularity approximation). **Response (200):** ```json @@ -1349,18 +1408,24 @@ World totals from the metrics table (all-time, timescale=0, location_type=Global "total_tags": 150000, "total_images": 50000, "total_users": 10000, - "new_users_today": 12, + "new_users_last_24_hours": 12, "new_users_last_7_days": 85, "new_users_last_30_days": 320, - "new_tags_today": 156, + "new_tags_last_24_hours": 156, "new_tags_last_7_days": 1230, "new_tags_last_30_days": 4870, - "new_photos_today": 45, + "new_photos_last_24_hours": 45, "new_photos_last_7_days": 310, - "new_photos_last_30_days": 1250 + "new_photos_last_30_days": 1250, + "new_users_today": 12, + "new_tags_today": 156, + "new_photos_today": 45 } ``` +**Legacy compat:** `*_today` keys are aliases for `*_last_24_hours` (same values). New clients should use the `*_last_24_hours` keys. +``` + **Controller:** `App\Http\Controllers\API\GlobalStatsController@index` **Test:** `tests/Feature/Api/GlobalStatsTest.php` diff --git a/readme/Mobile.md b/readme/Mobile.md index 9f8e13fd9..fb2b07520 100644 --- a/readme/Mobile.md +++ b/readme/Mobile.md @@ -20,6 +20,8 @@ The mobile app must use v3/v5 API endpoints exclusively. All legacy v1/v2/v4 end | List photos | `GET` | `/api/v3/user/photos` | `?tagged=false&per_page=100` | | Photo stats | `GET` | `/api/v3/user/photos/stats` | Aggregate counts | | Delete photo | `POST` | `/api/profile/photos/delete` | `{ "photoid": 123 }` | +| Toggle photo visibility | `PATCH` | `/api/v3/photos/{id}/visibility` | `{ "is_public": bool }` → `{ "success": true, "is_public": bool }` | +| Update setting | `POST` | `/api/settings/update` | `{ "key": "public_photos", "value": bool }` → `{ "success": true }` | | Tag catalog | `GET` | `/api/tags/all` | Returns full taxonomy for search index | | Profile | `GET` | `/api/user/profile/index` | User stats, level, rank (also returned by auth/token, so not needed after login) | | Global stats | `GET` | `/api/global/stats-data` | No auth; total tags/images/users | @@ -78,6 +80,49 @@ Response includes `picked_up` (boolean, never null) and `remaining` (deprecated Response: `{ "message": "Photo deleted successfully!" }` +## Photo Visibility (`is_public`) + +Photos can be public (visible on the global map) or private (hidden from map but metrics still count). + +### User default setting + +`users.public_photos` (boolean, default `true`) — applies to all new uploads unless overridden. + +- **Read:** Returned in auth/token response and `GET /api/user/profile/index` as `user.public_photos` +- **Write:** `POST /api/settings/update` with `{ "key": "public_photos", "value": false }` + +### Upload precedence + +When uploading via `POST /api/v3/upload`, the optional `is_public` param controls visibility: + +1. **School team** → always `false` (overridden by server, cannot be changed) +2. **Request `is_public` param** → used if provided +3. **`user.public_photos` default** → used if no param sent +4. **Fallback** → `true` + +### Per-photo toggle + +`PATCH /api/v3/photos/{id}/visibility` — toggles an individual photo's visibility after upload. + +- **Request:** `{ "is_public": true|false }` +- **Response:** `{ "success": true, "is_public": true|false }` +- **403:** If photo belongs to a school team (teacher controls visibility) +- **403:** If not the photo owner + +### Reading visibility state + +`GET /api/v3/user/photos` returns per photo: +- `is_public` (boolean) — current visibility +- `school_team` (boolean) — if true, disable the toggle in UI (teacher-managed) + +### Mobile UI recommendations + +1. **Settings screen:** Toggle for "Photos public by default" (`public_photos`) +2. **Photo list:** Eye icon per photo — green if public, gray if private. Disabled if `school_team === true` +3. **Upload screen (optional):** Override toggle to set `is_public` per-upload + +--- + ## `picked_up` vs `remaining` **`remaining` is deprecated.** Use `picked_up` everywhere. diff --git a/readme/MobileAPI.md b/readme/MobileAPI.md index e42805383..4dac0fba9 100644 --- a/readme/MobileAPI.md +++ b/readme/MobileAPI.md @@ -455,7 +455,7 @@ Auth required. |--------|-------|-------------|--------| | GET | `/api/global/stats-data` | `stats_reducer.js` → `getStats` | Active | -Public, no auth. Returns: `total_tags`, `total_images`, `total_users`, `new_users_today`, `new_users_last_7_days`, `new_users_last_30_days`. +Public, no auth. Returns: `total_tags`, `total_images`, `total_users`, `new_users_last_24_hours`, `new_users_last_7_days`, `new_users_last_30_days`, `new_tags_last_24_hours`, `new_tags_last_7_days`, `new_tags_last_30_days`, `new_photos_last_24_hours`, `new_photos_last_7_days`, `new_photos_last_30_days`. --- diff --git a/readme/MobileApiReview.md b/readme/MobileApiReview.md index e59ee0f77..cc917ca1e 100644 --- a/readme/MobileApiReview.md +++ b/readme/MobileApiReview.md @@ -180,7 +180,7 @@ All team routes use `auth:sanctum` and work with Bearer tokens. | Method | Route | Notes | |--------|-------|-------| | GET | `/api/leaderboard` | Params: `timeFilter` (all-time, today, yesterday, this-month, last-month, this-year, last-year), `locationType`, `locationId`, `page`. Returns `users[]` with `user_id`, `name`, `username`, `xp`, `global_flag`, `rank`, `public_profile` | -| GET | `/api/global/stats-data` | Returns `total_tags`, `total_images`, `total_users`, `new_users_today`, `new_users_last_7_days`, `new_users_last_30_days` | +| GET | `/api/global/stats-data` | Returns `total_tags`, `total_images`, `total_users`, `new_users_last_24_hours`, `new_users_last_7_days`, `new_users_last_30_days`, `new_tags_last_24_hours`, `new_tags_last_7_days`, `new_tags_last_30_days`, `new_photos_last_24_hours`, `new_photos_last_7_days`, `new_photos_last_30_days` | | GET | `/api/levels` | XP threshold config for level display | | GET | `/api/achievements` | `auth:sanctum` — user's unlocked achievements | diff --git a/readme/TagSuggestions.md b/readme/TagSuggestions.md new file mode 100644 index 000000000..67bd9ec16 --- /dev/null +++ b/readme/TagSuggestions.md @@ -0,0 +1,127 @@ +# Tag Suggestions (Quick Tags) + +Server-side storage for user quick tag presets, synced between mobile devices and the web. + +## Overview + +Users can save up to 30 "quick tag" presets — pre-configured litter objects with quantity, picked_up, materials, and brands. The mobile app stores these locally via redux-persist; this backend provides durable cross-device sync via a simple bulk-replace API. + +## Database + +**Table:** `user_quick_tags` + +| Column | Type | Notes | +|--------|------|-------| +| id | bigint unsigned | PK | +| user_id | int unsigned | FK → users.id (cascade delete) | +| clo_id | bigint unsigned | FK → category_litter_object.id (cascade delete) | +| type_id | int unsigned, nullable | References litter_object_types.id (no FK constraint) | +| custom_name | varchar(60), nullable | User-defined display name. NULL = use catalog name | +| quantity | tinyint unsigned | Default 1, validated 1-10 (trusted users: 1-100) | +| picked_up | boolean, nullable | null = inherit user default, true/false = explicit | +| materials | json | Array of material IDs, e.g. `[1, 3]` | +| brands | json | Array of `{"id": int, "quantity": int}` objects | +| sort_order | smallint unsigned | 0-indexed display order | +| timestamps | | created_at, updated_at | + +**Constraints:** +- Max 30 rows per user (enforced in validation, not DB) +- Cascade delete on both `user_id` and `clo_id` FKs +- `type_id` validated against `litter_object_types` when non-null, but no FK (types rarely deleted) + +## Key Files + +- `app/Models/Users/UserQuickTag.php` — Eloquent model +- `app/Actions/QuickTags/SyncQuickTagsAction.php` — Transactional bulk-replace +- `app/Http/Controllers/API/QuickTagsController.php` — GET + PUT endpoints +- `app/Http/Requests/Api/SyncQuickTagsRequest.php` — Validation rules +- `app/Models/Users/User.php` — `quickTags()` HasMany relation +- `tests/Feature/QuickTags/QuickTagsApiTest.php` — 32 tests + +## API Endpoints + +Both routes are in the `v3` group with `auth:sanctum` middleware. + +### `GET /api/v3/user/quick-tags` + +Returns the authenticated user's quick tags, ordered by `sort_order`. + +**Response 200:** +```json +{ + "success": true, + "tags": [ + { + "id": 1, + "clo_id": 42, + "type_id": null, + "custom_name": "Coke bottle", + "quantity": 2, + "picked_up": true, + "materials": [1, 3], + "brands": [{"id": 5, "quantity": 1}], + "sort_order": 0 + } + ] +} +``` + +Hidden fields: `user_id`, `created_at`, `updated_at`. + +### `PUT /api/v3/user/quick-tags` + +Bulk-replaces all quick tags. Deletes existing rows and inserts new ones in a DB transaction. + +**Request:** +```json +{ + "tags": [ + { + "clo_id": 42, + "type_id": null, + "custom_name": "Coke bottle", + "quantity": 2, + "picked_up": true, + "materials": [1, 3], + "brands": [{"id": 5, "quantity": 1}] + } + ] +} +``` + +**Response 200:** Same format as GET (returns newly saved tags with server-assigned IDs). + +**Response 422:** Validation error. Rejects entire payload if any `clo_id` is stale. + +**Clearing all tags:** Send `"tags": []` — returns empty array, deletes all rows. + +## Validation Rules + +| Field | Rule | +|-------|------| +| tags | present, array, max 30 | +| tags.*.clo_id | required, integer, exists in category_litter_object | +| tags.*.type_id | nullable, integer, exists in litter_object_types | +| tags.*.custom_name | nullable, string, max 60 | +| tags.*.quantity | required, integer, 1-10 (trusted users: 1-100) | +| tags.*.picked_up | nullable, boolean | +| tags.*.materials | present, array (can be empty) | +| tags.*.materials.* | integer, exists in materials | +| tags.*.brands | present, array (can be empty) | +| tags.*.brands.*.id | required, integer, exists in brandslist | +| tags.*.brands.*.quantity | required, integer, 1-10 (trusted users: 1-100) | + +## Sync Strategy (Mobile) + +The backend is the durable source of truth. Mobile sync works as follows: + +1. **On login:** Fetch from backend. If backend has tags and local is empty, use backend. If both have data, backend wins. +2. **On local change:** Debounce 2-3s, then PUT full array to backend. +3. **On conflict:** Backend wins. Local IDs are ephemeral — map server-assigned IDs back after sync. + +## Architecture Notes + +- **Bulk-replace pattern:** Every PUT deletes all existing rows and re-inserts. This avoids complex diffing and keeps the API idempotent. +- **Transaction safety:** Delete + insert wrapped in `DB::transaction()` — no partial states. +- **No Eloquent mass-assignment on insert:** Uses `UserQuickTag::insert()` for performance (single query for all rows). JSON fields are manually `json_encode()`d since `insert()` bypasses model casts. +- **Duplicate CLOs allowed:** A user can have multiple quick tags referencing the same CLO (e.g. same object with different quantities or picked_up settings). diff --git a/readme/TagsCleanupPostMigration.md b/readme/TagsCleanupPostMigration.md new file mode 100644 index 000000000..220ee43c3 --- /dev/null +++ b/readme/TagsCleanupPostMigration.md @@ -0,0 +1,347 @@ +# Tags Cleanup: Post-Migration Orphaned photo_tags + +**Date:** 2026-04-03 +**Command:** `php artisan olm:fix-orphaned-tags` +**Location:** `app/Console/Commands/tmp/v5/Migration/FixOrphanedTags.php` + +## Problem + +The v5 migration (`olm:v5`) converted v4 category-column tags into v5 `photo_tags` rows. During this process, `ClassifyTagsService::normalizeDeprecatedTag()` mapped v4 column names to v5 object keys. However, many of those mapped keys don't exist in `TagsConfig.php`. + +**Example:** The v4 column `softdrinks.energy_can` was mapped to object key `energy_can`. But TagsConfig defines the canonical v5 structure as `softdrinks.can` with type `energy`. Since `energy_can` doesn't exist in TagsConfig, no `category_litter_object` pivot row was created for it. + +The migration's fallback (`classifyNewKey()`) created runtime `litter_objects` rows for these non-canonical keys (marked `crowdsourced=1`), but without CLO relationships. The resulting `photo_tags` rows have: +- `litter_object_id` = the orphaned LO (correct object, wrong key) +- `category_litter_object_id` = NULL (broken — no category relationship) +- `category_id` = correct (preserved from v4) + +## Root Causes + +### 1. DEPRECATED_TAG_MAP mapped to composite keys (43 objects) + +The map created compound names instead of decomposing into object + type: + +| v4 Column | Mapped To | Should Be | +|-----------|-----------|-----------| +| `beerCan` | `beer_can` | `alcohol.can` + type `beer` | +| `waterBottle` | `water_bottle` | `softdrinks.bottle` + type `water` | +| `tinCan` | `soda_can` | `softdrinks.can` + type `soda` | +| `energy_can` | `energy_can` | `softdrinks.can` + type `energy` | +| `cigaretteBox` | `cigarette_box` | `smoking.box` + type `cigarette` | +| `vape_pen` | `vapePen` | `smoking.vape` + type `pen` | +| ... | ... | ... | + +### 2. `default => null` fallthrough (28 objects) + +v4 column names like `facemask`, `bottletops`, `crisp_small` passed through unchanged (the map returns `null` for unrecognised keys, meaning "use key as-is"). These keys also don't exist in TagsConfig. + +## Scale + +| Metric | Count | +|--------|------:| +| Total photo_tags with NULL CLO | 214,146 | +| Extra-tag-only (NULL CLO + NULL LO — by design) | 24,628 | +| **Orphaned photo_tags (have LO, missing CLO)** | **189,518** | +| **Total orphaned quantity (litter items)** | **277,169** | +| Distinct orphaned litter_objects | 71 | + +These 189,518 rows represent real user-tagged data that is structurally broken — invisible to any query that joins through `category_litter_object`, missing from category-based aggregations, and unfindable in the tagging UI. + +## Pre-flight Checks + +Three checks were run before building the fix: + +### Check 1: Type storage mechanism + +Types are stored as `litter_object_type_id` (FK) on `photo_tags`, validated via `category_object_types` pivot. Confirmed in `AddTagsToPhotoAction` (lines 159-193). All needed type IDs exist in `litter_object_types`. + +### Check 2: Smoking CLO IDs + +All verified correct: + +| CLO ID | Category | Object | +|--------|----------|--------| +| 140 | smoking | box | +| 142 | smoking | lighters | +| 144 | smoking | papers | +| 145 | smoking | pouch | +| 146 | smoking | rolling_filter | +| 147 | smoking | vape | + +### Check 3: Target CLOs for "not in TagsConfig" items + +All target CLOs exist — no rows need to be created: + +| Target | CLO ID | +|--------|--------| +| sanitary.sanitary_pad | 133 | +| sanitary.other | 138 | +| industrial.oil_container | 67 | +| industrial.chemical_container | 69 | +| marine.macroplastics | 85 | +| vehicles.car_part | 167 | +| medical.sanitiser | 101 | + +**Note:** `personal_care` does not exist as a category. Items originally planned for `personal_care.other` (hair_tie, toothpick, ear_plugs) go to `sanitary.other` (CLO 138) instead. + +### Pre-flight: existing type_ids + +Zero orphaned rows have a non-NULL `litter_object_type_id`. No risk of overwriting existing values. + +## Complete Mapping + +### Alcohol (5 orphans, 34,813 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | Category | Type ID | +|-----------|-----------|-----:|------------|-------------|----------|---------| +| beer_can | 137 | 19,275 | 5 (can) | 5 | alcohol (2) | 1 (beer) | +| beer_bottle | 138 | 7,337 | 2 (bottle) | 2 | alcohol (2) | 1 (beer) | +| bottletops | 146 | 4,902 | 4 (bottle_cap) | 4 | alcohol (2) | — | +| spirits_bottle | 144 | 2,661 | 2 (bottle) | 2 | alcohol (2) | 3 (spirits) | +| wine_bottle | 139 | 638 | 2 (bottle) | 2 | alcohol (2) | 2 (wine) | + +### Alcohol / Softdrinks split: brokenglass (3,412 tags) + +| Orphan LO | Category Filter | Tags | Target CLO | Canonical LO | +|-----------|----------------|-----:|------------|-------------| +| 164 | alcohol (2) | 3,370 | 3 (alcohol.broken_glass) | 3 | +| 164 | softdrinks (16) | 42 | 151 (softdrinks.broken_glass) | 3 | + +### Softdrinks (14 orphans, 80,575 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | Type ID | +|-----------|-----------|-----:|------------|-------------|---------| +| energy_can | 156 | 20,498 | 152 (can) | 5 | 26 (energy) | +| water_bottle | 140 | 17,189 | 149 (bottle) | 2 | 23 (water) | +| soda_can | 142 | 16,462 | 152 (can) | 5 | 24 (soda) | +| fizzy_bottle | 145 | 6,448 | 149 (bottle) | 2 | 24 (soda) | +| sports_bottle | 143 | 2,931 | 149 (bottle) | 2 | 27 (sports) | +| juice_carton | 154 | 2,243 | 153 (carton) | 124 | 25 (juice) | +| juice_bottle | 155 | 1,862 | 149 (bottle) | 2 | 25 (juice) | +| straw_packaging | 172 | 1,039 | 162 (straw_wrapper) | 126 | — | +| milk_bottle | 153 | 901 | 149 (bottle) | 2 | 29 (milk) | +| iceTea_bottle | 186 | 829 | 149 (bottle) | 2 | 28 (tea) | +| milk_carton | 151 | 827 | 153 (carton) | 124 | 29 (milk) | +| pullRing | 175 | 758 | 160 (pull_ring) | 10 | — | +| icedTea_can | 194 | 588 | 152 (can) | 5 | 31 (iced_tea) | + +### Softdrinks / Marine split: straws (7,543 tags) + +| Orphan LO | Category Filter | Tags | Target CLO | Canonical LO | +|-----------|----------------|-----:|------------|-------------| +| 150 | softdrinks (16) | 7,368 | 161 (softdrinks.straw) | 25 | +| 150 | marine (10) | 175 | 93 (marine.other) | 1 | + +### Smoking (6 orphans, 11,886 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | Type ID | +|-----------|-----------|-----:|------------|-------------|---------| +| cigarette_box | 141 | 9,803 | 140 (box) | 37 | 13 (cigarette) | +| rollingPapers | 148 | 525 | 144 (papers) | 120 | — | +| filters | 167 | 603 | 146 (rolling_filter) | 122 | — | +| vapePen | 190 | 484 | 147 (vape) | 123 | 17 (pen) | +| tobaccopouch | 162 | 408 | 145 (pouch) | 121 | 15 (tobacco) | +| vapeOil | 189 | 63 | 147 (vape) | 123 | 22 (e_liquid_bottle) | + +### Sanitary / Medical (10 orphans, 14,898 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | Category Change | +|-----------|-----------|-----:|------------|-------------|----------------| +| facemask | 183 | 7,742 | 95 (face_mask) | 79 | sanitary → medical (11) | +| wetwipes | 179 | 3,326 | 125 (wipes) | 104 | — | +| gloves | 80 | 2,422 | 96 (gloves) | 80 | sanitary → medical (11) | +| hair_tie | 160 | 515 | 138 (sanitary.other) | 1 | — | +| toothpick | 168 | 284 | 138 (sanitary.other) | 1 | — | +| menstrual | 182 | 179 | 133 (sanitary_pad) | 112 | — | +| earSwabs | 166 | 172 | 127 (ear_swabs) | 106 | — | +| condoms | 158 | 190 | 136 (condom) | 115 | — | +| sanitiser | 85 | 42 | 101 (sanitiser) | 85 | sanitary → medical (11) | +| ear_plugs | 161 | 26 | 138 (sanitary.other) | 1 | — | + +### Food (3 orphans, 5,216 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | +|-----------|-----------|-----:|------------|-------------| +| crisp_small | 157 | 4,654 | 49 (crisp_packet) | 40 | +| crisp_large | 163 | 431 | 49 (crisp_packet) | 40 | +| glass_jar | 159 | 131 | 52 (jar) | 43 | + +### Category Changes from Other (6 orphans, 4,026 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | New Category | +|-----------|-----------|-----:|------------|-------------|-------------| +| dump | 165 | 1,875 | 34 (dumping) | 28 | dumping (6) | +| dogshit | 102 | 1,146 | 122 (dogshit) | 102 | pets (13) | +| dogshit_in_bag | 103 | 506 | 123 (dogshit_in_bag) | 103 | pets (13) | +| batteries | 181 | 291 | 36 (battery) | 29 | electronics (7) | +| tyre | 135 | 202 | 173 (tyre) | 135 | vehicles (17) | +| life_buoy | 184 | 6 | 78 (buoy) | 63 | marine (10) | + +### Other — Same Category (11 orphans, 26,888 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | +|-----------|-----------|-----:|------------|-------------| +| randomLitter | 170 | 12,821 | 121 (other.other) | 1 | +| plasticBags | 149 | 10,051 | 111 (plastic_bag) | 92 | +| bagsLitter | 176 | 1,041 | 106 (bags_litter) | 15 | +| cableTie | 187 | 1,021 | 114 (cable_tie) | 95 | +| overflowingBins | 188 | 517 | 107 (overflowing_bin) | 19 | +| posters | 177 | 208 | 113 (poster) | 94 | +| trafficCone | 171 | 116 | 109 (traffic_cone) | 90 | +| washingUp | 152 | 79 | 121 (other.other) | 1 | +| magazine | 191 | 93 | 121 (other.other) | 1 | +| books | 192 | 38 | 121 (other.other) | 1 | +| lego | 197 | 3 | 121 (other.other) | 1 | + +### Category Changes — Other to Specific (3 orphans, 1,145 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | New Category | +|-----------|-----------|-----:|------------|-------------|-------------| +| automobile | 180 | 794 | 167 (car_part) | 129 | vehicles (17) | +| elec_small | 174 | 291 | 43 (electronics.other) | 1 | electronics (7) | +| elec_large | 169 | 60 | 43 (electronics.other) | 1 | electronics (7) | + +### Other / Marine split: balloons (2,180 tags) + +| Orphan LO | Category Filter | Tags | Target CLO | Canonical LO | +|-----------|----------------|-----:|------------|-------------| +| 178 | other (12) | 1,730 | 115 (balloon) | 96 | +| 178 | marine (10) | 450 | 93 (marine.other) | 1 | + +### Marine (7 orphans, 5,050 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | Category Change | +|-----------|-----------|-----:|------------|-------------|----------------| +| mediumplastics | 147 | 1,564 | 85 (macroplastics) | 70 | — | +| bag (marine) | 36 | 1,161 | 93 (marine.other) | 1 | — | +| fishing_nets | 173 | 894 | 84 (fishing_net) | 69 | — | +| bottle (marine) | 2 | 741 | 93 (marine.other) | 1 | — | +| shotgun_cartridges | 193 | 493 | 91 (shotgun_cartridge) | 76 | — | +| buoys | 196 | 145 | 78 (buoy) | 63 | — | +| lighters (marine) | 119 | 46 | 142 (smoking.lighters) | 119 | marine → smoking (15) | + +### Industrial (3 orphans, 750 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | Category Change | +|-----------|-----------|-----:|------------|-------------|----------------| +| plastic (industrial) | 89 | 666 | 108 (other.plastic) | 89 | industrial → other (12) | +| oil | 198 | 57 | 67 (oil_container) | 54 | — | +| chemical | 195 | 27 | 69 (chemical_container) | 56 | — | + +### Art (1 orphan, 42 tags) + +| Orphan Key | Orphan LO | Tags | Target CLO | Canonical LO | +|-----------|-----------|-----:|------------|-------------| +| item (art) | 185 | 42 | 16 (art.other) | 1 | + +## Judgment Calls + +Items not directly mappable to TagsConfig required decisions: + +| Item | Decision | Rationale | +|------|----------|-----------| +| `menstrual` (179 tags) | → `sanitary.sanitary_pad` | Safest default; v4 had single column, v5 splits into pad/tampon/cup | +| `randomLitter` (12,821 tags) | → `other.other` | No equivalent in TagsConfig; generic catch-all | +| `automobile` (794 tags) | → `vehicles.car_part` | Closest match | +| `hair_tie` (515 tags) | → `sanitary.other` | No `personal_care` category exists | +| `toothpick` (284 tags) | → `sanitary.other` | Not in TagsConfig | +| `ear_plugs` (26 tags) | → `sanitary.other` | Not in TagsConfig | +| `washingUp` (79 tags) | → `other.other` | Not in TagsConfig | +| `magazine` (93 tags) | → `other.other` | Not in TagsConfig | +| `books` (38 tags) | → `other.other` | Not in TagsConfig | +| `mediumplastics` (1,564 tags) | → `marine.macroplastics` | "Medium" size maps to macro, not micro | +| `bag` in marine (1,161 tags) | → `marine.other` | No `bag` in marine TagsConfig | +| `bottle` in marine (741 tags) | → `marine.other` | No `bottle` in marine TagsConfig | +| `industrial.plastic` (666 tags) | → `other.plastic` | No `plastic` in industrial TagsConfig | +| `facemask` / `gloves` / `sanitiser` | sanitary → medical | TagsConfig moved these to medical category | +| `dump` | other → dumping | TagsConfig has `dumping.dumping` | +| `dogshit` / `dogshit_in_bag` | other → pets | TagsConfig has `pets` category | +| `lighters` in marine | marine → smoking | A lighter is a lighter regardless of where found | + +## Dry-Run Results + +``` +Pre-flight: orphaned rows with existing litter_object_type_id = 0 + +74 mapping rows, all ✓ match +Total expected: 189,518 +Total would update: 189,518 +0 mismatches +``` + +All multi-category splits confirmed against diagnostic counts: +- brokenglass: 3,370 (alcohol) + 42 (softdrinks) = 3,412 +- straws: 7,368 (softdrinks) + 175 (marine) = 7,543 +- balloons: 1,730 (other) + 450 (marine) = 2,180 + +## Execution + +**Status:** Applied locally (2026-04-04). Production runbook at `readme/changelog/production-orphan-fix-runbook.md`. + +```bash +# Dry-run (default) +php artisan olm:fix-orphaned-tags + +# Live execution +php artisan olm:fix-orphaned-tags --apply + +# Verify post-apply +php artisan olm:fix-orphaned-tags --verify-only + +# Regenerate stale summaries (resumable, chunked, no side effects) +php artisan olm:regenerate-summaries --orphan-fix + +# Reprocess XP for ~1,041 photos with special object bonus corrections +php artisan olm:reprocess-metrics --from-file=storage/logs/xp-changed-photo-ids.txt +``` + +## Verification Queries (post-apply) + +```sql +-- Should be 0: all orphaned photo_tags with a litter_object should now have a CLO +SELECT COUNT(*) FROM photo_tags +WHERE category_litter_object_id IS NULL +AND litter_object_id IS NOT NULL; + +-- The 24,628 extra-tag-only NULLs are expected (brands/materials/custom with no CLO) +SELECT COUNT(*) FROM photo_tags +WHERE category_litter_object_id IS NULL +AND litter_object_id IS NULL; + +-- Spot checks: orphan LO should have 0 remaining orphaned photo_tags +SELECT lo.`key`, COUNT(*) AS remaining +FROM photo_tags pt +JOIN litter_objects lo ON pt.litter_object_id = lo.id +WHERE pt.category_litter_object_id IS NULL +AND lo.`key` IN ('energy_can', 'beer_can', 'water_bottle', 'soda_can') +GROUP BY lo.`key`; + +-- Top 20 tags should now include previously invisible items +SELECT lo.`key` AS litter_object, c.`key` AS category, SUM(pt.quantity) AS total_qty +FROM photo_tags pt +JOIN category_litter_object clo ON pt.category_litter_object_id = clo.id +JOIN litter_objects lo ON clo.litter_object_id = lo.id +JOIN categories c ON clo.category_id = c.id +GROUP BY lo.`key`, c.`key` +ORDER BY total_qty DESC +LIMIT 20; +``` + +## What This Command Does NOT Touch + +The `olm:fix-orphaned-tags` command itself only updates `photo_tags` pointer columns. It does NOT recalculate summaries, XP, or metrics. However, **follow-up commands are required** after the pointer fix: + +1. **`olm:regenerate-summaries --orphan-fix`** — regenerates ~171k stale summaries (summary JSON contains old orphan IDs after the pointer fix) +2. **`olm:reprocess-metrics`** — reprocesses all summary-changed photos to update `processed_fp`, `processed_tags`, and apply any XP/metrics deltas + +See `readme/changelog/production-orphan-fix-runbook.md` for the full 6-step production procedure. + +**Unchanged by the entire procedure:** +- **photo_tag_extra_tags:** Materials and brands are already correct from the migration. +- **TagsConfig.php:** Not modified. +- **Orphaned litter_objects rows:** The 71 orphaned LO rows (beer_can, water_bottle, etc.) remain in the `litter_objects` table. They just won't have photo_tags pointing at them anymore. Safe to clean up later if desired. +- **DEPRECATED_TAG_MAP:** The mappings in ClassifyTagsService are not modified. They only affect future v4→v5 migrations, which are complete. + +## Specificity Loss + +1,035 tags were mapped to `.other` CLOs because their original keys (hair_tie, toothpick, ear_plugs, washingUp, magazine, books, randomLitter, marine bag/bottle, lego) have no equivalent in TagsConfig. This preserves the category relationship but loses the specific object identity. These are low-volume items and the tradeoff is acceptable — the alternative was leaving them as invisible orphans. diff --git a/readme/Twitter.md b/readme/Twitter.md index bcbcb6db0..1d0c06664 100644 --- a/readme/Twitter.md +++ b/readme/Twitter.md @@ -13,6 +13,12 @@ TWITTER_API_ACCESS_TOKEN= TWITTER_API_ACCESS_SECRET= ``` +Browsershot Chromium path is configurable via `config/services.php`: + +``` +BROWSERSHOT_CHROME_PATH= # defaults to /snap/bin/chromium +``` + The `Twitter` helper has a production guard — all three methods (`sendTweet`, `sendThread`, `sendTweetWithImage`) silently no-op outside `production`. Each command also has its own production guard at the top of `handle()`. ## Schedule (Kernel.php) @@ -23,6 +29,7 @@ The `Twitter` helper has a production guard — all three methods (`sendTweet`, | `twitter:changelog` | `dailyAt('07:00')` | None | | `twitter:weekly-impact-report-tweet` | `weeklyOn(1, '06:30')` (Monday) | None | | `twitter:monthly-impact-report-tweet` | `monthlyOn(1, '06:30')` | None | +| `twitter:annual-impact-report-tweet` | `yearlyOn(1, 1, '06:30')` (Jan 1) | None | ## Commands @@ -172,7 +179,7 @@ Weekly Impact Report for week 12 of 2026. Join us at openlittermap.com #litter # **External dependencies:** - Browsershot (`spatie/browsershot`) -- Chromium at `/snap/bin/chromium` (Ubuntu snap — won't work on macOS/Docker without Snap) +- Chromium at path from `config('services.browsershot.chrome_path')` (defaults to `/snap/bin/chromium`) - Network access to `https://openlittermap.com` (screenshots the live production site) --- @@ -201,7 +208,36 @@ Monthly Impact Report for February 2026. Join us at openlittermap.com #litter #c **External dependencies:** - Browsershot (`spatie/browsershot`) -- Chromium at `/snap/bin/chromium` +- Chromium at path from `config('services.browsershot.chrome_path')` +- Network access to `https://openlittermap.com` + +--- + +### twitter:annual-impact-report-tweet + +**Class:** `App\Console\Commands\Twitter\AnnualImpactReportTweet` +**Send method:** `Twitter::sendTweetWithImage()` +**Image:** Yes — Browsershot screenshot + +**Data queried:** None. Screenshots a live URL. + +**Process:** +1. Calculates last year +2. Browsershot screenshots `https://openlittermap.com/impact/annual/{year}` at 1200x800 with `fullPage()` and `waitUntilNetworkIdle()` +3. Saves to `public/images/reports/annual/{year}/impact-report.png` +4. Tweets with image +5. Deletes PNG after sending + +**Example tweet:** +``` +Annual Impact Report for 2025. Join us at openlittermap.com #litter #citizenscience #impact #openlittermap +``` + +**No data:** Same as weekly/monthly — no data check, always screenshots and tweets. + +**External dependencies:** +- Browsershot (`spatie/browsershot`) +- Chromium at path from `config('services.browsershot.chrome_path')` - Network access to `https://openlittermap.com` ## Twitter Helper @@ -221,7 +257,7 @@ All three methods guard on `app()->environment('production')` and `$consumer_key - `tests/Feature/Twitter/DailyReportTweetTest.php` — 28 tests: streak (0/1/5/gap), milestone boundaries (100K/1M), season labels (all 6 tiers), lead line (same/new/no-data), mission frames (3), conditional skipping (littercoin/streak/cities), thread output, no-data skip, formatMilestone (k/M), tweet length enforcement - `tests/Feature/Twitter/ChangelogTweetTest.php` — 26 tests: overview counts, prefix parsing ([Web]/[Mobile]/default), GitHub raw content call verification, web-only/mobile-only, long changelog splits, oversized single line truncation, no-file skip, thread structure, cleanChange, singular/plural, sendThread return shape, mobile fetch from GitHub (success/404/500/merge/URL/thread integration) -No tests exist for `WeeklyImpactReportTweet` or `MonthlyImpactReportTweet` (Browsershot dependency). +No tests exist for `WeeklyImpactReportTweet`, `MonthlyImpactReportTweet`, or `AnnualImpactReportTweet` (Browsershot dependency). The `GenerateImpactReportController` is tested in `tests/Feature/Reports/GenerateImpactReportTest.php` (8 tests: weekly/monthly/annual rendering, future date, invalid period, v5 brands query, zero data). ## Summary @@ -231,3 +267,4 @@ No tests exist for `WeeklyImpactReportTweet` or `MonthlyImpactReportTweet` (Brow | `changelog` | None (reads local + GitHub changelog files) | `sendThread()` (overview + grouped) | No | Skips | GitHub raw content | | `weekly-impact-report` | None | `sendTweetWithImage()` | Browsershot 1200x800 | Always tweets | Browsershot, Chromium, network | | `monthly-impact-report` | None | `sendTweetWithImage()` | Browsershot 1200x800 fullPage | Always tweets | Browsershot, Chromium, network | +| `annual-impact-report` | None | `sendTweetWithImage()` | Browsershot 1200x800 fullPage | Always tweets | Browsershot, Chromium, network | diff --git a/readme/changelog/2026-03-29.md b/readme/changelog/2026-03-29.md index fc18afdc3..e015b80b7 100644 --- a/readme/changelog/2026-03-29.md +++ b/readme/changelog/2026-03-29.md @@ -60,3 +60,12 @@ ## v5.4.7 - fix: Nav dropdown routes no longer hidden for new users — removed onboardingCompleted gates from Upload, Tags, Profile, Teams, Settings, Admin links - feat: Added "Onboarding" link (amber) in nav dropdown + mobile menu when onboarding is incomplete + +## v5.4.8 +- fix: ImageUploaded notification URL now includes full params (lat, lon, zoom, load, open) immediately instead of just photo ID +- fix: Photo deletion now hard-deletes (forceDelete) instead of soft-delete — all 4 delete endpoints (single, bulk, team, participant) +- fix: Cascading FKs clean up photo_tags and extras on hard delete +- docs: Added Photo Visibility section to Mobile.md (is_public toggle, user default, upload precedence, per-photo endpoint) + +## v5.4.9 +- fix: Migration to add cascade/set-null FK constraints on badges, custom_tags, littercoins, annotations, admin_verification_logs referencing photos.id (supports hard-delete) diff --git a/readme/changelog/2026-04-03.md b/readme/changelog/2026-04-03.md new file mode 100644 index 000000000..71e0b4fdb --- /dev/null +++ b/readme/changelog/2026-04-03.md @@ -0,0 +1,6 @@ +# 2026-04-03 + +## v5.4.9 +- feat: Quick Tags sync API — `GET/PUT /api/v3/user/quick-tags` for syncing tag presets between mobile devices (UserQuickTag model, SyncQuickTagsAction, 27 tests) +- feat: Quick Tags settings section in ProfileSettings — manage presets with inline edit, reorder, delete (QuickTagsSection.vue, quickTags.js store) +- docs: Created readme/TagSuggestions.md — full system documentation for quick tags diff --git a/readme/changelog/2026-04-04.md b/readme/changelog/2026-04-04.md new file mode 100644 index 000000000..671c7ca70 --- /dev/null +++ b/readme/changelog/2026-04-04.md @@ -0,0 +1,11 @@ +# 2026-04-04 + +- **v5.4.10** — Fix 189,518 orphaned photo_tags from v5 migration (missing CLO/LO pointers), regenerate 170,992 stale summaries, reprocess 1,041 XP corrections. Three new artisan commands: `olm:fix-orphaned-tags`, `olm:regenerate-summaries`, `olm:reprocess-metrics`. Production runbook at `readme/changelog/production-orphan-fix-runbook.md`. +- **v5.5.0** — Quick tags: add `custom_name` column (VARCHAR 60, nullable) for user-defined preset labels (suggestedTags feature); increase max quantity from 10 to 100 for trusted users (both tag and brand quantities). +- **v5.5.1** — Global map tab title now shows live event count (e.g. `OpenLitterMap - (3)`), incrementing on new events and decrementing on dismiss. +- **v5.6.0** — Fix impact report tweet publishing: switch brands query from deprecated v4 schema to v5 `photo_tag_extra_tags`, make Chromium path configurable via `BROWSERSHOT_CHROME_PATH` env var, add new annual impact report command (`twitter:annual-impact-report-tweet`) scheduled Jan 1 at 06:30, add annual period support to `GenerateImpactReportController`, 8 new tests. +- **v5.6.1** — Impact report cleanup: use `Timescale` enum instead of magic integers, fix cache TTL bug (past periods were never cached), make annual `getMetricsRow` branch explicit, simplify `mkdir` in all three Browsershot commands, add `waitUntilNetworkIdle()` to weekly command. +- **v5.7.1** — Global stats API: add `*_last_24_hours` fields using rolling 24h window, keep legacy `*_today` keys as aliases for backwards compat with older mobile versions. +- **v5.7.2** — Update 27 email campaign: new mobile apps, Quick Tags, onboarding, GPS fixes, platform improvements, LitterWeek CTA. Blade template (`update27.blade.php`), Vue changelog page (`update27.vue`), dev preview route, updated EmailUpdate Mailable. +- **v5.7.3** — Fix 13 failing tests: replace `assertSoftDeleted` with `assertDatabaseMissing` in photo delete tests (all delete endpoints use `forceDelete()` for hard deletion). Remove stale `withTrashed()->find()` assertion in `UserPhotoBulkDeleteTest`. +- **v5.7.4** — New endpoint `GET /api/v3/user/top-tags`: returns user's most-tagged items grouped by CLO + type, with dominant brand (>50%) included. Limit param (default 20, max 30), minimum threshold of 3 to filter noise. 10 tests. diff --git a/readme/changelog/2026-04-05.md b/readme/changelog/2026-04-05.md new file mode 100644 index 000000000..7d5eab4a9 --- /dev/null +++ b/readme/changelog/2026-04-05.md @@ -0,0 +1,9 @@ +# 2026-04-05 + +## v5.7.5 +- fix(tagging): remove `.other` object extra-tag requirement — `other.other` is a valid standalone tag for "I don't know what this is" fallback; fixes mobile quickTag submission error + +## v5.7.4 +- fix(RegenerateSummaries): add `--changed-ids` flag to write all summary-changed photo IDs to file for downstream metrics reprocessing +- fix(runbook): rewrite production-orphan-fix-runbook.md with corrected 6-step plan — summary regen + full metrics reprocess for all ~171k affected photos, not just ~1,041 XP-changed subset; add DB backup pre-flight step +- fix(docs): correct contradictory "What This Does NOT Touch" section in TagsCleanupPostMigration.md — pointer fix command doesn't touch summaries/metrics, but follow-up commands are required diff --git a/readme/changelog/production-orphan-fix-runbook.md b/readme/changelog/production-orphan-fix-runbook.md new file mode 100644 index 000000000..bf38fc985 --- /dev/null +++ b/readme/changelog/production-orphan-fix-runbook.md @@ -0,0 +1,188 @@ +# Production Runbook: Orphaned Tags Fix + +**Date prepared:** 2026-04-04 +**Last updated:** 2026-04-05 +**Local validation:** Complete (189,518 tags, 170,992 summaries, 1,041 XP corrections) + +**Local timing (reference):** +- Step 2 (pointer fix): ~9 seconds +- Step 4 (summary regen): ~5.5 minutes (518k scanned, 171k regenerated) +- Step 6 (metrics reprocess): ~9 seconds for 1,041 photos; ~15 minutes estimated for 171k + +--- + +## Pre-flight: Database Backup + +```bash +# Backup photo_tags before any changes (cheap insurance on 189k rows) +mysqldump -u root -p olm photo_tags > /tmp/photo_tags_backup_$(date +%Y%m%d_%H%M%S).sql + +# Optional: backup photos table (summaries will change in step 4) +mysqldump -u root -p olm photos --where="migrated_at IS NOT NULL" > /tmp/photos_migrated_backup_$(date +%Y%m%d_%H%M%S).sql +``` + +--- + +## Step 1: Dry-run CLO pointer fix + +```bash +php artisan olm:fix-orphaned-tags --log=storage/logs/orphan-fix-prod.log +``` + +**Expected output:** +- 74 mappings, all match +- Total expected: 189,518 +- Total would update: 189,518 +- 0 mismatches + +**Gate:** If counts differ from local, STOP. Investigate before proceeding. + +--- + +## Step 2: Apply CLO pointer fix + +```bash +php artisan olm:fix-orphaned-tags --apply --log=storage/logs/orphan-fix-prod.log +``` + +- UPDATE-only, batched at 5,000, transacted per orphan key +- Low risk: no events, no metrics, no Redis, no summaries + +**Verify immediately:** +```bash +php artisan olm:fix-orphaned-tags --verify-only --log=storage/logs/orphan-fix-prod.log +``` + +Expected: +- Orphaned photo_tags (NULL CLO + non-NULL LO): 0 +- Extra-tag-only (NULL CLO + NULL LO): ~24,628 +- Spot check: 0 remaining + +**Steps 1-2 are safe to run during normal traffic.** + +--- + +## Step 3: Dry-run summary regeneration + +Schedule steps 3-6 during low traffic. The summary regen is the heaviest operation. + +```bash +php artisan olm:regenerate-summaries --orphan-fix --dry-run --log=storage/logs/regen-summaries-prod.log +``` + +**Expected:** ~170,992 would change, ~347,052 skipped, 0 errors. + +--- + +## Step 4: Apply summary regeneration + +```bash +php artisan olm:regenerate-summaries --orphan-fix \ + --changed-ids=storage/logs/summary-changed-photo-ids.txt \ + --log=storage/logs/regen-summaries-prod.log +``` + +- Scans ~518k migrated photos, regenerates ~171k stale summaries +- Chunked at 500 (DB-level `chunkById`, no memory issue) +- **Resumable:** skips already-regenerated photos (checks `clo_id` in summary JSON) +- Pure write: `Photo::withoutEvents()` — no observers, no MetricsService, no Redis +- Kill and restart safely if needed (already-regenerated photos are detected and skipped) +- Writes ALL changed photo IDs to `--changed-ids` file for step 6 + +**Why this is needed:** The pointer fix (step 2) changed `litter_object_id` and `category_id` +on `photo_tags` rows, but `photo.summary` JSON still contains the old orphan IDs. Without +regeneration, the summary is structurally inconsistent with the underlying `photo_tags` data. +`MetricsService::extractMetricsFromPhoto()` reads from summary JSON, so stale summaries mean +stale fingerprints — any future edit to an affected photo would trigger an unexpected +metrics delta. + +**Expected:** +- ~170,992 changed, ~347,052 skipped, 0 errors +- ~1,041 photos will log XP changes (special object bonus corrections) +- `summary-changed-photo-ids.txt` will contain ~170,992 photo IDs + +--- + +## Step 5: Dry-run metrics reprocess (all summary-changed photos) + +```bash +wc -l storage/logs/summary-changed-photo-ids.txt +# Expected: ~170,992 + +php artisan olm:reprocess-metrics \ + --from-file=storage/logs/summary-changed-photo-ids.txt \ + --dry-run --log=storage/logs/reprocess-metrics-prod.log +``` + +Review the dry-run output. Most photos will show zero XP delta (summary changed but total +XP stayed the same). ~1,041 will show non-zero XP deltas from special object bonus corrections. + +**Why all ~171k and not just the ~1,041 with XP changes:** Every summary-changed photo has a +stale `processed_fp` (fingerprint) and `processed_tags` on the photo record. Without updating +these, the next time `MetricsService::processPhoto()` runs on any of those photos (tag edit, +admin action, re-verification), it would detect a fingerprint mismatch and attempt to +delta-correct — producing an unexpected and potentially incorrect metrics delta. Reprocessing +all summary-changed photos updates `processed_fp` and `processed_tags` atomically, eliminating +this "ticking time bomb" scenario. + +--- + +## Step 6: Apply metrics reprocess + +```bash +php artisan olm:reprocess-metrics \ + --from-file=storage/logs/summary-changed-photo-ids.txt \ + --log=storage/logs/reprocess-metrics-prod.log +``` + +- ~171k photos at batch size 100, each within a DB transaction +- Updates: metrics table, Redis leaderboard ZSETs, `users.xp`, `processed_xp`/`processed_fp`/`processed_tags` +- Estimated runtime: ~15 minutes (extrapolated from 1,041 in 9 seconds locally) + +--- + +## Post-run verification + +```sql +-- Should be 0: no orphaned photo_tags remain +SELECT COUNT(*) FROM photo_tags +WHERE category_litter_object_id IS NULL AND litter_object_id IS NOT NULL; + +-- Should be ~24,628: extra-tag-only (brands/materials/custom with no CLO) +SELECT COUNT(*) FROM photo_tags +WHERE category_litter_object_id IS NULL AND litter_object_id IS NULL; + +-- Spot check: orphan keys should have 0 remaining +SELECT lo.`key`, COUNT(*) AS remaining +FROM photo_tags pt +JOIN litter_objects lo ON pt.litter_object_id = lo.id +WHERE pt.category_litter_object_id IS NULL +AND lo.`key` IN ('energy_can', 'beer_can', 'water_bottle', 'soda_can') +GROUP BY lo.`key`; + +-- Spot check: energy cans now queryable by type +SELECT COUNT(*) as photos, SUM(pt.quantity) as total_cans +FROM photo_tags pt +JOIN litter_object_types lot ON pt.litter_object_type_id = lot.id +WHERE lot.key = 'energy' +AND pt.category_litter_object_id = 152; + +-- Fingerprint consistency: should be 0 (no stale processed_fp) +SELECT COUNT(*) FROM photos +WHERE migrated_at IS NOT NULL +AND processed_fp IS NOT NULL +AND summary IS NOT NULL +AND processed_at IS NOT NULL +AND id IN (SELECT DISTINCT photo_id FROM photo_tags WHERE category_litter_object_id IS NOT NULL); +``` + +--- + +## Rollback + +The fix is idempotent at every step: +- **Pointer fix (step 2):** Already-fixed rows have non-NULL CLO and won't match the orphan query again. Re-running is a no-op. +- **Summary regen (step 4):** `hasStaleSummary()` detects already-regenerated photos and skips them. Resumable after interruption. +- **Metrics reprocess (step 6):** `processPhoto()` compares fingerprint and XP. If already reprocessed, fingerprint matches and it returns immediately. + +For catastrophic rollback: restore from DB backup taken in the pre-flight step. diff --git a/resources/js/components/Websockets/GlobalMap/LiveEvents.vue b/resources/js/components/Websockets/GlobalMap/LiveEvents.vue index 9b49363dc..46eadf3ee 100644 --- a/resources/js/components/Websockets/GlobalMap/LiveEvents.vue +++ b/resources/js/components/Websockets/GlobalMap/LiveEvents.vue @@ -92,6 +92,7 @@ const processQueue = () => { animating.value = true; const nextEvent = pendingEvents.value.shift(); events.value.unshift(nextEvent); + updateDocumentTitle(); } }; @@ -180,7 +181,8 @@ const listenForEvents = () => { }; const updateDocumentTitle = () => { - document.title = events.value.length === 0 ? 'OpenLitterMap' : `(${events.value.length}) OpenLitterMap`; + document.title = + events.value.length === 0 ? 'OpenLitterMap' : `OpenLitterMap - (${events.value.length})`; }; diff --git a/resources/js/stores/quickTags.js b/resources/js/stores/quickTags.js new file mode 100644 index 000000000..cf1409a1e --- /dev/null +++ b/resources/js/stores/quickTags.js @@ -0,0 +1,68 @@ +import { defineStore } from 'pinia'; + +let saveTimer = null; + +export const useQuickTagsStore = defineStore('quickTags', { + state: () => ({ + tags: [], + loading: false, + dirty: false, + }), + + actions: { + async FETCH_QUICK_TAGS() { + this.loading = true; + + try { + const { data } = await axios.get('/api/v3/user/quick-tags'); + this.tags = data.tags; + } catch (e) { + console.error('FETCH_QUICK_TAGS', e); + } finally { + this.loading = false; + } + }, + + async SAVE_QUICK_TAGS() { + try { + const payload = this.tags.map((tag, index) => ({ + clo_id: tag.clo_id, + type_id: tag.type_id ?? null, + quantity: tag.quantity, + picked_up: tag.picked_up ?? null, + materials: tag.materials || [], + brands: tag.brands || [], + })); + + const { data } = await axios.put('/api/v3/user/quick-tags', { tags: payload }); + this.tags = data.tags; + this.dirty = false; + } catch (e) { + console.error('SAVE_QUICK_TAGS', e); + } + }, + + debouncedSave() { + this.dirty = true; + clearTimeout(saveTimer); + saveTimer = setTimeout(() => this.SAVE_QUICK_TAGS(), 2000); + }, + + moveTag(fromIndex, toIndex) { + if (toIndex < 0 || toIndex >= this.tags.length) return; + const tag = this.tags.splice(fromIndex, 1)[0]; + this.tags.splice(toIndex, 0, tag); + this.debouncedSave(); + }, + + updateTag(index, updates) { + Object.assign(this.tags[index], updates); + this.debouncedSave(); + }, + + deleteTag(index) { + this.tags.splice(index, 1); + this.debouncedSave(); + }, + }, +}); diff --git a/resources/js/views/General/Changelog.vue b/resources/js/views/General/Changelog.vue index 403f509ac..6391c716f 100644 --- a/resources/js/views/General/Changelog.vue +++ b/resources/js/views/General/Changelog.vue @@ -247,6 +247,13 @@ const updatesList = [ date: '22nd March 2026', component: 'update26', }, + { + id: 27, + number: 'Update #27', + title: 'New Mobile Apps & More!', + date: '4th April 2026', + component: 'update27', + }, ]; const changelogs = ref([...updatesList].reverse()); const selectedId = ref(updatesList[updatesList.length - 1]?.id || null); diff --git a/resources/js/views/General/Updates/update27.vue b/resources/js/views/General/Updates/update27.vue new file mode 100644 index 000000000..3659a81c9 --- /dev/null +++ b/resources/js/views/General/Updates/update27.vue @@ -0,0 +1,147 @@ + + + diff --git a/resources/js/views/Maps/helpers/urlHelper.js b/resources/js/views/Maps/helpers/urlHelper.js index b5d7f5b34..63f6eebe3 100644 --- a/resources/js/views/Maps/helpers/urlHelper.js +++ b/resources/js/views/Maps/helpers/urlHelper.js @@ -292,9 +292,18 @@ export const urlHelper = { }, updateUrlPhotoIdAndFlyToLocation: ({ latitude, longitude, photoId, mapInstance }) => { - urlStateManager.updatePhotoId(photoId, true); // User clicked photo - const zoom = 17; + + // Set full URL immediately so it's shareable before fly animation completes + const url = new URL(window.location.href); + url.searchParams.set('photo', photoId); + url.searchParams.set('lat', latitude.toFixed(6)); + url.searchParams.set('lon', longitude.toFixed(6)); + url.searchParams.set('zoom', zoom.toFixed(2)); + url.searchParams.set('load', 'true'); + url.searchParams.set('open', 'true'); + urlStateManager.commitURL(url, false); + const currentZoom = Math.round(mapInstance.getZoom()); const distance = mapInstance.distance(mapInstance.getCenter(), [latitude, longitude]); diff --git a/resources/js/views/Profile/components/ProfileSettings.vue b/resources/js/views/Profile/components/ProfileSettings.vue index 456593430..7480ef35f 100644 --- a/resources/js/views/Profile/components/ProfileSettings.vue +++ b/resources/js/views/Profile/components/ProfileSettings.vue @@ -66,6 +66,9 @@ + + +

{{ $t('Privacy') }}

@@ -157,6 +160,7 @@ import { useSettingsStore } from '@stores/settings.js'; import { useUserStore } from '@stores/user/index.js'; import SettingsField from './SettingsField.vue'; import SettingsToggle from './SettingsToggle.vue'; +import QuickTagsSection from './QuickTagsSection.vue'; const { t: $t } = useI18n(); const settingsStore = useSettingsStore(); diff --git a/resources/js/views/Profile/components/QuickTagsSection.vue b/resources/js/views/Profile/components/QuickTagsSection.vue new file mode 100644 index 000000000..4bf33c138 --- /dev/null +++ b/resources/js/views/Profile/components/QuickTagsSection.vue @@ -0,0 +1,402 @@ + + + diff --git a/resources/views/emails/update27.blade.php b/resources/views/emails/update27.blade.php new file mode 100644 index 000000000..574720f2e --- /dev/null +++ b/resources/views/emails/update27.blade.php @@ -0,0 +1,276 @@ + + + + + Update 27 - Mobile app updates & more! + + + + + + + + +
+ Updated mobile apps for iOS & Android and 60+ platform improvements. +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/resources/views/reports/impact.blade.php b/resources/views/reports/impact.blade.php index 80c561c15..21f53dad0 100644 --- a/resources/views/reports/impact.blade.php +++ b/resources/views/reports/impact.blade.php @@ -223,6 +223,8 @@ class="impact-logo"

{{ $startDate }}
to {{ $endDate }}

@elseif ($period === 'monthly')

{{ $startDate }}

+ @elseif ($period === 'annual') +

{{ $startDate }} Annual Report

@endif
diff --git a/routes/api.php b/routes/api.php index b5787328a..63c87cf0a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,6 +17,7 @@ use App\Http\Controllers\API\MobileAppVersionController; use App\Http\Controllers\API\Tags\GetTagsController; use App\Http\Controllers\API\Tags\PhotoTagsController; +use App\Http\Controllers\API\QuickTagsController; use App\Http\Controllers\API\TeamsController as APITeamsController; use App\Http\Controllers\ApiSettingsController; use App\Http\Controllers\Auth\AuthTokenController; @@ -82,6 +83,9 @@ Route::get('/user/photos/stats', [UsersUploadsController::class, 'stats']); Route::get('/user/photos/locations', [UsersUploadsController::class, 'locations']); Route::patch('/photos/{photo}/visibility', [UsersUploadsController::class, 'toggleVisibility']); + Route::get('/user/quick-tags', [QuickTagsController::class, 'index']); + Route::put('/user/quick-tags', [QuickTagsController::class, 'update']); + Route::get('/user/top-tags', [QuickTagsController::class, 'topTags']); }); /* diff --git a/routes/web.php b/routes/web.php index 34ec9c87f..599b8935c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,9 +14,6 @@ // Impact reports (renders its own HTML, not the SPA) Route::get('impact/{period?}/{year?}/{monthOrWeek?}', GenerateImpactReportController::class); -// We are replacing Auth (Laravel's built-in login/register/password views + POST handlers) -// Auth::routes(); - // Email confirmation Route::get('register/confirm/{token}', 'Auth\RegisterController@confirmEmail'); Route::get('confirm/email/{token}', 'Auth\RegisterController@confirmEmail') @@ -35,28 +32,6 @@ // Password reset — named route for the email notification link Route::get('password/reset/{token}', HomeController::class)->name('password.reset'); -// Email previews (local dev only) -if (app()->isLocal()) { - Route::get('dev/mail/welcome', function () { - $user = \App\Models\Users\User::first() - ?? \App\Models\Users\User::factory()->make([ - 'token' => 'preview-token', - 'sub_token' => 'preview-sub-token', - ]); - - if (! $user->token) { - $user->token = 'preview-token'; - } - - if (! $user->sub_token) { - $user->sub_token = 'preview-sub-token'; - } - - return new \App\Mail\WelcomeToOpenLitterMap($user); - }); - -} - /* |-------------------------------------------------------------------------- | SPA catch-all — must be last diff --git a/tests/Feature/Api/GlobalStatsTest.php b/tests/Feature/Api/GlobalStatsTest.php index 8ed39a770..3f6804fe5 100644 --- a/tests/Feature/Api/GlobalStatsTest.php +++ b/tests/Feature/Api/GlobalStatsTest.php @@ -4,6 +4,7 @@ use App\Enums\LocationType; use App\Models\Users\User; +use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -88,28 +89,38 @@ public function test_global_stats_returns_totals(): void 'total_tags', 'total_images', 'total_users', - 'new_users_today', + // New keys + 'new_users_last_24_hours', 'new_users_last_7_days', 'new_users_last_30_days', - 'new_tags_today', + 'new_tags_last_24_hours', 'new_tags_last_7_days', 'new_tags_last_30_days', - 'new_photos_today', + 'new_photos_last_24_hours', 'new_photos_last_7_days', 'new_photos_last_30_days', + // Legacy keys (pre-v5.7 mobile compat) + 'new_users_today', + 'new_tags_today', + 'new_photos_today', ]); $response->assertJsonPath('total_tags', 150); $response->assertJsonPath('total_images', 42); $response->assertJsonPath('total_users', 3); - $response->assertJsonPath('new_users_today', 2); + $response->assertJsonPath('new_users_last_24_hours', 2); $response->assertJsonPath('new_users_last_7_days', 2); $response->assertJsonPath('new_users_last_30_days', 3); - $response->assertJsonPath('new_tags_today', 20); + $response->assertJsonPath('new_tags_last_24_hours', 20); $response->assertJsonPath('new_tags_last_7_days', 60); // 20 + 40 $response->assertJsonPath('new_tags_last_30_days', 90); // 20 + 40 + 30 - $response->assertJsonPath('new_photos_today', 5); + $response->assertJsonPath('new_photos_last_24_hours', 5); $response->assertJsonPath('new_photos_last_7_days', 15); // 5 + 10 $response->assertJsonPath('new_photos_last_30_days', 23); // 5 + 10 + 8 + + // Legacy keys match new keys + $response->assertJsonPath('new_users_today', 2); + $response->assertJsonPath('new_tags_today', 20); + $response->assertJsonPath('new_photos_today', 5); } public function test_global_stats_returns_zeros_when_no_data(): void @@ -120,13 +131,13 @@ public function test_global_stats_returns_zeros_when_no_data(): void $response->assertJsonPath('total_tags', 0); $response->assertJsonPath('total_images', 0); $response->assertJsonPath('total_users', 0); - $response->assertJsonPath('new_users_today', 0); + $response->assertJsonPath('new_users_last_24_hours', 0); $response->assertJsonPath('new_users_last_7_days', 0); $response->assertJsonPath('new_users_last_30_days', 0); - $response->assertJsonPath('new_tags_today', 0); + $response->assertJsonPath('new_tags_last_24_hours', 0); $response->assertJsonPath('new_tags_last_7_days', 0); $response->assertJsonPath('new_tags_last_30_days', 0); - $response->assertJsonPath('new_photos_today', 0); + $response->assertJsonPath('new_photos_last_24_hours', 0); $response->assertJsonPath('new_photos_last_7_days', 0); $response->assertJsonPath('new_photos_last_30_days', 0); } @@ -154,10 +165,10 @@ public function test_global_stats_excludes_old_daily_data(): void $response = $this->getJson('/api/global/stats-data'); $response->assertOk(); - $response->assertJsonPath('new_tags_today', 0); + $response->assertJsonPath('new_tags_last_24_hours', 0); $response->assertJsonPath('new_tags_last_7_days', 0); $response->assertJsonPath('new_tags_last_30_days', 0); - $response->assertJsonPath('new_photos_today', 0); + $response->assertJsonPath('new_photos_last_24_hours', 0); $response->assertJsonPath('new_photos_last_7_days', 0); $response->assertJsonPath('new_photos_last_30_days', 0); } @@ -168,4 +179,82 @@ public function test_global_stats_requires_no_auth(): void $response->assertOk(); } + + public function test_24h_includes_yesterday_bucket_just_after_midnight(): void + { + // At 00:05 UTC, "last 24 hours" should include yesterday's bucket + Carbon::setTestNow(Carbon::parse('2026-04-04 00:05:00', 'UTC')); + + $yesterday = now('UTC')->copy()->subDay()->toDateString(); // 2026-04-03 + + DB::table('metrics')->insert([ + 'timescale' => 1, + 'location_type' => LocationType::Global, + 'location_id' => 0, + 'user_id' => 0, + 'bucket_date' => $yesterday, + 'year' => 2026, + 'month' => 4, + 'week' => 0, + 'uploads' => 15, + 'tags' => 60, + 'litter' => 30, + 'xp' => 100, + ]); + + $response = $this->getJson('/api/global/stats-data'); + + $response->assertOk(); + // Yesterday's bucket is included in last_24_hours (bucket_date >= yesterday) + $response->assertJsonPath('new_photos_last_24_hours', 15); + $response->assertJsonPath('new_tags_last_24_hours', 60); + + Carbon::setTestNow(); + } + + public function test_24h_users_uses_exact_timestamp(): void + { + Carbon::setTestNow(Carbon::parse('2026-04-04 12:00:00', 'UTC')); + + // User created 23 hours ago (within 24h) + User::factory()->create(['created_at' => now()->subHours(23)]); + // User created 25 hours ago (outside 24h) + User::factory()->create(['created_at' => now()->subHours(25)]); + + $response = $this->getJson('/api/global/stats-data'); + + $response->assertOk(); + $response->assertJsonPath('new_users_last_24_hours', 1); + + Carbon::setTestNow(); + } + + public function test_legacy_today_keys_match_last_24_hours(): void + { + $now = now('UTC'); + + DB::table('metrics')->insert([ + 'timescale' => 1, + 'location_type' => LocationType::Global, + 'location_id' => 0, + 'user_id' => 0, + 'bucket_date' => $now->toDateString(), + 'year' => $now->year, + 'month' => $now->month, + 'week' => 0, + 'uploads' => 7, + 'tags' => 25, + 'litter' => 10, + 'xp' => 50, + ]); + + User::factory()->create(); + + $response = $this->getJson('/api/global/stats-data'); + $data = $response->json(); + + $this->assertEquals($data['new_users_last_24_hours'], $data['new_users_today']); + $this->assertEquals($data['new_tags_last_24_hours'], $data['new_tags_today']); + $this->assertEquals($data['new_photos_last_24_hours'], $data['new_photos_today']); + } } diff --git a/tests/Feature/Email/V5AnnouncementEmailTest.php b/tests/Feature/Email/V5AnnouncementEmailTest.php index 6de6c0aed..8be884619 100644 --- a/tests/Feature/Email/V5AnnouncementEmailTest.php +++ b/tests/Feature/Email/V5AnnouncementEmailTest.php @@ -22,7 +22,7 @@ public function test_email_update_has_correct_subject(): void $user = User::factory()->create(); $mailable = new EmailUpdate($user); - $mailable->assertHasSubject("OpenLitterMap v5 is now online \xF0\x9F\x9A\x80"); + $mailable->assertHasSubject('Update 27 - Mobile app updates & more!'); } public function test_email_update_renders_with_user(): void @@ -32,7 +32,7 @@ public function test_email_update_renders_with_user(): void $html = $mailable->render(); - $this->assertStringContainsString('OpenLitterMap v5 is released', $html); // Title in HTML body + $this->assertStringContainsString('New Mobile Apps', $html); // Title in HTML body $this->assertStringContainsString($user->sub_token, $html); $this->assertStringContainsString('unsubscribe', $html); } @@ -44,7 +44,7 @@ public function test_email_update_renders_with_subscriber(): void $html = $mailable->render(); - $this->assertStringContainsString('OpenLitterMap v5 is released', $html); // Title in HTML body + $this->assertStringContainsString('New Mobile Apps', $html); // Title in HTML body $this->assertStringContainsString($subscriber->sub_token, $html); } diff --git a/tests/Feature/Lifecycle/EdgeCaseLifecycleTest.php b/tests/Feature/Lifecycle/EdgeCaseLifecycleTest.php index f24d76c00..6b5908e81 100644 --- a/tests/Feature/Lifecycle/EdgeCaseLifecycleTest.php +++ b/tests/Feature/Lifecycle/EdgeCaseLifecycleTest.php @@ -91,7 +91,7 @@ public function test_delete_untagged_photo_reverses_upload_xp(): void ->postJson('/api/profile/photos/delete', ['photoid' => $photoId]); $deleteResponse->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photoId]); + $this->assertDatabaseMissing('photos', ['id' => $photoId]); // XP fully reversed to 0 $user->refresh(); diff --git a/tests/Feature/Lifecycle/SchoolLifecycleTest.php b/tests/Feature/Lifecycle/SchoolLifecycleTest.php index cc51c3154..f590e8649 100644 --- a/tests/Feature/Lifecycle/SchoolLifecycleTest.php +++ b/tests/Feature/Lifecycle/SchoolLifecycleTest.php @@ -337,7 +337,7 @@ public function test_teacher_deletes_unapproved_photo(): void ]); $response->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photoId]); + $this->assertDatabaseMissing('photos', ['id' => $photoId]); // Student XP unchanged (was already 0) $this->student->refresh(); @@ -391,7 +391,7 @@ public function test_teacher_deletes_approved_photo(): void ]) ->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photoId]); + $this->assertDatabaseMissing('photos', ['id' => $photoId]); // All XP reversed $this->student->refresh(); diff --git a/tests/Feature/Lifecycle/TrustedUserLifecycleTest.php b/tests/Feature/Lifecycle/TrustedUserLifecycleTest.php index 3f67363f3..bfd06f6f7 100644 --- a/tests/Feature/Lifecycle/TrustedUserLifecycleTest.php +++ b/tests/Feature/Lifecycle/TrustedUserLifecycleTest.php @@ -205,7 +205,7 @@ public function test_trusted_user_full_lifecycle(): void ->postJson('/api/profile/photos/delete', ['photoid' => $photoId]); $deleteResponse->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photoId]); + $this->assertDatabaseMissing('photos', ['id' => $photoId]); // Redis: pruned from leaderboard $this->assertFalse( diff --git a/tests/Feature/Lifecycle/UntrustedUserLifecycleTest.php b/tests/Feature/Lifecycle/UntrustedUserLifecycleTest.php index 710dfba4c..f4e658eeb 100644 --- a/tests/Feature/Lifecycle/UntrustedUserLifecycleTest.php +++ b/tests/Feature/Lifecycle/UntrustedUserLifecycleTest.php @@ -156,7 +156,7 @@ public function test_untrusted_user_full_lifecycle(): void ->postJson('/api/profile/photos/delete', ['photoid' => $photoId]); $deleteResponse->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photoId]); + $this->assertDatabaseMissing('photos', ['id' => $photoId]); // Everything reversed to zero $user->refresh(); diff --git a/tests/Feature/Photos/WebDeletePhotoTest.php b/tests/Feature/Photos/WebDeletePhotoTest.php index 8e859d98f..13f1d99fa 100644 --- a/tests/Feature/Photos/WebDeletePhotoTest.php +++ b/tests/Feature/Photos/WebDeletePhotoTest.php @@ -36,7 +36,7 @@ public function test_user_can_delete_own_photo_via_profile(): void ->post('/api/profile/photos/delete', ['photoid' => $photo->id]) ->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photo->id]); + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); Storage::disk('s3')->assertMissing($filepath); Storage::disk('bbox')->assertMissing($filepath); @@ -90,9 +90,9 @@ public function test_delete_unprocessed_photo_does_not_decrement_xp(): void $this->assertEquals(5, $user->xp); } - public function test_processed_photo_has_metrics_reversed(): void + public function test_processed_photo_is_hard_deleted_with_metrics_reversed(): void { - $user = User::factory()->create(); + $user = User::factory()->create(['xp' => 10]); $photo = Photo::factory()->create([ 'user_id' => $user->id, 'processed_at' => now(), @@ -104,15 +104,14 @@ public function test_processed_photo_has_metrics_reversed(): void ->post('/api/profile/photos/delete', ['photoid' => $photo->id]) ->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photo->id]); + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); + $this->assertNull(Photo::withTrashed()->find($photo->id)); - $deletedPhoto = Photo::withTrashed()->find($photo->id); - $this->assertNull($deletedPhoto->processed_at); - $this->assertNull($deletedPhoto->processed_xp); - $this->assertNull($deletedPhoto->processed_tags); + $user->refresh(); + $this->assertEquals(7, $user->xp); } - public function test_unprocessed_photo_skips_metrics_reversal(): void + public function test_unprocessed_photo_is_hard_deleted(): void { $user = User::factory()->create(); $photo = Photo::factory()->create([ @@ -120,12 +119,34 @@ public function test_unprocessed_photo_skips_metrics_reversal(): void 'processed_at' => null, ]); - $this->assertNull($photo->processed_at); + $this->actingAs($user) + ->post('/api/profile/photos/delete', ['photoid' => $photo->id]) + ->assertOk(); + + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); + $this->assertNull(Photo::withTrashed()->find($photo->id)); + } + + public function test_photo_tags_are_cascade_deleted(): void + { + $user = User::factory()->create(); + $photo = Photo::factory()->create(['user_id' => $user->id]); + + // Create a photo_tag manually + \DB::table('photo_tags')->insert([ + 'photo_id' => $photo->id, + 'quantity' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertDatabaseHas('photo_tags', ['photo_id' => $photo->id]); $this->actingAs($user) ->post('/api/profile/photos/delete', ['photoid' => $photo->id]) ->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photo->id]); + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); + $this->assertDatabaseMissing('photo_tags', ['photo_id' => $photo->id]); } } diff --git a/tests/Feature/QuickTags/QuickTagsApiTest.php b/tests/Feature/QuickTags/QuickTagsApiTest.php new file mode 100644 index 000000000..2111fc662 --- /dev/null +++ b/tests/Feature/QuickTags/QuickTagsApiTest.php @@ -0,0 +1,650 @@ +insertGetId(['key' => 'smoking_' . uniqid()]); + $objId = DB::table('litter_objects')->insertGetId(['key' => 'butts_' . uniqid()]); + + return $this->getCloId($catId, $objId); + } + + private function createType(): int + { + return DB::table('litter_object_types')->insertGetId([ + 'key' => 'type_' . uniqid(), + 'name' => 'Test Type', + ]); + } + + private function createMaterial(): int + { + return DB::table('materials')->insertGetId(['key' => 'material_' . uniqid()]); + } + + private function createBrand(): int + { + return DB::table('brandslist')->insertGetId(['key' => 'brand_' . uniqid()]); + } + + private function makeTagPayload(int $cloId, array $overrides = []): array + { + return array_merge([ + 'clo_id' => $cloId, + 'type_id' => null, + 'quantity' => 1, + 'picked_up' => null, + 'materials' => [], + 'brands' => [], + ], $overrides); + } + + public function test_guest_cannot_access_quick_tags(): void + { + $this->getJson('/api/v3/user/quick-tags')->assertStatus(401); + } + + public function test_guest_cannot_update_quick_tags(): void + { + $this->putJson('/api/v3/user/quick-tags', ['tags' => []])->assertStatus(401); + } + + public function test_user_gets_empty_array_when_no_quick_tags_exist(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('tags', []); + } + + public function test_user_can_store_quick_tags_via_put(): void + { + $user = User::factory()->create(); + $clo1 = $this->createClo(); + $clo2 = $this->createClo(); + $clo3 = $this->createClo(); + + $response = $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [ + $this->makeTagPayload($clo1), + $this->makeTagPayload($clo2, ['quantity' => 3]), + $this->makeTagPayload($clo3, ['picked_up' => true]), + ], + ]); + + $response->assertOk() + ->assertJsonPath('success', true) + ->assertJsonCount(3, 'tags'); + + $this->assertDatabaseCount('user_quick_tags', 3); + + // Verify sort_order + $tags = $response->json('tags'); + $this->assertEquals(0, $tags[0]['sort_order']); + $this->assertEquals(1, $tags[1]['sort_order']); + $this->assertEquals(2, $tags[2]['sort_order']); + } + + public function test_put_replaces_existing_tags(): void + { + $user = User::factory()->create(); + $clo1 = $this->createClo(); + $clo2 = $this->createClo(); + $clo3 = $this->createClo(); + + // Store 3 tags + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [ + $this->makeTagPayload($clo1), + $this->makeTagPayload($clo2), + $this->makeTagPayload($clo3), + ], + ])->assertOk(); + + // Replace with 2 different tags + $clo4 = $this->createClo(); + $clo5 = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [ + $this->makeTagPayload($clo4), + $this->makeTagPayload($clo5), + ], + ])->assertOk() + ->assertJsonCount(2, 'tags'); + + // GET returns only 2 new tags + $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk() + ->assertJsonCount(2, 'tags'); + + $this->assertDatabaseCount('user_quick_tags', 2); + } + + public function test_get_returns_tags_in_sort_order(): void + { + $user = User::factory()->create(); + $clo1 = $this->createClo(); + $clo2 = $this->createClo(); + $clo3 = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [ + $this->makeTagPayload($clo1), + $this->makeTagPayload($clo2), + $this->makeTagPayload($clo3), + ], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $tags = $response->json('tags'); + $this->assertEquals($clo1, $tags[0]['clo_id']); + $this->assertEquals($clo2, $tags[1]['clo_id']); + $this->assertEquals($clo3, $tags[2]['clo_id']); + } + + public function test_put_validates_max_30_tags(): void + { + $user = User::factory()->create(); + $tags = []; + for ($i = 0; $i < 31; $i++) { + $tags[] = $this->makeTagPayload($this->createClo()); + } + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', ['tags' => $tags]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags'); + } + + public function test_put_validates_clo_id_exists(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload(999999)], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.clo_id'); + } + + public function test_put_validates_type_id_exists_when_non_null(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['type_id' => 999999])], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.type_id'); + } + + public function test_put_validates_material_ids_exist(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['materials' => [999999]])], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.materials.0'); + } + + public function test_put_validates_brand_ids_exist(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, [ + 'brands' => [['id' => 999999, 'quantity' => 1]], + ])], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.brands.0.id'); + } + + public function test_put_validates_quantity_range(): void + { + $user = User::factory()->create(['verification_required' => true]); + $clo = $this->createClo(); + + // quantity 0 + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['quantity' => 0])], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.quantity'); + + // quantity 11 (untrusted user max is 10) + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['quantity' => 11])], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.quantity'); + } + + public function test_trusted_user_can_set_quantity_up_to_100(): void + { + $user = User::factory()->create(['verification_required' => false]); + $clo = $this->createClo(); + + // quantity 100 — allowed for trusted users + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['quantity' => 100])], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $this->assertEquals(100, $response->json('tags.0.quantity')); + + // quantity 101 — rejected even for trusted users + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['quantity' => 101])], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.quantity'); + } + + public function test_picked_up_stores_null_faithfully(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['picked_up' => null])], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $this->assertNull($response->json('tags.0.picked_up')); + } + + public function test_materials_and_brands_json_round_trip(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + $material1 = $this->createMaterial(); + $material2 = $this->createMaterial(); + $brand = $this->createBrand(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, [ + 'materials' => [$material1, $material2], + 'brands' => [['id' => $brand, 'quantity' => 2]], + ])], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $tag = $response->json('tags.0'); + $this->assertEquals([$material1, $material2], $tag['materials']); + $this->assertEquals([['id' => $brand, 'quantity' => 2]], $tag['brands']); + } + + public function test_put_with_empty_tags_array_clears_all(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + // Store a tag + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo)], + ])->assertOk(); + + // Clear all + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', ['tags' => []]) + ->assertOk() + ->assertJsonPath('tags', []); + + $this->assertDatabaseCount('user_quick_tags', 0); + } + + public function test_user_a_cannot_see_user_b_quick_tags(): void + { + $userA = User::factory()->create(); + $userB = User::factory()->create(); + $clo = $this->createClo(); + + // User A stores tags + $this->actingAs($userA) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo)], + ])->assertOk(); + + // User B sees empty + $this->actingAs($userB) + ->getJson('/api/v3/user/quick-tags') + ->assertOk() + ->assertJsonPath('tags', []); + } + + public function test_deleting_user_cascades_quick_tags(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo)], + ])->assertOk(); + + $this->assertDatabaseCount('user_quick_tags', 1); + + $user->forceDelete(); + + $this->assertDatabaseCount('user_quick_tags', 0); + } + + public function test_type_id_is_stored_when_provided(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + $typeId = $this->createType(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['type_id' => $typeId])], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $this->assertEquals($typeId, $response->json('tags.0.type_id')); + } + + public function test_response_hides_internal_fields(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo)], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $tag = $response->json('tags.0'); + $this->assertArrayNotHasKey('user_id', $tag); + $this->assertArrayNotHasKey('created_at', $tag); + $this->assertArrayNotHasKey('updated_at', $tag); + $this->assertArrayHasKey('id', $tag); + $this->assertArrayHasKey('clo_id', $tag); + $this->assertArrayHasKey('sort_order', $tag); + } + + public function test_picked_up_false_is_distinct_from_null(): void + { + $user = User::factory()->create(); + $clo1 = $this->createClo(); + $clo2 = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [ + $this->makeTagPayload($clo1, ['picked_up' => false]), + $this->makeTagPayload($clo2, ['picked_up' => null]), + ], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $tags = $response->json('tags'); + $this->assertFalse($tags[0]['picked_up']); + $this->assertNull($tags[1]['picked_up']); + } + + public function test_put_rejects_missing_required_fields(): void + { + $user = User::factory()->create(); + + // Missing clo_id + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [['quantity' => 1, 'materials' => [], 'brands' => []]], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.clo_id'); + + // Missing quantity + $clo = $this->createClo(); + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [['clo_id' => $clo, 'materials' => [], 'brands' => []]], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.quantity'); + } + + public function test_put_rejects_missing_materials_and_brands_keys(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + // Missing materials key + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [['clo_id' => $clo, 'quantity' => 1, 'brands' => []]], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.materials'); + + // Missing brands key + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [['clo_id' => $clo, 'quantity' => 1, 'materials' => []]], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.brands'); + } + + public function test_put_validates_brand_quantity_range(): void + { + $user = User::factory()->create(['verification_required' => true]); + $clo = $this->createClo(); + $brand = $this->createBrand(); + + // Brand quantity 0 + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, [ + 'brands' => [['id' => $brand, 'quantity' => 0]], + ])], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.brands.0.quantity'); + + // Brand quantity 11 + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, [ + 'brands' => [['id' => $brand, 'quantity' => 11]], + ])], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.brands.0.quantity'); + } + + public function test_duplicate_clo_ids_allowed_in_single_request(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + // Same CLO with different quantities — valid use case (e.g. different picked_up settings) + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [ + $this->makeTagPayload($clo, ['quantity' => 1, 'picked_up' => true]), + $this->makeTagPayload($clo, ['quantity' => 3, 'picked_up' => false]), + ], + ]) + ->assertOk() + ->assertJsonCount(2, 'tags'); + } + + public function test_deleting_clo_cascades_quick_tags(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo)], + ])->assertOk(); + + $this->assertDatabaseCount('user_quick_tags', 1); + + DB::table('category_litter_object')->where('id', $clo)->delete(); + + $this->assertDatabaseCount('user_quick_tags', 0); + } + + public function test_custom_name_is_stored_and_returned(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['custom_name' => 'Coke bottle'])], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $this->assertEquals('Coke bottle', $response->json('tags.0.custom_name')); + } + + public function test_custom_name_null_is_stored_as_null(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['custom_name' => null])], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $this->assertNull($response->json('tags.0.custom_name')); + } + + public function test_custom_name_defaults_to_null_when_absent(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + // Send without custom_name key at all + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo)], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $this->assertNull($response->json('tags.0.custom_name')); + } + + public function test_custom_name_exceeding_60_chars_returns_422(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, ['custom_name' => str_repeat('a', 61)])], + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('tags.0.custom_name'); + } + + public function test_put_without_tags_key_returns_422(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', []) + ->assertStatus(422) + ->assertJsonValidationErrors('tags'); + } + + public function test_multiple_brands_per_tag(): void + { + $user = User::factory()->create(); + $clo = $this->createClo(); + $brand1 = $this->createBrand(); + $brand2 = $this->createBrand(); + + $this->actingAs($user) + ->putJson('/api/v3/user/quick-tags', [ + 'tags' => [$this->makeTagPayload($clo, [ + 'brands' => [ + ['id' => $brand1, 'quantity' => 1], + ['id' => $brand2, 'quantity' => 3], + ], + ])], + ])->assertOk(); + + $response = $this->actingAs($user) + ->getJson('/api/v3/user/quick-tags') + ->assertOk(); + + $brands = $response->json('tags.0.brands'); + $this->assertCount(2, $brands); + $this->assertEquals($brand1, $brands[0]['id']); + $this->assertEquals(3, $brands[1]['quantity']); + } +} diff --git a/tests/Feature/QuickTags/TopTagsTest.php b/tests/Feature/QuickTags/TopTagsTest.php new file mode 100644 index 000000000..ae2c2458c --- /dev/null +++ b/tests/Feature/QuickTags/TopTagsTest.php @@ -0,0 +1,247 @@ +user = User::factory()->create(); + } + + public function test_returns_empty_tags_for_user_with_no_photos(): void + { + $response = $this->actingAs($this->user) + ->getJson('/api/v3/user/top-tags'); + + $response->assertOk() + ->assertJsonPath('success', true) + ->assertJsonPath('tags', []); + } + + public function test_returns_top_tags_ordered_by_quantity(): void + { + $category = Category::factory()->create(['key' => 'smoking']); + $obj1 = LitterObject::factory()->create(['key' => 'butts']); + $obj2 = LitterObject::factory()->create(['key' => 'lighter']); + + $clo1 = CategoryObject::create(['category_id' => $category->id, 'litter_object_id' => $obj1->id]); + $clo2 = CategoryObject::create(['category_id' => $category->id, 'litter_object_id' => $obj2->id]); + + $photo = Photo::factory()->create(['user_id' => $this->user->id]); + + DB::table('photo_tags')->insert([ + ['photo_id' => $photo->id, 'category_litter_object_id' => $clo1->id, 'category_id' => $category->id, 'litter_object_id' => $obj1->id, 'quantity' => 50, 'created_at' => now(), 'updated_at' => now()], + ['photo_id' => $photo->id, 'category_litter_object_id' => $clo2->id, 'category_id' => $category->id, 'litter_object_id' => $obj2->id, 'quantity' => 10, 'created_at' => now(), 'updated_at' => now()], + ]); + + $response = $this->actingAs($this->user) + ->getJson('/api/v3/user/top-tags'); + + $response->assertOk() + ->assertJsonPath('success', true) + ->assertJsonCount(2, 'tags') + ->assertJsonPath('tags.0.clo_id', $clo1->id) + ->assertJsonPath('tags.0.object_key', 'butts') + ->assertJsonPath('tags.0.category_key', 'smoking') + ->assertJsonPath('tags.0.total', 50) + ->assertJsonPath('tags.1.clo_id', $clo2->id) + ->assertJsonPath('tags.1.total', 10); + } + + public function test_groups_by_clo_and_type(): void + { + $category = Category::factory()->create(['key' => 'alcohol']); + $obj = LitterObject::factory()->create(['key' => 'can']); + $clo = CategoryObject::create(['category_id' => $category->id, 'litter_object_id' => $obj->id]); + + $beerType = LitterObjectType::factory()->create(['key' => 'beer']); + $energyType = LitterObjectType::factory()->create(['key' => 'energy']); + + $photo = Photo::factory()->create(['user_id' => $this->user->id]); + + DB::table('photo_tags')->insert([ + ['photo_id' => $photo->id, 'category_litter_object_id' => $clo->id, 'category_id' => $category->id, 'litter_object_id' => $obj->id, 'litter_object_type_id' => $beerType->id, 'quantity' => 30, 'created_at' => now(), 'updated_at' => now()], + ['photo_id' => $photo->id, 'category_litter_object_id' => $clo->id, 'category_id' => $category->id, 'litter_object_id' => $obj->id, 'litter_object_type_id' => $energyType->id, 'quantity' => 20, 'created_at' => now(), 'updated_at' => now()], + ]); + + $response = $this->actingAs($this->user) + ->getJson('/api/v3/user/top-tags'); + + $response->assertOk() + ->assertJsonCount(2, 'tags') + ->assertJsonPath('tags.0.type_key', 'beer') + ->assertJsonPath('tags.0.total', 30) + ->assertJsonPath('tags.1.type_key', 'energy') + ->assertJsonPath('tags.1.total', 20); + } + + public function test_includes_dominant_brand_over_50_percent(): void + { + $category = Category::factory()->create(['key' => 'softdrinks']); + $obj = LitterObject::factory()->create(['key' => 'can']); + $clo = CategoryObject::create(['category_id' => $category->id, 'litter_object_id' => $obj->id]); + $type = LitterObjectType::factory()->create(['key' => 'energy']); + + $brandId = DB::table('brandslist')->insertGetId(['key' => 'redbull', 'is_custom' => false, 'created_at' => now(), 'updated_at' => now()]); + + $photo = Photo::factory()->create(['user_id' => $this->user->id]); + + // 10 total, 8 with redbull brand (80% > 50%) + $ptId1 = DB::table('photo_tags')->insertGetId([ + 'photo_id' => $photo->id, 'category_litter_object_id' => $clo->id, + 'category_id' => $category->id, 'litter_object_id' => $obj->id, + 'litter_object_type_id' => $type->id, 'quantity' => 8, + 'created_at' => now(), 'updated_at' => now(), + ]); + DB::table('photo_tag_extra_tags')->insert([ + 'photo_tag_id' => $ptId1, 'tag_type' => 'brand', 'tag_type_id' => $brandId, + 'quantity' => 8, 'created_at' => now(), 'updated_at' => now(), + ]); + + DB::table('photo_tags')->insert([ + 'photo_id' => $photo->id, 'category_litter_object_id' => $clo->id, + 'category_id' => $category->id, 'litter_object_id' => $obj->id, + 'litter_object_type_id' => $type->id, 'quantity' => 2, + 'created_at' => now(), 'updated_at' => now(), + ]); + + $response = $this->actingAs($this->user) + ->getJson('/api/v3/user/top-tags'); + + $response->assertOk() + ->assertJsonPath('tags.0.brand_id', $brandId) + ->assertJsonPath('tags.0.brand_key', 'redbull') + ->assertJsonPath('tags.0.total', 10); + } + + public function test_excludes_brand_under_50_percent(): void + { + $category = Category::factory()->create(['key' => 'softdrinks']); + $obj = LitterObject::factory()->create(['key' => 'bottle']); + $clo = CategoryObject::create(['category_id' => $category->id, 'litter_object_id' => $obj->id]); + + $brandId = DB::table('brandslist')->insertGetId(['key' => 'cocacola', 'is_custom' => false, 'created_at' => now(), 'updated_at' => now()]); + + $photo = Photo::factory()->create(['user_id' => $this->user->id]); + + // 10 total, 4 with brand (40% < 50%) + $ptId1 = DB::table('photo_tags')->insertGetId([ + 'photo_id' => $photo->id, 'category_litter_object_id' => $clo->id, + 'category_id' => $category->id, 'litter_object_id' => $obj->id, + 'quantity' => 4, 'created_at' => now(), 'updated_at' => now(), + ]); + DB::table('photo_tag_extra_tags')->insert([ + 'photo_tag_id' => $ptId1, 'tag_type' => 'brand', 'tag_type_id' => $brandId, + 'quantity' => 4, 'created_at' => now(), 'updated_at' => now(), + ]); + + DB::table('photo_tags')->insert([ + 'photo_id' => $photo->id, 'category_litter_object_id' => $clo->id, + 'category_id' => $category->id, 'litter_object_id' => $obj->id, + 'quantity' => 6, 'created_at' => now(), 'updated_at' => now(), + ]); + + $response = $this->actingAs($this->user) + ->getJson('/api/v3/user/top-tags'); + + $response->assertOk() + ->assertJsonPath('tags.0.brand_id', null) + ->assertJsonPath('tags.0.brand_key', null); + } + + public function test_limit_parameter(): void + { + $category = Category::factory()->create(); + $photo = Photo::factory()->create(['user_id' => $this->user->id]); + + // Create 5 distinct CLOs + for ($i = 0; $i < 5; $i++) { + $obj = LitterObject::factory()->create(); + $clo = CategoryObject::create(['category_id' => $category->id, 'litter_object_id' => $obj->id]); + DB::table('photo_tags')->insert([ + 'photo_id' => $photo->id, 'category_litter_object_id' => $clo->id, + 'category_id' => $category->id, 'litter_object_id' => $obj->id, + 'quantity' => 10 - $i, 'created_at' => now(), 'updated_at' => now(), + ]); + } + + $response = $this->actingAs($this->user) + ->getJson('/api/v3/user/top-tags?limit=3'); + + $response->assertOk() + ->assertJsonCount(3, 'tags'); + } + + public function test_limit_capped_at_30(): void + { + $response = $this->actingAs($this->user) + ->getJson('/api/v3/user/top-tags?limit=100'); + + $response->assertOk() + ->assertJsonPath('tags', []); + } + + public function test_requires_auth(): void + { + $response = $this->getJson('/api/v3/user/top-tags'); + + $response->assertUnauthorized(); + } + + public function test_excludes_tags_below_minimum_threshold(): void + { + $category = Category::factory()->create(); + $obj1 = LitterObject::factory()->create(); + $obj2 = LitterObject::factory()->create(); + $clo1 = CategoryObject::create(['category_id' => $category->id, 'litter_object_id' => $obj1->id]); + $clo2 = CategoryObject::create(['category_id' => $category->id, 'litter_object_id' => $obj2->id]); + + $photo = Photo::factory()->create(['user_id' => $this->user->id]); + + DB::table('photo_tags')->insert([ + ['photo_id' => $photo->id, 'category_litter_object_id' => $clo1->id, 'category_id' => $category->id, 'litter_object_id' => $obj1->id, 'quantity' => 5, 'created_at' => now(), 'updated_at' => now()], + ['photo_id' => $photo->id, 'category_litter_object_id' => $clo2->id, 'category_id' => $category->id, 'litter_object_id' => $obj2->id, 'quantity' => 2, 'created_at' => now(), 'updated_at' => now()], + ]); + + $response = $this->actingAs($this->user) + ->getJson('/api/v3/user/top-tags'); + + $response->assertOk() + ->assertJsonCount(1, 'tags') + ->assertJsonPath('tags.0.total', 5); + } + + public function test_does_not_include_other_users_tags(): void + { + $otherUser = User::factory()->create(); + $category = Category::factory()->create(['key' => 'smoking']); + $obj = LitterObject::factory()->create(['key' => 'butts']); + $clo = CategoryObject::create(['category_id' => $category->id, 'litter_object_id' => $obj->id]); + + $otherPhoto = Photo::factory()->create(['user_id' => $otherUser->id]); + DB::table('photo_tags')->insert([ + 'photo_id' => $otherPhoto->id, 'category_litter_object_id' => $clo->id, + 'category_id' => $category->id, 'litter_object_id' => $obj->id, + 'quantity' => 100, 'created_at' => now(), 'updated_at' => now(), + ]); + + $response = $this->actingAs($this->user) + ->getJson('/api/v3/user/top-tags'); + + $response->assertOk() + ->assertJsonPath('tags', []); + } +} diff --git a/tests/Feature/Reports/GenerateImpactReportTest.php b/tests/Feature/Reports/GenerateImpactReportTest.php new file mode 100644 index 000000000..4eb98929d --- /dev/null +++ b/tests/Feature/Reports/GenerateImpactReportTest.php @@ -0,0 +1,231 @@ +subWeek(); + $isoYear = (int) $lastWeek->format('o'); + $isoWeek = (int) $lastWeek->format('W'); + + $this->insertMetrics(Timescale::Weekly, $lastWeek, ['uploads' => 25, 'tags' => 100]); + $this->insertAllTimeMetrics(['uploads' => 5000, 'tags' => 20000]); + + $response = $this->get("/impact/weekly/{$isoYear}/{$isoWeek}"); + + $response->assertOk(); + $response->assertViewIs('reports.impact'); + $response->assertViewHas('period', 'weekly'); + $response->assertViewHas('newPhotos', 25); + $response->assertViewHas('newTags', 100); + } + + // ─── Monthly ───────────────────────────────────────────────────── + + public function test_monthly_report_renders_with_metrics(): void + { + $lastMonth = now()->subMonth(); + + $this->insertMetrics(Timescale::Monthly, $lastMonth, ['uploads' => 200, 'tags' => 800]); + $this->insertAllTimeMetrics(['uploads' => 5000, 'tags' => 20000]); + + $response = $this->get("/impact/monthly/{$lastMonth->year}/{$lastMonth->month}"); + + $response->assertOk(); + $response->assertViewIs('reports.impact'); + $response->assertViewHas('period', 'monthly'); + $response->assertViewHas('newPhotos', 200); + $response->assertViewHas('newTags', 800); + } + + // ─── Annual ────────────────────────────────────────────────────── + + public function test_annual_report_renders_with_metrics(): void + { + $lastYear = now()->subYear()->year; + + $this->insertMetrics(Timescale::Yearly, Carbon::createFromDate($lastYear, 1, 1), ['uploads' => 1000, 'tags' => 5000]); + $this->insertAllTimeMetrics(['uploads' => 10000, 'tags' => 50000]); + + $response = $this->get("/impact/annual/{$lastYear}"); + + $response->assertOk(); + $response->assertViewIs('reports.impact'); + $response->assertViewHas('period', 'annual'); + $response->assertViewHas('newPhotos', 1000); + $response->assertViewHas('newTags', 5000); + } + + public function test_annual_report_shows_year_in_date(): void + { + $lastYear = now()->subYear()->year; + + $this->insertMetrics(Timescale::Yearly, Carbon::createFromDate($lastYear, 1, 1), ['uploads' => 0, 'tags' => 0]); + $this->insertAllTimeMetrics(); + + $response = $this->get("/impact/annual/{$lastYear}"); + + $response->assertOk(); + $response->assertViewHas('startDate', (string) $lastYear); + } + + // ─── Edge cases ────────────────────────────────────────────────── + + public function test_future_date_returns_not_found(): void + { + $futureYear = now()->addYears(5)->year; + + $response = $this->get("/impact/weekly/{$futureYear}/1"); + + $response->assertOk(); + $response->assertViewIs('pages.not-found'); + } + + public function test_invalid_period_defaults_to_weekly(): void + { + $lastWeek = now()->subWeek(); + $isoYear = (int) $lastWeek->format('o'); + $isoWeek = (int) $lastWeek->format('W'); + + $this->insertMetrics(Timescale::Weekly, $lastWeek, ['uploads' => 10, 'tags' => 50]); + $this->insertAllTimeMetrics(); + + $response = $this->get("/impact/invalid/{$isoYear}/{$isoWeek}"); + + $response->assertOk(); + $response->assertViewHas('period', 'weekly'); + } + + // ─── Top brands uses v5 schema ─────────────────────────────────── + + public function test_top_brands_uses_photo_tag_extra_tags(): void + { + $lastWeek = now()->subWeek(); + $isoYear = (int) $lastWeek->format('o'); + $isoWeek = (int) $lastWeek->format('W'); + + $this->insertMetrics(Timescale::Weekly, $lastWeek, ['uploads' => 10, 'tags' => 50]); + $this->insertAllTimeMetrics(['uploads' => 100, 'tags' => 500]); + + // Create a photo in the target week + $user = User::factory()->create(); + $photo = Photo::factory()->create([ + 'user_id' => $user->id, + 'created_at' => $lastWeek->copy()->startOfWeek()->addDay(), + ]); + + // Create a brand in brandslist + $brandId = DB::table('brandslist')->insertGetId([ + 'key' => 'test_brand', + 'is_custom' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Create a photo_tag + $photoTagId = DB::table('photo_tags')->insertGetId([ + 'photo_id' => $photo->id, + 'quantity' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Create the brand extra tag + DB::table('photo_tag_extra_tags')->insert([ + 'photo_tag_id' => $photoTagId, + 'tag_type' => 'brand', + 'tag_type_id' => $brandId, + 'quantity' => 3, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->get("/impact/weekly/{$isoYear}/{$isoWeek}"); + + $response->assertOk(); + $response->assertViewHas('topBrands', function (array $brands) { + return isset($brands['Test Brand']) && $brands['Test Brand'] === 3; + }); + } + + // ─── Zero data renders gracefully ──────────────────────────────── + + public function test_report_renders_with_no_data(): void + { + $lastWeek = now()->subWeek(); + $isoYear = (int) $lastWeek->format('o'); + $isoWeek = (int) $lastWeek->format('W'); + + $response = $this->get("/impact/weekly/{$isoYear}/{$isoWeek}"); + + $response->assertOk(); + $response->assertViewIs('reports.impact'); + $response->assertViewHas('newPhotos', 0); + $response->assertViewHas('newTags', 0); + $response->assertViewHas('topUsers', []); + $response->assertViewHas('topBrands', []); + $response->assertViewHas('topTags', []); + } + + // ─── Helpers ───────────────────────────────────────────────────── + + private function insertMetrics(Timescale $timescale, Carbon $date, array $data = []): void + { + // Weekly uses ISO year; monthly/yearly use calendar year (matches MetricsService) + $year = $timescale === Timescale::Weekly ? (int) $date->format('o') : $date->year; + + DB::table('metrics')->insert(array_merge([ + 'timescale' => $timescale->value, + 'location_type' => LocationType::Global->value, + 'location_id' => 0, + 'user_id' => 0, + 'bucket_date' => $date->toDateString(), + 'year' => $year, + 'month' => $date->month, + 'week' => (int) $date->format('W'), + 'uploads' => 0, + 'tags' => 0, + 'litter' => 0, + 'brands' => 0, + 'materials' => 0, + 'custom_tags' => 0, + 'xp' => 0, + ], $data)); + } + + private function insertAllTimeMetrics(array $data = []): void + { + DB::table('metrics')->insert(array_merge([ + 'timescale' => Timescale::AllTime->value, + 'location_type' => LocationType::Global->value, + 'location_id' => 0, + 'user_id' => 0, + 'bucket_date' => '1970-01-01', + 'year' => 0, + 'month' => 0, + 'week' => 0, + 'uploads' => 0, + 'tags' => 0, + 'litter' => 0, + 'brands' => 0, + 'materials' => 0, + 'custom_tags' => 0, + 'xp' => 0, + ], $data)); + } +} diff --git a/tests/Feature/Teams/ParticipantSessionTest.php b/tests/Feature/Teams/ParticipantSessionTest.php index de4983e2b..d24635002 100644 --- a/tests/Feature/Teams/ParticipantSessionTest.php +++ b/tests/Feature/Teams/ParticipantSessionTest.php @@ -356,7 +356,7 @@ public function test_participant_can_delete_unapproved_photo(): void $response->assertOk() ->assertJsonPath('success', true); - $this->assertSoftDeleted('photos', ['id' => $photo->id]); + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); } public function test_participant_cannot_delete_approved_photo(): void diff --git a/tests/Feature/Teams/TeamPhotosTest.php b/tests/Feature/Teams/TeamPhotosTest.php index f7176c45f..8e0c72b89 100644 --- a/tests/Feature/Teams/TeamPhotosTest.php +++ b/tests/Feature/Teams/TeamPhotosTest.php @@ -724,8 +724,8 @@ public function test_teacher_can_delete_team_photo() $response->assertOk() ->assertJsonPath('success', true); - // Soft-deleted - $this->assertSoftDeleted('photos', ['id' => $photo->id]); + // Hard-deleted + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); // Student XP unchanged (no processed_xp → no XP change) $this->student->refresh(); @@ -757,7 +757,7 @@ public function test_teacher_can_delete_processed_photo_with_metrics_reversal() $response->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photo->id]); + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); // XP reversed on student $this->student->refresh(); diff --git a/tests/Feature/Twitter/ImpactReportTweetTest.php b/tests/Feature/Twitter/ImpactReportTweetTest.php new file mode 100644 index 000000000..77679320c --- /dev/null +++ b/tests/Feature/Twitter/ImpactReportTweetTest.php @@ -0,0 +1,210 @@ +subWeek(); + $isoYear = (int) $lastWeek->format('o'); + $isoWeek = (int) $lastWeek->format('W'); + + $this->assertEquals(2026, $isoYear); + $this->assertEquals(14, $isoWeek); + + $url = "https://openlittermap.com/impact/weekly/{$isoYear}/{$isoWeek}"; + $this->assertEquals('https://openlittermap.com/impact/weekly/2026/14', $url); + + $msg = "Weekly Impact Report for week {$isoWeek} of {$isoYear}." + . " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; + $this->assertStringContainsString('week 14 of 2026', $msg); + $this->assertLessThanOrEqual(280, mb_strlen($msg)); + + Carbon::setTestNow(); + } + + public function test_weekly_iso_week_at_year_boundary(): void + { + // Jan 5 2026 (Monday) — last week starts Dec 29 2025 + Carbon::setTestNow(Carbon::parse('2026-01-05 06:30:00')); + + $lastWeek = now()->subWeek(); + $isoYear = (int) $lastWeek->format('o'); + $isoWeek = (int) $lastWeek->format('W'); + + // Dec 29 2025 is ISO week 1 of 2026 (ISO weeks can span year boundaries) + $url = "https://openlittermap.com/impact/weekly/{$isoYear}/{$isoWeek}"; + $this->assertMatchesRegularExpression('#/impact/weekly/\d{4}/\d{1,2}$#', $url); + + Carbon::setTestNow(); + } + + public function test_weekly_save_path(): void + { + Carbon::setTestNow(Carbon::parse('2026-04-07 06:30:00')); + + $lastWeek = now()->subWeek(); + $isoYear = (int) $lastWeek->format('o'); + $isoWeek = (int) $lastWeek->format('W'); + + $dir = public_path("images/reports/weekly/{$isoYear}/{$isoWeek}"); + $path = "{$dir}/impact-report.png"; + + $this->assertStringContainsString('weekly/2026/14/impact-report.png', $path); + + Carbon::setTestNow(); + } + + // ─── Monthly: date logic ───────────────────────────────────────── + + public function test_monthly_computes_correct_month(): void + { + Carbon::setTestNow(Carbon::parse('2026-04-01 06:30:00')); + + $lastMonth = now()->subMonth(); + + $this->assertEquals(2026, $lastMonth->year); + $this->assertEquals(3, $lastMonth->month); + + $url = "https://openlittermap.com/impact/monthly/{$lastMonth->year}/{$lastMonth->month}"; + $this->assertEquals('https://openlittermap.com/impact/monthly/2026/3', $url); + + $time = $lastMonth->format('F Y'); + $msg = "Monthly Impact Report for {$time}." + . " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; + $this->assertStringContainsString('March 2026', $msg); + $this->assertLessThanOrEqual(280, mb_strlen($msg)); + + Carbon::setTestNow(); + } + + public function test_monthly_at_year_boundary(): void + { + Carbon::setTestNow(Carbon::parse('2026-01-01 06:30:00')); + + $lastMonth = now()->subMonth(); + + $this->assertEquals(2025, $lastMonth->year); + $this->assertEquals(12, $lastMonth->month); + + $url = "https://openlittermap.com/impact/monthly/{$lastMonth->year}/{$lastMonth->month}"; + $this->assertEquals('https://openlittermap.com/impact/monthly/2025/12', $url); + + $time = $lastMonth->format('F Y'); + $this->assertEquals('December 2025', $time); + + Carbon::setTestNow(); + } + + public function test_monthly_save_path(): void + { + Carbon::setTestNow(Carbon::parse('2026-04-01 06:30:00')); + + $lastMonth = now()->subMonth(); + $dir = public_path("images/reports/monthly/{$lastMonth->year}/{$lastMonth->month}"); + $path = "{$dir}/impact-report.png"; + + $this->assertStringContainsString('monthly/2026/3/impact-report.png', $path); + + Carbon::setTestNow(); + } + + // ─── Annual: date logic ────────────────────────────────────────── + + public function test_annual_computes_correct_year(): void + { + Carbon::setTestNow(Carbon::parse('2027-01-01 06:30:00')); + + $lastYear = now()->subYear()->year; + + $this->assertEquals(2026, $lastYear); + + $url = "https://openlittermap.com/impact/annual/{$lastYear}"; + $this->assertEquals('https://openlittermap.com/impact/annual/2026', $url); + + $msg = "Annual Impact Report for {$lastYear}." + . " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; + $this->assertStringContainsString('2026', $msg); + $this->assertLessThanOrEqual(280, mb_strlen($msg)); + + Carbon::setTestNow(); + } + + public function test_annual_save_path(): void + { + Carbon::setTestNow(Carbon::parse('2027-01-01 06:30:00')); + + $lastYear = now()->subYear()->year; + $dir = public_path("images/reports/annual/{$lastYear}"); + $path = "{$dir}/impact-report.png"; + + $this->assertStringContainsString('annual/2026/impact-report.png', $path); + + Carbon::setTestNow(); + } + + // ─── Browsershot failure ───────────────────────────────────────── + + public function test_weekly_returns_failure_when_browsershot_fails(): void + { + // No Chromium locally — Browsershot will throw, command should return FAILURE + $this->artisan('twitter:weekly-impact-report-tweet') + ->assertFailed(); + } + + public function test_monthly_returns_failure_when_browsershot_fails(): void + { + $this->artisan('twitter:monthly-impact-report-tweet') + ->assertFailed(); + } + + public function test_annual_returns_failure_when_browsershot_fails(): void + { + $this->artisan('twitter:annual-impact-report-tweet') + ->assertFailed(); + } + + // ─── Config ────────────────────────────────────────────────────── + + public function test_chrome_path_defaults_to_snap_chromium(): void + { + $this->assertEquals('/snap/bin/chromium', config('services.browsershot.chrome_path')); + } + + public function test_chrome_path_is_configurable(): void + { + config(['services.browsershot.chrome_path' => '/usr/bin/google-chrome']); + $this->assertEquals('/usr/bin/google-chrome', config('services.browsershot.chrome_path')); + } + + // ─── Tweet length ──────────────────────────────────────────────── + + public function test_all_tweet_messages_fit_280_chars(): void + { + // Worst-case: longest month name + 4-digit year + $weekly = "Weekly Impact Report for week 53 of 2026." + . " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; + $monthly = "Monthly Impact Report for September 2026." + . " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; + $annual = "Annual Impact Report for 2026." + . " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; + + $this->assertLessThanOrEqual(280, mb_strlen($weekly), "Weekly tweet exceeds 280 chars"); + $this->assertLessThanOrEqual(280, mb_strlen($monthly), "Monthly tweet exceeds 280 chars"); + $this->assertLessThanOrEqual(280, mb_strlen($annual), "Annual tweet exceeds 280 chars"); + } +} diff --git a/tests/Feature/User/UserPhotoBulkDeleteTest.php b/tests/Feature/User/UserPhotoBulkDeleteTest.php index 1b4f47093..615c41315 100644 --- a/tests/Feature/User/UserPhotoBulkDeleteTest.php +++ b/tests/Feature/User/UserPhotoBulkDeleteTest.php @@ -48,7 +48,7 @@ public function test_bulk_delete_soft_deletes_user_photos(): void ->assertJsonPath('success', true); foreach ($photos as $photo) { - $this->assertSoftDeleted('photos', ['id' => $photo->id]); + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); } } @@ -131,11 +131,7 @@ public function test_bulk_delete_calls_metrics_reversal_for_processed_photos(): ]) ->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photo->id]); - - // processed_at should be cleared by MetricsService::deletePhoto() - $deletedPhoto = Photo::withTrashed()->find($photo->id); - $this->assertNull($deletedPhoto->processed_at); + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); } public function test_bulk_delete_with_select_all(): void @@ -159,7 +155,7 @@ public function test_bulk_delete_with_select_all(): void ->assertOk(); foreach ($photos as $photo) { - $this->assertSoftDeleted('photos', ['id' => $photo->id]); + $this->assertDatabaseMissing('photos', ['id' => $photo->id]); } } } diff --git a/tests/Feature/UserLifecycleTest.php b/tests/Feature/UserLifecycleTest.php index f49d7eb8f..21d447c01 100644 --- a/tests/Feature/UserLifecycleTest.php +++ b/tests/Feature/UserLifecycleTest.php @@ -175,7 +175,7 @@ public function test_full_user_lifecycle(): void ->postJson('/api/profile/photos/delete', ['photoid' => $photoId]); $deleteResponse->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photoId]); + $this->assertDatabaseMissing('photos', ['id' => $photoId]); // Redis: user pruned from leaderboard $this->assertFalse( @@ -349,7 +349,7 @@ public function test_delete_reverses_metrics_and_redis(): void ->postJson('/api/profile/photos/delete', ['photoid' => $photoId]); $response->assertOk(); - $this->assertSoftDeleted('photos', ['id' => $photoId]); + $this->assertDatabaseMissing('photos', ['id' => $photoId]); // Redis: pruned from leaderboard $this->assertFalse(Redis::zScore(RedisKeys::xpRanking($globalScope), (string) $user->id));