Skip to content

Commit bfeed5e

Browse files
authored
Merge pull request #684 from OpenLitterMap/fixes/2026/03/29
Next Updates! Quick Tags, Top Tags, Email Update 27 & more
2 parents 748b486 + 9666818 commit bfeed5e

68 files changed

Lines changed: 4800 additions & 207 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.ai/skills/v5-migration/SKILL.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,22 @@ public function handle(): int
118118
}
119119
```
120120

121+
## Post-Migration Fix: Orphaned Tags (2026-04-04)
122+
123+
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.
124+
125+
**Fix commands:**
126+
- `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`.
127+
- `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()`.
128+
- `php artisan olm:reprocess-metrics --from-file=<ids>` — Delta-based MetricsService reprocess for ~1,041 photos with XP changes (special object bonus corrections).
129+
130+
**Key files:**
131+
- `app/Console/Commands/tmp/v5/Migration/FixOrphanedTags.php`
132+
- `app/Console/Commands/tmp/v5/Migration/RegenerateSummaries.php`
133+
- `app/Console/Commands/tmp/v5/Migration/ReprocessPhotoMetrics.php`
134+
- `readme/TagsCleanupPostMigration.md` — Full mapping tables and judgment calls
135+
- `readme/changelog/production-orphan-fix-runbook.md` — Production execution steps
136+
121137
## Common Mistakes
122138

123139
- **Removing `migrated_at` check.** This is the idempotency guard. Without it, photos get double-migrated.

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ TWITTER_API_CONSUMER_SECRET=
6666
TWITTER_API_ACCESS_TOKEN=
6767
TWITTER_API_ACCESS_SECRET=
6868

69+
# BROWSERSHOT_CHROME_PATH=/snap/bin/chromium
70+
6971
LOCATION_API_KEY=
7072

7173
BACKUP_NAME=OpenLitterMap_Backup

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Built by a single developer over 17 years.
142142
- Replace tags (`PUT /api/v3/tags`) accepts empty `tags: []` to clear all tags from a photo
143143
- `TagsConfig` provides helper methods: `buildObjectMap()`, `buildObjectMaps()`, `allMaterialKeys()`, `allTypeKeys()` — use these instead of hardcoding lists
144144
- Legacy v1/v2 mobile endpoints removed (2026-03-01) — mobile uses v3 endpoints with CLO format only
145-
- `Photo` model uses `SoftDeletes` `$photo->delete()` soft-deletes, `Photo::public()` auto-excludes
145+
- `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
146146
- Locations API uses `locations`/`location_type` keys (not `children`/`children_type`)
147147
- `UsersUploadsController` returns tags under key `'new_tags'` (frontend reads `photo.new_tags`)
148148
- 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.
163163
- Admin roles: `superadmin` (all access), `admin` (photo review), `helper` (tag editing only) — Spatie Permission on `web` guard
164164
- Photo deletion must reverse metrics first: call `MetricsService::deletePhoto()` BEFORE `$photo->delete()`
165165
- 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`
166-
- `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
166+
- `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
167167

168168
## Level System
169169
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
213213
- `readme/Terms.md` — Terms & Conditions source content (Vue component renders this)
214214
- `readme/Privacy.md` — Privacy Policy source content with GDPR legal basis (Vue component renders this)
215215
- `readme/Twitter.md` — Automated Twitter/X commands (schedule, data sources, tweet format, Browsershot config)
216+
- `readme/TagSuggestions.md` — Quick tags sync API (mobile presets, bulk-replace, cross-device sync)
216217

217218
## Daily Changelog
218219
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.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace App\Actions\QuickTags;
4+
5+
use App\Models\Users\User;
6+
use App\Models\Users\UserQuickTag;
7+
use Illuminate\Support\Facades\DB;
8+
9+
class SyncQuickTagsAction
10+
{
11+
/**
12+
* Bulk-replace all quick tags for a user.
13+
* Deletes existing rows and inserts new ones in a transaction.
14+
*
15+
* @param User $user
16+
* @param array $tags Validated array of tag presets
17+
* @return \Illuminate\Database\Eloquent\Collection
18+
*/
19+
public function run(User $user, array $tags)
20+
{
21+
return DB::transaction(function () use ($user, $tags) {
22+
UserQuickTag::where('user_id', $user->id)->delete();
23+
24+
$now = now();
25+
$rows = [];
26+
27+
foreach ($tags as $index => $tag) {
28+
$rows[] = [
29+
'user_id' => $user->id,
30+
'clo_id' => $tag['clo_id'],
31+
'type_id' => $tag['type_id'] ?? null,
32+
'custom_name' => $tag['custom_name'] ?? null,
33+
'quantity' => $tag['quantity'] ?? 1,
34+
'picked_up' => $tag['picked_up'] ?? null,
35+
'materials' => json_encode($tag['materials'] ?? []),
36+
'brands' => json_encode($tag['brands'] ?? []),
37+
'sort_order' => $index,
38+
'created_at' => $now,
39+
'updated_at' => $now,
40+
];
41+
}
42+
43+
if (!empty($rows)) {
44+
UserQuickTag::insert($rows);
45+
}
46+
47+
return $user->quickTags()->get();
48+
});
49+
}
50+
}

app/Actions/Tags/AddTagsToPhotoAction.php

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -170,20 +170,6 @@ protected function createTagFromClo(int $userId, int $photoId, array $tag): Phot
170170
}
171171
}
172172

173-
// Validate "other" object requires at least one extra tag
174-
$object = LitterObject::find($clo->litter_object_id);
175-
if ($object && $object->key === 'other') {
176-
$hasMaterials = ! empty($tag['materials']);
177-
$hasBrands = ! empty($tag['brands']);
178-
$hasCustomTags = ! empty($tag['custom_tags']);
179-
180-
if (! $hasMaterials && ! $hasBrands && ! $hasCustomTags) {
181-
throw ValidationException::withMessages([
182-
'tags' => ['Object "other" requires at least one material, brand, or custom tag.'],
183-
]);
184-
}
185-
}
186-
187173
// Create the PhotoTag
188174
$photoTag = PhotoTag::create([
189175
'photo_id' => $photoId,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Console\Commands\Twitter;
6+
7+
use App\Helpers\Twitter;
8+
use Illuminate\Console\Command;
9+
use Spatie\Browsershot\Browsershot;
10+
11+
class AnnualImpactReportTweet extends Command
12+
{
13+
protected $signature = 'twitter:annual-impact-report-tweet';
14+
15+
protected $description = 'Generates an image of the annual impact report and tweets it via OLM_bot';
16+
17+
public function handle(): int
18+
{
19+
if (! app()->environment('production') && ! app()->runningUnitTests()) {
20+
$this->info('Skipping — not production environment.');
21+
return self::SUCCESS;
22+
}
23+
24+
$lastYear = now()->subYear()->year;
25+
26+
$url = "https://openlittermap.com/impact/annual/{$lastYear}";
27+
$dir = public_path("images/reports/annual/{$lastYear}");
28+
29+
@mkdir($dir, 0755, true);
30+
31+
$path = "{$dir}/impact-report.png";
32+
33+
try {
34+
Browsershot::url($url)
35+
->windowSize(1200, 800)
36+
->fullPage()
37+
->waitUntilNetworkIdle()
38+
->setChromePath(config('services.browsershot.chrome_path'))
39+
->save($path);
40+
} catch (\Throwable $e) {
41+
$this->error("Browsershot failed: {$e->getMessage()}");
42+
return self::FAILURE;
43+
}
44+
45+
$this->info("Image saved to {$path}");
46+
47+
$msg = "Annual Impact Report for {$lastYear}."
48+
. " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap";
49+
50+
Twitter::sendTweetWithImage($msg, $path);
51+
52+
$this->info('Tweet sent');
53+
54+
@unlink($path);
55+
56+
return self::SUCCESS;
57+
}
58+
}

app/Console/Commands/Twitter/MonthlyImpactReportTweet.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,7 @@ public function handle(): int
2828
$url = "https://openlittermap.com/impact/monthly/{$year}/{$month}";
2929
$dir = public_path("images/reports/monthly/{$year}/{$month}");
3030

31-
if (! file_exists($dir)) {
32-
mkdir($dir, 0755, true);
33-
}
31+
@mkdir($dir, 0755, true);
3432

3533
$path = "{$dir}/impact-report.png";
3634

@@ -39,7 +37,7 @@ public function handle(): int
3937
->windowSize(1200, 800)
4038
->fullPage()
4139
->waitUntilNetworkIdle()
42-
->setChromePath('/snap/bin/chromium')
40+
->setChromePath(config('services.browsershot.chrome_path'))
4341
->save($path);
4442
} catch (\Throwable $e) {
4543
$this->error("Browsershot failed: {$e->getMessage()}");

app/Console/Commands/Twitter/WeeklyImpactReportTweet.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,15 @@ public function handle(): int
2929
$url = "https://openlittermap.com/impact/weekly/{$isoYear}/{$isoWeek}";
3030
$dir = public_path("images/reports/weekly/{$isoYear}/{$isoWeek}");
3131

32-
if (! file_exists($dir)) {
33-
mkdir($dir, 0755, true);
34-
}
32+
@mkdir($dir, 0755, true);
3533

3634
$path = "{$dir}/impact-report.png";
3735

3836
try {
3937
Browsershot::url($url)
4038
->windowSize(1200, 800)
41-
->setChromePath('/snap/bin/chromium')
39+
->waitUntilNetworkIdle()
40+
->setChromePath(config('services.browsershot.chrome_path'))
4241
->save($path);
4342
} catch (\Throwable $e) {
4443
$this->error("Browsershot failed: {$e->getMessage()}");

0 commit comments

Comments
 (0)