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
7 changes: 7 additions & 0 deletions .ai/skills/tagging-system/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ V5 uses a normalized hierarchy: Photo -> PhotoTag (category + object + quantity)
7. **No unique constraint on `photo_tags` for (CLO, type) pairs.** There is no DB-level unique constraint on `(photo_id, category_litter_object_id, litter_object_type_id)`. Duplicate CLO+type pairs are possible (each is a separate PhotoTag row). Do NOT assume uniqueness. Extra-tag deduplication (materials/brands within a single tag) is handled via `upsert` inside a single PhotoTag's extra tags, not across multiple PhotoTag rows.
8. **`getNewTags()` serializer contract.** `UsersUploadsController::getNewTags()` conditionally includes `category` and `object` only when both `category_id` and `litter_object_id` resolve. For extra-tag-only PhotoTags (brand/material/custom-only), `category` and `object` are returned as `null`. Always includes `litter_object_type_id` (may be null), `quantity`, `picked_up` (cast to bool with photo-level fallback), and `extra_tags` array.

## Object retirement

- `olm:migrate-tag OLD NEW` previews one category-preserving object migration; `--apply` updates observations and records the replacement on the old object.
- `ResolveLitterObject` follows `retired_at`, `merged_into_id` and `merged_into_type_id` before choosing the destination CLO. No source CLO or global seeder is required.
- The latest recorded replacement type wins. Without a recorded type, invalid client types return 422. Explicit object-format categories are preserved or rejected.
- See `readme/PostTagMigrationClean.md` for the write freeze, rehearsal, Redis recovery and follow-up scope.

## Patterns

### Creating a tag with extras
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ yarn-error.log
.eslintignore
.DS_Store
/storage/seeders/brands.txt
readme/audit/TagCleanupReview-2026-09-12.csv
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ tests/

## CI (GitHub Actions)

Runs on push to `master`, `staging`, `upgrade/tagging-2025` and PRs.
Pipeline: PHP 8.2, Node 18, MySQL 5.7, Redis 7 — composer install, npm build, phpunit.
Runs on pushes and PRs targeting `master` or another branch.
Pipeline: PHP 8.3, MySQL 8.0, Redis 7 — composer install, migrations and PHPUnit. No Node or frontend build step.

## Current State (v5 — shipped to `master`)

