Skip to content

feat(34): add bulk album edit#4296

Open
ildyria wants to merge 18 commits intomasterfrom
copilot/add-bulk-editing-feature
Open

feat(34): add bulk album edit#4296
ildyria wants to merge 18 commits intomasterfrom
copilot/add-bulk-editing-feature

Conversation

@ildyria
Copy link
Copy Markdown
Member

@ildyria ildyria commented Apr 14, 2026

Summary by CodeRabbit

  • New Features

    • Admin Bulk Album Edit: paginated or infinite-scroll list with search, select-all-matching (1,000 cap), inline edits, and modals for bulk Edit Fields, Set Owner, and Delete; new admin UI route and menu entry; client service integration.
  • Localization

    • UI translations added for 23+ languages.
  • Documentation

    • Full spec, plan, tasks, roadmap and resolved questions for Bulk Album Edit.
  • Tests

    • End-to-end feature tests for list, ids, patch, set-owner, and delete flows.

Copilot AI and others added 2 commits April 14, 2026 21:32
…stions

Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an admin Bulk Album Edit feature: backend action and DTO, controller endpoints (index/ids/patch/setOwner/delete) with requests/resources, frontend page/components and service, routes/menu, translations, docs, and feature tests; includes minor JS fixes and search/model signature updates.

Changes

