Compare commits

...

1 Commits

Author SHA1 Message Date
Alex Tran 59384b90b0 feat: album order per user 2026-05-03 14:12:04 -05:00
12 changed files with 137 additions and 37 deletions
+2
View File
@@ -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<AlbumTable> & {
order: AssetOrder;
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
};
+52 -13
View File
@@ -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<DB, 'album'>) =
.where('album_user.userId', '=', ownerId),
);
const withOrder = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'album'>) => {
const defaultOrder = sql<AssetOrder>`${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<DB>) {}
@@ -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<AlbumTable>,
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<AlbumTable>, 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<AlbumTable>, 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<void> {
@@ -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<DB, 'album'>) => {
const withSharedLinkAlbum = (eb: ExpressionBuilder<DB, 'shared_link'>) => {
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>`${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(),
)
+2 -2
View File
@@ -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();
@@ -0,0 +1,21 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
+4 -1
View File
@@ -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<AlbumUserRole>;
@Column({ default: AssetOrder.Desc })
order!: Generated<AssetOrder>;
@CreateIdColumn({ index: true })
createId!: Generated<string>;
-4
View File
@@ -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<boolean>;
@Column({ default: AssetOrder.Desc })
order!: Generated<AssetOrder>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
+27 -8
View File
@@ -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,
+14 -4
View File
@@ -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 });
}
+2 -1
View File
@@ -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(),
+1 -1
View File
@@ -15,7 +15,7 @@ export class AlbumFactory {
#albumUsers: AlbumUserFactory[] = [];
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<AlbumTable>) {}
private constructor(private readonly value: Selectable<AlbumTable> & { order: AssetOrder }) {}
static create(dto: AlbumLike = {}) {
return AlbumFactory.from(dto).build();
+2 -1
View File
@@ -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<Selectable<AssetTable>>;
export type AssetExifLike = Partial<Selectable<AssetExifTable>>;
export type AssetEditLike = Partial<Selectable<AssetEditTable>>;
export type AssetFileLike = Partial<Selectable<AssetFileTable>>;
export type AlbumLike = Partial<Selectable<AlbumTable>>;
export type AlbumLike = Partial<Selectable<AlbumTable> & { order: AssetOrder }>;
export type AlbumUserLike = Partial<Selectable<AlbumUserTable>>;
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
export type UserLike = Partial<Selectable<UserTable>>;