Expand Down Expand Up @@ -138,8 +138,10 @@ Built by a single developer over 17 years.
- `VerificationStatus` enum cast is on Photo model — use `->value` for `>=`/`<` comparisons, direct enum for `===`
- `Photo.geom` column is binary spatial data — hidden from JSON via `$hidden` array
- `photo_tags` table uses FK columns (`category_id`, `litter_object_id`), NOT string columns. All three (`category_litter_object_id`, `category_id`, `litter_object_id`) are **nullable** — extra-tag-only PhotoTags have null CLO
- `AddTagsToPhotoAction` (v5) auto-resolves category from object — frontend need not send category. Brand-only, material-only, and custom-only tags use `createExtraTagOnly()` with null CLO
- `AddTagsToPhotoAction` preserves explicit categories. Without one, prefer an existing `other` CLO, then a sole category; reject ambiguity. Editors should send the recorded category or CLO. Brand-only, material-only, and custom-only tags use `createExtraTagOnly()` with null CLO
- Replace tags (`PUT /api/v3/tags`) accepts empty `tags: []` to clear all tags from a photo
- Object retirement uses `litter_objects.retired_at`, `merged_into_id` and `merged_into_type_id`; resolve the object replacement before choosing its CLO. `olm:migrate-tag` needs no source CLO or production seeder. See `readme/PostTagMigrationClean.md`.
- `LitterObject::active()` delegates to `retired(false)`; `retired()` selects retired objects. Use `active()` explicitly for catalogue/search/top tags. No global scope: stale submissions, migration retries and historical reads need retired records. See `readme/Tags.md#active-and-retired-objects`.
- `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 has `SoftDeletes` trait but all delete endpoints use `forceDelete()` for hard deletion. Cascading FKs on `photo_tags` (→ `photo_tag_extras`) handle relationship cleanup
Expand All @@ -153,7 +155,7 @@ Built by a single developer over 17 years.
- Points API returns `page` (not `current_page`) at root level — frontend normalizes to `current_page`
- Nav.vue `isAdmin` check includes `'superadmin'` role (not just `'admin'` and `'helper'`)
- `MetricsService` is the SINGLE WRITER for all metrics (MySQL `metrics` table + Redis). Never use `DB::table('metrics')->increment()` or `Redis::hincrby()` directly
- Redis is a derived cache, rebuildable from `metrics` table. Never treat Redis as source of truth
- Redis is a derived cache; per-object counts rebuild from processed photo snapshots, not the totals-only `metrics` table. Never treat Redis as source of truth
- Location tables store identity only (no aggregate counters). Stats live in Redis + `metrics` table
- `auth:sanctum` routes in tests: use `actingAs($user)` with NO guard arg, NOT `actingAs($user, 'api')`
- `result_string` and `total_litter` columns are write-only — all active endpoints use `summary` and `total_tags`
Expand Down
10 changes: 10 additions & 0 deletions app/Actions/QuickTags/SyncQuickTagsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Actions\QuickTags;

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

Expand All @@ -19,6 +20,15 @@ class SyncQuickTagsAction
public function run(User $user, array $tags)
{
return DB::transaction(function () use ($user, $tags) {
foreach ($tags as &$tag) {
[$clo, $typeId] = app(ResolveLitterObject::class)->fromClo(
(int) $tag['clo_id'], isset($tag['type_id']) ? (int) $tag['type_id'] : null
);
$tag['clo_id'] = $clo->id;
$tag['type_id'] = $typeId;
}
unset($tag);

UserQuickTag::where('user_id', $user->id)->delete();

$now = now();
Expand Down
88 changes: 45 additions & 43 deletions app/Actions/Tags/AddTagsToPhotoAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use App\Models\Litter\Tags\Materials;
use App\Models\Litter\Tags\PhotoTag;
use App\Models\Photo;
use App\Services\Tags\ResolveLitterObject;
use App\Models\Teams\Team;
use App\Models\Users\User;
use Illuminate\Support\Facades\DB;
Expand Down Expand Up @@ -58,9 +59,8 @@ public function run(int $userId, int $photoId, array $tags, bool $skipVerificati
/**
* Create PhotoTag records with extra tags (materials, brands, custom tags).
*
* Accepts two payload formats:
* - New: { category_litter_object_id, litter_object_type_id?, ... }
* - Legacy: { object: {id, key}, category?, brand_only?, material_only?, ... }
* - Accept a CLO ID, object/category fields, or standalone extras.
* - Example: { category_litter_object_id: 111, quantity: 2 }.
*
* @throws \Exception
*/
Expand All @@ -71,11 +71,11 @@ protected function addTagsToPhoto(int $userId, int $photoId, array $tags): array
foreach ($tags as $tag) {
// Detect payload format
if (isset($tag['category_litter_object_id'])) {
$photoTags[] = $this->createTagFromClo($userId, $photoId, $tag);
$photoTags[] = $this->createPhotoTagFromClo($userId, $photoId, $tag);
} elseif ($this->isExtraTagOnly($tag)) {
$photoTags[] = $this->createExtraTagOnly($userId, $photoId, $tag);
} else {
$photoTags[] = $this->createTagLegacy($userId, $photoId, $tag);
$photoTags[] = $this->createPhotoTagFromObject($userId, $photoId, $tag);
}
}

Expand Down Expand Up @@ -137,39 +137,22 @@ protected function createExtraTagOnly(int $userId, int $photoId, array $tag): Ph
}

/**
* New CLO-based tag creation.
* - Create a PhotoTag from category_litter_object_id and attach its extras.
* - Follow a retired object's replacement before saving.
* - Example: { category_litter_object_id: 111, quantity: 2 }.
*
* @throws \Exception
*/
protected function createTagFromClo(int $userId, int $photoId, array $tag): PhotoTag
protected function createPhotoTagFromClo(int $userId, int $photoId, array $tag): PhotoTag
{
$cloId = $tag['category_litter_object_id'];
$clo = CategoryObject::find($cloId);

if (! $clo) {
throw ValidationException::withMessages([
'tags' => ["Invalid category_litter_object_id: {$cloId}"],
]);
}

[$clo, $typeId] = app(ResolveLitterObject::class)->fromClo(
(int) $tag['category_litter_object_id'],
isset($tag['litter_object_type_id']) ? (int) $tag['litter_object_type_id'] : null
);
$cloId = $clo->id;
$quantity = max(1, (int) ($tag['quantity'] ?? 1));
$pickedUp = $tag['picked_up'] ?? null;

// Validate type if provided
$typeId = $tag['litter_object_type_id'] ?? null;
if ($typeId) {
$validType = DB::table('category_object_types')
->where('category_litter_object_id', $cloId)
->where('litter_object_type_id', $typeId)
->exists();

if (! $validType) {
throw ValidationException::withMessages([
'tags' => ["Type {$typeId} is not valid for CLO {$cloId}"],
]);
}
}

// Create the PhotoTag
$photoTag = PhotoTag::create([
'photo_id' => $photoId,
Expand All @@ -194,14 +177,33 @@ protected function createTagFromClo(int $userId, int $photoId, array $tag): Phot
}

/**
* Legacy format tag creation (backward compatibility for old frontend/mobile).
* - Web editors can send object and category separately instead of category_litter_object_id.
* - Example: { object: "plasticBags", category_id: 8, quantity: 2 }.
* - Resolve retired objects before creating the observation.
*
* @throws \Exception
*/
protected function createTagLegacy(int $userId, int $photoId, array $tag): PhotoTag
protected function createPhotoTagFromObject(int $userId, int $photoId, array $tag): PhotoTag
{
[$category, $object, $quantity, $pickedUp] = $this->resolveTag($tag);

if (! $object) {
throw ValidationException::withMessages(['tags' => 'Unknown litter object.']);
}
if ($object->retired_at !== null) {
[$clo, $typeId] = app(ResolveLitterObject::class)->resolve(
$object, $category?->id,
isset($tag['litter_object_type_id']) ? (int) $tag['litter_object_type_id'] : null
);
$tag['category_litter_object_id'] = $clo->id;
$tag['litter_object_type_id'] = $typeId;

return $this->createPhotoTagFromClo($userId, $photoId, $tag);
}
if (! $category) {
throw ValidationException::withMessages(['tags' => 'Category does not contain object.']);
}

// Resolve CLO from category + object
$clo = null;
if ($category && $object) {
Expand Down Expand Up @@ -363,7 +365,8 @@ protected function attachCustomTags(int $userId, PhotoTag $photoTag, array $cust
/**
* Resolve category and object from tag input (legacy format).
* Accepts either { id: int } or string key for both.
* Auto-resolves category from object if not explicitly provided.
* - Preserve an explicit category.
* - Otherwise prefer an existing other CLO, then a sole category; reject ambiguity.
*/
protected function resolveTag(array $tag): array
{
Expand All @@ -383,18 +386,17 @@ protected function resolveTag(array $tag): array
? LitterObject::find($tag['object']['id'])
: LitterObject::where('key', $tag['object'])->first();

if ($object) {
// Validate provided category belongs to this object, fall back otherwise
if ($category && ! $object->categories()->where('categories.id', $category->id)->exists()) {
$category = null;
}

if (! $category) {
$category = $object->categories()->first();
}
if ($object && $object->retired_at === null && ! $category
&& ! isset($tag['category_id']) && ! isset($tag['category'])) {
[$clo] = app(ResolveLitterObject::class)->resolve($object, null);
$category = $clo->category;
}
}

if ((isset($tag['category_id']) || isset($tag['category'])) && ! $category) {
throw ValidationException::withMessages(['tags' => 'Unknown category.']);
}

return [
$category,
$object,
Expand Down
Loading
Loading