Cohort / File(s) Summary
Core backend logic
app/Actions/Admin/BulkEditAlbumsAction.php, app/DTO/BulkAlbumPatchData.php
New action to apply partial bulk updates (separate mass-updates for base_albums vs albums) and per-album visibility overlay. DTO preserves presence semantics and exposes fromValidated() and has().
Controller & routing
app/Http/Controllers/Admin/BulkAlbumController.php, routes/api_v2.php, routes/web_v2.php, app/Metadata/Cache/RouteCacheManager.php
New admin controller with five endpoints; routes registered; route-cache entries marked non-cacheable.
Form requests
app/Http/Requests/Admin/BulkAlbumEdit/*
Added Index/Ids/Patch/SetOwner/Delete requests with admin-only authorization, detailed validation (enums, booleans, required_with), presence tracking; Patch constructs BulkAlbumPatchData.
API resources
app/Http/Resources/Admin/BulkAlbumResource.php, .../BulkAlbumIdsResource.php, .../PaginatedBulkAlbumResource.php
Spatie Data DTOs (TypeScript-ready): album rows include _lft/_rgt, visibility/grants; IDs response includes capped; paginated wrapper added.
Frontend: page, dialogs & service
resources/js/views/BulkAlbumEdit.vue, resources/js/components/forms/bulk-album-edit/*, resources/js/services/bulk-album-edit-service.ts
New Vue page with pagination/infinite-scroll, selection (page/all matching with 1,000 cap), inline edits/toggles, bulk-edit and set-owner dialogs, and typed axios service methods.
Frontend routing & menu
resources/js/router/routes.ts, resources/js/composables/contextMenus/leftMenu.ts
Added lazy route /bulk-album-edit and admin left-menu entry.
Translations
lang/*/bulk_album_edit.php
Added localization files across many locales for UI copy, modals, and toasts.
Docs & planning
docs/specs/4-architecture/features/034-bulk-album-edit/*
Added plan, spec, tasks, open-questions updates, and roadmap entry describing API contract, UI behaviors, increments, and tests.
Tests
tests/Feature_v2/BulkAlbumEdit/*
Feature tests for index/ids/patch/setOwner/delete covering auth (401/403), validation (422), listing/search/pagination/order, IDs cap, partial patch semantics (including access-permission create/remove), ownership transfer, and delete.
Minor JS fixes
resources/js/config/constants.ts, resources/js/components/.../AlbumProperties.vue, resources/js/components/settings/*.vue, resources/js/services/renamer-service.ts, resources/js/services/webhook-service.ts
Renamed aspect-ratio constant (aspectRationOptionsaspectRatioOptions) and updated usages; updated file header years (non-functional).
Search strategy signature
app/Actions/Search/Strategies/Album/AlbumFieldLikeStrategy.php
apply() now accepts `Builder
Model docblock
app/Models/Album.php
Docblock updated to declare album_sorting_col and album_sorting_order properties.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I hopped through branches, code, and light,

Checked each checkbox, set each flag just right.
Dialogs opened, ids in tidy rows,
Admins click carrots—edits swift as those.
A tiny rabbit shipped the feature tonight.

🚥 Pre-merge checks | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI and others added 2 commits April 14, 2026 20:55
…uests, resources, routes, tests

Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/d0cca004-8d62-4085-9349-2c4620ad4010

Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com>
…enu, translations, Vue component

Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/d0cca004-8d62-4085-9349-2c4620ad4010

Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com>
@ildyria ildyria marked this pull request as ready for review April 20, 2026 19:47
@ildyria ildyria requested a review from a team as a code owner April 20, 2026 19:47
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

🧹 Nitpick comments (12)
docs/specs/4-architecture/open-questions.md (1)

3002-3056: Resolved Q-034 entries look consistent.

The four Q-034 entries follow the established format (Feature/Priority/Status/Opened/Resolved/Resolution/Spec Impact) used by adjacent resolved questions and capture the decisions that drive the implementation (TagAlbum exclusion, client-side depth via _lft/_rgt, delete-confirmation modal, unfiltered "select all matching").

Minor nit: the coding guideline asks that Markdown docs end with an hr + *Last updated: [date]*. This file already has *Last updated: 2026-03-15* at line 2718 but subsequent additions (Q-023-01, Q-030-01..12, Q-033-, now Q-034-) have been appended after it. Consider moving that footer to the very end and refreshing the date, so it reflects the actual last update.

As per coding guidelines: "At the bottom of documentation files, add an hr line followed by "Last updated: [date of the update]""

tests/Feature_v2/BulkAlbumEdit/SetOwnerTest.php (1)

88-89: Use snake_case for the local variable.

$subAlbum should be $sub_album to match the PHP variable naming convention.

♻️ Proposed rename
-		$subAlbum = Album::find($this->subAlbum1->id);
-		$this->assertSame($this->userMayUpload2->id, $subAlbum->owner_id);
+		$sub_album = Album::find($this->subAlbum1->id);
+		$this->assertSame($this->userMayUpload2->id, $sub_album->owner_id);

As per coding guidelines, “Variable names should be in snake_case”.

app/Http/Requests/Admin/BulkAlbumEdit/PatchBulkAlbumRequest.php (1)

77-117: DRY: extract the optional-fields list into a class constant.

The same 17-element $optional_fields array is duplicated in after() and processValidatedValues(). If a new field is added later, it's easy to forget one of the two locations, leading to silently-ignored fields in the DTO. Extract to a private constant.

♻️ Suggested refactor
 class PatchBulkAlbumRequest extends BaseApiRequest
 {
 	protected BulkAlbumPatchData $bulk_album_patch_data;
+
+	private const OPTIONAL_FIELDS = [
+		'description', 'copyright', 'license', 'photo_layout',
+		'photo_sorting_col', 'photo_sorting_order',
+		'album_sorting_col', 'album_sorting_order',
+		'album_thumb_aspect_ratio', 'album_timeline', 'photo_timeline',
+		'is_nsfw', 'is_public', 'is_link_required',
+		'grants_full_photo_access', 'grants_download', 'grants_upload',
+	];
@@
 	public function after(): array
 	{
 		return [
 			function (\Illuminate\Validation\Validator $validator): void {
-				$optional_fields = [
-					'description', 'copyright', 'license', 'photo_layout',
-					'photo_sorting_col', 'photo_sorting_order',
-					'album_sorting_col', 'album_sorting_order',
-					'album_thumb_aspect_ratio', 'album_timeline', 'photo_timeline',
-					'is_nsfw', 'is_public', 'is_link_required',
-					'grants_full_photo_access', 'grants_download', 'grants_upload',
-				];
-
 				$has_any = false;
-				foreach ($optional_fields as $field) {
+				foreach (self::OPTIONAL_FIELDS as $field) {
 					if ($this->has($field)) {
 						$has_any = true;
 						break;
 					}
 				}
@@
 	protected function processValidatedValues(array $values, array $files): void
 	{
-		$optional_fields = [
-			'description', 'copyright', 'license', 'photo_layout',
-			'photo_sorting_col', 'photo_sorting_order',
-			'album_sorting_col', 'album_sorting_order',
-			'album_thumb_aspect_ratio', 'album_timeline', 'photo_timeline',
-			'is_nsfw', 'is_public', 'is_link_required',
-			'grants_full_photo_access', 'grants_download', 'grants_upload',
-		];
-
 		$present = [];
-		foreach ($optional_fields as $field) {
+		foreach (self::OPTIONAL_FIELDS as $field) {
 			if ($this->has($field)) {
 				$present[] = $field;
 			}
 		}
app/Actions/Admin/BulkEditAlbumsAction.php (1)

30-35: Prefer constructor injection over new SetProtectionPolicy().

Since BulkEditAlbumsAction is instantiated inside the controller (new BulkEditAlbumsAction() in BulkAlbumController::patch) and SetProtectionPolicy has no constructor args either, the current form works but forgoes Laravel's container for no gain. If you also convert the controller to app(BulkEditAlbumsAction::class) or type-hint it in the method, you get testability (mock injection) for free.

resources/js/services/bulk-album-edit-service.ts (1)

83-90: data: {} on axios.get is unnecessary and inconsistent.

axios.get(url, { params, data: {} }) is unusual — GET requests do not carry a body, and data is ignored by most servers. If there's a specific reason (e.g. a proxy or request interceptor in the project requires it), please leave a comment; otherwise drop it to match the patch/post calls below.

app/Http/Controllers/Admin/BulkAlbumController.php (1)

99-107: Re-constructed LengthAwarePaginator loses path/query info and duplicates work.

Creating a brand-new LengthAwarePaginator drops the request path, query string, and page name that the original $paginated had set, so any downstream rendering of next_page_url/prev_page_url is broken. setCollection() maps items in-place and preserves everything else.

♻️ Suggested refactor
-		$resource_collection = $paginated->getCollection()->map(fn ($row) => $this->rowToResource($row));
-		$mapped = new \Illuminate\Pagination\LengthAwarePaginator(
-			$resource_collection,
-			$paginated->total(),
-			$paginated->perPage(),
-			$paginated->currentPage(),
-		);
-
-		return new PaginatedBulkAlbumResource($mapped);
+		$paginated->setCollection(
+			$paginated->getCollection()->map(fn ($row) => $this->rowToResource($row))
+		);
+
+		return new PaginatedBulkAlbumResource($paginated);
resources/js/views/BulkAlbumEdit.vue (3)

337-340: UsersService.count() runs eagerly at <script setup> execution, unhandled rejections.

Moving this to onMounted() keeps component initialization cheap and synchronous with the rest of the lifecycle; more importantly, add a .catch() so a transient 5xx doesn't trigger an unhandled promise rejection in the console.

🛠️ Suggested fix
 const numUsers = ref(0);
-UsersService.count().then((data) => {
-	numUsers.value = data.data;
-});
+onMounted(() => {
+	UsersService.count()
+		.then((data) => {
+			numUsers.value = data.data;
+		})
+		.catch(() => {
+			// non-fatal: hide the "Set Owner" button by default
+			numUsers.value = 0;
+		});
+});

367-367: activeSortingSelect template-ref pattern is fragile.

The same ref="activeSortingSelect" is declared on two Select elements (photo sorting at line 204, album sorting at line 244) that are alternated by v-if. Vue treats same-named refs inside a v-for as an array, but here both <Select> are outside a v-for (they sit inside each row's cell). The ref is typed as an array (ref<Array<{...}>>([])), and you then call activeSortingSelect.value[0]?.show() — which assumes the currently-rendered Select lands at index 0. This happens to work when only one is visible, but it's easy to break when a row re-renders or both cells race during transition. Prefer separate refs per sorting mode.

Also applies to: 501-509


129-131: selectedIds.includes(...) on every row is O(n) per render.

With selectedIds as an array and isPageAllSelected / row checkboxes calling .includes(), rendering 100+ rows is O(n·m). Switching selectedIds to a Set<string> (or maintaining a companion Set computed once per change) keeps per-row lookup O(1) and materially improves responsiveness when "select all matching" dumps 1 000 IDs into the selection.

Also applies to: 387-389, 437-454

tests/Feature_v2/BulkAlbumEdit/PatchTest.php (1)

109-112: Potential NPE if license fails to cast.

$album->license->value will throw if $album->license is null. Given the model casts license to LicenseType and the PATCH just set 'CC0', this should always resolve to an enum, but consider a safer assertion such as assertSame(LicenseType::CC0, $album->license) to make the test fail with a clearer message if the cast changes in the future.

app/DTO/BulkAlbumPatchData.php (1)

146-151: filter_var(FILTER_VALIDATE_BOOLEAN) is redundant after Laravel 'boolean' validation.

PatchBulkAlbumRequest validates each boolean field with 'boolean', which via validated() already yields a proper PHP bool (Laravel accepts true/false/1/0/"1"/"0"). Running filter_var again adds nothing and — more importantly — silently coerces unexpected values to false instead of surfacing bugs. A plain (bool) $values[$field] or direct assignment is sufficient and clearer.

docs/specs/4-architecture/features/034-bulk-album-edit/spec.md (1)

51-51: FR-034-05 banner text mismatches the ASCII mock-up.

FR-034-05 quotes the banner as "⚠ Any modification made on this page is final. There is no confirmation step." while the page layout mock-up on line 106 says "⚠ Any modification made on this page is final. There is no confirmation step." — these do match, but Goal #7 on line 50 states "except delete". Consider also noting the delete-confirmation carve-out in FR-034-05 so the requirement is self-consistent with Goal #7 and FR-034-10.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0ab4a0f5-e1a3-4b49-b898-3afbdded328b

📥 Commits

Reviewing files that changed from the base of the PR and between 86298f9 and 62ed0be.

📒 Files selected for processing (53)
  • app/Actions/Admin/BulkEditAlbumsAction.php
  • app/DTO/BulkAlbumPatchData.php
  • app/Http/Controllers/Admin/BulkAlbumController.php
  • app/Http/Requests/Admin/BulkAlbumEdit/DeleteBulkAlbumRequest.php
  • app/Http/Requests/Admin/BulkAlbumEdit/IdsBulkAlbumRequest.php
  • app/Http/Requests/Admin/BulkAlbumEdit/IndexBulkAlbumRequest.php
  • app/Http/Requests/Admin/BulkAlbumEdit/PatchBulkAlbumRequest.php
  • app/Http/Requests/Admin/BulkAlbumEdit/SetOwnerBulkAlbumRequest.php
  • app/Http/Resources/Admin/BulkAlbumIdsResource.php
  • app/Http/Resources/Admin/BulkAlbumResource.php
  • app/Http/Resources/Admin/PaginatedBulkAlbumResource.php
  • app/Metadata/Cache/RouteCacheManager.php
  • docs/specs/4-architecture/features/034-bulk-album-edit/plan.md
  • docs/specs/4-architecture/features/034-bulk-album-edit/spec.md
  • docs/specs/4-architecture/features/034-bulk-album-edit/tasks.md
  • docs/specs/4-architecture/open-questions.md
  • docs/specs/4-architecture/roadmap.md
  • lang/ar/bulk_album_edit.php
  • lang/bg/bulk_album_edit.php
  • lang/cz/bulk_album_edit.php
  • lang/de/bulk_album_edit.php
  • lang/el/bulk_album_edit.php
  • lang/en/bulk_album_edit.php
  • lang/es/bulk_album_edit.php
  • lang/fa/bulk_album_edit.php
  • lang/fr/bulk_album_edit.php
  • lang/hu/bulk_album_edit.php
  • lang/it/bulk_album_edit.php
  • lang/ja/bulk_album_edit.php
  • lang/nl/bulk_album_edit.php
  • lang/no/bulk_album_edit.php
  • lang/pl/bulk_album_edit.php
  • lang/pt/bulk_album_edit.php
  • lang/ru/bulk_album_edit.php
  • lang/sk/bulk_album_edit.php
  • lang/sv/bulk_album_edit.php
  • lang/tr/bulk_album_edit.php
  • lang/vi/bulk_album_edit.php
  • lang/zh_CN/bulk_album_edit.php
  • lang/zh_TW/bulk_album_edit.php
  • resources/js/components/forms/bulk-album-edit/BulkEditFieldsDialog.vue
  • resources/js/components/forms/bulk-album-edit/BulkSetOwnerDialog.vue
  • resources/js/composables/contextMenus/leftMenu.ts
  • resources/js/router/routes.ts
  • resources/js/services/bulk-album-edit-service.ts
  • resources/js/views/BulkAlbumEdit.vue
  • routes/api_v2.php
  • routes/web_v2.php
  • tests/Feature_v2/BulkAlbumEdit/DeleteTest.php
  • tests/Feature_v2/BulkAlbumEdit/IdsTest.php
  • tests/Feature_v2/BulkAlbumEdit/IndexTest.php
  • tests/Feature_v2/BulkAlbumEdit/PatchTest.php
  • tests/Feature_v2/BulkAlbumEdit/SetOwnerTest.php

Comment thread app/Actions/Admin/BulkEditAlbumsAction.php
Comment thread app/Actions/Admin/BulkEditAlbumsAction.php
Comment thread app/DTO/BulkAlbumPatchData.php
Comment thread app/Http/Controllers/Admin/BulkAlbumController.php
Comment thread app/Http/Controllers/Admin/BulkAlbumController.php
Comment thread resources/js/services/bulk-album-edit-service.ts Outdated
Comment thread resources/js/views/BulkAlbumEdit.vue
Comment thread resources/js/views/BulkAlbumEdit.vue Outdated
Comment thread tests/Feature_v2/BulkAlbumEdit/IndexTest.php
Comment thread tests/Feature_v2/BulkAlbumEdit/IndexTest.php
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
resources/js/views/BulkAlbumEdit.vue (1)

424-433: Debounced search has no in-flight cancellation — results can arrive out of order.

onSearchInput only debounces the trigger; if a slow response for query "foo" lands after a faster response for "foobar", albums.value ends up showing stale rows for the older query. Consider tracking a request token / aborting the previous axios call, or at least ignoring responses whose search no longer matches search.value.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7bba3f98-424f-4028-8328-b5b43381a5a9

📥 Commits

Reviewing files that changed from the base of the PR and between 62ed0be and b250e69.

📒 Files selected for processing (7)
  • app/DTO/BulkAlbumPatchData.php
  • app/Http/Requests/Admin/BulkAlbumEdit/PatchBulkAlbumRequest.php
  • resources/js/composables/contextMenus/leftMenu.ts
  • resources/js/services/bulk-album-edit-service.ts
  • resources/js/services/renamer-service.ts
  • resources/js/services/webhook-service.ts
  • resources/js/views/BulkAlbumEdit.vue
✅ Files skipped from review due to trivial changes (2)
  • resources/js/services/renamer-service.ts
  • resources/js/services/webhook-service.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • resources/js/composables/contextMenus/leftMenu.ts
  • app/Http/Requests/Admin/BulkAlbumEdit/PatchBulkAlbumRequest.php
  • resources/js/services/bulk-album-edit-service.ts
  • app/DTO/BulkAlbumPatchData.php

Comment thread resources/js/views/BulkAlbumEdit.vue
Comment thread resources/js/views/BulkAlbumEdit.vue
Comment thread resources/js/views/BulkAlbumEdit.vue
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Actions/Search/Strategies/Album/AlbumFieldLikeStrategy.php (1)

34-46: ⚠️ Potential issue | 🟡 Minor

Add QueryBuilder type to the closure parameter.

The apply() method accepts Builder|QueryBuilder, but the closure at line 43 only type-hints the parameter as Builder. This creates a type inconsistency: if a QueryBuilder is passed and execution reaches the else branch, the closure receives a type it doesn't declare, causing type-checking to fail.

Note: The method signature also violates the interface contract (AlbumSearchTokenStrategy declares Builder only). If QueryBuilder support is necessary, the interface should be updated; otherwise, consider reverting the method signature to match the interface.

🐛 Proposed fix
-			$query->where(function (Builder $q) use ($pattern): void {
+			$query->where(function (Builder|QueryBuilder $q) use ($pattern): void {
 				$q->whereRaw("base_albums.title LIKE ? ESCAPE '!'", [$pattern])
 					->orWhereRaw("base_albums.description LIKE ? ESCAPE '!'", [$pattern]);
 			});

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2eb0814f-8c17-42d1-ad00-cbc459a9ca2c

📥 Commits

Reviewing files that changed from the base of the PR and between b250e69 and 9d3ec67.

📒 Files selected for processing (10)
  • app/Actions/Search/Strategies/Album/AlbumFieldLikeStrategy.php
  • app/Http/Controllers/Admin/BulkAlbumController.php
  • app/Http/Requests/Admin/BulkAlbumEdit/SetOwnerBulkAlbumRequest.php
  • app/Http/Resources/Admin/BulkAlbumResource.php
  • resources/js/components/forms/album/AlbumProperties.vue
  • resources/js/components/forms/bulk-album-edit/BulkEditFieldsDialog.vue
  • resources/js/components/settings/ConfigGroup.vue
  • resources/js/components/settings/General.vue
  • resources/js/config/constants.ts
  • resources/js/views/BulkAlbumEdit.vue
✅ Files skipped from review due to trivial changes (1)
  • app/Http/Resources/Admin/BulkAlbumResource.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/Http/Requests/Admin/BulkAlbumEdit/SetOwnerBulkAlbumRequest.php
  • resources/js/views/BulkAlbumEdit.vue

Comment thread app/Http/Controllers/Admin/BulkAlbumController.php Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
app/Http/Controllers/Admin/BulkAlbumController.php (1)

133-138: ⚠️ Potential issue | 🟡 Minor

Reject requests when any requested album ID is not transferred.

whereIn() silently drops IDs that do not resolve to Album rows, so the endpoint can return success while transferring only a subset. Compare the loaded IDs against the requested IDs before calling Transfer.

🛠️ Proposed fix
+use Illuminate\Validation\ValidationException;
 		DB::transaction(function () use ($album_ids, $owner_id, $transfer): void {
 			/** `@var` Album[] $albums */
