feat: favorite albums

This commit is contained in:
Alex Tran
2026-03-29 05:05:44 +00:00
parent b09ebb11e9
commit 6876eb2f05
43 changed files with 1421 additions and 29 deletions
@@ -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();
});
}
}
+29 -2
View File
@@ -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)
+2
View File
@@ -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) {