chore(server): Improve add to multiple albums via bulk checks and inserts (#21052)

* - add addAssetIdsToAlbums to album repo
- update albumService to determine all albums and assets with access and coalesce into one set of album_assets to insert

* - remove hasAsset check (unnecessary)

* - lint

* - cleanup

* - remove success counts from addAssetsToAlbums results
- Fix tests

* open-api

* await album update
This commit is contained in:
xCJPECKOVERx 2025-08-24 22:33:10 -04:00 committed by GitHub
parent 28dce2d0df
commit 3f1e11afcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 117 additions and 110 deletions

View File

@ -13,16 +13,10 @@ part of openapi.api;
class AlbumsAddAssetsResponseDto { class AlbumsAddAssetsResponseDto {
/// Returns a new [AlbumsAddAssetsResponseDto] instance. /// Returns a new [AlbumsAddAssetsResponseDto] instance.
AlbumsAddAssetsResponseDto({ AlbumsAddAssetsResponseDto({
required this.albumSuccessCount,
required this.assetSuccessCount,
this.error, this.error,
required this.success, required this.success,
}); });
int albumSuccessCount;
int assetSuccessCount;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
@ -35,26 +29,20 @@ class AlbumsAddAssetsResponseDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto && bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
other.albumSuccessCount == albumSuccessCount &&
other.assetSuccessCount == assetSuccessCount &&
other.error == error && other.error == error &&
other.success == success; other.success == success;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(albumSuccessCount.hashCode) +
(assetSuccessCount.hashCode) +
(error == null ? 0 : error!.hashCode) + (error == null ? 0 : error!.hashCode) +
(success.hashCode); (success.hashCode);
@override @override
String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]'; String toString() => 'AlbumsAddAssetsResponseDto[error=$error, success=$success]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'albumSuccessCount'] = this.albumSuccessCount;
json[r'assetSuccessCount'] = this.assetSuccessCount;
if (this.error != null) { if (this.error != null) {
json[r'error'] = this.error; json[r'error'] = this.error;
} else { } else {
@ -73,8 +61,6 @@ class AlbumsAddAssetsResponseDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AlbumsAddAssetsResponseDto( return AlbumsAddAssetsResponseDto(
albumSuccessCount: mapValueOfType<int>(json, r'albumSuccessCount')!,
assetSuccessCount: mapValueOfType<int>(json, r'assetSuccessCount')!,
error: BulkIdErrorReason.fromJson(json[r'error']), error: BulkIdErrorReason.fromJson(json[r'error']),
success: mapValueOfType<bool>(json, r'success')!, success: mapValueOfType<bool>(json, r'success')!,
); );
@ -124,8 +110,6 @@ class AlbumsAddAssetsResponseDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'albumSuccessCount',
'assetSuccessCount',
'success', 'success',
}; };
} }

View File

@ -10007,12 +10007,6 @@
}, },
"AlbumsAddAssetsResponseDto": { "AlbumsAddAssetsResponseDto": {
"properties": { "properties": {
"albumSuccessCount": {
"type": "integer"
},
"assetSuccessCount": {
"type": "integer"
},
"error": { "error": {
"allOf": [ "allOf": [
{ {
@ -10025,8 +10019,6 @@
} }
}, },
"required": [ "required": [
"albumSuccessCount",
"assetSuccessCount",
"success" "success"
], ],
"type": "object" "type": "object"

View File

@ -389,8 +389,6 @@ export type AlbumsAddAssetsDto = {
assetIds: string[]; assetIds: string[];
}; };
export type AlbumsAddAssetsResponseDto = { export type AlbumsAddAssetsResponseDto = {
albumSuccessCount: number;
assetSuccessCount: number;
error?: BulkIdErrorReason; error?: BulkIdErrorReason;
success: boolean; success: boolean;
}; };

View File

@ -65,10 +65,6 @@ export class AlbumsAddAssetsDto {
export class AlbumsAddAssetsResponseDto { export class AlbumsAddAssetsResponseDto {
success!: boolean; success!: boolean;
@ApiProperty({ type: 'integer' })
albumSuccessCount!: number;
@ApiProperty({ type: 'integer' })
assetSuccessCount!: number;
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true }) @ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true })
error?: BulkIdErrorReason; error?: BulkIdErrorReason;
} }

View File

@ -321,6 +321,14 @@ export class AlbumRepository {
.execute(); .execute();
} }
@Chunked({ chunkSize: 30_000 })
async addAssetIdsToAlbums(values: { albumsId: string; assetsId: string }[]): Promise<void> {
if (values.length === 0) {
return;
}
await this.db.insertInto('album_asset').values(values).execute();
}
/** /**
* Makes sure all thumbnails for albums are updated by: * Makes sure all thumbnails for albums are updated by:
* - Removing thumbnails from albums without assets * - Removing thumbnails from albums without assets

View File

@ -778,9 +778,7 @@ describe(AlbumService.name, () => {
describe('addAssetsToAlbums', () => { describe('addAssetsToAlbums', () => {
it('should allow the owner to add assets', async () => { it('should allow the owner to add assets', async () => {
mocks.access.album.checkOwnerAccess mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) .mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
@ -792,7 +790,7 @@ describe(AlbumService.name, () => {
albumIds: ['album-123', 'album-321'], albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'], assetIds: ['asset-1', 'asset-2', 'asset-3'],
}), }),
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); ).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2); expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
@ -805,14 +803,18 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); { albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
{ albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
]);
}); });
it('should not set the thumbnail if the album has one already', async () => { it('should not set the thumbnail if the album has one already', async () => {
mocks.access.album.checkOwnerAccess mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })) .mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }))
@ -824,7 +826,7 @@ describe(AlbumService.name, () => {
albumIds: ['album-123', 'album-321'], albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'], assetIds: ['asset-1', 'asset-2', 'asset-3'],
}), }),
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); ).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2); expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
@ -837,14 +839,18 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-id', albumThumbnailAssetId: 'asset-id',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); { albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
{ albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
]);
}); });
it('should allow a shared user to add assets', async () => { it('should allow a shared user to add assets', async () => {
mocks.access.album.checkSharedAlbumAccess mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
@ -856,7 +862,7 @@ describe(AlbumService.name, () => {
albumIds: ['album-123', 'album-321'], albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'], assetIds: ['asset-1', 'asset-2', 'asset-3'],
}), }),
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); ).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2); expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
@ -869,8 +875,14 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); { albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
{ albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-123', id: 'album-123',
recipientId: 'admin_id', recipientId: 'admin_id',
@ -896,18 +908,14 @@ describe(AlbumService.name, () => {
}), }),
).resolves.toEqual({ ).resolves.toEqual({
success: false, success: false,
albumSuccessCount: 0, error: BulkIdErrorReason.NO_PERMISSION,
assetSuccessCount: 0,
error: BulkIdErrorReason.UNKNOWN,
}); });
expect(mocks.album.update).not.toHaveBeenCalled(); expect(mocks.album.update).not.toHaveBeenCalled();
}); });
it('should not allow a shared link user to add assets to multiple albums', async () => { it('should not allow a shared link user to add assets to multiple albums', async () => {
mocks.access.album.checkSharedLinkAccess mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set(['album-123']));
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set());
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
@ -919,7 +927,7 @@ describe(AlbumService.name, () => {
albumIds: ['album-123', 'album-321'], albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'], assetIds: ['asset-1', 'asset-2', 'asset-3'],
}), }),
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 }); ).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(1); expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
@ -927,22 +935,23 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); { albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: 'album-123', id: 'album-123',
recipientId: 'user-id', recipientId: 'user-id',
}); });
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id, authStub.adminSharedLink.sharedLink?.id,
new Set(['album-123']), new Set(['album-123', 'album-321']),
); );
}); });
it('should allow adding assets shared via partner sharing', async () => { it('should allow adding assets shared via partner sharing', async () => {
mocks.access.album.checkOwnerAccess mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) .mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
@ -954,7 +963,7 @@ describe(AlbumService.name, () => {
albumIds: ['album-123', 'album-321'], albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'], assetIds: ['asset-1', 'asset-2', 'asset-3'],
}), }),
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); ).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2); expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
@ -967,8 +976,14 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); { albumsId: 'album-123', assetsId: 'asset-1' },
{ albumsId: 'album-123', assetsId: 'asset-2' },
{ albumsId: 'album-123', assetsId: 'asset-3' },
{ albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
]);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set(['asset-1', 'asset-2', 'asset-3']), new Set(['asset-1', 'asset-2', 'asset-3']),
@ -976,23 +991,21 @@ describe(AlbumService.name, () => {
}); });
it('should skip some duplicate assets', async () => { it('should skip some duplicate assets', async () => {
mocks.access.album.checkOwnerAccess mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321']));
.mockResolvedValueOnce(new Set(['album-123']))
.mockResolvedValueOnce(new Set(['album-321']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
mocks.album.getAssetIds mocks.album.getAssetIds
.mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3'])) .mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3']))
.mockResolvedValueOnce(new Set()); .mockResolvedValueOnce(new Set());
mocks.album.getById
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
await expect( await expect(
sut.addAssetsToAlbums(authStub.admin, { sut.addAssetsToAlbums(authStub.admin, {
albumIds: ['album-123', 'album-321'], albumIds: ['album-123', 'album-321'],
assetIds: ['asset-1', 'asset-2', 'asset-3'], assetIds: ['asset-1', 'asset-2', 'asset-3'],
}), }),
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 }); ).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(1); expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', { expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', {
@ -1000,8 +1013,11 @@ describe(AlbumService.name, () => {
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
albumThumbnailAssetId: 'asset-1', albumThumbnailAssetId: 'asset-1',
}); });
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1); expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); { albumsId: 'album-321', assetsId: 'asset-1' },
{ albumsId: 'album-321', assetsId: 'asset-2' },
{ albumsId: 'album-321', assetsId: 'asset-3' },
]);
}); });
it('should skip all duplicate assets', async () => { it('should skip all duplicate assets', async () => {
@ -1021,8 +1037,6 @@ describe(AlbumService.name, () => {
}), }),
).resolves.toEqual({ ).resolves.toEqual({
success: false, success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.DUPLICATE, error: BulkIdErrorReason.DUPLICATE,
}); });
@ -1046,9 +1060,7 @@ describe(AlbumService.name, () => {
}), }),
).resolves.toEqual({ ).resolves.toEqual({
success: false, success: false,
albumSuccessCount: 0, error: BulkIdErrorReason.NO_PERMISSION,
assetSuccessCount: 0,
error: BulkIdErrorReason.UNKNOWN,
}); });
expect(mocks.album.update).not.toHaveBeenCalled(); expect(mocks.album.update).not.toHaveBeenCalled();
@ -1076,9 +1088,7 @@ describe(AlbumService.name, () => {
}), }),
).resolves.toEqual({ ).resolves.toEqual({
success: false, success: false,
albumSuccessCount: 0, error: BulkIdErrorReason.NO_PERMISSION,
assetSuccessCount: 0,
error: BulkIdErrorReason.UNKNOWN,
}); });
expect(mocks.album.update).not.toHaveBeenCalled(); expect(mocks.album.update).not.toHaveBeenCalled();
@ -1099,9 +1109,7 @@ describe(AlbumService.name, () => {
}), }),
).resolves.toEqual({ ).resolves.toEqual({
success: false, success: false,
albumSuccessCount: 0, error: BulkIdErrorReason.NO_PERMISSION,
assetSuccessCount: 0,
error: BulkIdErrorReason.UNKNOWN,
}); });
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled();

View File

@ -191,36 +191,57 @@ export class AlbumService extends BaseService {
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> { async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
const results: AlbumsAddAssetsResponseDto = { const results: AlbumsAddAssetsResponseDto = {
success: false, success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.DUPLICATE, error: BulkIdErrorReason.DUPLICATE,
}; };
const successfulAssetIds: Set<string> = new Set();
for (const albumId of dto.albumIds) {
try {
const albumResults = await this.addAssets(auth, albumId, { ids: dto.assetIds });
let success = false; const allowedAlbumIds = await this.checkAccess({
for (const res of albumResults) { auth,
if (res.success) { permission: Permission.AlbumAssetCreate,
success = true; ids: dto.albumIds,
results.success = true; });
results.error = undefined; if (allowedAlbumIds.size === 0) {
successfulAssetIds.add(res.id); results.error = BulkIdErrorReason.NO_PERMISSION;
} else if (results.error && res.error !== BulkIdErrorReason.DUPLICATE) { return results;
results.error = BulkIdErrorReason.UNKNOWN; }
}
} const allowedAssetIds = await this.checkAccess({ auth, permission: Permission.AssetShare, ids: dto.assetIds });
if (success) { if (allowedAssetIds.size === 0) {
results.albumSuccessCount++; results.error = BulkIdErrorReason.NO_PERMISSION;
} return results;
} catch { }
if (results.error) {
results.error = BulkIdErrorReason.UNKNOWN; const albumAssetValues: { albumsId: string; assetsId: string }[] = [];
} const events: { id: string; recipients: string[] }[] = [];
for (const albumId of allowedAlbumIds) {
const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]);
const notPresentAssetIds = [...allowedAssetIds].filter((id) => !existingAssetIds.has(id));
if (notPresentAssetIds.length === 0) {
continue;
}
const album = await this.findOrFail(albumId, { withAssets: false });
results.error = undefined;
results.success = true;
for (const assetId of notPresentAssetIds) {
albumAssetValues.push({ albumsId: albumId, assetsId: assetId });
}
await this.albumRepository.update(albumId, {
id: albumId,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
);
events.push({ id: albumId, recipients: allUsersExceptUs });
}
await this.albumRepository.addAssetIdsToAlbums(albumAssetValues);
for (const event of events) {
for (const recipientId of event.recipients) {
await this.eventRepository.emit('AlbumUpdate', { id: event.id, recipientId });
} }
} }
results.assetSuccessCount = successfulAssetIds.size;
return results; return results;
} }