diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts index 70c32313f1..9ce9b4b916 100644 --- a/e2e/src/api/specs/activity.e2e-spec.ts +++ b/e2e/src/api/specs/activity.e2e-spec.ts @@ -7,6 +7,7 @@ import { ReactionType, createActivity as create, createAlbum, + removeAssetFromAlbum, } from '@immich/sdk'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -342,5 +343,36 @@ describe('/activities', () => { expect(status).toBe(204); }); + + it('should return empty list when asset is removed', async () => { + const album3 = await createAlbum( + { + createAlbumDto: { + albumName: 'Album 3', + assetIds: [asset.id], + }, + }, + { headers: asBearerAuth(admin.accessToken) }, + ); + + await createActivity({ albumId: album3.id, assetId: asset.id, type: ReactionType.Like }); + + await removeAssetFromAlbum( + { + id: album3.id, + bulkIdsDto: { + ids: [asset.id], + }, + }, + { headers: asBearerAuth(admin.accessToken) }, + ); + + const { status, body } = await request(app) + .get('/activities') + .query({ albumId: album.id }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toEqual(200); + expect(body).toEqual([]); + }); }); }); diff --git a/server/src/schema/migrations/1750323941567-AddActivityAssetFk.ts b/server/src/schema/migrations/1750323941567-AddActivityAssetFk.ts new file mode 100644 index 0000000000..938c716014 --- /dev/null +++ b/server/src/schema/migrations/1750323941567-AddActivityAssetFk.ts @@ -0,0 +1,19 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`DELETE FROM activity AS a + WHERE a."assetId" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM albums_assets_assets AS aaa + WHERE a."albumId" = aaa."albumsId" + AND a."assetId" = aaa."assetsId" + );`.execute(db); + await sql`ALTER TABLE "activity" ADD CONSTRAINT "fk_activity_album_asset_composite" FOREIGN KEY ("albumId", "assetId") REFERENCES "albums_assets_assets" ("albumsId", "assetsId") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); + await sql`CREATE INDEX "IDX_86102d85cfa7f196073aebff68" ON "activity" ("albumId", "assetId")`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "IDX_86102d85cfa7f196073aebff68";`.execute(db); + await sql`ALTER TABLE "activity" DROP CONSTRAINT "fk_activity_album_asset_composite";`.execute(db); +} diff --git a/server/src/schema/tables/activity.table.ts b/server/src/schema/tables/activity.table.ts index 47fca24bb1..778cfb14bb 100644 --- a/server/src/schema/tables/activity.table.ts +++ b/server/src/schema/tables/activity.table.ts @@ -1,4 +1,5 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -7,6 +8,7 @@ import { Column, CreateDateColumn, ForeignKeyColumn, + ForeignKeyConstraint, Generated, Index, PrimaryGeneratedColumn, @@ -27,6 +29,14 @@ import { name: 'CHK_2ab1e70f113f450eb40c1e3ec8', expression: `("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)`, }) +@ForeignKeyConstraint({ + name: 'fk_activity_album_asset_composite', + columns: ['albumId', 'assetId'], + referenceTable: () => AlbumAssetTable, + referenceColumns: ['albumsId', 'assetsId'], + onUpdate: 'NO ACTION', + onDelete: 'CASCADE', +}) export class ActivityTable { @PrimaryGeneratedColumn() id!: Generated;