From 843051f0f5b05afc93bf8116ca317d59dfd4b623 Mon Sep 17 00:00:00 2001 From: "immich-push-o-matic[bot]" <179150890+immich-push-o-matic[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:06:45 +0000 Subject: [PATCH] fix(server): include album owner for album shared links (#31990) fix(server): include album owner for album shared links (#31837) (cherry picked from commit fd97f3e5a62a4af1a8e17d2e766eb952eefae24a) Co-authored-by: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> --- server/src/queries/shared.link.repository.sql | 107 +++++++++++------- .../repositories/shared-link.repository.ts | 40 +++---- .../services/shared-link.service.spec.ts | 9 +- 3 files changed, 87 insertions(+), 69 deletions(-) diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 33bb9d0e0..e9ad212f2 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -64,6 +64,40 @@ from left join lateral ( select "album".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "album_user"."role", + ( + select + to_json(obj) + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt" + from + ( + select + 1 + ) as "dummy" + ) as obj + ) as "user" + from + "album_user" + inner join "user" on "user"."id" = "album_user"."userId" + where + "album_user"."role" = 'owner' + and "album_user"."albumId" = "album"."id" + ) as agg + ) as "albumUsers", coalesce( json_agg( "assets" @@ -74,8 +108,7 @@ from "assets"."id" is not null ), '[]' - ) as "assets", - to_json("owner") as "owner" + ) as "assets" from "album" left join "album_asset" on "album_asset"."albumId" = "album"."id" @@ -128,34 +161,11 @@ from order by "asset"."fileCreatedAt" asc ) as "assets" on true - inner join lateral ( - select - "id", - "name", - "email", - "avatarColor", - "profileImagePath", - "profileChangedAt" - from - "user" - where - exists ( - select - from - "album_user" - where - "album_user"."role" = 'owner' - and "album_user"."albumId" = "album"."id" - and "album_user"."userId" = "user"."id" - ) - and "user"."deletedAt" is null - ) as "owner" on true where "album"."id" = "shared_link"."albumId" and "album"."deletedAt" is null group by - "album"."id", - "owner".* + "album"."id" ) as "album" on true where "shared_link"."id" = $1 @@ -195,31 +205,42 @@ from left join lateral ( select "album".*, - to_json("owner") as "owner" - from - "album" - inner join lateral ( + ( select - "id", - "name", - "email", - "avatarColor", - "profileImagePath", - "profileChangedAt" + coalesce(json_agg(agg), '[]') from - "user" - where - exists ( + ( select + "album_user"."role", + ( + select + to_json(obj) + from + ( + select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt" + from + ( + select + 1 + ) as "dummy" + ) as obj + ) as "user" from "album_user" + inner join "user" on "user"."id" = "album_user"."userId" where "album_user"."role" = 'owner' and "album_user"."albumId" = "album"."id" - and "album_user"."userId" = "user"."id" - ) - and "user"."deletedAt" is null - ) as "owner" on true + ) as agg + ) as "albumUsers" + from + "album" where "album"."id" = "shared_link"."albumId" and "album"."deletedAt" is null diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 71e53a304..735c4e89c 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -18,6 +18,7 @@ import { DB } from 'src/schema/index.js'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table.js'; import { AssetTable } from 'src/schema/tables/asset.table.js'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table.js'; +import { dummy } from 'src/utils/database.js'; export type SharedLinkSearchOptions = { userId: string; @@ -43,22 +44,18 @@ export const withExifInfo = (eb: ExpressionBuilder) => { .as('exifInfo'); }; -const withAlbumOwner = (eb: ExpressionBuilder) => { - return eb - .selectFrom('user') - .select(columns.user) - .where((eb) => - eb.exists( - eb - .selectFrom('album_user') - .where('album_user.role', '=', sql.lit(AlbumUserRole.Owner)) - .whereRef('album_user.albumId', '=', 'album.id') - .whereRef('album_user.userId', '=', 'user.id'), - ), - ) - .where('user.deletedAt', 'is', null) - .as('owner'); -}; +const withAlbumOwner = (eb: ExpressionBuilder) => + jsonArrayFrom( + eb + .selectFrom('album_user') + .innerJoin('user', 'user.id', 'album_user.userId') + .where('album_user.role', '=', sql.lit(AlbumUserRole.Owner)) + .whereRef('album_user.albumId', '=', 'album.id') + .select('album_user.role') + .select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user')), + ) + .$notNull() + .as('albumUsers'); const withSharedLinkAlbum = (eb: ExpressionBuilder) => { return eb @@ -101,7 +98,7 @@ export class SharedLinkRepository { .as('assets'), (join) => join.onTrue(), ) - .innerJoinLateral(withAlbumOwner, (join) => join.onTrue()) + .select(withAlbumOwner) .select((eb) => eb.fn .coalesce( @@ -114,8 +111,7 @@ export class SharedLinkRepository { ) .as('assets'), ) - .select((eb) => eb.fn.toJson('owner').as('owner')) - .groupBy(['album.id', sql`"owner".*`]) + .groupBy('album.id') .as('album'), (join) => join.onTrue(), ) @@ -135,11 +131,7 @@ export class SharedLinkRepository { .select((eb) => jsonArrayFrom(withSharedAssets(eb).limit(1)).as('assets')) .where('shared_link.userId', '=', userId) .leftJoinLateral( - (eb) => - withSharedLinkAlbum(eb) - .innerJoinLateral(withAlbumOwner, (join) => join.onTrue()) - .select((eb) => eb.fn.toJson('owner').as('owner')) - .as('album'), + (eb) => withSharedLinkAlbum(eb).select(withAlbumOwner).as('album'), (join) => join.onTrue(), ) .select((eb) => eb.fn.toJson('album').$castTo | null>().as('album')) diff --git a/server/test/medium/specs/services/shared-link.service.spec.ts b/server/test/medium/specs/services/shared-link.service.spec.ts index 165d3b2a9..39e0e895c 100644 --- a/server/test/medium/specs/services/shared-link.service.spec.ts +++ b/server/test/medium/specs/services/shared-link.service.spec.ts @@ -1,6 +1,6 @@ import { Kysely } from 'kysely'; import { randomBytes } from 'node:crypto'; -import { SharedLinkType } from 'src/enum.js'; +import { AlbumUserRole, SharedLinkType } from 'src/enum.js'; import { AccessRepository } from 'src/repositories/access.repository.js'; import { DatabaseRepository } from 'src/repositories/database.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js'; @@ -236,12 +236,14 @@ describe(SharedLinkService.name, () => { expect(result[0].id).toBe(link1.id); }); - it('should return album shared links with album data', async () => { + it('should return album shared links with album data and (only) the owner', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); + const { user: albumContributor } = await ctx.newUser(); const auth = factory.auth({ user }); const { album } = await ctx.newAlbum({ ownerId: user.id }); + await ctx.newAlbumUser({ albumId: album.id, userId: albumContributor.id, role: AlbumUserRole.Editor }); const sharedLinkRepo = ctx.get(SharedLinkRepository); @@ -258,6 +260,9 @@ describe(SharedLinkService.name, () => { expect(result).toHaveLength(1); expect(result[0].album).toBeDefined(); expect(result[0].album!.id).toBe(album.id); + expect(result[0].album!.albumUsers.length).toBe(1); + expect(result[0].album!.albumUsers[0].role).toBe(AlbumUserRole.Owner); + expect(result[0].album!.albumUsers[0].user.id).toBe(user.id); }); it('should return multiple album shared links without sql error from json group by', async () => {