mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
refactor(server)!: add/remove album assets (#3109)
* refactor: add/remove album assets * chore: open api * feat: remove owned assets from album * refactor: move to bulk id req/res dto * chore: open api * chore: merge main * dev: mobile work * fix: adding asset from web not sync with mobile * remove print statement --------- Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
This commit is contained in:
@@ -278,7 +278,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RemoveAssetsDto"
|
||||
"$ref": "#/components/schemas/BulkIdsDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -289,7 +289,10 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AlbumResponseDto"
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BulkIdResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -336,7 +339,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddAssetsDto"
|
||||
"$ref": "#/components/schemas/BulkIdsDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -347,7 +350,10 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddAssetsResponseDto"
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BulkIdResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4535,42 +4541,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AddAssetsDto": {
|
||||
"properties": {
|
||||
"assetIds": {
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetIds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AddAssetsResponseDto": {
|
||||
"properties": {
|
||||
"album": {
|
||||
"$ref": "#/components/schemas/AlbumResponseDto"
|
||||
},
|
||||
"alreadyInAlbum": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"successfullyAdded": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"successfullyAdded",
|
||||
"alreadyInAlbum"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AddUsersDto": {
|
||||
"properties": {
|
||||
"sharedUserIds": {
|
||||
@@ -5093,6 +5063,21 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"BulkIdsDto": {
|
||||
"properties": {
|
||||
"ids": {
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ids"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ChangePasswordDto": {
|
||||
"properties": {
|
||||
"newPassword": {
|
||||
@@ -6055,21 +6040,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RemoveAssetsDto": {
|
||||
"properties": {
|
||||
"assetIds": {
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetIds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SearchAlbumResponseDto": {
|
||||
"properties": {
|
||||
"count": {
|
||||
|
||||
@@ -12,9 +12,10 @@ export enum Permission {
|
||||
ASSET_DOWNLOAD = 'asset.download',
|
||||
|
||||
// ALBUM_CREATE = 'album.create',
|
||||
// ALBUM_READ = 'album.read',
|
||||
ALBUM_READ = 'album.read',
|
||||
ALBUM_UPDATE = 'album.update',
|
||||
ALBUM_DELETE = 'album.delete',
|
||||
ALBUM_REMOVE_ASSET = 'album.removeAsset',
|
||||
ALBUM_SHARE = 'album.share',
|
||||
ALBUM_DOWNLOAD = 'album.download',
|
||||
|
||||
@@ -39,6 +40,16 @@ export class AccessCore {
|
||||
}
|
||||
}
|
||||
|
||||
async hasAny(authUser: AuthUserDto, permissions: Array<{ permission: Permission; id: string }>) {
|
||||
for (const { permission, id } of permissions) {
|
||||
const hasAccess = await this.hasPermission(authUser, permission, id);
|
||||
if (hasAccess) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async hasPermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
|
||||
ids = Array.isArray(ids) ? ids : [ids];
|
||||
|
||||
@@ -76,12 +87,11 @@ export class AccessCore {
|
||||
// TODO: fix this to not use authUser.id for shared link access control
|
||||
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ALBUM_DOWNLOAD: {
|
||||
return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id));
|
||||
}
|
||||
case Permission.ALBUM_READ:
|
||||
return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
|
||||
|
||||
// case Permission.ALBUM_READ:
|
||||
// return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
|
||||
case Permission.ALBUM_DOWNLOAD:
|
||||
return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id));
|
||||
|
||||
default:
|
||||
return false;
|
||||
@@ -122,8 +132,11 @@ export class AccessCore {
|
||||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
// case Permission.ALBUM_READ:
|
||||
// return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
case Permission.ALBUM_READ:
|
||||
return (
|
||||
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ALBUM_UPDATE:
|
||||
return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
@@ -140,13 +153,17 @@ export class AccessCore {
|
||||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ALBUM_REMOVE_ASSET:
|
||||
return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.LIBRARY_READ:
|
||||
return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
|
||||
|
||||
case Permission.LIBRARY_DOWNLOAD:
|
||||
return authUser.id === id;
|
||||
}
|
||||
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface AlbumAssetCount {
|
||||
}
|
||||
|
||||
export interface IAlbumRepository {
|
||||
getById(id: string): Promise<AlbumEntity | null>;
|
||||
getByIds(ids: string[]): Promise<AlbumEntity[]>;
|
||||
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
|
||||
hasAsset(id: string, assetId: string): Promise<boolean>;
|
||||
@@ -21,4 +22,5 @@ export interface IAlbumRepository {
|
||||
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
||||
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
||||
delete(album: AlbumEntity): Promise<void>;
|
||||
updateThumbnails(): Promise<number | undefined>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
albumStub,
|
||||
assetStub,
|
||||
authStub,
|
||||
IAccessRepositoryMock,
|
||||
newAccessRepositoryMock,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
userStub,
|
||||
} from '@test';
|
||||
import _ from 'lodash';
|
||||
import { IAssetRepository } from '../asset';
|
||||
import { BulkIdErrorReason, IAssetRepository } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IAlbumRepository } from './album.repository';
|
||||
@@ -202,7 +203,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('should prevent updating an album that does not exist', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
albumMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.user1, 'invalid-id', {
|
||||
@@ -224,7 +225,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should require a valid thumbnail asset id', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.hasAsset.mockResolvedValue(false);
|
||||
|
||||
@@ -241,7 +242,7 @@ describe(AlbumService.name, () => {
|
||||
it('should allow the owner to update the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await sut.update(authStub.admin, albumStub.oneAsset.id, {
|
||||
@@ -263,7 +264,7 @@ describe(AlbumService.name, () => {
|
||||
describe('delete', () => {
|
||||
it('should throw an error for an album not found', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
albumMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -274,7 +275,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should not let a shared user delete the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -285,7 +286,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should let the owner delete an album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await sut.delete(authStub.admin, albumStub.empty.id);
|
||||
|
||||
@@ -305,7 +306,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should throw an error if the userId is already added', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -314,7 +315,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should throw an error if the userId does not exist', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }),
|
||||
@@ -324,7 +325,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should add valid shared users', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
|
||||
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(userStub.user2);
|
||||
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
|
||||
@@ -339,14 +340,14 @@ describe(AlbumService.name, () => {
|
||||
describe('removeUser', () => {
|
||||
it('should require a valid album id', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([]);
|
||||
albumMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a shared user from an owned album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id),
|
||||
@@ -362,7 +363,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple);
|
||||
|
||||
await expect(
|
||||
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
|
||||
@@ -373,7 +374,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id);
|
||||
|
||||
@@ -386,7 +387,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves using "me"', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
|
||||
|
||||
@@ -399,7 +400,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should not allow the owner to be removed', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -409,7 +410,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error for a user not in the album', async () => {
|
||||
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -418,4 +419,301 @@ describe(AlbumService.name, () => {
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlbumInfo', () => {
|
||||
it('should get a shared album', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
|
||||
await sut.get(authStub.admin, albumStub.oneAsset.id);
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id);
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
|
||||
});
|
||||
|
||||
it('should get a shared album via a shared link', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
|
||||
|
||||
await sut.get(authStub.adminSharedLink, 'album-123');
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123');
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLinkId,
|
||||
'album-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get a shared album via shared with user', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
|
||||
|
||||
await sut.get(authStub.user1, 'album-123');
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123');
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123');
|
||||
});
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.get(authStub.admin, 'album-123')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should allow the owner to add assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
{ success: true, id: 'asset-2' },
|
||||
{ success: true, id: 'asset-3' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set the thumbnail if the album has one already', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [{ id: 'asset-1' }],
|
||||
albumThumbnailAssetId: 'asset-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow a shared user to add assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
{ success: true, id: 'asset-2' },
|
||||
{ success: true, id: 'asset-3' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow a shared link user to add assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
{ success: true, id: 'asset-2' },
|
||||
{ success: true, id: 'asset-3' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLinkId,
|
||||
'album-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow adding assets shared via partner sharing', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-1' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [assetStub.image, { id: 'asset-1' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
|
||||
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
});
|
||||
|
||||
it('should skip duplicate assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip assets not shared with user', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
|
||||
]);
|
||||
|
||||
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
});
|
||||
|
||||
it('should not allow unauthorized access to the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalled();
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow unauthorized shared link access to the album', async () => {
|
||||
accessMock.album.hasSharedLinkAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
it('should allow the owner to remove assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-id' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [],
|
||||
albumThumbnailAssetId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip assets not in the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip assets without user permission to remove', async () => {
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: false, id: 'asset-id', error: BulkIdErrorReason.NO_PERMISSION },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset the thumbnail if it is removed', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([
|
||||
{ success: true, id: 'asset-id' },
|
||||
]);
|
||||
|
||||
expect(albumMock.update).toHaveBeenCalledWith({
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
assets: [assetStub.withLocation],
|
||||
albumThumbnailAssetId: assetStub.withLocation.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// // it('removes assets from shared album (shared with auth user)', async () => {
|
||||
// // const albumEntity = _getOwnedSharedAlbum();
|
||||
// // albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
// // await expect(
|
||||
// // sut.removeAssetsFromAlbum(
|
||||
// // authUser,
|
||||
// // {
|
||||
// // ids: ['1'],
|
||||
// // },
|
||||
// // albumEntity.id,
|
||||
// // ),
|
||||
// // ).resolves.toBeUndefined();
|
||||
// // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
// // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
// // ids: ['1'],
|
||||
// // });
|
||||
// // });
|
||||
|
||||
// it('prevents removing assets from a not owned / shared album', async () => {
|
||||
// const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
|
||||
// const albumResponse: AddAssetsResponseDto = {
|
||||
// alreadyInAlbum: [],
|
||||
// successfullyAdded: 1,
|
||||
// };
|
||||
|
||||
// const albumId = albumEntity.id;
|
||||
|
||||
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
|
||||
|
||||
// await expect(sut.removeAssets(authUser, albumId, { ids: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { IAssetRepository, mapAsset } from '../asset';
|
||||
import { AccessCore, IAccessRepository, Permission } from '../access';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository, mapAsset } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { AccessCore, IAccessRepository, Permission } from '../index';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IUserRepository } from '../user';
|
||||
import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto';
|
||||
@@ -37,7 +37,11 @@ export class AlbumService {
|
||||
}
|
||||
|
||||
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
await this.updateInvalidThumbnails();
|
||||
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
||||
for (const albumId of invalidAlbumIds) {
|
||||
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
|
||||
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
|
||||
}
|
||||
|
||||
let albums: AlbumEntity[];
|
||||
if (assetId) {
|
||||
@@ -73,15 +77,10 @@ export class AlbumService {
|
||||
);
|
||||
}
|
||||
|
||||
private async updateInvalidThumbnails(): Promise<number> {
|
||||
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
||||
|
||||
for (const albumId of invalidAlbumIds) {
|
||||
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
|
||||
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
|
||||
}
|
||||
|
||||
return invalidAlbumIds.length;
|
||||
async get(authUser: AuthUserDto, id: string) {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||
await this.albumRepository.updateThumbnails();
|
||||
return mapAlbum(await this.findOrFail(id));
|
||||
}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||
@@ -107,7 +106,7 @@ export class AlbumService {
|
||||
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id);
|
||||
|
||||
const album = await this.get(id);
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
if (dto.albumThumbnailAssetId) {
|
||||
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
|
||||
@@ -130,7 +129,7 @@ export class AlbumService {
|
||||
async delete(authUser: AuthUserDto, id: string): Promise<void> {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id);
|
||||
|
||||
const [album] = await this.albumRepository.getByIds([id]);
|
||||
const album = await this.albumRepository.getById(id);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
@@ -139,10 +138,88 @@ export class AlbumService {
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
|
||||
}
|
||||
|
||||
async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||
|
||||
const results: BulkIdResponseDto[] = [];
|
||||
for (const id of dto.ids) {
|
||||
const hasAsset = album.assets.find((asset) => asset.id === id);
|
||||
if (hasAsset) {
|
||||
results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, id);
|
||||
if (!hasAccess) {
|
||||
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ id, success: true });
|
||||
album.assets.push({ id } as AssetEntity);
|
||||
}
|
||||
|
||||
const newAsset = results.find(({ success }) => success);
|
||||
if (newAsset) {
|
||||
await this.albumRepository.update({
|
||||
id,
|
||||
assets: album.assets,
|
||||
updatedAt: new Date(),
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAsset.id,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||
|
||||
const results: BulkIdResponseDto[] = [];
|
||||
for (const id of dto.ids) {
|
||||
const hasAsset = album.assets.find((asset) => asset.id === id);
|
||||
if (!hasAsset) {
|
||||
results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAccess = await this.access.hasAny(authUser, [
|
||||
{ permission: Permission.ALBUM_REMOVE_ASSET, id },
|
||||
{ permission: Permission.ASSET_SHARE, id },
|
||||
]);
|
||||
if (!hasAccess) {
|
||||
results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ id, success: true });
|
||||
album.assets = album.assets.filter((asset) => asset.id !== id);
|
||||
if (album.albumThumbnailAssetId === id) {
|
||||
album.albumThumbnailAssetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
const hasSuccess = results.find(({ success }) => success);
|
||||
if (hasSuccess) {
|
||||
await this.albumRepository.update({
|
||||
id,
|
||||
assets: album.assets,
|
||||
updatedAt: new Date(),
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id);
|
||||
|
||||
const album = await this.get(id);
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
for (const userId of dto.sharedUserIds) {
|
||||
const exists = album.sharedUsers.find((user) => user.id === userId);
|
||||
@@ -172,7 +249,7 @@ export class AlbumService {
|
||||
userId = authUser.id;
|
||||
}
|
||||
|
||||
const album = await this.get(id);
|
||||
const album = await this.findOrFail(id);
|
||||
|
||||
if (album.ownerId === userId) {
|
||||
throw new BadRequestException('Cannot remove album owner');
|
||||
@@ -195,8 +272,8 @@ export class AlbumService {
|
||||
});
|
||||
}
|
||||
|
||||
private async get(id: string) {
|
||||
const [album] = await this.albumRepository.getByIds([id]);
|
||||
private async findOrFail(id: string) {
|
||||
const album = await this.albumRepository.getById(id);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ValidateUUID } from '../../domain.util';
|
||||
|
||||
/** @deprecated Use `BulkIdResponseDto` instead */
|
||||
export enum AssetIdErrorReason {
|
||||
DUPLICATE = 'duplicate',
|
||||
@@ -19,6 +21,11 @@ export enum BulkIdErrorReason {
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkIdResponseDto {
|
||||
id!: string;
|
||||
success!: boolean;
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { dataSource } from '@app/infra/database.config';
|
||||
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
||||
|
||||
export interface IAlbumRepository {
|
||||
get(albumId: string): Promise<AlbumEntity | null>;
|
||||
removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>;
|
||||
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
|
||||
updateThumbnails(): Promise<number | undefined>;
|
||||
}
|
||||
|
||||
export const IAlbumRepository = 'IAlbumRepository';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumRepository implements IAlbumRepository {
|
||||
constructor(
|
||||
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
) {}
|
||||
|
||||
async get(albumId: string): Promise<AlbumEntity | null> {
|
||||
return this.albumRepository.findOne({
|
||||
where: { id: albumId },
|
||||
relations: {
|
||||
owner: true,
|
||||
sharedUsers: true,
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
},
|
||||
sharedLinks: true,
|
||||
},
|
||||
order: {
|
||||
assets: {
|
||||
fileCreatedAt: 'DESC',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise<number> {
|
||||
const assetCount = album.assets.length;
|
||||
|
||||
album.assets = album.assets.filter((asset) => {
|
||||
return !removeAssetsDto.assetIds.includes(asset.id);
|
||||
});
|
||||
|
||||
const numRemovedAssets = assetCount - album.assets.length;
|
||||
if (numRemovedAssets > 0) {
|
||||
album.updatedAt = new Date();
|
||||
}
|
||||
await this.albumRepository.save(album, {});
|
||||
|
||||
return numRemovedAssets;
|
||||
}
|
||||
|
||||
async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto> {
|
||||
const alreadyExisting: string[] = [];
|
||||
|
||||
for (const assetId of addAssetsDto.assetIds) {
|
||||
// Album already contains that asset
|
||||
if (album.assets?.some((a) => a.id === assetId)) {
|
||||
alreadyExisting.push(assetId);
|
||||
continue;
|
||||
}
|
||||
|
||||
album.assets.push({ id: assetId } as AssetEntity);
|
||||
}
|
||||
|
||||
// Add album thumbnail if not exist.
|
||||
if (!album.albumThumbnailAssetId && album.assets.length > 0) {
|
||||
album.albumThumbnailAssetId = album.assets[0].id;
|
||||
}
|
||||
|
||||
const successfullyAdded = addAssetsDto.assetIds.length - alreadyExisting.length;
|
||||
if (successfullyAdded > 0) {
|
||||
album.updatedAt = new Date();
|
||||
}
|
||||
await this.albumRepository.save(album);
|
||||
|
||||
return {
|
||||
successfullyAdded,
|
||||
alreadyInAlbum: alreadyExisting,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure all thumbnails for albums are updated by:
|
||||
* - Removing thumbnails from albums without assets
|
||||
* - Removing references of thumbnails to assets outside the album
|
||||
* - Setting a thumbnail when none is set and the album contains assets
|
||||
*
|
||||
* @returns Amount of updated album thumbnails or undefined when unknown
|
||||
*/
|
||||
async updateThumbnails(): Promise<number | undefined> {
|
||||
// Subquery for getting a new thumbnail.
|
||||
const newThumbnail = this.assetRepository
|
||||
.createQueryBuilder('assets')
|
||||
.select('albums_assets2.assetsId')
|
||||
.addFrom('albums_assets_assets', 'albums_assets2')
|
||||
.where('albums_assets2.assetsId = assets.id')
|
||||
.andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query
|
||||
.orderBy('assets.fileCreatedAt', 'DESC')
|
||||
.limit(1);
|
||||
|
||||
// Using dataSource, because there is no direct access to albums_assets_assets.
|
||||
const albumHasAssets = dataSource
|
||||
.createQueryBuilder()
|
||||
.select('1')
|
||||
.from('albums_assets_assets', 'albums_assets')
|
||||
.where('"albums"."id" = "albums_assets"."albumsId"');
|
||||
|
||||
const albumContainsThumbnail = albumHasAssets
|
||||
.clone()
|
||||
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
|
||||
|
||||
const updateAlbums = this.albumRepository
|
||||
.createQueryBuilder('albums')
|
||||
.update(AlbumEntity)
|
||||
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` })
|
||||
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
|
||||
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`);
|
||||
|
||||
const result = await updateAlbums.execute();
|
||||
|
||||
return result.affected;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { AlbumResponseDto, AuthUserDto } from '@app/domain';
|
||||
import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Authenticated, AuthUser, SharedLinkRoute } from '../../app.guard';
|
||||
import { UseValidation } from '../../app.utils';
|
||||
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
|
||||
import { AlbumService } from './album.service';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
||||
|
||||
@ApiTags('Album')
|
||||
@Controller('album')
|
||||
@Authenticated()
|
||||
@UseValidation()
|
||||
export class AlbumController {
|
||||
constructor(private service: AlbumService) {}
|
||||
|
||||
@SharedLinkRoute()
|
||||
@Put(':id/assets')
|
||||
addAssetsToAlbum(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: AddAssetsDto,
|
||||
): Promise<AddAssetsResponseDto> {
|
||||
// TODO: Handle nonexistent assetIds.
|
||||
// TODO: Disallow adding assets of another user to an album.
|
||||
return this.service.addAssets(authUser, id, dto);
|
||||
}
|
||||
|
||||
@SharedLinkRoute()
|
||||
@Get(':id')
|
||||
getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
|
||||
return this.service.get(authUser, id);
|
||||
}
|
||||
|
||||
@Delete(':id/assets')
|
||||
removeAssetFromAlbum(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
@Body() dto: RemoveAssetsDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<AlbumResponseDto> {
|
||||
return this.service.removeAssets(authUser, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AlbumRepository, IAlbumRepository } from './album-repository';
|
||||
import { AlbumController } from './album.controller';
|
||||
import { AlbumService } from './album.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity])],
|
||||
controllers: [AlbumController],
|
||||
providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }],
|
||||
})
|
||||
export class AlbumModule {}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { AlbumResponseDto, AuthUserDto, mapUser } from '@app/domain';
|
||||
import { AlbumEntity, UserEntity } from '@app/infra/entities';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { userStub } from '@test';
|
||||
import { IAlbumRepository } from './album-repository';
|
||||
import { AlbumService } from './album.service';
|
||||
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
||||
|
||||
describe('Album service', () => {
|
||||
let sut: AlbumService;
|
||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||
|
||||
const authUser: AuthUserDto = Object.freeze({
|
||||
id: '1111',
|
||||
email: 'auth@test.com',
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const albumOwner: UserEntity = Object.freeze({
|
||||
...authUser,
|
||||
firstName: 'auth',
|
||||
lastName: 'user',
|
||||
createdAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: false,
|
||||
oauthId: '',
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: null,
|
||||
externalPath: null,
|
||||
});
|
||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||
const sharedAlbumOwnerId = '2222';
|
||||
const sharedAlbumSharedAlsoWithId = '3333';
|
||||
|
||||
const _getOwnedAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = albumOwner.id;
|
||||
albumEntity.owner = albumOwner;
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
albumEntity.updatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
albumEntity.sharedUsers = [];
|
||||
albumEntity.assets = [];
|
||||
albumEntity.albumThumbnailAssetId = null;
|
||||
albumEntity.sharedLinks = [];
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
const _getSharedWithAuthUserAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = sharedAlbumOwnerId;
|
||||
albumEntity.owner = albumOwner;
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
albumEntity.assets = [];
|
||||
albumEntity.albumThumbnailAssetId = null;
|
||||
albumEntity.sharedUsers = [
|
||||
{
|
||||
...userStub.user1,
|
||||
id: authUser.id,
|
||||
},
|
||||
{
|
||||
...userStub.user1,
|
||||
id: sharedAlbumSharedAlsoWithId,
|
||||
},
|
||||
];
|
||||
albumEntity.sharedLinks = [];
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
const _getNotOwnedNotSharedAlbum = () => {
|
||||
const albumEntity = new AlbumEntity();
|
||||
albumEntity.ownerId = '5555';
|
||||
albumEntity.id = albumId;
|
||||
albumEntity.albumName = 'name';
|
||||
albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
albumEntity.sharedUsers = [];
|
||||
albumEntity.assets = [];
|
||||
albumEntity.albumThumbnailAssetId = null;
|
||||
|
||||
return albumEntity;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
albumRepositoryMock = {
|
||||
addAssets: jest.fn(),
|
||||
get: jest.fn(),
|
||||
removeAssets: jest.fn(),
|
||||
updateThumbnails: jest.fn(),
|
||||
};
|
||||
|
||||
sut = new AlbumService(albumRepositoryMock);
|
||||
});
|
||||
|
||||
it('gets an owned album', async () => {
|
||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const expectedResult: AlbumResponseDto = {
|
||||
ownerId: albumOwner.id,
|
||||
owner: mapUser(albumOwner),
|
||||
id: albumId,
|
||||
albumName: 'name',
|
||||
createdAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
sharedUsers: [],
|
||||
assets: [],
|
||||
albumThumbnailAssetId: null,
|
||||
shared: false,
|
||||
assetCount: 0,
|
||||
};
|
||||
await expect(sut.get(authUser, albumId)).resolves.toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('gets a shared album', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
const result = await sut.get(authUser, albumId);
|
||||
expect(result.id).toEqual(albumId);
|
||||
expect(result.ownerId).toEqual(sharedAlbumOwnerId);
|
||||
expect(result.shared).toEqual(true);
|
||||
expect(result.sharedUsers).toHaveLength(2);
|
||||
expect(result.sharedUsers[0].id).toEqual(authUser.id);
|
||||
expect(result.sharedUsers[1].id).toEqual(sharedAlbumSharedAlsoWithId);
|
||||
});
|
||||
|
||||
it('prevents retrieving an album that is not owned or shared', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
await expect(sut.get(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('throws a not found exception if the album is not found', async () => {
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve(null));
|
||||
await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('adds assets to owned album', async () => {
|
||||
const albumEntity = _getOwnedAlbum();
|
||||
|
||||
const albumResponse: AddAssetsResponseDto = {
|
||||
alreadyInAlbum: [],
|
||||
successfullyAdded: 1,
|
||||
};
|
||||
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
|
||||
|
||||
const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto;
|
||||
|
||||
// TODO: stub and expect album rendered
|
||||
expect(result.album?.id).toEqual(albumId);
|
||||
});
|
||||
|
||||
it('adds assets to shared album (shared with auth user)', async () => {
|
||||
const albumEntity = _getSharedWithAuthUserAlbum();
|
||||
|
||||
const albumResponse: AddAssetsResponseDto = {
|
||||
alreadyInAlbum: [],
|
||||
successfullyAdded: 1,
|
||||
};
|
||||
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
|
||||
|
||||
const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto;
|
||||
|
||||
// TODO: stub and expect album rendered
|
||||
expect(result.album?.id).toEqual(albumId);
|
||||
});
|
||||
|
||||
it('prevents adding assets to a not owned / shared album', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
|
||||
const albumResponse: AddAssetsResponseDto = {
|
||||
alreadyInAlbum: [],
|
||||
successfullyAdded: 1,
|
||||
};
|
||||
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
|
||||
|
||||
await expect(sut.addAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
// it('removes assets from owned album', async () => {
|
||||
// const albumEntity = _getOwnedAlbum();
|
||||
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
// await expect(
|
||||
// sut.removeAssetsFromAlbum(
|
||||
// authUser,
|
||||
// {
|
||||
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
|
||||
// },
|
||||
// albumEntity.id,
|
||||
// ),
|
||||
// ).resolves.toBeUndefined();
|
||||
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
// assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'],
|
||||
// });
|
||||
// });
|
||||
|
||||
// it('removes assets from shared album (shared with auth user)', async () => {
|
||||
// const albumEntity = _getOwnedSharedAlbum();
|
||||
// albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
// albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
|
||||
// await expect(
|
||||
// sut.removeAssetsFromAlbum(
|
||||
// authUser,
|
||||
// {
|
||||
// assetIds: ['1'],
|
||||
// },
|
||||
// albumEntity.id,
|
||||
// ),
|
||||
// ).resolves.toBeUndefined();
|
||||
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1);
|
||||
// expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, {
|
||||
// assetIds: ['1'],
|
||||
// });
|
||||
// });
|
||||
|
||||
it('prevents removing assets from a not owned / shared album', async () => {
|
||||
const albumEntity = _getNotOwnedNotSharedAlbum();
|
||||
|
||||
const albumResponse: AddAssetsResponseDto = {
|
||||
alreadyInAlbum: [],
|
||||
successfullyAdded: 1,
|
||||
};
|
||||
|
||||
const albumId = albumEntity.id;
|
||||
|
||||
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
|
||||
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
|
||||
|
||||
await expect(sut.removeAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { AlbumResponseDto, AuthUserDto, mapAlbum } from '@app/domain';
|
||||
import { AlbumEntity } from '@app/infra/entities';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { IAlbumRepository } from './album-repository';
|
||||
import { AddAssetsDto } from './dto/add-assets.dto';
|
||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumService {
|
||||
private logger = new Logger(AlbumService.name);
|
||||
|
||||
constructor(@Inject(IAlbumRepository) private repository: IAlbumRepository) {}
|
||||
|
||||
private async _getAlbum({
|
||||
authUser,
|
||||
albumId,
|
||||
validateIsOwner = true,
|
||||
}: {
|
||||
authUser: AuthUserDto;
|
||||
albumId: string;
|
||||
validateIsOwner?: boolean;
|
||||
}): Promise<AlbumEntity> {
|
||||
await this.repository.updateThumbnails();
|
||||
|
||||
const album = await this.repository.get(albumId);
|
||||
if (!album) {
|
||||
throw new NotFoundException('Album Not Found');
|
||||
}
|
||||
const isOwner = album.ownerId == authUser.id;
|
||||
|
||||
if (validateIsOwner && !isOwner) {
|
||||
throw new ForbiddenException('Unauthorized Album Access');
|
||||
} else if (!isOwner && !album.sharedUsers?.some((user) => user.id == authUser.id)) {
|
||||
throw new ForbiddenException('Unauthorized Album Access');
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
async get(authUser: AuthUserDto, albumId: string): Promise<AlbumResponseDto> {
|
||||
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||
return mapAlbum(album);
|
||||
}
|
||||
|
||||
async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
const deletedCount = await this.repository.removeAssets(album, dto);
|
||||
const newAlbum = await this._getAlbum({ authUser, albumId });
|
||||
|
||||
if (deletedCount !== dto.assetIds.length) {
|
||||
throw new BadRequestException('Some assets were not found in the album');
|
||||
}
|
||||
|
||||
return mapAlbum(newAlbum);
|
||||
}
|
||||
|
||||
async addAssets(authUser: AuthUserDto, albumId: string, dto: AddAssetsDto): Promise<AddAssetsResponseDto> {
|
||||
if (authUser.isPublicUser && !authUser.isAllowUpload) {
|
||||
this.logger.warn('Deny public user attempt to add asset to album');
|
||||
throw new ForbiddenException('Public user is not allowed to upload');
|
||||
}
|
||||
|
||||
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||
const result = await this.repository.addAssets(album, dto);
|
||||
const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
|
||||
|
||||
return {
|
||||
...result,
|
||||
album: mapAlbum(newAlbum),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ValidateUUID } from '@app/domain';
|
||||
|
||||
export class AddAssetsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
assetIds!: string[];
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ValidateUUID } from '@app/domain';
|
||||
|
||||
export class AddUsersDto {
|
||||
@ValidateUUID({ each: true })
|
||||
sharedUserIds!: string[];
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ValidateUUID } from '@app/domain';
|
||||
|
||||
export class RemoveAssetsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
assetIds!: string[];
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { AlbumResponseDto } from '@app/domain';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AddAssetsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
successfullyAdded!: number;
|
||||
|
||||
@ApiProperty()
|
||||
alreadyInAlbum!: string[];
|
||||
|
||||
@ApiProperty()
|
||||
album?: AlbumResponseDto;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AlbumModule } from './api-v1/album/album.module';
|
||||
import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository';
|
||||
import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller';
|
||||
import { AssetService } from './api-v1/asset/asset.service';
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
imports: [
|
||||
//
|
||||
DomainModule.register({ imports: [InfraModule] }),
|
||||
AlbumModule,
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
AlbumCountResponseDto,
|
||||
AlbumService,
|
||||
AuthUserDto,
|
||||
BulkIdResponseDto,
|
||||
BulkIdsDto,
|
||||
CreateAlbumDto,
|
||||
UpdateAlbumDto,
|
||||
} from '@app/domain';
|
||||
@@ -10,7 +12,7 @@ import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
|
||||
import { Authenticated, AuthUser } from '../app.guard';
|
||||
import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
|
||||
import { UseValidation } from '../app.utils';
|
||||
import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||
|
||||
@@ -36,6 +38,12 @@ export class AlbumController {
|
||||
return this.service.create(authUser, dto);
|
||||
}
|
||||
|
||||
@SharedLinkRoute()
|
||||
@Get(':id')
|
||||
getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
|
||||
return this.service.get(authUser, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) {
|
||||
return this.service.update(authUser, id, dto);
|
||||
@@ -46,6 +54,25 @@ export class AlbumController {
|
||||
return this.service.delete(authUser, id);
|
||||
}
|
||||
|
||||
@SharedLinkRoute()
|
||||
@Put(':id/assets')
|
||||
addAssetsToAlbum(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: BulkIdsDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.addAssets(authUser, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/assets')
|
||||
removeAssetFromAlbum(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
@Body() dto: BulkIdsDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<BulkIdResponseDto[]> {
|
||||
return this.service.removeAssets(authUser, id, dto);
|
||||
}
|
||||
|
||||
@Put(':id/users')
|
||||
addUsersToAlbum(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) {
|
||||
return this.service.addUsers(authUser, id, dto);
|
||||
|
||||
@@ -3,11 +3,35 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
import { dataSource } from '../database.config';
|
||||
import { AlbumEntity } from '../entities';
|
||||
import { AlbumEntity, AssetEntity } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumRepository implements IAlbumRepository {
|
||||
constructor(@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>) {}
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>,
|
||||
) {}
|
||||
|
||||
getById(id: string): Promise<AlbumEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
relations: {
|
||||
owner: true,
|
||||
sharedUsers: true,
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
},
|
||||
sharedLinks: true,
|
||||
},
|
||||
order: {
|
||||
assets: {
|
||||
fileCreatedAt: 'DESC',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getByIds(ids: string[]): Promise<AlbumEntity[]> {
|
||||
return this.repository.find({
|
||||
@@ -161,4 +185,46 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure all thumbnails for albums are updated by:
|
||||
* - Removing thumbnails from albums without assets
|
||||
* - Removing references of thumbnails to assets outside the album
|
||||
* - Setting a thumbnail when none is set and the album contains assets
|
||||
*
|
||||
* @returns Amount of updated album thumbnails or undefined when unknown
|
||||
*/
|
||||
async updateThumbnails(): Promise<number | undefined> {
|
||||
// Subquery for getting a new thumbnail.
|
||||
const newThumbnail = this.assetRepository
|
||||
.createQueryBuilder('assets')
|
||||
.select('albums_assets2.assetsId')
|
||||
.addFrom('albums_assets_assets', 'albums_assets2')
|
||||
.where('albums_assets2.assetsId = assets.id')
|
||||
.andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query
|
||||
.orderBy('assets.fileCreatedAt', 'DESC')
|
||||
.limit(1);
|
||||
|
||||
// Using dataSource, because there is no direct access to albums_assets_assets.
|
||||
const albumHasAssets = dataSource
|
||||
.createQueryBuilder()
|
||||
.select('1')
|
||||
.from('albums_assets_assets', 'albums_assets')
|
||||
.where('"albums"."id" = "albums_assets"."albumsId"');
|
||||
|
||||
const albumContainsThumbnail = albumHasAssets
|
||||
.clone()
|
||||
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
|
||||
|
||||
const updateAlbums = this.repository
|
||||
.createQueryBuilder('albums')
|
||||
.update(AlbumEntity)
|
||||
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` })
|
||||
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
|
||||
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`);
|
||||
|
||||
const result = await updateAlbums.execute();
|
||||
|
||||
return result.affected;
|
||||
}
|
||||
}
|
||||
|
||||
13
server/test/fixtures/album.stub.ts
vendored
13
server/test/fixtures/album.stub.ts
vendored
@@ -69,6 +69,19 @@ export const albumStub = {
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
}),
|
||||
twoAssets: Object.freeze<AlbumEntity>({
|
||||
id: 'album-4a',
|
||||
albumName: 'Album with two assets',
|
||||
ownerId: authStub.admin.id,
|
||||
owner: userStub.admin,
|
||||
assets: [assetStub.image, assetStub.withLocation],
|
||||
albumThumbnailAsset: assetStub.image,
|
||||
albumThumbnailAssetId: assetStub.image.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
sharedLinks: [],
|
||||
sharedUsers: [],
|
||||
}),
|
||||
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
|
||||
id: 'album-5',
|
||||
albumName: 'Empty album with invalid thumbnail',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IAlbumRepository } from '@app/domain';
|
||||
|
||||
export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
|
||||
return {
|
||||
getById: jest.fn(),
|
||||
getByIds: jest.fn(),
|
||||
getByAssetId: jest.fn(),
|
||||
getAssetCountForIds: jest.fn(),
|
||||
@@ -15,5 +16,6 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
updateThumbnails: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user