fix(web): sorting options for albums (#5233)

* fix: albums

* pr feedback

* fix: current behavior

* rename

* fix: album metadatas

* fix: tests

* fix: e2e test

* simplify

* fix: cover shared links

* rename function

* merge main

* merge main

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martin
2023-11-26 16:23:43 +01:00
committed by GitHub
parent c04340c63e
commit 3aa2927dae
9 changed files with 223 additions and 81 deletions

View File

@@ -58,9 +58,9 @@ describe(AlbumService.name, () => {
describe('getAll', () => {
it('gets list of albums for auth user', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
albumMock.getAssetCountForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0 },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0 },
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
@@ -72,7 +72,14 @@ describe(AlbumService.name, () => {
it('gets list of albums that have a specific asset', async () => {
albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
albumMock.getMetadataForIds.mockResolvedValue([
{
albumId: albumStub.oneAsset.id,
assetCount: 1,
startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'),
},
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
@@ -83,7 +90,9 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => {
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.sharedWithUser.id, assetCount: 0 }]);
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
const result = await sut.getAll(authStub.admin, { shared: true });
@@ -94,7 +103,9 @@ describe(AlbumService.name, () => {
it('gets list of albums that are NOT shared', async () => {
albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.empty.id, assetCount: 0 }]);
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
const result = await sut.getAll(authStub.admin, { shared: false });
@@ -106,7 +117,14 @@ describe(AlbumService.name, () => {
it('counts assets correctly', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]);
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
albumMock.getMetadataForIds.mockResolvedValue([
{
albumId: albumStub.oneAsset.id,
assetCount: 1,
startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'),
},
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
const result = await sut.getAll(authStub.admin, {});
@@ -118,8 +136,13 @@ describe(AlbumService.name, () => {
it('updates the album thumbnail by listing all albums', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]);
albumMock.getAssetCountForIds.mockResolvedValue([
{ albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 },
albumMock.getMetadataForIds.mockResolvedValue([
{
albumId: albumStub.oneAssetInvalidThumbnail.id,
assetCount: 1,
startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'),
},
]);
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail);
@@ -134,8 +157,13 @@ describe(AlbumService.name, () => {
it('removes the thumbnail for an empty album', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]);
albumMock.getAssetCountForIds.mockResolvedValue([
{ albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 },
albumMock.getMetadataForIds.mockResolvedValue([
{
albumId: albumStub.emptyWithInvalidThumbnail.id,
assetCount: 1,
startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'),
},
]);
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail);
@@ -413,10 +441,18 @@ describe(AlbumService.name, () => {
it('should get a shared album', async () => {
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
albumMock.getMetadataForIds.mockResolvedValue([
{
albumId: albumStub.oneAsset.id,
assetCount: 1,
startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'),
},
]);
await sut.get(authStub.admin, albumStub.oneAsset.id, {});
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: false });
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.id,
new Set([albumStub.oneAsset.id]),
@@ -426,10 +462,18 @@ describe(AlbumService.name, () => {
it('should get a shared album via a shared link', async () => {
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
albumMock.getMetadataForIds.mockResolvedValue([
{
albumId: albumStub.oneAsset.id,
assetCount: 1,
startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'),
},
]);
await sut.get(authStub.adminSharedLink, 'album-123', {});
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false });
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
new Set(['album-123']),
@@ -439,10 +483,18 @@ describe(AlbumService.name, () => {
it('should get a shared album via shared with user', async () => {
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
albumMock.getMetadataForIds.mockResolvedValue([
{
albumId: albumStub.oneAsset.id,
assetCount: 1,
startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'),
},
]);
await sut.get(authStub.user1, 'album-123', {});
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false });
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['album-123']));
});

View File

@@ -6,6 +6,7 @@ import { AuthUserDto } from '../auth';
import { setUnion } from '../domain.util';
import { JobName } from '../job';
import {
AlbumAssetCount,
AlbumInfoOptions,
IAccessRepository,
IAlbumRepository,
@@ -69,11 +70,19 @@ export class AlbumService {
// Get asset count for each album. Then map the result to an object:
// { [albumId]: assetCount }
const albumsAssetCount = await this.albumRepository.getAssetCountForIds(albums.map((album) => album.id));
const albumsAssetCountObj = albumsAssetCount.reduce((obj: Record<string, number>, { albumId, assetCount }) => {
obj[albumId] = assetCount;
return obj;
}, {});
const albumMetadataForIds = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
const albumMetadataForIdsObj: Record<string, AlbumAssetCount> = albumMetadataForIds.reduce(
(obj: Record<string, AlbumAssetCount>, { albumId, assetCount, startDate, endDate }) => {
obj[albumId] = {
albumId,
assetCount,
startDate,
endDate,
};
return obj;
},
{},
);
return Promise.all(
albums.map(async (album) => {
@@ -81,7 +90,9 @@ export class AlbumService {
return {
...mapAlbumWithoutAssets(album),
sharedLinks: undefined,
assetCount: albumsAssetCountObj[album.id],
startDate: albumMetadataForIdsObj[album.id].startDate,
endDate: albumMetadataForIdsObj[album.id].endDate,
assetCount: albumMetadataForIdsObj[album.id].assetCount,
lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt,
};
}),
@@ -91,7 +102,16 @@ export class AlbumService {
async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
await this.albumRepository.updateThumbnails();
return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets);
const withAssets = dto.withoutAssets === undefined ? false : !dto.withoutAssets;
const album = await this.findOrFail(id, { withAssets });
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
return {
...mapAlbum(album, withAssets),
startDate: albumMetadataForIds.startDate,
endDate: albumMetadataForIds.endDate,
assetCount: albumMetadataForIds.assetCount,
};
}
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {

View File

@@ -5,6 +5,8 @@ export const IAlbumRepository = 'IAlbumRepository';
export interface AlbumAssetCount {
albumId: string;
assetCount: number;
startDate: Date | undefined;
endDate: Date | undefined;
}
export interface AlbumInfoOptions {
@@ -30,7 +32,7 @@ export interface IAlbumRepository {
hasAsset(asset: AlbumAsset): Promise<boolean>;
removeAsset(assetId: string): Promise<void>;
removeAssets(assets: AlbumAssets): Promise<void>;
getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]>;
getInvalidThumbnail(): Promise<string[]>;
getOwned(ownerId: string): Promise<AlbumEntity[]>;
getShared(ownerId: string): Promise<AlbumEntity[]>;

View File

@@ -59,25 +59,30 @@ export class AlbumRepository implements IAlbumRepository {
});
}
async getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]> {
async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> {
// Guard against running invalid query when ids list is empty.
if (!ids.length) {
return [];
}
// Only possible with query builder because of GROUP BY.
const countByAlbums = await this.repository
const albumMetadatas = await this.repository
.createQueryBuilder('album')
.select('album.id')
.addSelect('COUNT(albums_assets.assetsId)', 'asset_count')
.leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id')
.addSelect('MIN(assets.fileCreatedAt)', 'start_date')
.addSelect('MAX(assets.fileCreatedAt)', 'end_date')
.addSelect('COUNT(album_assets.assetsId)', 'asset_count')
.leftJoin('albums_assets_assets', 'album_assets', 'album_assets.albumsId = album.id')
.leftJoin('assets', 'assets', 'assets.id = album_assets.assetsId')
.where('album.id IN (:...ids)', { ids })
.groupBy('album.id')
.getRawMany();
return countByAlbums.map<AlbumAssetCount>((albumCount) => ({
albumId: albumCount['album_id'],
assetCount: Number(albumCount['asset_count']),
return albumMetadatas.map<AlbumAssetCount>((metadatas) => ({
albumId: metadatas['album_id'],
assetCount: Number(metadatas['asset_count']),
startDate: metadatas['end_date'] ? new Date(metadatas['start_date']) : undefined,
endDate: metadatas['end_date'] ? new Date(metadatas['end_date']) : undefined,
}));
}