mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: favorite albums
This commit is contained in:
@@ -79,6 +79,21 @@ describe(AlbumController.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /albums/:id/user-metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}/user-metadata`).send({ isFavorite: true });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject an invalid favorite payload', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.patch(`/albums/${factory.uuid()}/user-metadata`)
|
||||
.send({ isFavorite: 'invalid' });
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /albums/:id/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/albums/${factory.uuid()}/assets`);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
GetAlbumsDto,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
UpdateAlbumUserMetadataDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -89,6 +90,21 @@ export class AlbumController {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/user-metadata')
|
||||
@Authenticated({ permission: Permission.AlbumRead })
|
||||
@Endpoint({
|
||||
summary: 'Update album user metadata',
|
||||
description: 'Update metadata for the authenticated user on a specific album.',
|
||||
history: new HistoryBuilder().added('v2.7.0').beta('v2.7.0'),
|
||||
})
|
||||
updateAlbumUserMetadata(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: UpdateAlbumUserMetadataDto,
|
||||
): Promise<AlbumResponseDto> {
|
||||
return this.service.updateAlbumUserMetadata(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ permission: Permission.AlbumDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -403,6 +403,11 @@ export const columns = {
|
||||
'asset.isEdited',
|
||||
],
|
||||
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
|
||||
syncAlbumUserMetadata: [
|
||||
'album_user_metadata.albumId as albumId',
|
||||
'album_user_metadata.userId as userId',
|
||||
'album_user_metadata.isFavorite',
|
||||
],
|
||||
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
|
||||
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
|
||||
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
|
||||
|
||||
@@ -20,4 +20,14 @@ describe('mapAlbum', () => {
|
||||
expect(dto.startDate).toBeUndefined();
|
||||
expect(dto.endDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should default isFavorite to false', () => {
|
||||
const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false);
|
||||
expect(dto.isFavorite).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve a provided favorite state', () => {
|
||||
const dto = mapAlbum({ ...getForAlbum(AlbumFactory.create()), isFavorite: true }, false);
|
||||
expect(dto.isFavorite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,11 @@ export class UpdateAlbumDto {
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
export class UpdateAlbumUserMetadataDto {
|
||||
@ValidateBoolean({ description: 'Favorite status' })
|
||||
isFavorite!: boolean;
|
||||
}
|
||||
|
||||
export class GetAlbumsDto {
|
||||
@ValidateBoolean({
|
||||
optional: true,
|
||||
@@ -183,6 +188,8 @@ export class AlbumResponseDto {
|
||||
endDate?: string;
|
||||
@ApiProperty({ description: 'Activity feed enabled' })
|
||||
isActivityEnabled!: boolean;
|
||||
@ApiProperty({ description: 'Is favorite' })
|
||||
isFavorite!: boolean;
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
|
||||
order?: AssetOrder;
|
||||
|
||||
@@ -205,6 +212,7 @@ export type MapAlbumDto = {
|
||||
ownerId: string;
|
||||
owner: ShallowDehydrateObject<User>;
|
||||
isActivityEnabled: boolean;
|
||||
isFavorite?: boolean;
|
||||
order: AssetOrder;
|
||||
};
|
||||
|
||||
@@ -256,6 +264,7 @@ export const mapAlbum = (
|
||||
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
|
||||
assetCount: entity.assets?.length || 0,
|
||||
isActivityEnabled: entity.isActivityEnabled,
|
||||
isFavorite: auth?.sharedLink ? false : (entity.isFavorite ?? false),
|
||||
order: entity.order,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -279,6 +279,24 @@ export class SyncAlbumUserV1 {
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumUserMetadataDeleteV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumUserMetadataV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
@ApiProperty({ description: 'Is favorite' })
|
||||
isFavorite!: boolean;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
@@ -511,6 +529,8 @@ export type SyncItem = {
|
||||
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
|
||||
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
|
||||
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
|
||||
[SyncEntityType.AlbumUserMetadataV1]: SyncAlbumUserMetadataV1;
|
||||
[SyncEntityType.AlbumUserMetadataDeleteV1]: SyncAlbumUserMetadataDeleteV1;
|
||||
[SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1;
|
||||
[SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1;
|
||||
[SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1;
|
||||
|
||||
@@ -723,6 +723,7 @@ export enum ExitCode {
|
||||
export enum SyncRequestType {
|
||||
AlbumsV1 = 'AlbumsV1',
|
||||
AlbumUsersV1 = 'AlbumUsersV1',
|
||||
AlbumUserMetadataV1 = 'AlbumUserMetadataV1',
|
||||
AlbumToAssetsV1 = 'AlbumToAssetsV1',
|
||||
AlbumAssetsV1 = 'AlbumAssetsV1',
|
||||
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
||||
@@ -777,6 +778,8 @@ export enum SyncEntityType {
|
||||
AlbumUserV1 = 'AlbumUserV1',
|
||||
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
|
||||
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
|
||||
AlbumUserMetadataV1 = 'AlbumUserMetadataV1',
|
||||
AlbumUserMetadataDeleteV1 = 'AlbumUserMetadataDeleteV1',
|
||||
|
||||
AlbumAssetCreateV1 = 'AlbumAssetCreateV1',
|
||||
AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1',
|
||||
|
||||
@@ -21,6 +21,18 @@ select
|
||||
"user"."id" = "album"."ownerId"
|
||||
) as obj
|
||||
) as "owner",
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -88,12 +100,24 @@ select
|
||||
from
|
||||
"album"
|
||||
where
|
||||
"album"."id" = $1
|
||||
"album"."id" = $2
|
||||
and "album"."deletedAt" is null
|
||||
|
||||
-- AlbumRepository.getByAssetId
|
||||
select
|
||||
"album".*,
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
@@ -148,17 +172,17 @@ from
|
||||
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
|
||||
where
|
||||
(
|
||||
"album"."ownerId" = $1
|
||||
"album"."ownerId" = $2
|
||||
or exists (
|
||||
select
|
||||
from
|
||||
"album_user"
|
||||
where
|
||||
"album_user"."albumId" = "album"."id"
|
||||
and "album_user"."userId" = $2
|
||||
and "album_user"."userId" = $3
|
||||
)
|
||||
)
|
||||
and "album_asset"."assetId" = $3
|
||||
and "album_asset"."assetId" = $4
|
||||
and "album"."deletedAt" is null
|
||||
order by
|
||||
"album"."createdAt" desc,
|
||||
@@ -210,6 +234,18 @@ group by
|
||||
-- AlbumRepository.getOwned
|
||||
select
|
||||
"album".*,
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
@@ -275,7 +311,7 @@ select
|
||||
from
|
||||
"album"
|
||||
where
|
||||
"album"."ownerId" = $1
|
||||
"album"."ownerId" = $2
|
||||
and "album"."deletedAt" is null
|
||||
order by
|
||||
"album"."createdAt" desc
|
||||
@@ -283,6 +319,18 @@ order by
|
||||
-- AlbumRepository.getShared
|
||||
select
|
||||
"album".*,
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -356,8 +404,8 @@ where
|
||||
where
|
||||
"album_user"."albumId" = "album"."id"
|
||||
and (
|
||||
"album"."ownerId" = $1
|
||||
or "album_user"."userId" = $2
|
||||
"album"."ownerId" = $2
|
||||
or "album_user"."userId" = $3
|
||||
)
|
||||
)
|
||||
or exists (
|
||||
@@ -366,7 +414,7 @@ where
|
||||
"shared_link"
|
||||
where
|
||||
"shared_link"."albumId" = "album"."id"
|
||||
and "shared_link"."userId" = $3
|
||||
and "shared_link"."userId" = $4
|
||||
)
|
||||
)
|
||||
and "album"."deletedAt" is null
|
||||
@@ -376,6 +424,18 @@ order by
|
||||
-- AlbumRepository.getNotShared
|
||||
select
|
||||
"album".*,
|
||||
coalesce(
|
||||
(
|
||||
select
|
||||
"album_user_metadata"."isFavorite"
|
||||
from
|
||||
"album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."albumId" = "album"."id"
|
||||
and "album_user_metadata"."userId" = $1
|
||||
),
|
||||
false
|
||||
) as "isFavorite",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
@@ -397,7 +457,7 @@ select
|
||||
from
|
||||
"album"
|
||||
where
|
||||
"album"."ownerId" = $1
|
||||
"album"."ownerId" = $2
|
||||
and "album"."deletedAt" is null
|
||||
and not exists (
|
||||
select
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- AlbumUserMetadataRepository.upsert
|
||||
insert into
|
||||
"album_user_metadata" ("albumId", "userId", "isFavorite")
|
||||
values
|
||||
($1, $2, $3)
|
||||
on conflict ("albumId", "userId") do update
|
||||
set
|
||||
"isFavorite" = "excluded"."isFavorite"
|
||||
@@ -1,6 +1,7 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- AlbumUserRepository.create
|
||||
begin
|
||||
insert into
|
||||
"album_user" ("userId", "albumId")
|
||||
values
|
||||
@@ -9,6 +10,7 @@ returning
|
||||
"userId",
|
||||
"albumId",
|
||||
"role"
|
||||
rollback
|
||||
|
||||
-- AlbumUserRepository.update
|
||||
update "album_user"
|
||||
@@ -19,7 +21,13 @@ where
|
||||
and "albumId" = $3
|
||||
|
||||
-- AlbumUserRepository.delete
|
||||
begin
|
||||
delete from "album_user_metadata"
|
||||
where
|
||||
"userId" = $1
|
||||
and "albumId" = $2
|
||||
delete from "album_user"
|
||||
where
|
||||
"userId" = $1
|
||||
and "albumId" = $2
|
||||
commit
|
||||
|
||||
@@ -285,6 +285,35 @@ where
|
||||
order by
|
||||
"album_asset"."updateId" asc
|
||||
|
||||
-- SyncRepository.albumUserMetadata.getDeletes
|
||||
select
|
||||
"id",
|
||||
"albumId",
|
||||
"userId"
|
||||
from
|
||||
"album_user_metadata_audit" as "album_user_metadata_audit"
|
||||
where
|
||||
"album_user_metadata_audit"."id" < $1
|
||||
and "album_user_metadata_audit"."id" > $2
|
||||
and "userId" = $3
|
||||
order by
|
||||
"album_user_metadata_audit"."id" asc
|
||||
|
||||
-- SyncRepository.albumUserMetadata.getUpserts
|
||||
select
|
||||
"album_user_metadata"."albumId" as "albumId",
|
||||
"album_user_metadata"."userId" as "userId",
|
||||
"album_user_metadata"."isFavorite",
|
||||
"album_user_metadata"."updateId"
|
||||
from
|
||||
"album_user_metadata" as "album_user_metadata"
|
||||
where
|
||||
"album_user_metadata"."updateId" < $1
|
||||
and "album_user_metadata"."updateId" > $2
|
||||
and "userId" = $3
|
||||
order by
|
||||
"album_user_metadata"."updateId" asc
|
||||
|
||||
-- SyncRepository.albumToAsset.getBackfill
|
||||
select
|
||||
"album_asset"."assetId" as "assetId",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { DB } from 'src/schema';
|
||||
import { AlbumUserMetadataTable } from 'src/schema/tables/album-user-metadata.table';
|
||||
|
||||
export type AlbumUserMetadataId = {
|
||||
albumId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AlbumUserMetadataRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [{ albumId: DummyValue.UUID, userId: DummyValue.UUID, isFavorite: true }] })
|
||||
async upsert(dto: Insertable<AlbumUserMetadataTable>) {
|
||||
await this.db
|
||||
.insertInto('album_user_metadata')
|
||||
.values(dto)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['albumId', 'userId']).doUpdateSet((eb) => ({ isFavorite: eb.ref('excluded.isFavorite') })),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,21 @@ export class AlbumUserRepository {
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
|
||||
create(albumUser: Insertable<AlbumUserTable>) {
|
||||
return this.db
|
||||
.insertInto('album_user')
|
||||
.values(albumUser)
|
||||
.returning(['userId', 'albumId', 'role'])
|
||||
.executeTakeFirstOrThrow();
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
const result = await tx
|
||||
.insertInto('album_user')
|
||||
.values(albumUser)
|
||||
.returning(['userId', 'albumId', 'role'])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await tx
|
||||
.insertInto('album_user_metadata')
|
||||
.values({ albumId: albumUser.albumId, userId: albumUser.userId, isFavorite: false })
|
||||
.onConflict((oc) => oc.columns(['albumId', 'userId']).doNothing())
|
||||
.execute();
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] })
|
||||
@@ -36,6 +46,9 @@ export class AlbumUserRepository {
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, albumId: DummyValue.UUID }] })
|
||||
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
|
||||
await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
|
||||
await this.db.transaction().execute(async (tx) => {
|
||||
await tx.deleteFrom('album_user_metadata').where('userId', '=', userId).where('albumId', '=', albumId).execute();
|
||||
await tx.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,18 @@ export interface AlbumInfoOptions {
|
||||
withAssets: boolean;
|
||||
}
|
||||
|
||||
const withFavorite = (eb: ExpressionBuilder<DB, 'album'>, userId?: string) => {
|
||||
if (!userId) {
|
||||
return sql<boolean>`false`.as('isFavorite');
|
||||
}
|
||||
|
||||
return sql<boolean>`coalesce(${eb
|
||||
.selectFrom('album_user_metadata')
|
||||
.select('album_user_metadata.isFavorite')
|
||||
.whereRef('album_user_metadata.albumId', '=', 'album.id')
|
||||
.where('album_user_metadata.userId', '=', userId)}, false)`.as('isFavorite');
|
||||
};
|
||||
|
||||
const withOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album.ownerId'))
|
||||
.$notNull()
|
||||
@@ -84,14 +96,15 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
export class AlbumRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
|
||||
async getById(id: string, options: AlbumInfoOptions) {
|
||||
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }, DummyValue.UUID] })
|
||||
async getById(id: string, options: AlbumInfoOptions, userId?: string) {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.where('album.id', '=', id)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select(withOwner)
|
||||
.select((eb) => withFavorite(eb, userId))
|
||||
.select(withAlbumUsers)
|
||||
.select(withSharedLink)
|
||||
.$if(options.withAssets, (eb) => eb.select(withAssets))
|
||||
@@ -119,6 +132,7 @@ export class AlbumRepository {
|
||||
.where('album_asset.assetId', '=', assetId)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
.select((eb) => withFavorite(eb, ownerId))
|
||||
.select(withOwner)
|
||||
.select(withAlbumUsers)
|
||||
.orderBy('album.createdAt', 'desc')
|
||||
@@ -194,6 +208,7 @@ export class AlbumRepository {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.select((eb) => withFavorite(eb, ownerId))
|
||||
.select(withOwner)
|
||||
.select(withAlbumUsers)
|
||||
.select(withSharedLink)
|
||||
@@ -211,6 +226,7 @@ export class AlbumRepository {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.select((eb) => withFavorite(eb, ownerId))
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.exists(
|
||||
@@ -243,6 +259,7 @@ export class AlbumRepository {
|
||||
return this.db
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.select((eb) => withFavorite(eb, ownerId))
|
||||
.where('album.ownerId', '=', ownerId)
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
|
||||
@@ -318,6 +335,15 @@ export class AlbumRepository {
|
||||
throw new Error('Failed to create album');
|
||||
}
|
||||
|
||||
await tx
|
||||
.insertInto('album_user_metadata')
|
||||
.values([
|
||||
{ albumId: newAlbum.id, userId: album.ownerId, isFavorite: false },
|
||||
...albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, isFavorite: false })),
|
||||
])
|
||||
.onConflict((oc) => oc.columns(['albumId', 'userId']).doNothing())
|
||||
.execute();
|
||||
|
||||
if (assetIds.length > 0) {
|
||||
await this.addAssets(tx, newAlbum.id, assetIds);
|
||||
}
|
||||
@@ -335,6 +361,7 @@ export class AlbumRepository {
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.where('id', '=', newAlbum.id)
|
||||
.select((eb) => withFavorite(eb, album.ownerId))
|
||||
.select(withOwner)
|
||||
.select(withAssets)
|
||||
.select(withAlbumUsers)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
@@ -55,6 +56,7 @@ export const repositories = [
|
||||
AccessRepository,
|
||||
ActivityRepository,
|
||||
AlbumRepository,
|
||||
AlbumUserMetadataRepository,
|
||||
AlbumUserRepository,
|
||||
AuditRepository,
|
||||
ApiKeyRepository,
|
||||
|
||||
@@ -49,6 +49,7 @@ export class SyncRepository {
|
||||
album: AlbumSync;
|
||||
albumAsset: AlbumAssetSync;
|
||||
albumAssetExif: AlbumAssetExifSync;
|
||||
albumUserMetadata: AlbumUserMetadataSync;
|
||||
albumToAsset: AlbumToAssetSync;
|
||||
albumUser: AlbumUserSync;
|
||||
asset: AssetSync;
|
||||
@@ -72,6 +73,7 @@ export class SyncRepository {
|
||||
this.album = new AlbumSync(this.db);
|
||||
this.albumAsset = new AlbumAssetSync(this.db);
|
||||
this.albumAssetExif = new AlbumAssetExifSync(this.db);
|
||||
this.albumUserMetadata = new AlbumUserMetadataSync(this.db);
|
||||
this.albumToAsset = new AlbumToAssetSync(this.db);
|
||||
this.albumUser = new AlbumUserSync(this.db);
|
||||
this.asset = new AssetSync(this.db);
|
||||
@@ -385,6 +387,29 @@ class AlbumUserSync extends BaseSync {
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumUserMetadataSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getDeletes(options: SyncQueryOptions) {
|
||||
return this.auditQuery('album_user_metadata_audit', options)
|
||||
.select(['id', 'albumId', 'userId'])
|
||||
.where('userId', '=', options.userId)
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('album_user_metadata_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('album_user_metadata', options)
|
||||
.select(columns.syncAlbumUserMetadata)
|
||||
.select('album_user_metadata.updateId')
|
||||
.where('userId', '=', options.userId)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
class AssetSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getDeletes(options: SyncQueryOptions) {
|
||||
|
||||
@@ -165,6 +165,19 @@ export const album_user_delete_audit = registerFunction({
|
||||
END`,
|
||||
});
|
||||
|
||||
export const album_user_metadata_audit = registerFunction({
|
||||
name: 'album_user_metadata_audit',
|
||||
returnType: 'TRIGGER',
|
||||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
INSERT INTO album_user_metadata_audit ("albumId", "userId")
|
||||
SELECT "albumId", "userId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END`,
|
||||
});
|
||||
|
||||
export const memory_delete_audit = registerFunction({
|
||||
name: 'memory_delete_audit',
|
||||
returnType: 'TRIGGER',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
album_delete_audit,
|
||||
album_user_after_insert,
|
||||
album_user_delete_audit,
|
||||
album_user_metadata_audit,
|
||||
asset_delete_audit,
|
||||
asset_face_audit,
|
||||
asset_metadata_audit,
|
||||
@@ -25,6 +26,8 @@ import { AlbumAssetAuditTable } from 'src/schema/tables/album-asset-audit.table'
|
||||
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
|
||||
import { AlbumAuditTable } from 'src/schema/tables/album-audit.table';
|
||||
import { AlbumUserAuditTable } from 'src/schema/tables/album-user-audit.table';
|
||||
import { AlbumUserMetadataAuditTable } from 'src/schema/tables/album-user-metadata-audit.table';
|
||||
import { AlbumUserMetadataTable } from 'src/schema/tables/album-user-metadata.table';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
||||
@@ -83,6 +86,8 @@ export class ImmichDatabase {
|
||||
AlbumAssetTable,
|
||||
AlbumAssetAuditTable,
|
||||
AlbumAuditTable,
|
||||
AlbumUserMetadataAuditTable,
|
||||
AlbumUserMetadataTable,
|
||||
AlbumUserAuditTable,
|
||||
AlbumUserTable,
|
||||
AlbumTable,
|
||||
@@ -150,6 +155,7 @@ export class ImmichDatabase {
|
||||
asset_delete_audit,
|
||||
album_delete_audit,
|
||||
album_user_after_insert,
|
||||
album_user_metadata_audit,
|
||||
album_user_delete_audit,
|
||||
memory_delete_audit,
|
||||
memory_asset_delete_audit,
|
||||
@@ -178,6 +184,8 @@ export interface DB {
|
||||
album_audit: AlbumAuditTable;
|
||||
album_asset: AlbumAssetTable;
|
||||
album_asset_audit: AlbumAssetAuditTable;
|
||||
album_user_metadata: AlbumUserMetadataTable;
|
||||
album_user_metadata_audit: AlbumUserMetadataAuditTable;
|
||||
album_user: AlbumUserTable;
|
||||
album_user_audit: AlbumUserAuditTable;
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION album_user_metadata_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO album_user_metadata_audit ("albumId", "userId")
|
||||
SELECT "albumId", "userId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE TABLE "album_user_metadata_audit" (
|
||||
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"albumId" uuid NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT "album_user_metadata_audit_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_audit_albumId_idx" ON "album_user_metadata_audit" ("albumId");`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_audit_userId_idx" ON "album_user_metadata_audit" ("userId");`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_audit_deletedAt_idx" ON "album_user_metadata_audit" ("deletedAt");`.execute(db);
|
||||
await sql`CREATE TABLE "album_user_metadata" (
|
||||
"albumId" uuid NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"isFavorite" boolean NOT NULL DEFAULT false,
|
||||
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "album_user_metadata_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "album" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "album_user_metadata_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "album_user_metadata_pkey" PRIMARY KEY ("albumId", "userId")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_userId_idx" ON "album_user_metadata" ("userId");`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_updateId_idx" ON "album_user_metadata" ("updateId");`.execute(db);
|
||||
await sql`CREATE INDEX "album_user_metadata_updatedAt_idx" ON "album_user_metadata" ("updatedAt");`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "album_user_metadata_audit"
|
||||
AFTER DELETE ON "album_user_metadata"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() = 0)
|
||||
EXECUTE FUNCTION album_user_metadata_audit();`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "album_user_metadata_updated_at"
|
||||
BEFORE UPDATE ON "album_user_metadata"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||
await sql`INSERT INTO "album_user_metadata" ("albumId", "userId", "isFavorite")
|
||||
SELECT "id", "ownerId", false FROM "album"
|
||||
ON CONFLICT ("albumId", "userId") DO NOTHING;`.execute(db);
|
||||
await sql`INSERT INTO "album_user_metadata" ("albumId", "userId", "isFavorite")
|
||||
SELECT "albumId", "userId", false FROM "album_user"
|
||||
ON CONFLICT ("albumId", "userId") DO NOTHING;`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_user_metadata_audit', '{"type":"function","name":"album_user_metadata_audit","sql":"CREATE OR REPLACE FUNCTION album_user_metadata_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_user_metadata_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"albumId\\", \\"userId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_metadata_audit', '{"type":"trigger","name":"album_user_metadata_audit","sql":"CREATE OR REPLACE TRIGGER \\"album_user_metadata_audit\\"\\n AFTER DELETE ON \\"album_user_metadata\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION album_user_metadata_audit();"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_metadata_updated_at', '{"type":"trigger","name":"album_user_metadata_updated_at","sql":"CREATE OR REPLACE TRIGGER \\"album_user_metadata_updated_at\\"\\n BEFORE UPDATE ON \\"album_user_metadata\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TABLE "album_user_metadata_audit";`.execute(db);
|
||||
await sql`DROP TABLE "album_user_metadata";`.execute(db);
|
||||
await sql`DROP FUNCTION album_user_metadata_audit;`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_user_metadata_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_metadata_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_metadata_updated_at';`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
|
||||
@Table('album_user_metadata_audit')
|
||||
export class AlbumUserMetadataAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ type: 'uuid', index: true })
|
||||
albumId!: string;
|
||||
|
||||
@Column({ type: 'uuid', index: true })
|
||||
userId!: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||
deletedAt!: Generated<Timestamp>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { album_user_metadata_audit } from 'src/schema/functions';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@UpdatedAtTrigger('album_user_metadata_updated_at')
|
||||
@Table('album_user_metadata')
|
||||
@AfterDeleteTrigger({
|
||||
scope: 'statement',
|
||||
function: album_user_metadata_audit,
|
||||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
export class AlbumUserMetadataTable {
|
||||
@ForeignKeyColumn(() => AlbumTable, {
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
primary: true,
|
||||
index: false,
|
||||
})
|
||||
albumId!: string;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, {
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
primary: true,
|
||||
index: true,
|
||||
})
|
||||
userId!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isFavorite!: Generated<boolean>;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
@UpdateDateColumn({ index: true })
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
}
|
||||
@@ -112,6 +112,23 @@ describe(AlbumService.name, () => {
|
||||
expect(mocks.album.getShared).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('includes favorite status in album lists', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getOwned.mockResolvedValue([{ ...getForAlbum(album), isFavorite: true }]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.getAll(AuthFactory.create(album.owner), {});
|
||||
expect(result[0].isFavorite).toBe(true);
|
||||
});
|
||||
|
||||
it('gets list of albums that are NOT shared', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]);
|
||||
@@ -337,12 +354,42 @@ describe(AlbumService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.update).toHaveBeenCalledWith(album.id, { id: album.id, albumName: 'new album name' });
|
||||
});
|
||||
|
||||
it('should preserve favorite status in the response', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true });
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
|
||||
expect(result.isFavorite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
@@ -430,7 +477,16 @@ describe(AlbumService.name, () => {
|
||||
const user = UserFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
|
||||
|
||||
@@ -468,7 +524,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, undefined);
|
||||
});
|
||||
|
||||
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||
@@ -565,10 +621,28 @@ describe(AlbumService.name, () => {
|
||||
|
||||
await sut.get(AuthFactory.create(album.owner), album.id, {});
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }, album.owner.id);
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([album.id]));
|
||||
});
|
||||
|
||||
it('should include favorite status for the authenticated user', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true });
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
lastModifiedAssetTimestamp: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.get(AuthFactory.create(album.owner), album.id, {});
|
||||
expect(result.isFavorite).toBe(true);
|
||||
});
|
||||
|
||||
it('should get a shared album via a shared link', async () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
@@ -586,7 +660,7 @@ describe(AlbumService.name, () => {
|
||||
const auth = AuthFactory.from().sharedLink().build();
|
||||
await sut.get(auth, album.id, {});
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }, auth.user.id);
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink!.id, new Set([album.id]));
|
||||
});
|
||||
|
||||
@@ -607,7 +681,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
await sut.get(AuthFactory.create(user), album.id, {});
|
||||
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
|
||||
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }, user.id);
|
||||
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
new Set([album.id]),
|
||||
@@ -615,6 +689,26 @@ describe(AlbumService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not expose favorite status over shared links', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
lastModifiedAssetTimestamp: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
|
||||
const auth = AuthFactory.from().sharedLink().build();
|
||||
const result = await sut.get(auth, album.id, {});
|
||||
|
||||
expect(result.isFavorite).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
await expect(sut.get(auth, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -628,6 +722,53 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAlbumUserMetadata', () => {
|
||||
it('should update favorite status for an owned album', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.albumUserMetadata.upsert.mockResolvedValue();
|
||||
mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true });
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.updateAlbumUserMetadata(AuthFactory.create(album.owner), album.id, { isFavorite: true });
|
||||
|
||||
expect(mocks.albumUserMetadata.upsert).toHaveBeenCalledWith({
|
||||
albumId: album.id,
|
||||
userId: album.owner.id,
|
||||
isFavorite: true,
|
||||
});
|
||||
expect(result.isFavorite).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow shared viewers to update favorite status', async () => {
|
||||
const viewer = UserFactory.create();
|
||||
const album = AlbumFactory.from().albumUser({ userId: viewer.id, role: AlbumUserRole.Viewer }).build();
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.albumUserMetadata.upsert.mockResolvedValue();
|
||||
mocks.album.getById.mockResolvedValue({ ...getForAlbum(album), isFavorite: true });
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
assetCount: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastModifiedAssetTimestamp: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sut.updateAlbumUserMetadata(AuthFactory.create(viewer), album.id, { isFavorite: true });
|
||||
expect(result.isFavorite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should allow the owner to add assets', async () => {
|
||||
const owner = UserFactory.create({ isAdmin: true });
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
mapAlbumWithoutAssets,
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
UpdateAlbumUserMetadataDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -77,7 +78,7 @@ export class AlbumService extends BaseService {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
|
||||
await this.albumRepository.updateThumbnails();
|
||||
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
|
||||
const album = await this.findOrFail(id, { withAssets });
|
||||
const album = await this.findOrFail(id, { withAssets }, auth.user.id);
|
||||
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
|
||||
|
||||
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0;
|
||||
@@ -147,7 +148,7 @@ export class AlbumService extends BaseService {
|
||||
throw new BadRequestException('Invalid album thumbnail');
|
||||
}
|
||||
}
|
||||
const updatedAlbum = await this.albumRepository.update(album.id, {
|
||||
await this.albumRepository.update(album.id, {
|
||||
id: album.id,
|
||||
albumName: dto.albumName,
|
||||
description: dto.description,
|
||||
@@ -156,7 +157,15 @@ export class AlbumService extends BaseService {
|
||||
order: dto.order,
|
||||
});
|
||||
|
||||
return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });
|
||||
return this.get(auth, id, { withoutAssets: true });
|
||||
}
|
||||
|
||||
async updateAlbumUserMetadata(auth: AuthDto, id: string, dto: UpdateAlbumUserMetadataDto): Promise<AlbumResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
|
||||
|
||||
await this.albumUserMetadataRepository.upsert({ albumId: id, userId: auth.user.id, isFavorite: dto.isFavorite });
|
||||
|
||||
return this.get(auth, id, { withoutAssets: true });
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
@@ -306,7 +315,7 @@ export class AlbumService extends BaseService {
|
||||
await this.eventRepository.emit('AlbumInvite', { id, userId });
|
||||
}
|
||||
|
||||
return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets);
|
||||
return this.get(auth, id, { withoutAssets: true });
|
||||
}
|
||||
|
||||
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
|
||||
@@ -338,8 +347,8 @@ export class AlbumService extends BaseService {
|
||||
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
|
||||
}
|
||||
|
||||
private async findOrFail(id: string, options: AlbumInfoOptions) {
|
||||
const album = await this.albumRepository.getById(id, options);
|
||||
private async findOrFail(id: string, options: AlbumInfoOptions, userId?: string) {
|
||||
const album = await this.albumRepository.getById(id, options, userId);
|
||||
if (!album) {
|
||||
throw new BadRequestException('Album not found');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StorageCore } from 'src/cores/storage.core';
|
||||
import { UserAdmin } from 'src/database';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
@@ -66,6 +67,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
AccessRepository,
|
||||
ActivityRepository,
|
||||
AlbumRepository,
|
||||
AlbumUserMetadataRepository,
|
||||
AlbumUserRepository,
|
||||
ApiKeyRepository,
|
||||
AppRepository,
|
||||
@@ -125,6 +127,7 @@ export class BaseService {
|
||||
protected accessRepository: AccessRepository,
|
||||
protected activityRepository: ActivityRepository,
|
||||
protected albumRepository: AlbumRepository,
|
||||
protected albumUserMetadataRepository: AlbumUserMetadataRepository,
|
||||
protected albumUserRepository: AlbumUserRepository,
|
||||
protected apiKeyRepository: ApiKeyRepository,
|
||||
protected appRepository: AppRepository,
|
||||
|
||||
@@ -78,6 +78,7 @@ export const SYNC_TYPES_ORDER = [
|
||||
SyncRequestType.AlbumAssetsV1,
|
||||
SyncRequestType.AlbumsV1,
|
||||
SyncRequestType.AlbumUsersV1,
|
||||
SyncRequestType.AlbumUserMetadataV1,
|
||||
SyncRequestType.AlbumToAssetsV1,
|
||||
SyncRequestType.AssetExifsV1,
|
||||
SyncRequestType.AlbumAssetExifsV1,
|
||||
@@ -183,6 +184,7 @@ export class SyncService extends BaseService {
|
||||
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumUserMetadataV1]: () => this.syncAlbumUserMetadataV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumAssetExifsV1]: () =>
|
||||
@@ -213,6 +215,7 @@ export class SyncService extends BaseService {
|
||||
|
||||
await this.syncRepository.album.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.albumUser.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.albumUserMetadata.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.albumToAsset.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.asset.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold);
|
||||
@@ -489,6 +492,20 @@ export class SyncService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAlbumUserMetadataV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
||||
const deleteType = SyncEntityType.AlbumUserMetadataDeleteV1;
|
||||
const deletes = this.syncRepository.albumUserMetadata.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
||||
for await (const { id, ...data } of deletes) {
|
||||
send(response, { type: deleteType, ids: [id], data });
|
||||
}
|
||||
|
||||
const upsertType = SyncEntityType.AlbumUserMetadataV1;
|
||||
const upserts = this.syncRepository.albumUserMetadata.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
send(response, { type: upsertType, ids: [updateId], data });
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAlbumAssetsV1(
|
||||
options: SyncQueryOptions,
|
||||
response: Writable,
|
||||
|
||||
@@ -76,6 +76,7 @@ export const getDehydrated = <T extends Record<string, unknown>>(entity: T) => {
|
||||
|
||||
export const getForAlbum = (album: ReturnType<AlbumFactory['build']>) => ({
|
||||
...album,
|
||||
isFavorite: false,
|
||||
assets: album.assets.map((asset) =>
|
||||
getDehydrated({ ...getForAsset(asset), exifInfo: getDehydrated(asset.exifInfo) }),
|
||||
),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
@@ -401,6 +402,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
switch (key) {
|
||||
case AccessRepository:
|
||||
case AlbumRepository:
|
||||
case AlbumUserMetadataRepository:
|
||||
case AlbumUserRepository:
|
||||
case ActivityRepository:
|
||||
case AssetRepository:
|
||||
@@ -465,6 +467,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
||||
switch (key) {
|
||||
case ActivityRepository:
|
||||
case AlbumRepository:
|
||||
case AlbumUserMetadataRepository:
|
||||
case AssetRepository:
|
||||
case AssetJobRepository:
|
||||
case ConfigRepository:
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
sut: ctx.get(AlbumUserMetadataRepository),
|
||||
albumUserRepo: ctx.get(AlbumUserRepository),
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(AlbumUserMetadataRepository.name, () => {
|
||||
it('should create an owner metadata row when an album is created', async () => {
|
||||
const { ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('album_user_metadata')
|
||||
.select(['albumId', 'userId', 'isFavorite'])
|
||||
.where('albumId', '=', album.id)
|
||||
.where('userId', '=', user.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({
|
||||
albumId: album.id,
|
||||
userId: user.id,
|
||||
isFavorite: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a shared-user metadata row when an album user is added', async () => {
|
||||
const { ctx, albumUserRepo } = setup();
|
||||
const { user: owner } = await ctx.newUser();
|
||||
const { user: sharedUser } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: owner.id });
|
||||
|
||||
await albumUserRepo.create({ albumId: album.id, userId: sharedUser.id });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('album_user_metadata')
|
||||
.select(['albumId', 'userId', 'isFavorite'])
|
||||
.where('albumId', '=', album.id)
|
||||
.where('userId', '=', sharedUser.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({
|
||||
albumId: album.id,
|
||||
userId: sharedUser.id,
|
||||
isFavorite: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete metadata and write an audit row when album access is removed', async () => {
|
||||
const { ctx, albumUserRepo, sut } = setup();
|
||||
const { user: owner } = await ctx.newUser();
|
||||
const { user: sharedUser } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: owner.id });
|
||||
|
||||
await albumUserRepo.create({ albumId: album.id, userId: sharedUser.id });
|
||||
await sut.upsert({ albumId: album.id, userId: sharedUser.id, isFavorite: true });
|
||||
|
||||
await albumUserRepo.delete({ albumId: album.id, userId: sharedUser.id });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('album_user_metadata')
|
||||
.select('albumId')
|
||||
.where('albumId', '=', album.id)
|
||||
.where('userId', '=', sharedUser.id)
|
||||
.executeTakeFirst(),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('album_user_metadata_audit')
|
||||
.select(['albumId', 'userId'])
|
||||
.where('albumId', '=', album.id)
|
||||
.where('userId', '=', sharedUser.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({
|
||||
albumId: album.id,
|
||||
userId: sharedUser.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { SyncEntityType, SyncRequestType } from 'src/enum';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { SyncTestContext } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = async (db?: Kysely<DB>) => {
|
||||
const ctx = new SyncTestContext(db || defaultDatabase);
|
||||
const { auth, user, session } = await ctx.newSyncAuthUser();
|
||||
return { auth, user, session, ctx };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(SyncEntityType.AlbumUserMetadataV1, () => {
|
||||
it('should sync owner album metadata rows', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
albumId: album.id,
|
||||
userId: auth.user.id,
|
||||
isFavorite: false,
|
||||
},
|
||||
type: SyncEntityType.AlbumUserMetadataV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sync favorite updates', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const repo = ctx.get(AlbumUserMetadataRepository);
|
||||
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
|
||||
const initial = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
await ctx.syncAckAll(auth, initial);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
|
||||
await repo.upsert({ albumId: album.id, userId: auth.user.id, isFavorite: true });
|
||||
|
||||
const updated = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
expect(updated).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
albumId: album.id,
|
||||
userId: auth.user.id,
|
||||
isFavorite: true,
|
||||
},
|
||||
type: SyncEntityType.AlbumUserMetadataV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SyncEntityType.AlbumUserMetadataDeleteV1, () => {
|
||||
it('should sync metadata deletes when shared album access is removed', async () => {
|
||||
const { auth, ctx } = await setup();
|
||||
const albumUserRepo = ctx.get(AlbumUserRepository);
|
||||
const { user: owner } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: owner.id });
|
||||
await albumUserRepo.create({ albumId: album.id, userId: auth.user.id });
|
||||
|
||||
const initial = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
await ctx.syncAckAll(auth, initial);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
|
||||
await albumUserRepo.delete({ albumId: album.id, userId: auth.user.id });
|
||||
|
||||
const deleted = await ctx.syncStream(auth, [SyncRequestType.AlbumUserMetadataV1]);
|
||||
expect(deleted).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
albumId: album.id,
|
||||
userId: auth.user.id,
|
||||
},
|
||||
type: SyncEntityType.AlbumUserMetadataDeleteV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { AuthGuard } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserMetadataRepository } from 'src/repositories/album-user-metadata.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
@@ -211,6 +212,7 @@ export type ServiceOverrides = {
|
||||
access: AccessRepository;
|
||||
activity: ActivityRepository;
|
||||
album: AlbumRepository;
|
||||
albumUserMetadata: AlbumUserMetadataRepository;
|
||||
albumUser: AlbumUserRepository;
|
||||
apiKey: ApiKeyRepository;
|
||||
app: AppRepository;
|
||||
@@ -296,6 +298,7 @@ export const getMocks = () => {
|
||||
activity: automock(ActivityRepository),
|
||||
audit: automock(AuditRepository),
|
||||
album: automock(AlbumRepository, { strict: false }),
|
||||
albumUserMetadata: automock(AlbumUserMetadataRepository),
|
||||
albumUser: automock(AlbumUserRepository),
|
||||
asset: newAssetRepositoryMock(),
|
||||
assetEdit: automock(AssetEditRepository),
|
||||
@@ -362,6 +365,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.access || (mocks.access as IAccessRepository as AccessRepository),
|
||||
overrides.activity || (mocks.activity as As<ActivityRepository>),
|
||||
overrides.album || (mocks.album as As<AlbumRepository>),
|
||||
overrides.albumUserMetadata || (mocks.albumUserMetadata as As<AlbumUserMetadataRepository>),
|
||||
overrides.albumUser || (mocks.albumUser as As<AlbumUserRepository>),
|
||||
overrides.apiKey || (mocks.apiKey as As<ApiKeyRepository>),
|
||||
overrides.app || (mocks.app as As<AppRepository>),
|
||||
|
||||
Reference in New Issue
Block a user