diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index d4572886af..79a46d1834 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -88,6 +88,28 @@ export class TagRepository { await this.db.deleteFrom('tag').where('id', '=', id).execute(); } + @GenerateSql({ params: [DummyValue.UUID] }) + async getAssetIdsForTag(tagId: string): Promise { + const results = await this.db + .selectFrom('tag_asset') + .select(['assetId']) + .where('tagId', '=', tagId) + .execute(); + + return results.map(({ assetId }) => assetId); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getDescendantTagIds(tagId: string): Promise { + const results = await this.db + .selectFrom('tag_closure') + .select(['id_descendant']) + .where('id_ancestor', '=', tagId) + .execute(); + + return results.map(({ id_descendant }) => id_descendant); + } + @ChunkedSet({ paramIndex: 1 }) @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) async getAssetIds(tagId: string, assetIds: string[]): Promise> { diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 6bb92abd8c..cbc9f704a5 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -169,12 +169,53 @@ describe(TagService.name, () => { expect(mocks.tag.delete).not.toHaveBeenCalled(); }); - it('should remove a tag', async () => { - mocks.tag.get.mockResolvedValue(tagStub.tag); + it('should remove a tag and emit AssetUntag events for affected assets', async () => { + mocks.tag.getDescendantTagIds.mockResolvedValue(['tag-1']); + mocks.tag.getAssetIdsForTag.mockResolvedValue(['asset-1', 'asset-2']); mocks.tag.delete.mockResolvedValue(); await sut.remove(authStub.admin, 'tag-1'); + + expect(mocks.tag.getDescendantTagIds).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.getAssetIdsForTag).toHaveBeenCalledWith('tag-1'); expect(mocks.tag.delete).toHaveBeenCalledWith('tag-1'); + expect(mocks.event.emit).toHaveBeenNthCalledWith(1, 'AssetUntag', { assetId: 'asset-1' }); + expect(mocks.event.emit).toHaveBeenNthCalledWith(2, 'AssetUntag', { assetId: 'asset-2' }); + }); + + it('should remove a tag with descendants and emit AssetUntag events for all affected assets', async () => { + mocks.tag.getDescendantTagIds.mockResolvedValue(['tag-1', 'tag-1-child', 'tag-1-grandchild']); + mocks.tag.getAssetIdsForTag.mockResolvedValueOnce(['asset-1', 'asset-2']); + mocks.tag.getAssetIdsForTag.mockResolvedValueOnce(['asset-2', 'asset-3']); + mocks.tag.getAssetIdsForTag.mockResolvedValueOnce(['asset-4']); + mocks.tag.delete.mockResolvedValue(); + + await sut.remove(authStub.admin, 'tag-1'); + + expect(mocks.tag.getDescendantTagIds).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.getAssetIdsForTag).toHaveBeenNthCalledWith(1, 'tag-1'); + expect(mocks.tag.getAssetIdsForTag).toHaveBeenNthCalledWith(2, 'tag-1-child'); + expect(mocks.tag.getAssetIdsForTag).toHaveBeenNthCalledWith(3, 'tag-1-grandchild'); + expect(mocks.tag.delete).toHaveBeenCalledWith('tag-1'); + // Should emit events for all affected assets (de-duplicated) + expect(mocks.event.emit).toHaveBeenCalledTimes(4); + expect(mocks.event.emit).toHaveBeenCalledWith('AssetUntag', { assetId: 'asset-1' }); + expect(mocks.event.emit).toHaveBeenCalledWith('AssetUntag', { assetId: 'asset-2' }); + expect(mocks.event.emit).toHaveBeenCalledWith('AssetUntag', { assetId: 'asset-3' }); + expect(mocks.event.emit).toHaveBeenCalledWith('AssetUntag', { assetId: 'asset-4' }); + }); + + it('should remove a tag with no affected assets', async () => { + mocks.tag.getDescendantTagIds.mockResolvedValue(['tag-1']); + mocks.tag.getAssetIdsForTag.mockResolvedValue([]); + mocks.tag.delete.mockResolvedValue(); + + await sut.remove(authStub.admin, 'tag-1'); + + expect(mocks.tag.getDescendantTagIds).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.getAssetIdsForTag).toHaveBeenCalledWith('tag-1'); + expect(mocks.tag.delete).toHaveBeenCalledWith('tag-1'); + expect(mocks.event.emit).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 3ee5d29b75..8c37da7a00 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -70,9 +70,19 @@ export class TagService extends BaseService { async remove(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.TagDelete, ids: [id] }); - // TODO sync tag changes for affected assets + const descendantTagIds = await this.tagRepository.getDescendantTagIds(id); + + const affectedAssetIds = new Set(); + for (const tagId of descendantTagIds) { + const assetIds = await this.tagRepository.getAssetIdsForTag(tagId); + assetIds.forEach((assetId) => affectedAssetIds.add(assetId)); + } await this.tagRepository.delete(id); + + for (const assetId of affectedAssetIds) { + await this.eventRepository.emit('AssetUntag', { assetId }); + } } async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise {