From 59384b90b008f914af1950f35d77f6d8cad2be6e Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sun, 3 May 2026 14:12:04 -0500 Subject: [PATCH] feat: album order per user --- server/src/database.ts | 2 + server/src/repositories/album.repository.ts | 65 +++++++++++++++---- .../repositories/shared-link.repository.ts | 12 +++- server/src/repositories/sync.repository.ts | 4 +- .../1778000000000-AddOrderToAlbumUser.ts | 21 ++++++ server/src/schema/tables/album-user.table.ts | 5 +- server/src/schema/tables/album.table.ts | 4 -- server/src/services/album.service.spec.ts | 35 +++++++--- server/src/services/album.service.ts | 18 +++-- server/test/factories/album-user.factory.ts | 3 +- server/test/factories/album.factory.ts | 2 +- server/test/factories/types.ts | 3 +- 12 files changed, 137 insertions(+), 37 deletions(-) create mode 100644 server/src/schema/migrations/1778000000000-AddOrderToAlbumUser.ts diff --git a/server/src/database.ts b/server/src/database.ts index c001388e79..582b053ff5 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -3,6 +3,7 @@ import { MapAsset } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, AssetFileType, + AssetOrder, AssetType, AssetVisibility, ChecksumAlgorithm, @@ -195,6 +196,7 @@ export type SharedLink = { }; export type Album = Selectable & { + order: AssetOrder; assets: ShallowDehydrateObject>[]; }; diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index a910673c62..e1187170c5 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -14,7 +14,7 @@ import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { AlbumUserCreateDto } from 'src/dtos/album.dto'; -import { AlbumUserRole } from 'src/enum'; +import { AlbumUserRole, AssetOrder } from 'src/enum'; import { DB } from 'src/schema'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -82,6 +82,23 @@ const isAlbumOwned = (ownerId: string) => (eb: ExpressionBuilder) = .where('album_user.userId', '=', ownerId), ); +const withOrder = (authUserId?: string) => (eb: ExpressionBuilder) => { + const defaultOrder = sql`${AssetOrder.Desc}`; + + return authUserId + ? eb.fn + .coalesce( + eb + .selectFrom('album_user') + .select('album_user.order') + .whereRef('album_user.albumId', '=', 'album.id') + .where('album_user.userId', '=', authUserId), + defaultOrder, + ) + .as('order') + : defaultOrder.as('order'); +}; + @Injectable() export class AlbumRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -95,6 +112,7 @@ export class AlbumRepository { .where('album.id', '=', id) .where('album.deletedAt', 'is', null) .select(withAlbumUsers(authUserId)) + .select(withOrder(authUserId)) .select(withSharedLink) .$if(options.withAssets, (eb) => eb.select(withAssets)) .$narrowType<{ assets: NotNull }>() @@ -118,6 +136,7 @@ export class AlbumRepository { .where('album_asset.assetId', '=', assetId) .where('album.deletedAt', 'is', null) .select(withAlbumUsers(ownerId)) + .select(withOrder(ownerId)) .orderBy('album.createdAt', 'desc') .execute(); } @@ -195,6 +214,7 @@ export class AlbumRepository { .on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)), ) .where('album.deletedAt', 'is', null) + .select('album_user.order as order') .select(withAlbumUsers(ownerId)) .select(withSharedLink) .orderBy('album.createdAt', 'desc') @@ -239,6 +259,7 @@ export class AlbumRepository { ) .where('album.deletedAt', 'is', null) .select(withAlbumUsers(ownerId)) + .select(withOrder(ownerId)) .select(withSharedLink) .orderBy('album.createdAt', 'desc') .execute(); @@ -271,6 +292,7 @@ export class AlbumRepository { .where(({ not, exists, selectFrom }) => not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))), ) + .select('album_user.order as order') .select(withSharedLink) .select(withAlbumUsers(ownerId)) .orderBy('album.createdAt', 'desc') @@ -350,13 +372,13 @@ export class AlbumRepository { params: [ { albumName: DummyValue.STRING }, [], - [{ userId: DummyValue.UUID, role: AlbumUserRole.Owner }, DummyValue.UUID], + [{ userId: DummyValue.UUID, role: AlbumUserRole.Owner, order: AssetOrder.Desc }, DummyValue.UUID], ], }) async create( album: Insertable, assetIds: string[], - albumUsers: AlbumUserCreateDto[], + albumUsers: (AlbumUserCreateDto & { order?: AssetOrder })[], authUserId: string, ) { if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) { @@ -365,12 +387,14 @@ export class AlbumRepository { const userIds = albumUsers.map((u) => u.userId); const roles = albumUsers.map((u) => u.role); + const orders = albumUsers.map((u) => u.order ?? AssetOrder.Desc); const result = await this.db .with('album', (db) => db.insertInto('album').values(album).returningAll()) .with('album_user', (db) => db .insertInto('album_user') + .columns(['albumId', 'userId', 'role', 'order']) .expression((eb) => eb .selectFrom('album') @@ -378,13 +402,15 @@ export class AlbumRepository { ref('album.id').as('albumId'), sql`unnest(${userIds}::uuid[])`.as('userId'), sql`unnest(${roles}::album_user_role_enum[])`.as('role'), + sql`unnest(${orders}::varchar[])`.as('order'), ]), ) - .returning(['album_user.albumId', 'album_user.userId', 'album_user.role']), + .returning(['album_user.albumId', 'album_user.userId', 'album_user.role', 'album_user.order']), ) .with('album_asset', (db) => db .insertInto('album_asset') + .columns(['albumId', 'assetId']) .expression((eb) => eb .selectFrom('album') @@ -396,6 +422,7 @@ export class AlbumRepository { .selectFrom('album') .selectAll('album') .select(withAlbumUsers(authUserId)) + .select(withOrder(authUserId)) .select(withAssets) .$narrowType<{ assets: NotNull }>() .executeTakeFirstOrThrow(); @@ -403,15 +430,27 @@ export class AlbumRepository { return result; } - update(id: string, album: Updateable, authUserId: string) { - return this.db - .updateTable('album') - .set(album) - .where('album.id', '=', id) - .returningAll('album') - .returning(withSharedLink) - .returning(withAlbumUsers(authUserId)) - .executeTakeFirstOrThrow(); + update(id: string, album: Updateable, authUserId: string, order?: AssetOrder) { + return this.db.transaction().execute(async (db) => { + if (order !== undefined) { + await db + .updateTable('album_user') + .set({ order }) + .where('albumId', '=', id) + .where('userId', '=', authUserId) + .execute(); + } + + return db + .updateTable('album') + .set(album) + .where('album.id', '=', id) + .returningAll('album') + .returning(withSharedLink) + .returning(withAlbumUsers(authUserId)) + .returning(withOrder(authUserId)) + .executeTakeFirstOrThrow(); + }); } async delete(id: string): Promise { diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 22839205a3..0aaa523aa0 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -5,7 +5,7 @@ import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Album, columns } from 'src/database'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AlbumUserRole, SharedLinkType } from 'src/enum'; +import { AlbumUserRole, AssetOrder, SharedLinkType } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetTable } from 'src/schema/tables/asset.table'; @@ -55,7 +55,15 @@ const withAlbumOwner = (eb: ExpressionBuilder) => { const withSharedLinkAlbum = (eb: ExpressionBuilder) => { return eb .selectFrom('album') + .leftJoin('album_user as album_order', (join) => + join + .onRef('album_order.albumId', '=', 'album.id') + .onRef('album_order.userId', '=', 'shared_link.userId'), + ) .selectAll('album') + .select((eb) => + eb.fn.coalesce('album_order.order', sql`${AssetOrder.Desc}`).as('order'), + ) .whereRef('album.id', '=', 'shared_link.albumId') .where('album.deletedAt', 'is', null); }; @@ -107,7 +115,7 @@ export class SharedLinkRepository { .as('assets'), ) .select((eb) => eb.fn.toJson('owner').as('owner')) - .groupBy(['album.id', sql`"owner".*`]) + .groupBy(['album.id', 'album_order.order', sql`"owner".*`]) .as('album'), (join) => join.onTrue(), ) diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 320b6e6094..ffd59767c5 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -170,7 +170,7 @@ class AlbumSync extends BaseSync { const userId = options.userId; return this.upsertQuery('album', options) .distinctOn(['album.id', 'album.updateId']) - .leftJoin('album_user as album_users', 'album.id', 'album_users.albumId') + .innerJoin('album_user as album_users', 'album.id', 'album_users.albumId') .where('album_users.userId', '=', userId) .select([ 'album.id', @@ -180,7 +180,7 @@ class AlbumSync extends BaseSync { 'album.updatedAt', 'album.albumThumbnailAssetId as thumbnailAssetId', 'album.isActivityEnabled', - 'album.order', + 'album_users.order as order', 'album.updateId', ]) .stream(); diff --git a/server/src/schema/migrations/1778000000000-AddOrderToAlbumUser.ts b/server/src/schema/migrations/1778000000000-AddOrderToAlbumUser.ts new file mode 100644 index 0000000000..f4977889a6 --- /dev/null +++ b/server/src/schema/migrations/1778000000000-AddOrderToAlbumUser.ts @@ -0,0 +1,21 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "album_user" ADD "order" character varying NOT NULL DEFAULT 'desc';`.execute(db); + await sql`UPDATE "album_user" SET "order" = "album"."order" FROM "album" WHERE "album_user"."albumId" = "album"."id";`.execute( + db, + ); + await sql`ALTER TABLE "album" DROP COLUMN "order";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "album" ADD "order" character varying NOT NULL DEFAULT 'desc';`.execute(db); + await sql` + UPDATE "album" + SET "order" = "album_user"."order" + FROM "album_user" + WHERE "album_user"."albumId" = "album"."id" + AND "album_user"."role" = 'owner'; + `.execute(db); + await sql`ALTER TABLE "album_user" DROP COLUMN "order";`.execute(db); +} diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 677d6ca2f2..14accfa9d0 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -11,7 +11,7 @@ import { UpdateDateColumn, } from '@immich/sql-tools'; import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AlbumUserRole } from 'src/enum'; +import { AlbumUserRole, AssetOrder } from 'src/enum'; import { album_user_role_enum } from 'src/schema/enums'; import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; import { AlbumTable } from 'src/schema/tables/album.table'; @@ -58,6 +58,9 @@ export class AlbumUserTable { @Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor }) role!: Generated; + @Column({ default: AssetOrder.Desc }) + order!: Generated; + @CreateIdColumn({ index: true }) createId!: Generated; diff --git a/server/src/schema/tables/album.table.ts b/server/src/schema/tables/album.table.ts index f54658be65..8e5544e38f 100644 --- a/server/src/schema/tables/album.table.ts +++ b/server/src/schema/tables/album.table.ts @@ -10,7 +10,6 @@ import { UpdateDateColumn, } from '@immich/sql-tools'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AssetOrder } from 'src/enum'; import { AssetTable } from 'src/schema/tables/asset.table'; @Table({ name: 'album' }) @@ -45,9 +44,6 @@ export class AlbumTable { @Column({ type: 'boolean', default: true }) isActivityEnabled!: Generated; - @Column({ default: AssetOrder.Desc }) - order!: Generated; - @UpdateIdColumn({ index: true }) updateId!: Generated; } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 24e28a9701..58dc11b111 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -182,19 +182,19 @@ describe(AlbumService.name, () => { { albumName: 'test', description: 'description', - order: album.order, albumThumbnailAssetId: assetId, }, [assetId], [ - { userId: owner.id, role: AlbumUserRole.Owner }, - { userId: albumUser.userId, role: AlbumUserRole.Editor }, + { userId: owner.id, role: AlbumUserRole.Owner, order: AssetOrder.Desc }, + { userId: albumUser.userId, role: AlbumUserRole.Editor, order: AssetOrder.Desc }, ], owner.id, ); expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {}); expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(albumUser.userId); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false); expect(mocks.event.emit).toHaveBeenCalledTimes(1); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { @@ -238,16 +238,19 @@ describe(AlbumService.name, () => { { albumName: album.albumName, description: album.description, - order: 'asc', albumThumbnailAssetId: assetId, }, [assetId], - [{ userId: owner.id, role: AlbumUserRole.Owner }, albumUser], + [ + { userId: owner.id, role: AlbumUserRole.Owner, order: 'asc' }, + { ...albumUser, order: 'asc' }, + ], owner.id, ); expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {}); expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(albumUser.userId); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, @@ -290,11 +293,10 @@ describe(AlbumService.name, () => { { albumName: album.albumName, description: album.description, - order: 'desc', albumThumbnailAssetId: assetId, }, [assetId], - [{ userId: owner.id, role: AlbumUserRole.Owner }], + [{ userId: owner.id, role: AlbumUserRole.Owner, order: 'desc' }], owner.id, ); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId, 'asset-2']), false); @@ -364,10 +366,24 @@ describe(AlbumService.name, () => { expect(mocks.album.update).toHaveBeenCalledTimes(1); expect(mocks.album.update).toHaveBeenCalledWith( album.id, - { id: album.id, albumName: 'new album name' }, + expect.objectContaining({ id: album.id, albumName: 'new album name' }), owner.id, + undefined, ); }); + + it('should update the album order for the auth user', async () => { + const album = AlbumFactory.create({ order: AssetOrder.Desc }); + const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!; + mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); + mocks.album.getById.mockResolvedValue(getForAlbum(album)); + mocks.album.update.mockResolvedValue(getForAlbum({ ...album, order: AssetOrder.Asc })); + + await sut.update(AuthFactory.create(owner), album.id, { order: AssetOrder.Asc }); + + expect(mocks.album.update).toHaveBeenCalledTimes(1); + expect(mocks.album.update).toHaveBeenCalledWith(album.id, { id: album.id }, owner.id, AssetOrder.Asc); + }); }); describe('delete', () => { @@ -464,6 +480,7 @@ describe(AlbumService.name, () => { mocks.album.getById.mockResolvedValue(getForAlbum(album)); mocks.album.update.mockResolvedValue(getForAlbum(album)); mocks.user.get.mockResolvedValue(user); + mocks.user.getMetadata.mockResolvedValue([]); mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build()); await sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId: user.id }] }); @@ -471,7 +488,9 @@ describe(AlbumService.name, () => { expect(mocks.albumUser.create).toHaveBeenCalledWith({ userId: user.id, albumId: album.id, + order: AssetOrder.Desc, }); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(user.id); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, userId: user.id, diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 7bfc0bdcc2..fc1cc31eab 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -124,16 +124,22 @@ export class AlbumService extends BaseService { const assetIds = [...allowedAssetIdsSet].map((id) => id); const userMetadata = await this.userRepository.getMetadata(auth.user.id); + const ownerOrder = getPreferences(userMetadata).albums.defaultAssetOrder; + const albumUsersWithOrder = await Promise.all( + albumUsers.map(async (albumUser) => { + const userMetadata = await this.userRepository.getMetadata(albumUser.userId); + return { ...albumUser, order: getPreferences(userMetadata).albums.defaultAssetOrder }; + }), + ); const album = await this.albumRepository.create( { albumName: dto.albumName, description: dto.description, albumThumbnailAssetId: assetIds[0] || null, - order: getPreferences(userMetadata).albums.defaultAssetOrder, }, assetIds, - [{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers], + [{ userId: auth.user.id, role: AlbumUserRole.Owner, order: ownerOrder }, ...albumUsersWithOrder], auth.user.id, ); @@ -155,6 +161,7 @@ export class AlbumService extends BaseService { throw new BadRequestException('Invalid album thumbnail'); } } + const updatedAlbum = await this.albumRepository.update( album.id, { @@ -163,9 +170,9 @@ export class AlbumService extends BaseService { description: dto.description, albumThumbnailAssetId: dto.albumThumbnailAssetId, isActivityEnabled: dto.isActivityEnabled, - order: dto.order, }, auth.user.id, + dto.order, ); return mapAlbum({ ...updatedAlbum, assets: album.assets }); @@ -307,7 +314,10 @@ export class AlbumService extends BaseService { throw new BadRequestException('Invalid user'); } - await this.albumUserRepository.create({ userId, albumId: id, role }); + const userMetadata = await this.userRepository.getMetadata(userId); + const order = getPreferences(userMetadata).albums.defaultAssetOrder; + + await this.albumUserRepository.create({ userId, albumId: id, role, order }); await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name }); } diff --git a/server/test/factories/album-user.factory.ts b/server/test/factories/album-user.factory.ts index 6e2f8cb832..2d4fab430b 100644 --- a/server/test/factories/album-user.factory.ts +++ b/server/test/factories/album-user.factory.ts @@ -1,5 +1,5 @@ import { Selectable } from 'kysely'; -import { AlbumUserRole } from 'src/enum'; +import { AlbumUserRole, AssetOrder } from 'src/enum'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumFactory } from 'test/factories/album.factory'; import { build } from 'test/factories/builder.factory'; @@ -24,6 +24,7 @@ export class AlbumUserFactory { albumId: newUuid(), userId: newUuid(), role: AlbumUserRole.Editor, + order: AssetOrder.Desc, createId: newUuidV7(), createdAt: newDate(), updateId: newUuidV7(), diff --git a/server/test/factories/album.factory.ts b/server/test/factories/album.factory.ts index 336a9747cd..992ca5cebd 100644 --- a/server/test/factories/album.factory.ts +++ b/server/test/factories/album.factory.ts @@ -15,7 +15,7 @@ export class AlbumFactory { #albumUsers: AlbumUserFactory[] = []; #assets: AssetFactory[] = []; - private constructor(private readonly value: Selectable) {} + private constructor(private readonly value: Selectable & { order: AssetOrder }) {} static create(dto: AlbumLike = {}) { return AlbumFactory.from(dto).build(); diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 5c8c8ee2c2..447f04f13d 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -1,4 +1,5 @@ import { Selectable } from 'kysely'; +import { AssetOrder } from 'src/enum'; import { OAuthProfile } from 'src/repositories/oauth.repository'; import { ActivityTable } from 'src/schema/tables/activity.table'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; @@ -23,7 +24,7 @@ export type AssetLike = Partial>; export type AssetExifLike = Partial>; export type AssetEditLike = Partial>; export type AssetFileLike = Partial>; -export type AlbumLike = Partial>; +export type AlbumLike = Partial & { order: AssetOrder }>; export type AlbumUserLike = Partial>; export type SharedLinkLike = Partial>; export type UserLike = Partial>;