Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .ai/skills/v5-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<ids>` — 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.
Expand Down
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions app/Actions/QuickTags/SyncQuickTagsAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace App\Actions\QuickTags;

use App\Models\Users\User;
use App\Models\Users\UserQuickTag;
use Illuminate\Support\Facades\DB;

class SyncQuickTagsAction
{
/**
* Bulk-replace all quick tags for a user.
* Deletes existing rows and inserts new ones in a transaction.
*
* @param User $user
* @param array $tags Validated array of tag presets
* @return \Illuminate\Database\Eloquent\Collection
*/
public function run(User $user, array $tags)
{
return DB::transaction(function () use ($user, $tags) {
UserQuickTag::where('user_id', $user->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();
});
}
}
14 changes: 0 additions & 14 deletions app/Actions/Tags/AddTagsToPhotoAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions app/Console/Commands/Twitter/AnnualImpactReportTweet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands\Twitter;

use App\Helpers\Twitter;
use Illuminate\Console\Command;
use Spatie\Browsershot\Browsershot;

class AnnualImpactReportTweet extends Command
{
protected $signature = 'twitter:annual-impact-report-tweet';

protected $description = 'Generates an image of the annual impact report and tweets it via OLM_bot';

public function handle(): int
{
if (! app()->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;
}
}
6 changes: 2 additions & 4 deletions app/Console/Commands/Twitter/MonthlyImpactReportTweet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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()}");
Expand Down
7 changes: 3 additions & 4 deletions app/Console/Commands/Twitter/WeeklyImpactReportTweet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()}");
Expand Down
Loading
Loading