diff --git a/app/Actions/Tag/MergeTag.php b/app/Actions/Tag/MergeTag.php index f7bf968b74a..84d67f7d0f7 100644 --- a/app/Actions/Tag/MergeTag.php +++ b/app/Actions/Tag/MergeTag.php @@ -46,6 +46,7 @@ public function do(Tag $source, Tag $into): void } $this->handlePhotos($source, $into, $user); + $this->handleAlbums($source, $into, $user); $this->handleTagAlbums($source, $into, $user); // Cleanup unused tags after merging @@ -109,6 +110,48 @@ private function handlePhotos(Tag $source, Tag $into, User $user): void }); } + private function handleAlbums(Tag $source, Tag $into, User $user): void + { + $source_album_ids = DB::table('albums_tags') + ->where('tag_id', $source->id) + ->when( + $user->may_administrate === false, + fn ($q) => $q + ->whereExists(fn (Builder $query) => $query->select(DB::raw(1)) + ->from('base_albums') + ->whereColumn('base_albums.id', 'albums_tags.album_id') + ->where('base_albums.owner_id', $user->id) + ) + ) + ->select('album_id') + ->pluck('album_id') + ->toArray(); + + $existing_album_ids = DB::table('albums_tags') + ->where('tag_id', $into->id) + ->whereIn('album_id', $source_album_ids) + ->select('album_id') + ->pluck('album_id') + ->toArray(); + + $album_ids_to_add = array_diff($source_album_ids, $existing_album_ids); + + DB::transaction(function () use ($album_ids_to_add, $into, $source, $source_album_ids): void { + if (count($album_ids_to_add) > 0) { + $insert_data = array_map(fn ($album_id) => [ + 'album_id' => $album_id, + 'tag_id' => $into->id, + ], $album_ids_to_add); + DB::table('albums_tags')->insert($insert_data); + } + + DB::table('albums_tags') + ->where('tag_id', $source->id) + ->whereIn('album_id', $source_album_ids) + ->delete(); + }); + } + private function handleTagAlbums(Tag $source, Tag $into, User $user): void { // Select all the albums impacted by this tag. diff --git a/app/Actions/Tag/TagCleanupTrait.php b/app/Actions/Tag/TagCleanupTrait.php index 97c0cdb91f2..7ba8904bae7 100644 --- a/app/Actions/Tag/TagCleanupTrait.php +++ b/app/Actions/Tag/TagCleanupTrait.php @@ -14,7 +14,7 @@ trait TagCleanupTrait { /** - * Cleans up tags that are not linked to any photos. + * Cleans up tags that are not linked to photos or albums. */ protected function cleanupUnusedTags(): void { @@ -27,6 +27,20 @@ function (Builder $query): void { ->whereColumn('photos_tags.tag_id', 'tags.id'); } ) + ->whereNotExists( + function (Builder $query): void { + $query->select(DB::raw(1)) + ->from('tag_albums_tags') + ->whereColumn('tag_albums_tags.tag_id', 'tags.id'); + } + ) + ->whereNotExists( + function (Builder $query): void { + $query->select(DB::raw(1)) + ->from('albums_tags') + ->whereColumn('albums_tags.tag_id', 'tags.id'); + } + ) ->pluck('id') ->all(); @@ -39,7 +53,10 @@ function (Builder $query): void { ->whereIn('tag_id', $ids) ->delete(); - // Remove any links to the tags in tag albums. + // Remove any links to the tags in albums and tag albums. + DB::table('albums_tags') + ->whereIn('tag_id', $ids) + ->delete(); DB::table('tag_albums_tags') ->whereIn('tag_id', $ids) ->delete(); diff --git a/app/Factories/AlbumFactory.php b/app/Factories/AlbumFactory.php index 19f6fc3ee30..ae2336e8bc5 100644 --- a/app/Factories/AlbumFactory.php +++ b/app/Factories/AlbumFactory.php @@ -122,7 +122,7 @@ public function findBaseAlbumOrFail(string $album_id, bool $with_relations = tru $tag_album_query = TagAlbum::query()->with(['access_permissions']); if ($with_relations) { - $album_query->with(['photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); + $album_query->with(['tags', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); $tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); } @@ -192,7 +192,7 @@ public function findBaseAlbumsOrFail(array $album_ids, bool $with_relations = tr if ($with_relations) { $tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); - $album_query->with(['photos', 'children', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); + $album_query->with(['tags', 'photos', 'children', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); } /** @var ($albums_only is true ? array : array)&array */ diff --git a/app/Http/Controllers/Gallery/AlbumController.php b/app/Http/Controllers/Gallery/AlbumController.php index 63471fa175b..ec4408f96d9 100644 --- a/app/Http/Controllers/Gallery/AlbumController.php +++ b/app/Http/Controllers/Gallery/AlbumController.php @@ -33,6 +33,7 @@ use App\Http\Requests\Album\MoveAlbumsRequest; use App\Http\Requests\Album\RenameAlbumRequest; use App\Http\Requests\Album\SetAlbumProtectionPolicyRequest; +use App\Http\Requests\Album\SetAlbumTagsRequest; use App\Http\Requests\Album\SetAlbumTrackRequest; use App\Http\Requests\Album\SetAsCoverRequest; use App\Http\Requests\Album\SetAsHeaderRequest; @@ -120,7 +121,19 @@ public function updateAlbum(UpdateAlbumRequest $request, SetHeader $set_header): shall_override: true ); - return EditableBaseAlbumResource::fromModel($album); + return EditableBaseAlbumResource::fromModel($album->loadMissing('tags')); + } + + /** + * Set the tags of an album. + */ + public function setAlbumTags(SetAlbumTagsRequest $request): void + { + $tags = $request->tags(); + $album = $request->album(); + + $existing_tags = Tag::from($tags); + $album->tags()->sync($existing_tags->pluck('id')->all()); } /** diff --git a/app/Http/Requests/Album/SetAlbumTagsRequest.php b/app/Http/Requests/Album/SetAlbumTagsRequest.php new file mode 100644 index 00000000000..5c20fc8bd9a --- /dev/null +++ b/app/Http/Requests/Album/SetAlbumTagsRequest.php @@ -0,0 +1,49 @@ + ['required', new RandomIDRule(false)], + RequestAttribute::TAGS_ATTRIBUTE => 'present|array', + RequestAttribute::TAGS_ATTRIBUTE . '.*' => 'required|string|min:1', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->album = Album::query() + ->with('tags') + ->findOrFail($values[RequestAttribute::ALBUM_ID_ATTRIBUTE]); + $this->tags = $values[RequestAttribute::TAGS_ATTRIBUTE]; + } +} diff --git a/app/Http/Resources/Editable/EditableBaseAlbumResource.php b/app/Http/Resources/Editable/EditableBaseAlbumResource.php index a819f8b5a9c..308d62412f5 100644 --- a/app/Http/Resources/Editable/EditableBaseAlbumResource.php +++ b/app/Http/Resources/Editable/EditableBaseAlbumResource.php @@ -70,10 +70,11 @@ public function __construct(Album|TagAlbum $album) $this->cover_id = $album->cover_id; $this->aspect_ratio = $album->album_thumb_aspect_ratio; $this->album_timeline = $album->album_timeline; + $this->tags = $album->tags->map(fn ($tag) => $tag->name)->all(); } if ($album instanceof TagAlbum) { - $this->tags = $album->tags->map(fn ($t) => $t->name)->all(); + $this->tags = $album->tags->map(fn ($tag) => $tag->name)->all(); $this->is_and = $album->is_and; } } diff --git a/app/Http/Resources/Models/HeadAlbumResource.php b/app/Http/Resources/Models/HeadAlbumResource.php index 9843fa3bca2..fac0371d4db 100644 --- a/app/Http/Resources/Models/HeadAlbumResource.php +++ b/app/Http/Resources/Models/HeadAlbumResource.php @@ -87,7 +87,7 @@ public function __construct(Album $album) $this->is_pinned = $album->is_pinned; if ($this->rights->can_edit) { - $this->editable = EditableBaseAlbumResource::fromModel($album); + $this->editable = EditableBaseAlbumResource::fromModel($album->loadMissing('tags')); } if (request()->configs()->getValueAsBool('metrics_enabled') && Gate::check(AlbumPolicy::CAN_READ_METRICS, [Album::class, $album])) { diff --git a/app/Models/Album.php b/app/Models/Album.php index 9ce8513bcaf..3c195d3cb16 100644 --- a/app/Models/Album.php +++ b/app/Models/Album.php @@ -33,6 +33,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Query\Builder as BaseBuilder; @@ -52,6 +53,7 @@ * @property Collection $children * @property int $num_children The number of children. * @property Collection $all_photos + * @property Collection $tags * @property int $num_photos The number of photos in this album (excluding photos in subalbums). * @property Carbon|null $max_taken_at Maximum taken_at timestamp of all photos in album and descendants. * @property Carbon|null $min_taken_at Minimum taken_at timestamp of all photos in album and descendants. @@ -230,6 +232,21 @@ public function photos(): HasManyChildPhotos return new HasManyChildPhotos($this); } + /** + * Returns the relationship between an album and all tags associated with it. + * + * @return BelongsToMany + */ + public function tags(): BelongsToMany + { + return $this->belongsToMany( + Tag::class, + 'albums_tags', + 'album_id', + 'tag_id', + ); + } + /** * Returns the relationship between this album and all photos incl. * photos which are recursive children of this album. diff --git a/database/migrations/2026_05_31_000001_add_albums_tags_table.php b/database/migrations/2026_05_31_000001_add_albums_tags_table.php new file mode 100644 index 00000000000..718356346a9 --- /dev/null +++ b/database/migrations/2026_05_31_000001_add_albums_tags_table.php @@ -0,0 +1,44 @@ +bigIncrements('id'); + $table->unsignedBigInteger(self::TAG_ID)->nullable(false); + $table->char(self::ALBUM_ID, self::RANDOM_ID_LENGTH)->nullable(false); + + $table->index([self::TAG_ID]); + $table->index([self::ALBUM_ID]); + $table->index([self::TAG_ID, self::ALBUM_ID]); + $table->unique([self::TAG_ID, self::ALBUM_ID]); + $table->foreign(self::TAG_ID)->references('id')->on('tags')->cascadeOnUpdate()->cascadeOnDelete(); + $table->foreign(self::ALBUM_ID)->references('id')->on('base_albums')->cascadeOnUpdate()->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('albums_tags'); + } +}; diff --git a/docs/specs/4-architecture/features/041-album-tags/plan.md b/docs/specs/4-architecture/features/041-album-tags/plan.md new file mode 100644 index 00000000000..7861de50772 --- /dev/null +++ b/docs/specs/4-architecture/features/041-album-tags/plan.md @@ -0,0 +1,17 @@ +# Feature 041 – Album Tags Plan + +## Scope +Implement direct tag assignment for regular albums across persistence, API, editable album resources, and the album properties UI. + +## Increments +1. Backend schema and model support (`albums_tags`, `Album::tags()`, resource/eager-loading updates). +2. Mutation API (`SetAlbumTagsRequest`, controller action, route). +3. Frontend wiring (`album-service.ts`, `AlbumProperties.vue`, translation key). +4. Verification (feature tests, formatting, TypeScript, PHPStan). + +## Verification +- `vendor/bin/php-cs-fixer fix` +- `npm run format` +- `npm run check` +- `php artisan test --filter AlbumTags` +- `make phpstan` diff --git a/docs/specs/4-architecture/features/041-album-tags/spec.md b/docs/specs/4-architecture/features/041-album-tags/spec.md new file mode 100644 index 00000000000..b0efcc8c082 --- /dev/null +++ b/docs/specs/4-architecture/features/041-album-tags/spec.md @@ -0,0 +1,24 @@ +# Feature 041 – Album Tags + +| Field | Value | +|-------|-------| +| Status | Complete | +| Last updated | 2026-05-31 | +| Owners | LycheeOrg | +| Linked plan | [plan.md](plan.md) | +| Linked tasks | [tasks.md](tasks.md) | +| Roadmap entry | #041 | + +## Overview +Allow regular albums to have tags associated with them directly, enabling organizational tagging and search by tag. Related issue: [#42](https://github.com/LycheeOrg/Lychee/issues/42). + +## Goals +1. Add a pivot table `albums_tags` for album-to-tag many-to-many relationship. +2. Expose `tags` on the `Album` model via `tags()` BelongsToMany. +3. Provide a `PATCH /api/Album::albumTags` endpoint to set tags on a regular album. +4. Include tags in `EditableBaseAlbumResource` for regular albums. +5. Allow users to edit album tags in the `AlbumProperties` panel. + +## Non-Goals +- Tag-based album search (search UI remains as-is). +- Inheriting tags from parent to child albums. diff --git a/docs/specs/4-architecture/features/041-album-tags/tasks.md b/docs/specs/4-architecture/features/041-album-tags/tasks.md new file mode 100644 index 00000000000..510060c048e --- /dev/null +++ b/docs/specs/4-architecture/features/041-album-tags/tasks.md @@ -0,0 +1,7 @@ +# Feature 041 – Album Tags Tasks + +- [x] Add backend album-tag persistence and eager loading. +- [x] Add album-tag mutation request, controller action, and API route. +- [x] Update album properties UI and service wiring for regular album tags. +- [x] Add feature coverage for album tag editing and editable resource output. +- [x] Run formatting, targeted tests, frontend checks, and PHPStan. diff --git a/docs/specs/4-architecture/features/042-order-item-photo-link/plan.md b/docs/specs/4-architecture/features/042-order-item-photo-link/plan.md new file mode 100644 index 00000000000..cc2e48ae262 --- /dev/null +++ b/docs/specs/4-architecture/features/042-order-item-photo-link/plan.md @@ -0,0 +1,135 @@ +# Feature Plan 042 – Order Item Photo Link + +_Linked specification:_ `docs/specs/4-architecture/features/042-order-item-photo-link/spec.md` +_Status:_ Planning +_Last updated:_ 2026-05-31 + +> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](docs/specs/4-architecture/open-questions.md), and assume clarifications are resolved only when the spec's normative sections (requirements/NFR/behaviour/telemetry) and, where applicable, ADRs under `docs/specs/5-decisions/` have been updated. + +## Vision & Success Criteria + +Operators and customers looking at an order-download page can immediately navigate to the source gallery of any purchased photo. When a gallery is deleted the UI provides a clear audit trail (photo ID reference) instead of a dead link. When both gallery and photo have been removed the item is visually de-emphasised as a historical record only. + +**Success signals:** +- `OrderItemResource` TypeScript type includes `album_exists: boolean` and `photo_exists: boolean` fields. +- All three rendering states (linked / forbidden / ghost) are visible in the browser for an order seeded with each condition. +- No N+1 queries: a 20-item order request hits the DB once for album checks and once for photo checks (eager-load batches). +- All existing PHP and Vue tests remain green; new tests pass for each scenario. + +## Scope Alignment + +**In scope:** +- `OrderItemResource` — add `album_exists` and `photo_exists` boolean fields (DO-042-01). +- `OrderResource::fromModel()` — eager-load `items.album` and `items.photo` whenever items are loaded. +- `OrderDownload.vue` — replace unconditional `RouterLink` with three-state conditional (UI-042-01 … UI-042-03). +- PHP feature tests for the new resource fields. +- Vue component tests for the three UI states. + +**Out of scope:** +- Changes to `OrderList.vue` (order-level table). +- Handling `album_id IS NULL` items beyond the existing ghost/muted fallback. +- Any UI changes on pages other than `OrderDownload.vue`. + +## Dependencies & Interfaces + +| Dependency | Detail | +|-----------|--------| +| `App\Models\OrderItem` | Has `album()` and `photo()` BelongsTo relations already defined; no model changes needed. | +| `App\Http\Resources\Shop\OrderItemResource` | Spatie Data class — adding fields triggers TypeScript type regeneration. | +| `App\Http\Resources\Shop\OrderResource` | Drives eager-loading strategy; `load('items.size_variant')` pattern to extend. | +| Spatie TypeScript Transformer | `npm run generate-types` (or project equivalent) must be run after PHP resource change. | +| `resources/js/views/webshop/OrderDownload.vue` | UI change; depends on new TypeScript type fields. | + +## Assumptions & Risks + +**Assumptions:** +- `OrderItem::album()` and `OrderItem::photo()` BelongsTo relations are already defined and return `null` when the referenced entity is deleted (no FK cascade → soft-absent). +- `php artisan test` and `npm run check` are the full test gates; no additional test runners required. +- TypeScript types are regenerated from PHP via a project script (e.g. `php artisan typescript:transform`). + +**Risks / Mitigations:** +- _Risk_: `orderResource` is returned from many checkout controller actions; eager-loading `items.album/photo` on all of them could add latency on hot checkout paths. + _Mitigation_: Load the relations only when `items` are already being loaded (i.e., guard with `$order->relationLoaded('items')`), so non-item-bearing responses are unaffected. +- _Risk_: PHPStan may flag the nullable `album` / `photo` relation access without null guards. + _Mitigation_: Use `$item->album !== null` and `$item->photo !== null` boolean checks; no direct attribute access on potentially null relations. + +## Implementation Drift Gate + +After each increment: run `vendor/bin/php-cs-fixer fix`, `php artisan test`, `make phpstan`, and `npm run check`. Record outcomes in the tasks checklist. If a test turns red and cannot be fixed in the same session, disable it with a `// TODO:` comment and log the follow-up in this plan's Follow-ups section. + +## Increment Map + +### I1 – Backend: Extend `OrderItemResource` with existence flags + +- _Goal:_ Add `album_exists: bool` and `photo_exists: bool` to the Spatie Data resource; update `OrderResource::fromModel()` to eager-load `items.album` and `items.photo` so the flags are set without N+1 queries. Regenerate TypeScript types. +- _Preconditions:_ `OrderItem::album()` and `OrderItem::photo()` BelongsTo relations confirmed present. +- _Steps:_ + 1. Write failing unit tests in `OrderItemResourceTest` for all four existence combinations (S-042-01 … S-042-05 backend coverage, NFR-042-01). + 2. Add `public bool $album_exists` and `public bool $photo_exists` constructor params to `OrderItemResource`. + 3. Update `OrderItemResource::fromModel()` to set both flags from the loaded relations: `album_exists: $item->album !== null`, `photo_exists: $item->photo !== null`. + 4. In `OrderResource::fromModel()`, extend the `load('items.size_variant')` call to `load(['items.size_variant', 'items.album', 'items.photo'])`, and add a parallel eager-load when items are already loaded (e.g. for basket responses that call `OrderItemResource::collect()` directly). + 5. Run `php artisan typescript:transform` (or equivalent) to regenerate `lychee.d.ts`. + 6. Run `vendor/bin/php-cs-fixer fix`, `make phpstan`, `php artisan test`. +- _Commands:_ `php artisan test --filter=OrderItemResource`, `make phpstan`, `vendor/bin/php-cs-fixer fix` +- _Exit:_ Tests green; PHPStan 0 errors; `lychee.d.ts` contains `album_exists` and `photo_exists` on `OrderItemResource`. + +### I2 – Frontend: Three-state title rendering in `OrderDownload.vue` + +- _Goal:_ Replace the unconditional `RouterLink` on the order-item title with a `v-if/v-else-if/v-else` block implementing UI-042-01, UI-042-02, and UI-042-03. +- _Preconditions:_ I1 complete; `OrderItemResource` TypeScript type includes `album_exists` and `photo_exists`. +- _Steps:_ + 1. Write Vitest component tests for the three states (linked / forbidden / ghost) in a new test file targeting `OrderDownload.vue` or an extracted `OrderItemTitle.vue` sub-component. + 2. Extract the title cell into a small `OrderItemTitle.vue` component (optional but recommended for testability) that accepts `item: App.Http.Resources.Shop.OrderItemResource` as a prop. + 3. Implement the three conditional blocks: + - `v-if="item.album_exists"` → `{{ item.title }}` + - `v-else-if="item.photo_exists"` → `{{ item.title }}{{ item.photo_id }}` + - `v-else` → `{{ item.title }}` + 4. Run `npm run format`, `npm run check`. +- _Commands:_ `npm run format`, `npm run check` +- _Exit:_ Vitest tests green; vue-tsc clean; browser smoke-test shows three states for a seeded order. + +### I3 – Quality Gates & Roadmap Update + +- _Goal:_ Full quality gate pass; update roadmap and knowledge map. +- _Steps:_ + 1. `vendor/bin/php-cs-fixer fix` + 2. `npm run format` + 3. `npm run check` + 4. `php artisan test` + 5. `make phpstan` + 6. Update `docs/specs/4-architecture/roadmap.md` — move Feature 042 status to Complete. + 7. Update `docs/specs/4-architecture/knowledge-map.md` — note `OrderItemResource` extension under Shop Implementation. +- _Exit:_ All gates green; roadmap and knowledge map updated. + +## Scenario Tracking + +| Scenario ID | Increment / Task reference | Notes | +|-------------|---------------------------|-------| +| S-042-01 | I2 / T-042-04 | RouterLink rendered when album exists. | +| S-042-02 | I2 / T-042-04 | RouterLink with null photoId when album exists. | +| S-042-03 | I2 / T-042-05 | Forbidden icon + photo_id when album deleted. | +| S-042-04 | I2 / T-042-06 | Ghost italic when both deleted. | +| S-042-05 | I2 / T-042-06 | Ghost italic when both IDs null. | +| S-042-06 | I1 / T-042-02 | N+1 prevention via eager-load. | + +## Analysis Gate + +_Not yet run. Gate checklist at [docs/specs/5-operations/analysis-gate-checklist.md](docs/specs/5-operations/analysis-gate-checklist.md) to be executed before I1 implementation begins._ + +## Exit Criteria + +- [ ] `OrderItemResource` TypeScript type includes `album_exists: boolean` and `photo_exists: boolean`. +- [ ] `OrderResource::fromModel()` eager-loads `items.album` and `items.photo` without N+1 queries. +- [ ] `OrderDownload.vue` renders the three states (linked / forbidden / ghost) based on the flags. +- [ ] PHP feature tests cover all four existence combinations. +- [ ] Vue component tests cover all three rendering states. +- [ ] `vendor/bin/php-cs-fixer fix` → no diff. +- [ ] `php artisan test` → all green. +- [ ] `make phpstan` → 0 errors. +- [ ] `npm run check` → passes. +- [ ] Roadmap and knowledge map updated. + +## Follow-ups / Backlog + +- Consider adding a similar three-state navigation to the `BasketList.vue` / checkout preview for consistency (out of scope for this increment). +- If the `album_id IS NULL` case (album-level purchases without a photo) grows in importance, add a dedicated display state for it in a follow-on feature. diff --git a/docs/specs/4-architecture/features/042-order-item-photo-link/spec.md b/docs/specs/4-architecture/features/042-order-item-photo-link/spec.md new file mode 100644 index 00000000000..1b7a0c7d363 --- /dev/null +++ b/docs/specs/4-architecture/features/042-order-item-photo-link/spec.md @@ -0,0 +1,175 @@ +# Feature 042 – Order Item Photo Link + +| Field | Value | +|-------|-------| +| Status | Planning | +| Last updated | 2026-05-31 | +| Owners | LycheeOrg | +| Linked plan | `docs/specs/4-architecture/features/042-order-item-photo-link/plan.md` | +| Linked tasks | `docs/specs/4-architecture/features/042-order-item-photo-link/tasks.md` | +| Roadmap entry | #042 | + +> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in [docs/specs/4-architecture/open-questions.md](docs/specs/4-architecture/open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/5-decisions/` for architecturally significant clarifications (referencing their IDs from the relevant spec sections). + +## Overview + +The webshop Order Download page (`OrderDownload.vue`) currently displays each purchased item's title as plain text only. +When a title matches a file name that appears in multiple albums, operators and customers cannot identify which gallery the image came from. +This feature adds three-state navigation cues to each order item title: a clickable link when the source album still exists, a red forbidden badge with the photo ID when only the album is gone, and an italic/muted title when both the album and photo records have been deleted. + +Affected modules: REST (Shop), UI (webshop/OrderDownload). + +## Goals + +1. Clickable link from an order-item title to the album/photo in the gallery viewer when the album entity still exists. +2. Red forbidden icon (`pi pi-ban`) plus the stored `photo_id` when the album is deleted but the photo record still exists. +3. Italic muted title when both the album and photo records no longer exist in the database. +4. Backend `OrderItemResource` exposes `album_exists` and `photo_exists` boolean flags so the frontend can derive the state without additional API calls. +5. No N+1 queries: album and photo presence checked via eager-loaded relations. + +## Non-Goals + +- Resurrecting deleted albums or photos. +- Changing how the order-item title itself is stored or displayed in non-webshop views. +- Handling the edge-case of `album_id IS NULL` (album-level purchases) beyond the existing plain-text rendering. +- Modifying the `OrderList.vue` admin table (which shows order-level summaries, not item-level titles). + +## Functional Requirements + +| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | +|----|-------------|--------------|-----------------|--------------|--------------------|----| +| FR-042-01 | `OrderItemResource` exposes `album_exists: bool` and `photo_exists: bool`. | Backend sets `album_exists = true` when the `album` relation is non-null after eager load; `photo_exists = true` when the `photo` relation is non-null. | PHPStan level-6 clean; TypeScript type regenerated via `npm run generate-types` (or equivalent). | If eager-load fails, the flag defaults to `false` so the degraded UI state is shown rather than a broken link. | — | Problem statement; OrderItem model. | +| FR-042-02 | `OrderResource::fromModel()` eager-loads `items.album` and `items.photo` relations whenever items are loaded. | No additional queries when the `OrderItemResource` reads the flags. | PHPStan; `php artisan test`. | Relation load failure surfaced as `album_exists=false`. | — | NFR-042-01 (N+1 prevention). | +| FR-042-03 | **Linked state**: when `album_id` is non-null AND `album_exists === true`, the order-item title renders as a `RouterLink` navigating to `{ name: 'album', params: { albumId, photoId } }`. | Clicking the title opens the correct gallery album/photo view in a new tab or same window. | Vue component renders `` tag; `album_id` prop non-null. | Not applicable — state is reached only when album exists. | — | Problem statement. | +| FR-042-04 | **Forbidden state**: when `album_id` is non-null AND `album_exists === false` AND `photo_exists === true`, the title cell shows a red `pi pi-ban` icon followed by the `photo_id` string as a textual reference. | User sees: `🚫 ` next to the stored title, visually distinguishing the item. | Template conditional; unit/component test. | — | — | Problem statement. | +| FR-042-05 | **Ghost state**: when `album_exists === false` AND `photo_exists === false` (or `album_id` is null and `photo_id` is null), the title renders as italic and muted (`text-muted-color italic`). | Title shows in muted italic styling. | Template conditional; unit/component test. | — | — | Problem statement. | +| FR-042-06 | The existing `RouterLink` on line 118 of `OrderDownload.vue` is replaced by the three-state conditional so no broken links are emitted when IDs are null or entities are deleted. | No 404 navigation errors in the browser console for orders with deleted albums/photos. | Manual smoke test with seeded stale order items; automated test. | — | — | Existing code analysis. | + +## Non-Functional Requirements + +| ID | Requirement | Driver | Measurement | Dependencies | Source | +|----|-------------|--------|-------------|--------------|--------| +| NFR-042-01 | Fetching a single order must not issue more than O(1) additional queries regardless of the number of line items. | Performance / avoid N+1. | No additional query count increase when comparing a 1-item vs 20-item order response in debugbar / query log. | Eloquent eager-loading (`with`/`load`). | Problem statement; Laravel best practice. | +| NFR-042-02 | TypeScript types for `OrderItemResource` stay in sync with the PHP definition. | Type safety across REST/UI boundary. | `npm run generate-types` produces no diff on CI. | Spatie TypeScript Transformer. | Coding conventions. | +| NFR-042-03 | All new PHP conforms to coding conventions: strict comparison, no `empty()`, `in_array()` with third param `true`, license headers in new files. | Consistency. | `vendor/bin/php-cs-fixer fix` leaves no diff; PHPStan level-6 passes. | php-cs-fixer, PHPStan. | `docs/specs/3-reference/coding-conventions.md`. | + +## UI / Interaction Mock-ups (required for UI-facing work) + +Three states for the title cell inside the order-items list on `OrderDownload.vue`: + +``` + Order Items + ┌───────────────────────────────────────────────────────────────┐ + │ Title / Navigation Size Variant License Price │ + ├───────────────────────────────────────────────────────────────┤ + │ │ + │ [State 1 – Linked] │ + │ ┌───────────────────────────────────────────────────────┐ │ + │ │ 🔗 beach-sunset-2024.jpg ← RouterLink (primary-color) │ │ + │ │ MEDIUM · PERSONAL · $12.00 │ │ + │ └───────────────────────────────────────────────────────┘ │ + │ │ + │ [State 2 – Forbidden (album deleted, photo still exists)] │ + │ ┌───────────────────────────────────────────────────────┐ │ + │ │ 🚫 beach-sunset-2024.jpg │ │ + │ │ photo_id: AbCdEfGhIj123456 │ │ + │ │ MEDIUM · PERSONAL · $12.00 │ │ + │ └───────────────────────────────────────────────────────┘ │ + │ │ + │ [State 3 – Ghost (album + photo deleted)] │ + │ ┌───────────────────────────────────────────────────────┐ │ + │ │ *beach-sunset-2024.jpg* ← italic + muted colour │ │ + │ │ MEDIUM · PERSONAL · $12.00 │ │ + │ └───────────────────────────────────────────────────────┘ │ + └───────────────────────────────────────────────────────────────┘ +``` + +## Branch & Scenario Matrix + +| Scenario ID | Description / Expected outcome | +|-------------|-------------------------------| +| S-042-01 | Album exists, photo_id set → title is a `RouterLink` linking to `{ name:'album', params:{ albumId, photoId } }`. | +| S-042-02 | Album exists, photo_id null (album purchase) → title is a `RouterLink` linking to `{ name:'album', params:{ albumId } }`. | +| S-042-03 | Album deleted, photo still exists → title area shows `pi-ban` icon (red) + `photo_id` text; no broken link. | +| S-042-04 | Both album and photo deleted → title renders italic and `text-muted-color`; no link or badge. | +| S-042-05 | `album_id` is null AND `photo_id` is null (historical/album-level item) → title renders italic and muted (ghost state). | +| S-042-06 | Order with 20 line items → exactly one additional query batch (eager load of album + photo), not 40 individual queries. | + +## Test Strategy + +- **Application (PHP Feature tests):** + - `OrderItemResourceTest` — asserts `album_exists` and `photo_exists` flags for all four combinations (both exist / album only / photo only / neither). + - Verifies `OrderResource::fromModel()` eager-loads relations (query count assertion with `DB::enableQueryLog()`). +- **UI (Vue component tests):** + - Three snapshot/selector tests on `OrderDownload.vue` (one per state) using Vitest + Vue Test Utils, verifying `RouterLink` presence/absence and CSS classes. +- **REST:** + - Existing checkout/order feature tests remain green; add assertions for the new flags in the order JSON response. + +## Interface & Contract Catalogue + +### Domain Objects + +| ID | Description | Modules | +|----|-------------|---------| +| DO-042-01 | `OrderItemResource` — extended with `album_exists: bool` and `photo_exists: bool` fields. | REST, UI | + +### API Routes / Services + +| ID | Transport | Description | Notes | +|----|-----------|-------------|-------| +| API-042-01 | REST GET `/api/v2/Shop/Order/{id}` | Response payload now includes `album_exists` and `photo_exists` on each item. | Additive change; no breaking modification. | + +### UI States + +| ID | State | Trigger / Expected outcome | +|----|-------|---------------------------| +| UI-042-01 | Linked | `item.album_exists === true` → `RouterLink` to album/photo. | +| UI-042-02 | Forbidden | `item.album_exists === false && item.photo_exists === true` → red `pi-ban` icon + `photo_id` text. | +| UI-042-03 | Ghost | `item.album_exists === false && item.photo_exists === false` (or both IDs null) → italic muted title. | + +## Telemetry & Observability + +No new telemetry events required. The new flags are computed at query time from existing relations. + +## Documentation Deliverables + +- Update `docs/specs/4-architecture/roadmap.md` — add Feature 042 to Active Features. +- Update `docs/specs/4-architecture/knowledge-map.md` — note the `OrderItemResource` extension under Shop Implementation. + +## Fixtures & Sample Data + +No new test-vector fixtures required. Existing `OrderItemFactory` and seeded test data cover the scenarios; specific relation presence/absence is controlled inline in the tests via factories. + +## Spec DSL + +```yaml +domain_objects: + - id: DO-042-01 + name: OrderItemResource + fields: + - name: album_exists + type: bool + constraints: "true when album relation is non-null after eager load" + - name: photo_exists + type: bool + constraints: "true when photo relation is non-null after eager load" +routes: + - id: API-042-01 + method: GET + path: /api/v2/Shop/Order/{id} +ui_states: + - id: UI-042-01 + description: Linked — RouterLink to album/photo + - id: UI-042-02 + description: Forbidden — pi-ban icon + photo_id text + - id: UI-042-03 + description: Ghost — italic muted title +``` + +## Appendix + +### Existing code note + +`OrderDownload.vue` line 118 (as of 2026-05-31) already wraps the title in a `RouterLink` but does not guard for null `album_id` or deleted albums/photos. This feature replaces that unconditional link with the three-state conditional (FR-042-03 through FR-042-05). + +`OrderResource::fromModel()` conditionally loads `items.size_variant` only when the order status is `CLOSED`. The new eager-load of `items.album` and `items.photo` should be applied whenever items are loaded (not gated on status) so the existence flags are always accurate. diff --git a/docs/specs/4-architecture/features/042-order-item-photo-link/tasks.md b/docs/specs/4-architecture/features/042-order-item-photo-link/tasks.md new file mode 100644 index 00000000000..06e6d872314 --- /dev/null +++ b/docs/specs/4-architecture/features/042-order-item-photo-link/tasks.md @@ -0,0 +1,81 @@ +# Feature 042 Tasks – Order Item Photo Link + +_Status: Planning_ +_Last updated: 2026-05-31_ + +> Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes). +> **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done. +> When referencing requirements, keep feature IDs (`FR-`), non-goal IDs, and scenario IDs (`S-042-`) inside the same parentheses immediately after the task title. +> When new high- or medium-impact questions arise during execution, add them to [docs/specs/4-architecture/open-questions.md](docs/specs/4-architecture/open-questions.md) instead of informal notes, and treat a task as fully resolved only once the governing spec sections reflect the clarified behaviour. + +## Checklist + +### I1 – Backend: Extend `OrderItemResource` with existence flags + +- [ ] T-042-01 – Write failing PHP tests for `OrderItemResource` existence flags (FR-042-01, S-042-06). + _Intent:_ Create `tests/Feature_v2/OrderItemResourceTest.php` (or equivalent test class extending `BaseApiWithDataTest`) that seeds an `OrderItem` linked to an existing album+photo, a deleted album (photo still present), a deleted photo (album still present), and both deleted. Assert that `album_exists` and `photo_exists` are set correctly in each case. + _Verification commands:_ + - `php artisan test --filter=OrderItemResource` + _Notes:_ Tests must fail (no `album_exists` field yet). Use `DatabaseTransactions` trait per project convention. + +- [ ] T-042-02 – Extend `OrderItemResource` with `album_exists` and `photo_exists` (FR-042-01, FR-042-02, NFR-042-01). + _Intent:_ Add `public bool $album_exists` and `public bool $photo_exists` to the `OrderItemResource` Spatie Data constructor. Update `fromModel()` to read `$item->album !== null` and `$item->photo !== null`. In `OrderResource::fromModel()`, extend all `load('items…')` calls to include `items.album` and `items.photo` so the flags are populated without N+1 queries. + _Verification commands:_ + - `vendor/bin/php-cs-fixer fix` + - `php artisan test --filter=OrderItemResource` + - `make phpstan` + _Notes:_ Guard with `$item->relationLoaded('album')` before reading the relation if there is any path where the relation is not loaded, to avoid lazy-loading and silent N+1 regression. + +- [ ] T-042-03 – Regenerate TypeScript types and verify (NFR-042-02). + _Intent:_ Run the project's TypeScript-transform artisan command (e.g. `php artisan typescript:transform`) to regenerate `resources/js/lychee.d.ts`. Confirm that `OrderItemResource` in the output includes `album_exists: boolean` and `photo_exists: boolean`. + _Verification commands:_ + - `php artisan typescript:transform` (or project equivalent) + - `npm run check` + _Notes:_ If the transform command differs from the above, document the correct command in this tasks file. + +### I2 – Frontend: Three-state title rendering in `OrderDownload.vue` + +- [ ] T-042-04 – Write failing Vue component tests for linked state (FR-042-03, S-042-01, S-042-02). + _Intent:_ Add a Vitest test that renders `OrderDownload.vue` (or an extracted `OrderItemTitle.vue`) with a mock item where `album_exists: true`. Assert that a `RouterLink` (or ``) element is present with the correct `to` params. Tests must fail before the code change. + _Verification commands:_ + - `npm run check` + +- [ ] T-042-05 – Write failing Vue component tests for forbidden state (FR-042-04, S-042-03). + _Intent:_ Add a test with `album_exists: false, photo_exists: true`. Assert that a `.pi-ban` icon element is present (with a red-colour class) and that the `photo_id` text appears. Assert no `RouterLink` is present. + _Verification commands:_ + - `npm run check` + +- [ ] T-042-06 – Write failing Vue component tests for ghost state (FR-042-05, S-042-04, S-042-05). + _Intent:_ Add a test with `album_exists: false, photo_exists: false` (and a variant with both IDs null). Assert that the title renders with `.italic` and `.text-muted-color` classes. Assert no `RouterLink` and no `pi-ban` are present. + _Verification commands:_ + - `npm run check` + +- [ ] T-042-07 – Implement three-state conditional in `OrderDownload.vue` (FR-042-03, FR-042-04, FR-042-05, FR-042-06). + _Intent:_ Replace the unconditional `RouterLink` at the order-item title location in `OrderDownload.vue` with a `v-if / v-else-if / v-else` block: + - `v-if="item.album_exists"` → `RouterLink` to `{ name: 'album', params: { albumId: item.album_id, photoId: item.photo_id } }` + - `v-else-if="item.photo_exists"` → `` + title text + `photo_id` in small muted span + - `v-else` → `{{ item.title }}` + _Verification commands:_ + - `npm run format` + - `npm run check` + _Notes:_ If extracting to `OrderItemTitle.vue` sub-component, ensure it is registered and imported in `OrderDownload.vue`. Follow the existing PrimeVue / Tailwind class conventions already used in the file. + +### I3 – Quality Gates & Documentation + +- [ ] T-042-08 – Full quality gate pass (NFR-042-01, NFR-042-02, NFR-042-03). + _Intent:_ Execute the complete quality gate and confirm all checks pass. + _Verification commands:_ + - `vendor/bin/php-cs-fixer fix` + - `npm run format` + - `npm run check` + - `php artisan test` + - `make phpstan` + +- [ ] T-042-09 – Update roadmap and knowledge map. + _Intent:_ In `docs/specs/4-architecture/roadmap.md`, move Feature 042 from Active to Completed (or update status to Complete). In `docs/specs/4-architecture/knowledge-map.md`, update the Shop Implementation entry to note that `OrderItemResource` now exposes `album_exists` and `photo_exists`. + _Verification commands:_ None (documentation only). + +## Notes / TODOs + +- T-042-02: If `BasketController` / `CheckoutController` constructs `OrderItemResource::collect()` directly from items loaded without the album/photo relations, add a `$items->loadMissing(['album', 'photo'])` call before the collect. Confirm all code paths in `OrderResource::fromModel()` and `BasketService` that produce `OrderItemResource` collections. +- T-042-07: The `item.photo_id` shown in the forbidden state should be the raw stored string (e.g. a short hash). No UI truncation is required at this stage — add a follow-up if needed. diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md index 810d6da620a..6fff4f4624b 100644 --- a/docs/specs/4-architecture/roadmap.md +++ b/docs/specs/4-architecture/roadmap.md @@ -6,6 +6,7 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Status | Priority | Assignee | Started | Updated | Progress | |------------|------|--------|----------|----------|---------|---------|----------| +| 042 | Order Item Photo Link | Planning | P2 | LycheeOrg | 2026-05-31 | 2026-05-31 | Spec, plan, tasks drafted. 9 tasks across 3 increments (I1 backend flags, I2 frontend three-state render, I3 quality gates). No open questions. Ready to begin T-042-01. | | 040 | Disable Request Caching | Planning | P2 | LycheeOrg | 2026-05-18 | 2026-05-18 | Spec, plan, tasks drafted. 9 tasks across 5 increments (I1 migration, I2 feature flag + .env.example, I3 controller filter, I4 feature tests, I5 quality gates). No open questions. Ready to begin T-040-01. | ## Paused Features @@ -18,6 +19,7 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Completed | Notes | |------------|------|-----------|-------| +| 041 | Album Tags | 2026-05-31 | Added direct tags for regular albums via `albums_tags`, `Album::albumTags` PATCH API, editable resource/head loading, AlbumProperties UI editing, and validated with frontend checks, targeted tests, and PHPStan. | | 037 | Admin Dashboard & `/admin/` URL Reorg | 2026-04-22 | Config migration (`use_admin_dashboard` toggle), `AdminStatsService` with 5-min cache, `GET /api/v2/Admin/Stats` endpoint, 9 admin views relocated to `views/admin/`, `AdminDashboard.vue` tile grid + stats panel + Refresh, left-menu collapse toggle, 22-locale i18n, 13 backend tests passing, TypeScript/PHPStan clean. | | 034 | Bulk Album Edit | 2026-04-12 | Spec, plan, tasks drafted. 25 tasks across 11 increments (I1 backend scaffold, I2-I6 REST endpoints, I7-I10 frontend, I11 quality gates). 4 open questions (Q-034-01 to Q-034-04; 1 high, 2 medium, 1 low). Ready to begin T-034-01 once Q-034-03 resolved. | | 032 | Security Advisories Check | 2026-04-06 | Spec, plan, tasks drafted. 18 tasks across 6 increments (I1 config/DTO, I2 fetch service, I3 diagnostic pipe, I4 REST endpoint, I5 frontend modal, I6 quality gates). All open questions resolved in spec. Ready to begin T-032-01. | @@ -111,4 +113,4 @@ features/ --- -*Last updated: 2026-05-18 (Feature 040 planned — Disable Request Caching)* +*Last updated: 2026-05-31 (Feature 042 planning — Order Item Photo Link)* diff --git a/lang/en/gallery.php b/lang/en/gallery.php index 1324a3de326..35f9d9b83aa 100644 --- a/lang/en/gallery.php +++ b/lang/en/gallery.php @@ -193,6 +193,7 @@ 'album_timeline' => 'Set album timeline mode', 'photo_timeline' => 'Set photo timeline mode', 'layout' => 'Set photo layout', + 'tags' => 'Tags', 'show_tags' => 'Set tags to show', 'tags_required' => 'Tags are required.', 'all_tags_must_match' => 'All tags must match.', diff --git a/resources/js/components/forms/album/AlbumProperties.vue b/resources/js/components/forms/album/AlbumProperties.vue index f0f2f714b1f..c3ed1135b61 100644 --- a/resources/js/components/forms/album/AlbumProperties.vue +++ b/resources/js/components/forms/album/AlbumProperties.vue @@ -84,7 +84,7 @@ -