From f183a50b4eb1bfb31347fe37150da53574d23bed Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Mon, 10 Aug 2026 20:51:56 +0200 Subject: [PATCH] fix: owner cascade delete album --- server/src/schema/functions.ts | 14 +++++++ server/src/schema/index.ts | 2 + .../1786385711807-AlbumOwnerDeleteTrigger.ts | 31 ++++++++++++++++ server/src/schema/tables/album-user.table.ts | 3 +- .../specs/services/album.service.spec.ts | 37 +++++++++++++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts create mode 100644 server/test/medium/specs/services/album.service.spec.ts diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 230f22478d..2ee61d82dd 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -133,6 +133,20 @@ export const album_asset_delete_audit = registerFunction({ END`, }); +export const album_user_delete = registerFunction({ + name: 'album_user_delete', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + DELETE FROM "album" + WHERE "album"."id" = OLD."albumId" + AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner'); + + RETURN NULL; + END`, +}); + export const album_user_delete_audit = registerFunction({ name: 'album_user_delete_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 4e55ff4cf6..171fefb50f 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -7,6 +7,7 @@ import { } from 'src/schema/enums'; import { album_user_after_insert, + album_user_delete, album_user_delete_audit, asset_delete_audit, asset_face_audit, @@ -174,6 +175,7 @@ export class ImmichDatabase { asset_metadata_audit, asset_face_audit, asset_ocr_delete_audit, + album_user_delete, ]; enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum]; diff --git a/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts b/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts new file mode 100644 index 0000000000..082e43ff23 --- /dev/null +++ b/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts @@ -0,0 +1,31 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`DELETE FROM "album" WHERE NOT EXISTS (SELECT * FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner');`.execute(db); + await sql`CREATE OR REPLACE FUNCTION album_user_delete() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + DELETE FROM "album" + WHERE "album"."id" = OLD."albumId" + AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner'); + + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_user_delete" + AFTER DELETE ON "album_user" + REFERENCING OLD TABLE AS "old" + FOR EACH ROW + EXECUTE FUNCTION album_user_delete();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_user_delete', '{"type":"function","name":"album_user_delete","sql":"CREATE OR REPLACE FUNCTION album_user_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n DELETE FROM \\"album\\"\\n WHERE \\"album\\".\\"id\\" = OLD.\\"albumId\\"\\n AND NOT EXISTS (SELECT \\"albumId\\" FROM \\"album_user\\" WHERE \\"album_user\\".\\"albumId\\" = \\"album\\".\\"id\\" AND \\"album_user\\".\\"role\\" = ''owner'');\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_delete', '{"type":"trigger","name":"album_user_delete","sql":"CREATE OR REPLACE TRIGGER \\"album_user_delete\\"\\n AFTER DELETE ON \\"album_user\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION album_user_delete();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "album_user_delete" ON "album_user";`.execute(db); + await sql`DROP FUNCTION album_user_delete;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_user_delete';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_delete';`.execute(db); +} diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 677d6ca2f2..c015c1354d 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -13,7 +13,7 @@ import { import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AlbumUserRole } 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 { album_user_after_insert, album_user_delete, album_user_delete_audit } from 'src/schema/functions'; import { AlbumTable } from 'src/schema/tables/album.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -38,6 +38,7 @@ import { UserTable } from 'src/schema/tables/user.table'; referencingOldTableAs: 'old', when: 'pg_trigger_depth() <= 1', }) +@AfterDeleteTrigger({ scope: 'row', function: album_user_delete, referencingOldTableAs: 'old' }) export class AlbumUserTable { @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', diff --git a/server/test/medium/specs/services/album.service.spec.ts b/server/test/medium/specs/services/album.service.spec.ts new file mode 100644 index 0000000000..bc73007593 --- /dev/null +++ b/server/test/medium/specs/services/album.service.spec.ts @@ -0,0 +1,37 @@ +import { Kysely } from 'kysely'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { AlbumService } from 'src/services/album.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(AlbumService, { + database: db || defaultDatabase, + real: [AlbumRepository, UserRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AlbumService.name, () => { + describe('database triggers', () => { + it('should cascade delete an album when the owner is deleted', async () => { + const { ctx } = setup(); + const { user } = await ctx.newUser(); + await ctx.newAlbum({ ownerId: user.id }); + + await ctx.get(UserRepository).delete({ id: user.id }, true); + + await expect(ctx.database.selectFrom('album').selectAll().execute()).resolves.toEqual([]); + await expect(ctx.database.selectFrom('album_user').selectAll().execute()).resolves.toEqual([]); + }); + }); +});