feat(server)!: add owned filter to albums API

BREAKING CHANGE: GET /albums with no parameters now returns all accessible albums (owned + shared-with-me) instead of only owned albums.
This commit is contained in:
timonrieger
2026-05-03 16:26:26 +02:00
parent 3decc864b5
commit 7d8be12330
8 changed files with 246 additions and 19 deletions
+65 -1
View File
@@ -188,7 +188,7 @@ describe('/albums', () => {
it('should return the album collection including owned and shared', async () => { it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user1.accessToken}`); const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(4); expect(body).toHaveLength(5);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -219,6 +219,13 @@ describe('/albums', () => {
]), ]),
shared: false, shared: false,
}), }),
expect.objectContaining({
albumName: user2SharedUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
]),
shared: true,
}),
]), ]),
); );
}); });
@@ -282,6 +289,63 @@ describe('/albums', () => {
); );
}); });
it('should return only owned albums when filtered by owned=true', async () => {
const { status, body } = await request(app)
.get('/albums?owned=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ albumName: user1SharedEditorUser }),
expect.objectContaining({ albumName: user1SharedViewerUser }),
expect.objectContaining({ albumName: user1SharedLink }),
expect.objectContaining({ albumName: user1NotShared }),
]),
);
});
it('should return only shared-with-me albums when filtered by owned=false', async () => {
const { status, body } = await request(app)
.get('/albums?owned=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
albumName: user2SharedUser,
albumUsers: expect.arrayContaining([
{ role: AlbumUserRole.Owner, user: expect.objectContaining({ id: user2.userId }) },
]),
}),
]),
);
});
it('should return owned shared-out albums when filtered by owned=true&shared=true', async () => {
const { status, body } = await request(app)
.get('/albums?owned=true&shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ albumName: user1SharedEditorUser }),
expect.objectContaining({ albumName: user1SharedViewerUser }),
expect.objectContaining({ albumName: user1SharedLink }),
]),
);
});
it('should return empty list when filtered by owned=false&shared=false', async () => {
const { status, body } = await request(app)
.get('/albums?owned=false&shared=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(0);
});
it('should return the album collection filtered by assetId', async () => { it('should return the album collection filtered by assetId', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get(`/albums?assetId=${user1Asset2.id}`) .get(`/albums?assetId=${user1Asset2.id}`)
+16 -7
View File
@@ -506,11 +506,14 @@ class AlbumsApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter) /// Filter albums containing this asset ID (ignores other parameters)
///
/// * [bool] owned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
/// ///
/// * [bool] shared: /// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums /// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async { Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? owned, bool? shared, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/albums'; final apiPath = r'/albums';
@@ -524,6 +527,9 @@ class AlbumsApi {
if (assetId != null) { if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId)); queryParams.addAll(_queryParams('', 'assetId', assetId));
} }
if (owned != null) {
queryParams.addAll(_queryParams('', 'owned', owned));
}
if (shared != null) { if (shared != null) {
queryParams.addAll(_queryParams('', 'shared', shared)); queryParams.addAll(_queryParams('', 'shared', shared));
} }
@@ -549,12 +555,15 @@ class AlbumsApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter) /// Filter albums containing this asset ID (ignores other parameters)
///
/// * [bool] owned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
/// ///
/// * [bool] shared: /// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums /// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? shared, }) async { Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? owned, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, ); final response = await getAllAlbumsWithHttpInfo( assetId: assetId, owned: owned, shared: shared, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+11 -2
View File
@@ -1641,18 +1641,27 @@
"name": "assetId", "name": "assetId",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter albums containing this asset ID (ignores shared parameter)", "description": "Filter albums containing this asset ID (ignores other parameters)",
"schema": { "schema": {
"format": "uuid", "format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string" "type": "string"
} }
}, },
{
"name": "owned",
"required": false,
"in": "query",
"description": "Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter",
"schema": {
"type": "boolean"
}
},
{ {
"name": "shared", "name": "shared",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter by shared status: true = only shared, false = not shared, undefined = all owned albums", "description": "Filter by shared status: true = only shared, false = not shared, undefined = no filter",
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
+3 -1
View File
@@ -3657,8 +3657,9 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/** /**
* List all albums * List all albums
*/ */
export function getAllAlbums({ assetId, shared }: { export function getAllAlbums({ assetId, owned, shared }: {
assetId?: string; assetId?: string;
owned?: boolean;
shared?: boolean; shared?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
@@ -3666,6 +3667,7 @@ export function getAllAlbums({ assetId, shared }: {
data: AlbumResponseDto[]; data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({ }>(`/albums${QS.query(QS.explode({
assetId, assetId,
owned,
shared shared
}))}`, { }))}`, {
...opts ...opts
+5 -2
View File
@@ -65,10 +65,13 @@ const UpdateAlbumSchema = z
const GetAlbumsSchema = z const GetAlbumsSchema = z
.object({ .object({
owned: stringToBool
.optional()
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
shared: stringToBool shared: stringToBool
.optional() .optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'), .describe('Filter by shared status: true = only shared, false = not shared, undefined = no filter'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'), assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores other parameters)'),
}) })
.meta({ id: 'GetAlbumsDto' }); .meta({ id: 'GetAlbumsDto' });
@@ -201,6 +201,81 @@ export class AlbumRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] })
getAllAccessible(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.userId', '=', ownerId),
)
.where('album.deletedAt', 'is', null)
.where(({ exists, selectFrom }) =>
exists(
selectFrom('album_user as au')
.whereRef('au.albumId', '=', 'album.id')
.where('au.role', '=', sql.lit(AlbumUserRole.Owner)),
),
)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getSharedWithMe(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.where(({ exists, selectFrom }) =>
exists(
selectFrom('album_user as au')
.whereRef('au.albumId', '=', 'album.id')
.where('au.role', '=', sql.lit(AlbumUserRole.Owner)),
),
)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getOwnedAndShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.where(({ or, exists, selectFrom }) =>
or([
exists(
selectFrom('album_user as au')
.whereRef('au.albumId', '=', 'album.id')
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
),
exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id')),
]),
)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
/** /**
* Get albums shared with and shared by owner. * Get albums shared with and shared by owner.
*/ */
+51 -3
View File
@@ -46,7 +46,7 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.from().albumUser().build(); const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const sharedWithUserAlbum = AlbumFactory.from().owner(owner).albumUser().build(); const sharedWithUserAlbum = AlbumFactory.from().owner(owner).albumUser().build();
mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]); mocks.album.getAllAccessible.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
mocks.album.getMetadataForIds.mockResolvedValue([ mocks.album.getMetadataForIds.mockResolvedValue([
{ {
albumId: album.id, albumId: album.id,
@@ -134,12 +134,60 @@ describe(AlbumService.name, () => {
expect(result[0].id).toEqual(album.id); expect(result[0].id).toEqual(album.id);
expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1); expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1);
}); });
it('gets only owned albums when owned=true', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{ albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null },
]);
const result = await sut.getAll(AuthFactory.create(owner), { owned: true });
expect(result).toHaveLength(1);
expect(mocks.album.getOwned).toHaveBeenCalledTimes(1);
});
it('gets only shared-with-me albums when owned=false', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getSharedWithMe.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{ albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null },
]);
const result = await sut.getAll(AuthFactory.create(owner), { owned: false });
expect(result).toHaveLength(1);
expect(mocks.album.getSharedWithMe).toHaveBeenCalledTimes(1);
});
it('gets owned shared-out albums when owned=true and shared=true', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getOwnedAndShared.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{ albumId: album.id, assetCount: 0, startDate: null, endDate: null, lastModifiedAssetTimestamp: null },
]);
const result = await sut.getAll(AuthFactory.create(owner), { owned: true, shared: true });
expect(result).toHaveLength(1);
expect(mocks.album.getOwnedAndShared).toHaveBeenCalledTimes(1);
});
it('returns empty list when owned=false and shared=false', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const result = await sut.getAll(AuthFactory.create(owner), { owned: false, shared: false });
expect(result).toHaveLength(0);
expect(mocks.album.getMetadataForIds).not.toHaveBeenCalled();
});
}); });
it('counts assets correctly', async () => { it('counts assets correctly', async () => {
const album = AlbumFactory.create(); const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]); mocks.album.getAllAccessible.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([ mocks.album.getMetadataForIds.mockResolvedValue([
{ {
albumId: album.id, albumId: album.id,
@@ -153,7 +201,7 @@ describe(AlbumService.name, () => {
const result = await sut.getAll(AuthFactory.create(owner), {}); const result = await sut.getAll(AuthFactory.create(owner), {});
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].assetCount).toEqual(1); expect(result[0].assetCount).toEqual(1);
expect(mocks.album.getOwned).toHaveBeenCalledTimes(1); expect(mocks.album.getAllAccessible).toHaveBeenCalledTimes(1);
}); });
describe('create', () => { describe('create', () => {
+20 -3
View File
@@ -38,18 +38,35 @@ export class AlbumService extends BaseService {
}; };
} }
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> { async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, owned, shared }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[]; let albums: MapAlbumDto[];
if (assetId) { if (assetId) {
albums = await this.albumRepository.getByAssetId(ownerId, assetId); albums = await this.albumRepository.getByAssetId(ownerId, assetId);
} else if (owned === false && shared === false) {
albums = [];
} else if (owned === false) {
albums = await this.albumRepository.getSharedWithMe(ownerId);
} else if (shared === true) { } else if (shared === true) {
albums = await this.albumRepository.getShared(ownerId); albums =
owned === true
? await this.albumRepository.getOwnedAndShared(ownerId)
: await this.albumRepository.getShared(ownerId);
} else if (shared === false) { } else if (shared === false) {
albums = await this.albumRepository.getNotShared(ownerId); albums = await this.albumRepository.getNotShared(ownerId);
} else { } else {
albums = await this.albumRepository.getOwned(ownerId); albums =
owned === true
? await this.albumRepository.getOwned(ownerId)
: await this.albumRepository.getAllAccessible(ownerId);
}
if (albums.length === 0) {
return [];
} }
// Get asset count for each album. Then map the result to an object: // Get asset count for each album. Then map the result to an object: