diff --git a/backend/src/services/assets.ts b/backend/src/services/assets.ts index 6e8982a..a3d8efb 100644 --- a/backend/src/services/assets.ts +++ b/backend/src/services/assets.ts @@ -1,7 +1,10 @@ import AssetsDBApi from '../db/api/assets.ts'; import { createEntityService } from '../factories/service.factory.ts'; +import db from '../db/models/index.ts'; import { assertCreateOptions, + assertDeleteByIdsOptions, + assertIdOptions, assertUpdateOptions, } from '../contracts/entity-options.ts'; import ValidationError from './notifications/errors/validation.ts'; @@ -11,13 +14,16 @@ import { logger } from '../utils/logger.ts'; import type { AssetCreateOptions, AssetData, + AssetDeleteByIdsOptions, AssetEmbedUrlResult, AssetFindByOptions, AssetListFilter, AssetMimePatternMap, AssetMimeValidationResult, + AssetRemoveOptions, AssetRecord, AssetUpdateOptions, + ServiceOptions, } from '../types/index.ts'; const VALID_MIME_PATTERNS: AssetMimePatternMap = { @@ -182,6 +188,35 @@ function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOption return findByOptions; } +function getCurrentUserId( + options: ServiceOptions, +): string | null { + return options.currentUser?.id ?? null; +} + +async function softDeleteAssetVariants( + assetIds: readonly string[], + options: ServiceOptions & Required>, +): Promise { + if (assetIds.length === 0) return; + + const now = new Date(); + await db.asset_variants.update( + { + deletedAt: now, + updatedAt: now, + updatedById: getCurrentUserId(options), + }, + { + where: { + assetId: assetIds, + deletedAt: null, + }, + transaction: options.transaction, + }, + ); +} + const BaseService = createEntityService< AssetRecord, AssetData, @@ -313,4 +348,57 @@ export default class AssetsService extends BaseService { ...serviceOptions, }); } + + static override async deleteByIds( + options: AssetDeleteByIdsOptions, + ): Promise { + assertDeleteByIdsOptions(options, 'Service'); + + const { transaction: externalTransaction } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; + + try { + const result = await super.deleteByIds({ + ...options, + transaction, + }); + await softDeleteAssetVariants( + result.map((asset) => asset.id), + { ...options, transaction }, + ); + + if (ownsTransaction) await transaction.commit(); + return result; + } catch (error) { + if (ownsTransaction) await transaction.rollback(); + throw error; + } + } + + static override async remove( + options: AssetRemoveOptions, + ): Promise { + assertIdOptions(options, 'Service', 'remove'); + + const { transaction: externalTransaction } = options; + const transaction = + externalTransaction || (await db.sequelize.transaction()); + const ownsTransaction = !externalTransaction; + + try { + const result = await super.remove({ + ...options, + transaction, + }); + await softDeleteAssetVariants([result.id], { ...options, transaction }); + + if (ownsTransaction) await transaction.commit(); + return result; + } catch (error) { + if (ownsTransaction) await transaction.rollback(); + throw error; + } + } } diff --git a/backend/src/types/db-models.ts b/backend/src/types/db-models.ts index c91dc9f..87a8cef 100644 --- a/backend/src/types/db-models.ts +++ b/backend/src/types/db-models.ts @@ -148,6 +148,20 @@ export interface ProjectCloneVariantModel { data: ProjectCloneVariantPayload, options: { transaction: Transaction }, ): Promise; + update( + data: { + deletedAt: Date; + updatedAt: Date; + updatedById: string | null; + }, + options: { + where: { + assetId: readonly string[]; + deletedAt: null; + }; + transaction: Transaction; + }, + ): Promise<[number]>; } export interface ProjectCloneCollectionModel { diff --git a/backend/tests/update-contracts.test.ts b/backend/tests/update-contracts.test.ts index ff6baa9..ba7ce78 100644 --- a/backend/tests/update-contracts.test.ts +++ b/backend/tests/update-contracts.test.ts @@ -3,11 +3,15 @@ import test from 'node:test'; import db from '../src/db/models/index.ts'; import AssetsDBApi from '../src/db/api/assets.ts'; +import AssetsService from '../src/services/assets.ts'; import GenericDBApi from '../src/db/api/base.api.ts'; import ProjectElementDefaultsDBApi from '../src/db/api/project_element_defaults.ts'; import { createEntityService } from '../src/factories/service.factory.ts'; import type { CurrentUser, + AssetDeleteByIdsOptions, + AssetRecord, + AssetRemoveOptions, DbData, EntityRecord, EntityServiceDbApi, @@ -53,6 +57,7 @@ interface UpdateContractCalls { serviceCreate?: unknown; serviceDeleteByIds?: unknown; serviceRemove?: unknown; + variantSoftDelete?: { payload: DbData; options: unknown }; associationThis?: TestAssociationRecord; committed?: true; rolledBack?: true; @@ -102,6 +107,63 @@ function replaceSequelizeTransaction( }; } +function replaceAssetVariantUpdate( + calls: UpdateContractCalls, +): () => void { + const originalUpdate = db.asset_variants.update.bind(db.asset_variants); + + Object.defineProperty(db.asset_variants, 'update', { + configurable: true, + value: (payload: DbData, options: unknown) => { + calls.variantSoftDelete = { payload, options }; + return Promise.resolve([1]); + }, + }); + + return () => { + Object.defineProperty(db.asset_variants, 'update', { + configurable: true, + value: originalUpdate, + }); + }; +} + +function replaceAssetsDbApiRemove( + value: (options: AssetRemoveOptions) => Promise, +): () => void { + const original = AssetsDBApi.remove.bind(AssetsDBApi); + + Object.defineProperty(AssetsDBApi, 'remove', { + configurable: true, + value, + }); + + return () => { + Object.defineProperty(AssetsDBApi, 'remove', { + configurable: true, + value: original, + }); + }; +} + +function replaceAssetsDbApiDeleteByIds( + value: (options: AssetDeleteByIdsOptions) => Promise, +): () => void { + const original = AssetsDBApi.deleteByIds.bind(AssetsDBApi); + + Object.defineProperty(AssetsDBApi, 'deleteByIds', { + configurable: true, + value, + }); + + return () => { + Object.defineProperty(AssetsDBApi, 'deleteByIds', { + configurable: true, + value: original, + }); + }; +} + function createServiceDbApi( calls: UpdateContractCalls, ): EntityServiceDbApi { @@ -421,6 +483,110 @@ void test('AssetsDBApi mapping keeps explicit project relation', () => { assert.equal(mapped.projectId, 'project-1'); }); +void test('AssetsService.remove soft-deletes asset variants when removing asset', async () => { + const calls: UpdateContractCalls = {}; + const transaction: TestManagedTransaction = { + id: 'tx-asset-remove', + commit() { + calls.committed = true; + return Promise.resolve(); + }, + rollback() { + calls.rolledBack = true; + return Promise.resolve(); + }, + }; + const currentUser = createCurrentUser(); + const restoreTransaction = replaceSequelizeTransaction(transaction); + const restoreVariantUpdate = replaceAssetVariantUpdate(calls); + const restoreRemove = replaceAssetsDbApiRemove((options) => { + calls.serviceRemove = options; + return Promise.resolve({ id: options.id }); + }); + + try { + const result = await AssetsService.remove({ + id: 'asset-1', + currentUser, + }); + + assert.deepEqual(result, { id: 'asset-1' }); + assert.equal(calls.variantSoftDelete?.payload.updatedById, 'user-1'); + assert.ok(calls.variantSoftDelete?.payload.deletedAt instanceof Date); + assert.ok(calls.variantSoftDelete?.payload.updatedAt instanceof Date); + assert.deepEqual(calls.variantSoftDelete?.options, { + where: { + assetId: ['asset-1'], + deletedAt: null, + }, + transaction, + }); + assert.deepEqual(calls.serviceRemove, { + id: 'asset-1', + currentUser, + transaction, + }); + assert.equal(calls.committed, true); + assert.equal(calls.rolledBack, undefined); + } finally { + restoreRemove(); + restoreVariantUpdate(); + restoreTransaction(); + } +}); + +void test('AssetsService.deleteByIds soft-deletes variants for deleted assets', async () => { + const calls: UpdateContractCalls = {}; + const transaction: TestManagedTransaction = { + id: 'tx-assets-delete', + commit() { + calls.committed = true; + return Promise.resolve(); + }, + rollback() { + calls.rolledBack = true; + return Promise.resolve(); + }, + }; + const currentUser = createCurrentUser(); + const restoreTransaction = replaceSequelizeTransaction(transaction); + const restoreVariantUpdate = replaceAssetVariantUpdate(calls); + const restoreDeleteByIds = replaceAssetsDbApiDeleteByIds((options) => { + calls.serviceDeleteByIds = options; + return Promise.resolve(options.ids.map((id) => ({ id }))); + }); + + try { + const result = await AssetsService.deleteByIds({ + ids: ['asset-1', 'asset-2'], + currentUser, + }); + + assert.deepEqual(result, [{ id: 'asset-1' }, { id: 'asset-2' }]); + assert.equal(calls.variantSoftDelete?.payload.updatedById, 'user-1'); + assert.ok(calls.variantSoftDelete?.payload.deletedAt instanceof Date); + assert.ok(calls.variantSoftDelete?.payload.updatedAt instanceof Date); + assert.deepEqual(calls.variantSoftDelete?.options, { + where: { + assetId: ['asset-1', 'asset-2'], + deletedAt: null, + }, + transaction, + }); + assert.deepEqual(calls.serviceDeleteByIds, { + ids: ['asset-1', 'asset-2'], + currentUser, + transaction, + }); + assert.equal(calls.committed, true); + assert.equal(calls.rolledBack, undefined); + } finally { + restoreDeleteByIds(); + restoreVariantUpdate(); + restoreTransaction(); + } +}); + void test('createEntityService update uses object signature and manages own transaction', async () => { const calls: UpdateContractCalls = {}; const transaction: TestManagedTransaction = {