--- name: tagging-system description: PhotoTag, PhotoTagExtraTags, categories, litter objects, materials, brands, ClassifyTagsService, GeneratePhotoSummaryService, tag migration, and the v4-to-v5 conversion. --- # Tagging System V5 uses a normalized hierarchy: Photo -> PhotoTag (category + object + quantity) -> PhotoTagExtraTags (materials, brands, custom tags). All tag data lives in `photo_tags` and `photo_tag_extra_tags` tables — not the old per-category tables. **V5.1 Architecture (Phase 1 complete — schema + seed only, no behavior changes):** Added `LitterObjectType` dimension ("what was in the container" — beer, water, soda, etc.), `category_object_types` pivot controlling which types are valid per category+object combo, and `category_litter_object_id`/`litter_object_type_id` nullable FK columns on `photo_tags`. Full spec: `readme/TaggingArchitectureSpec.md`. ## Key Files - `app/Models/Litter/Tags/PhotoTag.php` — Primary tag record (category + object) - `app/Models/Litter/Tags/PhotoTagExtraTags.php` — Materials, brands, custom tags per tag - `app/Models/Litter/Tags/Category.php` — Tag categories (smoking, food, etc.) - `app/Models/Litter/Tags/LitterObject.php` — Taggable objects (butts, wrapper, etc.) - `app/Models/Litter/Tags/BrandList.php` — Brand records (`brandslist` table) - `app/Models/Litter/Tags/Materials.php` — Material records (`materials` table) - `app/Models/Litter/Tags/CustomTagNew.php` — Custom tags (`custom_tags_new` table) - `app/Models/Litter/Tags/CategoryObject.php` — Pivot: `category_litter_object` + `types()` BelongsToMany - `app/Models/Litter/Tags/LitterObjectType.php` — Type lookup: "what was in the container" (beer, water, etc.) - `database/seeds/Tags/GenerateTagsSeeder.php` — Seeds all categories, objects, CLO pivots, materials, and types from TagsConfig. Also ensures `unclassified` system category exists. - `app/Services/Tags/ClassifyTagsService.php` — Tag classification + deprecated key mapping - `app/Services/Tags/UpdateTagsService.php` — V4->V5 migration per photo - `app/Services/Tags/GeneratePhotoSummaryService.php` — Summary JSON + XP from PhotoTags - `app/Services/Tags/XpCalculator.php` — XP scoring rules - `app/Enums/Dimension.php` — Tag type enum (object, category, material, brand, custom_tag) ## Invariants 1. **`photo_tags` uses FK columns:** `category_id` and `litter_object_id` (not string columns). Tests must create Category/LitterObject records and use their IDs. **These columns are now NULLABLE** — extra-tag-only tags (brands, materials, custom tags) can exist without a litter object. 2. **`photo_tag_extra_tags` is polymorphic:** `tag_type` is `'material'|'brand'|'custom_tag'`, `tag_type_id` is the FK to the respective table. 3. **Namespace is `App\Models\Litter\Tags\PhotoTag`**, not `App\Models\PhotoTag`. 4. **Summary generation MUST follow any tag change.** Call `$photo->generateSummary()` after creating/updating/deleting PhotoTags. 5. **Unknown tags are auto-created:** `LitterObject::firstOrCreate(['key' => $key], ['crowdsourced' => true])`. 6. **Loose PhotoTags (nullable CLO).** `category_litter_object_id`, `category_id`, `litter_object_id` are all nullable. `AddTagsToPhotoAction::createExtraTagOnly()` creates standalone extra-tag PhotoTags with null CLO fields. `GeneratePhotoSummaryService` counts objects only when `objectId > 0` (variable renamed `$totalLitter` → `$totalObjects`). `XpCalculator` awards object XP only when `object_id > 0` — extra-tag-only tags don't get phantom object XP. Frontend `useXpCalculator.js` mirrors this logic. 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. ## Patterns ### Creating a tag with extras ```php // Create primary tag $photoTag = PhotoTag::create([ 'photo_id' => $photo->id, 'category_id' => $category->id, 'litter_object_id' => $object->id, 'quantity' => 5, 'picked_up' => true, ]); // Attach materials $photoTag->attachExtraTags([ ['id' => $plasticId, 'quantity' => 5], ['id' => $paperId, 'quantity' => 3], ], 'material', 0); // Attach brands $photoTag->attachExtraTags([ ['id' => $marlboroId, 'quantity' => 3], ], 'brand', 0); ``` ### Custom-tag-only tags (no category/object) ```php $photoTag = PhotoTag::create([ 'photo_id' => $photo->id, 'custom_tag_primary_id' => $customTag->id, 'quantity' => $quantity, 'picked_up' => $pickedUp, ]); ``` ### Brand-only tags (no specific object) ```php $photoTag = PhotoTag::create([ 'photo_id' => $photo->id, 'category_id' => Category::where('key', 'brands')->value('id'), 'quantity' => array_sum($brandQuantities), ]); $photoTag->attachExtraTags($brands, Dimension::BRAND->value, 0); ``` ### Deprecated key normalization (v4 -> v5) ```php // ClassifyTagsService::normalizeDeprecatedTag('beerBottle') // Returns: ['object' => 'beer_bottle', 'materials' => ['glass']] // ClassifyTagsService::normalizeDeprecatedTag('coffeeCups') // Returns: ['object' => 'cup', 'materials' => ['paper']] // ClassifyTagsService::normalizeDeprecatedTag('butts') // Returns: ['object' => 'butts', 'materials' => ['plastic', 'paper']] ``` 130+ mappings from old camelCase keys to normalized keys with inferred materials. ### Category aliases (CATEGORY_ALIASES) `ClassifyTagsService::CATEGORY_ALIASES` resolves deprecated v4 category keys: `coastal→marine`, `trashdog→pets`, `dogshit→pets`, `automobile→vehicles`, `pathway→unclassified`, `drugs→unclassified`, `political→unclassified`, `stationery→unclassified`. The public `getCategory(string $rawKey)` method checks aliases before DB lookup. `TagsConfig` defines 16 active categories (ordered alphabetically): alcohol, art, civic, coffee, dumping, electronics, food, industrial, marine, medical, other, pets, sanitary, smoking, softdrinks, vehicles. The `unclassified` system category is NOT in TagsConfig but is created by `GenerateTagsSeeder` for v4 alias resolution. ### Dimension enum ```php enum Dimension: string { case LITTER_OBJECT = 'object'; // table: litter_objects case CATEGORY = 'category'; // table: categories case MATERIAL = 'material'; // table: materials case BRAND = 'brand'; // table: brandslist case CUSTOM_TAG = 'custom_tag'; // table: custom_tags_new public function table(): string public static function fromTable(string $table): ?self } ``` ### Database schema ```sql -- photo_tags: FK columns, NOT strings photo_tags ( id, photo_id, category_id, litter_object_id, category_litter_object_id, -- v5.1: nullable FK to category_litter_object (Phase 3: NOT NULL) litter_object_type_id, -- v5.1: nullable FK to litter_object_types custom_tag_primary_id, -- for custom-only tags quantity, picked_up, created_at, updated_at ) -- photo_tag_extra_tags: polymorphic extras photo_tag_extra_tags ( id, photo_tag_id, tag_type, -- 'material'|'brand'|'custom_tag' tag_type_id, -- FK to materials/brandslist/custom_tags_new quantity, index, created_at, updated_at ) -- Reference tables categories (id, key, parent_id) -- includes 'unclassified' (hidden from UI) litter_objects (id, key, crowdsourced) litter_object_types (id, key, name) -- v5.1: "what was in the container" (~17 rows) materials (id, key) brandslist (id, key, crowdsourced) custom_tags_new (id, key) category_litter_object (id, category_id, litter_object_id) -- CLO pivot -- v5.1: controls which types are valid per CLO category_object_types ( category_litter_object_id, -- FK to category_litter_object litter_object_type_id, -- FK to litter_object_types UNIQUE(category_litter_object_id, litter_object_type_id) ) ``` ### TagKeyCache for performance ```php use App\Services\Achievements\Tags\TagKeyCache; // Lookup $id = TagKeyCache::idFor('material', 'glass'); // null if not found $id = TagKeyCache::getOrCreateId('material', 'glass'); // creates if missing $key = TagKeyCache::keyFor('material', $id); // reverse lookup // Bulk preload (call once at script startup) TagKeyCache::preloadAll(); ``` Three-layer cache: in-memory array -> Redis hash (24h TTL) -> database fallback. ## Web Frontend Tag Types (POST /api/v3/tags) The Vue frontend sends 4 distinct tag types to `AddTagsToPhotoAction`: ### 1. Object tag (with optional materials/brands/custom_tags) ```json { "object": { "id": 5, "key": "butts" }, "quantity": 3, "picked_up": true, "materials": [{ "id": 2, "key": "plastic" }], "brands": [], "custom_tags": [] } ``` Backend auto-resolves category from `object->categories()->first()`. Category need NOT be sent. **Materials and brands accept flexible formats:** - Materials: `[50, 51]` (plain IDs) or `[{"id": 50}]` (objects). Quantity inherits from parent tag. - Brands: `[10]` (plain IDs, quantity=1) or `[{"id": 10, "quantity": 3}]` (objects with per-brand quantity). - `attachMaterials()` and `attachBrands()` both check `is_array($item) ? $item['id'] : $item`. ### 2. Custom-only tag ```json { "custom": true, "key": "dirty-bench", "quantity": 1, "picked_up": null } ``` `$tag['custom']` is boolean true (flag), `$tag['key']` is the actual tag name. Creates `CustomTagNew` via `$tag['key']`. **Custom tag sanitization (`AddTagsToPhotoAction::attachCustomTags`).** Custom tag keys are free text — there is **no allowlist regex**. The key is sanitized with `mb_substr(trim(strip_tags($key)), 0, 255)` (caps to the `custom_tags_new.key` varchar(255)) and accepted, including punctuation like `& . ' /` (real brand/product names, e.g. "Black & Mild"). An empty-after-sanitize key is **skipped** (`continue`) — never thrown. Do NOT reintroduce a throwing allowlist: the throw lived inside `run()`'s `DB::transaction`, so one bad custom tag would 500 the request and roll back the user's valid object tags. Same path for standalone (`createExtraTagOnly`) and object-attached (`createTagFromClo`) custom tags. (`bn:`→brand resolution is a separate deferred ticket; `bn:` currently stores as a literal custom string.) ### 3. Brand-only tag ```json { "brand_only": true, "brand": { "id": 1, "key": "coca-cola" }, "quantity": 1 } ``` Creates PhotoTag with null category/object, attaches brand as extra tag. ### 4. Material-only tag ```json { "material_only": true, "material": { "id": 2, "key": "plastic" }, "quantity": 1 } ``` Same pattern as brand-only — PhotoTag with null FKs, material as extra tag. ### GET /api/tags/all response (v5.1) ```json { "categories": [{"id": 1, "key": "alcohol"}], "objects": [{"id": 5, "key": "bottle", "categories": [{"id": 1, "key": "alcohol"}]}], "materials": [{"id": 1, "key": "glass"}], "brands": [{"id": 7, "key": "heineken"}], "types": [{"id": 3, "key": "beer", "name": "Beer"}], "category_objects": [{"id": 42, "category_id": 1, "litter_object_id": 5}], "category_object_types": [{"category_litter_object_id": 42, "litter_object_type_id": 3}] } ``` `unclassified` category is excluded from the response. `category_object_types` maps which types are valid per CLO. ### Frontend files | File | Purpose | |---|---| | `resources/js/views/General/Tagging/v2/AddTags.vue` | Main tagging page — dark glass UI, 55/45 split layout, search index with per-(object,category) entries, progress bar, auto-advance, success flash, keyboard shortcuts (/, Escape, J/K/←/→, Enter, Ctrl+Enter, ?), empty state | | `resources/js/views/General/Tagging/v2/components/UnifiedTagSearch.vue` | Debounced (100ms) search combobox, grouped results (object/type/material/brand/customTag), i18n translated labels, category breadcrumbs, emerald accent | | `resources/js/views/General/Tagging/v2/components/TagCard.vue` | Tag card with "Object · Category" display, type pills, picked-up pills, dark glass styling, red border on unresolved CLO | | `resources/js/views/General/Tagging/v2/components/TaggingHeader.vue` | XP bar (emerald), level titles, unresolved tags warning, submit disabled when unresolved, edit mode badge | | `resources/js/views/General/Tagging/v2/components/ActiveTagsList.vue` | Container for active tags, keyboard hint in empty state | | `resources/js/stores/photos/requests.js` | `UPLOAD_TAGS()` → POST, `REPLACE_TAGS()` → PUT, `GET_SINGLE_PHOTO()` | | `resources/js/stores/user/requests.js` | `REFRESH_USER()` — refreshes user XP/level after tag submission | | `resources/js/stores/tags/requests.js` | `GET_ALL_TAGS()` → GET /api/tags/all | ### Frontend category disambiguation The search index generates **one entry per (object, category) pair** with pre-resolved `cloId`, `categoryId`, `categoryKey`. Each entry has: - `label` — i18n translated display name via `translateTag(key, prefix)` (e.g. `coke` → "Coca-Cola" from `litter.brands.coke`). Falls back to `formatKey()` if no translation exists. - `categoryLabel` — translated category name (e.g. `litter.categories.alcohol` → "Alcohol") - `lowerKey` — includes both raw key AND translated label for search matching (e.g. `"coke coca-cola"`) Translation prefixes: objects use `litter.{categoryKey}.{objectKey}`, brands use `litter.brands.{key}`, materials use `litter.material.{key}`, categories use `litter.categories.{key}`. `formatKey(key)` converts `snake_case` → `Title Case` (e.g., `six_pack_rings` → "Six Pack Rings"). Used as fallback when no i18n translation exists. `hasUnresolvedTags` computed blocks submit when any object tag lacks a `cloId`. Keyboard shortcuts guard against firing inside form inputs (INPUT/SELECT/TEXTAREA). ### Dark glass design system All tagging components use a dark glass UI with emerald accent: - **Background:** `bg-gradient-to-br from-slate-900 via-blue-900 to-emerald-900` - **Glass panels:** `bg-white/5 border border-white/10 rounded-xl` - **Accent:** Emerald (`text-emerald-400`, `bg-emerald-500`, `focus:border-emerald-500/50`) - **Text:** `text-white` / `text-white/60` / `text-white/40` / `text-white/30` **Auto-advance flow:** Submit → success flash (green border pulse, 400ms) → clear tags → advance to next photo. **Keyboard shortcuts:** `/` focus search, `Escape` blur/close, `J/←` prev, `K/→` next, `Enter` confirm (bare), `Ctrl+Enter` confirm (in input), `?` toggle hints. ## Common Mistakes - **Using string keys in `photo_tags`.** The table uses `category_id` and `litter_object_id` (integer FKs), not string columns like `'smoking'` or `'butts'`. - **Forgetting to regenerate summary after tag changes.** Always call `$photo->generateSummary()` after modifying PhotoTags. - **Looking for PhotoTag in `App\Models\`.** The namespace is `App\Models\Litter\Tags\PhotoTag`. - **Confusing `brandslist` table name.** Not `brands` — the table is literally `brandslist`. - **Attaching brands directly to objects.** Brand matching is deferred. Brands go through `attachExtraTags()` or as brand-only PhotoTags. - **Not handling `custom_tag_primary_id`.** Custom-only tags have no `category_id` or `litter_object_id` — they use `custom_tag_primary_id` instead. - **Expecting category from frontend.** The web frontend sends `object.id` but NOT `category`. Backend auto-resolves category from `object->categories()->first()`. - **Reading `$tag['custom']` as the tag name.** It's a boolean flag. The actual name is `$tag['key']`. - **Checking `$tag['brands']` for brand-only tags.** Brand-only tags use `$tag['brand']` (singular) + `$tag['brand_only']` flag. - **Using `cot.id` for type entries.** The `category_object_types` API only returns `category_litter_object_id` and `litter_object_type_id` — no `id` column. Use composite key `type-${cot.category_litter_object_id}-${cot.litter_object_type_id}`. - **Relying on old localStorage recentTags.** Entries from before category disambiguation lack `cloId`. Filter them out on mount: `parsed.filter((t) => t.type !== 'object' || t.cloId)`. - **Losing `litter_object_type_id` on edit round-trip.** `UsersUploadsController::getNewTags()` must include `litter_object_type_id` in the response, and `convertExistingTags()` must read it into `typeId`. Without this, the type dimension (e.g., "beer" on a "bottle") is lost when editing tags. - **Replace tags without DB::transaction.** `PhotoTagsController::update()` must wrap delete + reset + add in `DB::transaction()`. If `AddTagsToPhotoAction::run()` throws after tags are deleted, the photo loses all data. - **Using `||` instead of `??` for counts that can be zero.** `photosStore.untaggedStats.leftToTag || fallback` treats `0` as falsy. Use `??` (nullish coalescing) to only fall through on `null`/`undefined`. - **Assuming one PhotoTag row per (photo, CLO, type).** There is no unique constraint. Multiple rows with the same `category_litter_object_id` and `litter_object_type_id` can exist on the same photo. Don't add a UNIQUE index or query logic that assumes uniqueness across rows. - **Expecting `category`/`object` to always be present in `getNewTags()` output.** For brand-only, material-only, or custom-only PhotoTags, `category` and `object` are `null` in the serializer output. The frontend must handle null gracefully.