feat(server,web): favorite albums per user

This commit is contained in:
Alex
2026-04-23 03:33:32 +00:00
parent f0835d06f8
commit 166f36e5bf
17 changed files with 170 additions and 20 deletions
+7 -1
View File
@@ -35,6 +35,7 @@ export type AuthUser = {
export type AlbumUser = {
user: ShallowDehydrateObject<User>;
role: AlbumUserRole;
isFavorite: boolean;
};
export type AssetFile = {
@@ -395,7 +396,12 @@ export const columns = {
'asset.height',
'asset.isEdited',
],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncAlbumUser: [
'album_user.albumId as albumId',
'album_user.userId as userId',
'album_user.role',
'album_user.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'],
+13 -2
View File
@@ -69,6 +69,7 @@ const GetAlbumsSchema = z
.optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'),
favorite: stringToBool.optional().describe('Filter to only albums favorited by the authenticated user'),
})
.meta({ id: 'GetAlbumsDto' });
@@ -82,7 +83,11 @@ const AlbumStatisticsResponseSchema = z
const UpdateAlbumUserSchema = z
.object({
role: AlbumUserRoleSchema,
role: AlbumUserRoleSchema.optional(),
isFavorite: z
.boolean()
.optional()
.describe('Mark album as favorite for the user (only the user themselves can update)'),
})
.meta({ id: 'UpdateAlbumUserDto' });
@@ -118,6 +123,7 @@ export const AlbumResponseSchema = z
'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.',
),
hasSharedLink: z.boolean().describe('Has shared link'),
isFavorite: z.boolean().describe('Whether the authenticated user has favorited this album'),
assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z
@@ -161,8 +167,9 @@ export type MapAlbumDto = {
order: AssetOrder;
};
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => {
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>, authUserId?: string): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = [];
let isFavorite = false;
if (entity.albumUsers) {
for (const albumUser of entity.albumUsers) {
@@ -171,6 +178,9 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
user,
role: albumUser.role,
});
if (authUserId && user.id === authUserId) {
isFavorite = albumUser.isFavorite ?? false;
}
}
}
@@ -196,6 +206,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
albumUsers,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
isFavorite,
startDate: asDateString(startDate),
endDate: asDateString(endDate),
assetCount: entity.assets?.length || 0,
+1
View File
@@ -195,6 +195,7 @@ const SyncAlbumUserV1Schema = z
albumId: z.string().describe('Album ID'),
userId: z.string().describe('User ID'),
role: AlbumUserRoleSchema,
isFavorite: z.boolean().describe('Favorite flag'),
})
.meta({ id: 'SyncAlbumUserV1' });
@@ -39,6 +39,7 @@ const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'albu
.innerJoin('user', 'user.id', 'album_user.userId')
.whereRef('album_user.albumId', '=', 'album.id')
.select('album_user.role')
.select('album_user.isFavorite')
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
.orderBy('album_user.role')
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
@@ -244,6 +245,27 @@ export class AlbumRepository {
.execute();
}
/**
* Get albums the user has favorited (owned or shared).
*/
@GenerateSql({ params: [DummyValue.UUID] })
getFavorites(userId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', userId)
.on('album_user.isFavorite', '=', sql.lit(true)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(userId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
/**
* Get albums of owner that are _not_ shared
*/
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" ADD "isFavorite" boolean NOT NULL DEFAULT false;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" DROP COLUMN "isFavorite";`.execute(db);
}
@@ -58,6 +58,9 @@ export class AlbumUserTable {
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;
@Column({ type: 'boolean', default: false })
isFavorite!: Generated<boolean>;
@CreateIdColumn({ index: true })
createId!: Generated<string>;
+27 -8
View File
@@ -38,12 +38,17 @@ export class AlbumService extends BaseService {
};
}
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, shared, favorite }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[];
if (assetId) {
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
} else if (favorite === true) {
albums = await this.albumRepository.getFavorites(ownerId);
} else if (shared === true) {
albums = await this.albumRepository.getShared(ownerId);
} else if (shared === false) {
@@ -61,7 +66,7 @@ export class AlbumService extends BaseService {
}
return albums.map((album) => ({
...mapAlbum(album),
...mapAlbum(album, ownerId),
sharedLinks: undefined,
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
@@ -82,7 +87,7 @@ export class AlbumService extends BaseService {
const isShared = hasSharedUsers || hasSharedLink;
return {
...mapAlbum(album),
...mapAlbum(album, auth.user.id),
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
assetCount: albumMetadataForIds?.assetCount ?? 0,
@@ -141,7 +146,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
return mapAlbum(album);
return mapAlbum(album, auth.user.id);
}
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
@@ -168,7 +173,7 @@ export class AlbumService extends BaseService {
auth.user.id,
);
return mapAlbum({ ...updatedAlbum, assets: album.assets });
return mapAlbum({ ...updatedAlbum, assets: album.assets }, auth.user.id);
}
async delete(auth: AuthDto, id: string): Promise<void> {
@@ -310,7 +315,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum);
return this.findOrFail(id, auth.user.id, { withAssets: true }).then((album) => mapAlbum(album, auth.user.id));
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@@ -341,8 +346,22 @@ export class AlbumService extends BaseService {
}
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
if (dto.role === undefined && dto.isFavorite === undefined) {
throw new BadRequestException('No updates provided');
}
if (dto.role !== undefined) {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
}
if (dto.isFavorite !== undefined) {
if (userId !== auth.user.id) {
throw new BadRequestException('Cannot favorite an album on behalf of another user');
}
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
}
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role, isFavorite: dto.isFavorite });
}
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
@@ -24,6 +24,7 @@ export class AlbumUserFactory {
albumId: newUuid(),
userId: newUuid(),
role: AlbumUserRole.Editor,
isFavorite: false,
createId: newUuidV7(),
createdAt: newDate(),
updateId: newUuidV7(),