-			$albums = Album::query()->whereIn('id', $album_ids)->get()->all();
+			$albums = Album::query()->whereIn('id', $album_ids)->get()->keyBy('id');
+			$missing_album_ids = array_values(array_diff($album_ids, $albums->keys()->all()));
+
+			if ($missing_album_ids !== []) {
+				throw ValidationException::withMessages([
+					'album_ids' => 'One or more selected albums could not be transferred.',
+				]);
+			}
+
 			foreach ($albums as $album) {
 				$transfer->do($album, $owner_id);
 			}
 		});
🧹 Nitpick comments (1)
app/Http/Controllers/Admin/BulkAlbumController.php (1)

52-64: Avoid lazy-loading owner and reduce repeated permission lookups in resource mapping.

The index query doesn't eagerly load owner, causing N+1 lazy-loads when rowToResource() accesses it. Additionally, public_permissions() is called five times but should be cached once per row.

♻️ Proposed refactor
-		$query = Album::query()->without(
+		$query = Album::query()->with('owner')->without(
 			['cover', 'cover.size_variants', 'min_privilege_cover', 'min_privilege_cover.size_variants', 'max_privilege_cover', 'max_privilege_cover.size_variants', 'thumb']
 		)->orderBy('albums._lft', 'asc');
 	private function rowToResource(Album $row): BulkAlbumResource
 	{
+		$public_permissions = $row->public_permissions();
+
 		return new BulkAlbumResource(
@@
-			is_public: $row->public_permissions() !== null,
-			is_link_required: $row->public_permissions()?->is_link_required === true,
-			grants_full_photo_access: $row->public_permissions()?->grants_full_photo_access === true,
-			grants_download: $row->public_permissions()?->grants_download === true,
-			grants_upload: $row->public_permissions()?->grants_upload === true,
+			is_public: $public_permissions !== null,
+			is_link_required: $public_permissions?->is_link_required === true,
+			grants_full_photo_access: $public_permissions?->grants_full_photo_access === true,
+			grants_download: $public_permissions?->grants_download === true,
+			grants_upload: $public_permissions?->grants_upload === true,

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: efa47f9c-a057-42eb-bf3b-f1c1948238ca

📥 Commits

Reviewing files that changed from the base of the PR and between 9d3ec67 and cbafb72.

📒 Files selected for processing (2)
  • app/Http/Controllers/Admin/BulkAlbumController.php
  • app/Models/Album.php
✅ Files skipped from review due to trivial changes (1)
  • app/Models/Album.php

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bd80cbed-0d61-4822-9d74-8396cefdc064

📥 Commits

Reviewing files that changed from the base of the PR and between cbafb72 and fb544d4.

📒 Files selected for processing (1)
  • app/Http/Controllers/Admin/BulkAlbumController.php

Comment thread app/Http/Controllers/Admin/BulkAlbumController.php
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants