From 6b9d131c1401d029e17e6a7e890e92ab18cfcd98 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 25 Aug 2026 12:07:18 +0200 Subject: [PATCH] feat(audit): implement supplier status activation and deactivation functionality with audit logging --- .../supplier-status-audit.integration.test.ts | 249 ++++++++++++++++++ .../api/application/audit/supplier-audit.ts | 2 + .../suppliers/src/api/application/di/index.ts | 1 + .../di/supplier-status-changer.di.ts | 11 + .../application/di/supplier-use-cases.di.ts | 19 +- .../src/api/application/services/index.ts | 1 + .../services/supplier-audit.services.test.ts | 120 ++++++++- .../supplier-status-changer.service.ts | 61 +++++ .../change-supplier-status.use-case.ts | 51 ++++ .../src/api/application/use-cases/index.ts | 1 + .../aggregates/supplier.aggregate.test.ts | 56 +++- .../domain/aggregates/supplier.aggregate.ts | 16 +- .../src/api/infrastructure/di/suppliers.di.ts | 24 ++ .../change-supplier-status.controller.ts | 31 +++ .../express/controllers/index.ts | 1 + .../express/suppliers.routes.test.ts | 15 ++ .../express/suppliers.routes.ts | 20 ++ .../change-supplier-status.request.dto.ts | 9 + .../suppliers/src/common/dto/request/index.ts | 1 + .../update-supplier-by-id.request.dto.ts | 48 ++-- 20 files changed, 700 insertions(+), 37 deletions(-) create mode 100644 apps/server/src/testing/http/supplier-status-audit.integration.test.ts create mode 100644 modules/suppliers/src/api/application/di/supplier-status-changer.di.ts create mode 100644 modules/suppliers/src/api/application/services/supplier-status-changer.service.ts create mode 100644 modules/suppliers/src/api/application/use-cases/change-supplier-status.use-case.ts create mode 100644 modules/suppliers/src/api/infrastructure/express/controllers/change-supplier-status.controller.ts create mode 100644 modules/suppliers/src/common/dto/request/change-supplier-status.request.dto.ts diff --git a/apps/server/src/testing/http/supplier-status-audit.integration.test.ts b/apps/server/src/testing/http/supplier-status-audit.integration.test.ts new file mode 100644 index 00000000..924a6aca --- /dev/null +++ b/apps/server/src/testing/http/supplier-status-audit.integration.test.ts @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { SequelizeTransactionManager } from "@erp/core/api"; +import type { IAudit } from "@repo/rdx-audit"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize"; + +import { SupplierStatusChanger } from "../../../../../modules/suppliers/src/api/application/services/supplier-status-changer.service"; +import { SequelizeSupplierDomainMapper } from "../../../../../modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper"; +import { SequelizeSupplierSummaryMapper } from "../../../../../modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper"; +import { SupplierModel } from "../../../../../modules/suppliers/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model"; +import { SupplierRepository } from "../../../../../modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository"; + +import { + buildCompanyHeaders, + createTestAccount, + createTestCompany, + createTestMembership, + loginByApi, +} from "./http-test-fixtures"; +import { createHttpTestRuntime } from "./http-test-runtime"; + +test("suppliers activa y desactiva con Audit transaccional en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + + const account = await createTestAccount(runtime.database, { + email: `supplier-status-${randomUUID()}@example.test`, + password: "Password123!", + }); + const company = await createTestCompany(runtime.database, { + legalName: "Supplier status company", + }); + const otherCompany = await createTestCompany(runtime.database, { + legalName: "Other supplier status company", + }); + await createTestMembership(runtime.database, { accountId: account.id, companyId: company.id }); + await createTestMembership(runtime.database, { + accountId: account.id, + companyId: otherCompany.id, + }); + + const loginResponse = await loginByApi({ + request: runtime.request, + email: account.email, + password: account.password, + }); + assert.equal(loginResponse.status, 200); + const accessToken = (loginResponse.body as { access_token: string }).access_token; + const supplierId = randomUUID(); + const headers = buildCompanyHeaders({ accessToken, companyId: company.id }); + + const createResponse = await runtime.request("/api/v1/suppliers", { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ id: supplierId, is_company: true, name: "Supplier status" }), + }); + assert.equal(createResponse.status, 201); + + const deactivateResponse = await runtime.request(`/api/v1/suppliers/${supplierId}/deactivate`, { + method: "POST", + headers: { ...headers, "x-correlation-id": "supplier-deactivate-correlation" }, + }); + assert.equal(deactivateResponse.status, 200); + assert.equal((deactivateResponse.body as { status: string }).status, "inactive"); + assert.equal((await SupplierModel.findByPk(supplierId))?.status, "inactive"); + + const deactivatedEvent = await AuditEventModel.findOne({ + where: { action: "supplier.deactivated", target_id: supplierId }, + }); + assert.ok(deactivatedEvent); + assert.equal(deactivatedEvent.source_type, "application"); + assert.equal(deactivatedEvent.source_id, "uecko-erp"); + assert.equal(deactivatedEvent.origin, "http"); + assert.equal(deactivatedEvent.operation_id, deactivateResponse.headers.get("x-request-id")); + assert.equal(deactivatedEvent.correlation_id, "supplier-deactivate-correlation"); + assert.equal(deactivatedEvent.actor_type, "user"); + assert.equal(deactivatedEvent.actor_id, account.id); + assert.equal(deactivatedEvent.scope_type, "company"); + assert.equal(deactivatedEvent.scope_id, company.id); + assert.equal(deactivatedEvent.target_type, "supplier"); + assert.equal(deactivatedEvent.target_id, supplierId); + assert.deepEqual( + (await AuditChangeModel.findAll({ where: { audit_event_id: deactivatedEvent.id } })).map( + (change) => ({ + path: change.path, + kind: change.kind, + before: change.before_value, + after: change.after_value, + }) + ), + [{ path: "status", kind: "change", before: "active", after: "inactive" }] + ); + + const getInactiveResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, { + headers, + }); + assert.equal(getInactiveResponse.status, 200); + assert.equal((getInactiveResponse.body as { status: string }).status, "inactive"); + + const eventsBeforeRepeatedDeactivate = await AuditEventModel.count({ + where: { target_id: supplierId }, + }); + const repeatedDeactivateResponse = await runtime.request( + `/api/v1/suppliers/${supplierId}/deactivate`, + { method: "POST", headers } + ); + assert.equal(repeatedDeactivateResponse.status, 200); + assert.equal( + await AuditEventModel.count({ where: { target_id: supplierId } }), + eventsBeforeRepeatedDeactivate + ); + + const activateResponse = await runtime.request(`/api/v1/suppliers/${supplierId}/activate`, { + method: "POST", + headers: { ...headers, "x-correlation-id": "supplier-activate-correlation" }, + }); + assert.equal(activateResponse.status, 200); + assert.equal((activateResponse.body as { status: string }).status, "active"); + assert.equal((await SupplierModel.findByPk(supplierId))?.status, "active"); + + const activatedEvent = await AuditEventModel.findOne({ + where: { action: "supplier.activated", target_id: supplierId }, + }); + assert.ok(activatedEvent); + assert.equal(activatedEvent.correlation_id, "supplier-activate-correlation"); + assert.deepEqual( + (await AuditChangeModel.findAll({ where: { audit_event_id: activatedEvent.id } })).map( + (change) => ({ + path: change.path, + kind: change.kind, + before: change.before_value, + after: change.after_value, + }) + ), + [{ path: "status", kind: "change", before: "inactive", after: "active" }] + ); + + const eventsBeforeRejectedRequests = await AuditEventModel.count(); + const crossCompanyResponse = await runtime.request(`/api/v1/suppliers/${supplierId}/deactivate`, { + method: "POST", + headers: buildCompanyHeaders({ accessToken, companyId: otherCompany.id }), + }); + assert.equal(crossCompanyResponse.status, 404); + const missingResponse = await runtime.request(`/api/v1/suppliers/${randomUUID()}/activate`, { + method: "POST", + headers, + }); + assert.equal(missingResponse.status, 404); + const patchStatusResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, { + method: "PATCH", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ status: "inactive" }), + }); + assert.notEqual(patchStatusResponse.status, 200); + assert.equal(await AuditEventModel.count(), eventsBeforeRejectedRequests); + + const repository = new SupplierRepository( + new SequelizeSupplierDomainMapper(), + new SequelizeSupplierSummaryMapper(), + runtime.database + ); + const statusChanger = new SupplierStatusChanger(repository, runtime.audit); + const transactionManager = new SequelizeTransactionManager( + runtime.database, + runtime.transactionContext + ); + const supplierIdResult = UniqueID.create(supplierId); + const companyIdResult = UniqueID.create(company.id); + assert.equal(supplierIdResult.isSuccess, true); + assert.equal(companyIdResult.isSuccess, true); + const changesBeforeRollback = await AuditChangeModel.count(); + + await assert.rejects( + () => + runtime.operationContext.run( + { + operationId: "supplier-status-rollback-operation", + correlationId: "supplier-status-rollback-correlation", + origin: "http", + actor: { type: "user", id: account.id, label: account.email }, + scope: { type: "company", id: company.id, label: null }, + }, + () => + transactionManager.complete(async (transaction) => { + const supplierResult = await repository.getByIdInCompany( + companyIdResult.data, + supplierIdResult.data, + transaction + ); + assert.equal(supplierResult.isSuccess, true); + const statusResult = await statusChanger.changeStatus({ + supplier: supplierResult.data, + action: "deactivate", + transaction, + }); + assert.equal(statusResult.isSuccess, true); + return Result.fail(new Error("forced supplier status rollback")); + }) + ), + /forced supplier status rollback/ + ); + + assert.equal((await SupplierModel.findByPk(supplierId))?.status, "active"); + assert.equal( + await AuditEventModel.count({ where: { operation_id: "supplier-status-rollback-operation" } }), + 0 + ); + assert.equal(await AuditChangeModel.count(), changesBeforeRollback); + + const auditFailureChanger = new SupplierStatusChanger(repository, { + record: async () => Result.ok(), + recordEvent: async () => Result.ok(), + recordMutation: async () => Result.fail(new Error("audit unavailable")), + } satisfies IAudit); + + await assert.rejects( + () => + runtime.operationContext.run( + { + operationId: "supplier-status-audit-failure-operation", + correlationId: "supplier-status-audit-failure-correlation", + origin: "http", + actor: { type: "user", id: account.id, label: account.email }, + scope: { type: "company", id: company.id, label: null }, + }, + () => + transactionManager.complete(async (transaction) => { + const supplierResult = await repository.getByIdInCompany( + companyIdResult.data, + supplierIdResult.data, + transaction + ); + assert.equal(supplierResult.isSuccess, true); + return auditFailureChanger.changeStatus({ + supplier: supplierResult.data, + action: "deactivate", + transaction, + }); + }) + ), + /audit unavailable/ + ); + assert.equal((await SupplierModel.findByPk(supplierId))?.status, "active"); +}); diff --git a/modules/suppliers/src/api/application/audit/supplier-audit.ts b/modules/suppliers/src/api/application/audit/supplier-audit.ts index 962dac92..b2904264 100644 --- a/modules/suppliers/src/api/application/audit/supplier-audit.ts +++ b/modules/suppliers/src/api/application/audit/supplier-audit.ts @@ -5,6 +5,8 @@ import type { Supplier } from "../../domain"; export const SUPPLIER_AUDIT_ACTIONS = { CREATED: "supplier.created", UPDATED: "supplier.updated", + ACTIVATED: "supplier.activated", + DEACTIVATED: "supplier.deactivated", } as const; type SupplierAuditState = Readonly<{ diff --git a/modules/suppliers/src/api/application/di/index.ts b/modules/suppliers/src/api/application/di/index.ts index 946b8110..0ce0ba8d 100644 --- a/modules/suppliers/src/api/application/di/index.ts +++ b/modules/suppliers/src/api/application/di/index.ts @@ -2,5 +2,6 @@ export * from "./supplier-creator.di"; export * from "./supplier-finder.di"; export * from "./supplier-input-mappers.di"; export * from "./supplier-snapshot-builders.di"; +export * from "./supplier-status-changer.di"; export * from "./supplier-updater.di"; export * from "./supplier-use-cases.di"; diff --git a/modules/suppliers/src/api/application/di/supplier-status-changer.di.ts b/modules/suppliers/src/api/application/di/supplier-status-changer.di.ts new file mode 100644 index 00000000..d1214d20 --- /dev/null +++ b/modules/suppliers/src/api/application/di/supplier-status-changer.di.ts @@ -0,0 +1,11 @@ +import type { IAudit } from "@repo/rdx-audit"; + +import type { ISupplierRepository } from "../repositories"; +import { SupplierStatusChanger } from "../services"; + +export function buildSupplierStatusChanger(params: { + repository: ISupplierRepository; + audit: IAudit; +}) { + return new SupplierStatusChanger(params.repository, params.audit); +} diff --git a/modules/suppliers/src/api/application/di/supplier-use-cases.di.ts b/modules/suppliers/src/api/application/di/supplier-use-cases.di.ts index bdbb53a6..cf6186d4 100644 --- a/modules/suppliers/src/api/application/di/supplier-use-cases.di.ts +++ b/modules/suppliers/src/api/application/di/supplier-use-cases.di.ts @@ -1,18 +1,35 @@ import type { ITransactionManager } from "@erp/core/api"; import type { ICreateSupplierInputMapper, IUpdateSupplierInputMapper } from "../mappers"; -import type { ISupplierCreator, ISupplierFinder, ISupplierUpdater } from "../services"; +import type { + ISupplierCreator, + ISupplierFinder, + ISupplierStatusChanger, + ISupplierUpdater, + SupplierStatusAction, +} from "../services"; import type { ISupplierFullSnapshotBuilder, ISupplierSummarySnapshotBuilder, } from "../snapshot-builders"; import { + ChangeSupplierStatusUseCase, CreateSupplierUseCase, GetSupplierByIdUseCase, ListSuppliersUseCase, UpdateSupplierUseCase, } from "../use-cases"; +export function buildChangeSupplierStatusUseCase(deps: { + finder: ISupplierFinder; + statusChanger: ISupplierStatusChanger; + fullSnapshotBuilder: ISupplierFullSnapshotBuilder; + transactionManager: ITransactionManager; + action: SupplierStatusAction; +}) { + return new ChangeSupplierStatusUseCase(deps); +} + export function buildGetSupplierByIdUseCase(deps: { finder: ISupplierFinder; fullSnapshotBuilder: ISupplierFullSnapshotBuilder; diff --git a/modules/suppliers/src/api/application/services/index.ts b/modules/suppliers/src/api/application/services/index.ts index 03a37ca3..2936c057 100644 --- a/modules/suppliers/src/api/application/services/index.ts +++ b/modules/suppliers/src/api/application/services/index.ts @@ -1,3 +1,4 @@ export * from "./supplier-creator.service"; export * from "./supplier-finder.service"; +export * from "./supplier-status-changer.service"; export * from "./supplier-updater.service"; diff --git a/modules/suppliers/src/api/application/services/supplier-audit.services.test.ts b/modules/suppliers/src/api/application/services/supplier-audit.services.test.ts index 37511442..21c4c7ca 100644 --- a/modules/suppliers/src/api/application/services/supplier-audit.services.test.ts +++ b/modules/suppliers/src/api/application/services/supplier-audit.services.test.ts @@ -22,6 +22,7 @@ import { type ISupplierCreateProps, Supplier, SupplierStatus } from "../../domai import type { ISupplierRepository } from "../repositories"; import { SupplierCreator } from "./supplier-creator.service"; +import { SupplierStatusChanger } from "./supplier-status-changer.service"; import { SupplierUpdater } from "./supplier-updater.service"; const unwrap = (result: { data: T; isSuccess: boolean }): T => { @@ -29,12 +30,12 @@ const unwrap = (result: { data: T; isSuccess: boolean }): T => { return result.data; }; -const buildSupplier = () => +const buildSupplier = (status = SupplierStatus.active()) => unwrap( Supplier.create( { companyId: UniqueID.generateNewID(), - status: SupplierStatus.active(), + status, reference: Maybe.some(unwrap(Name.create("SUP-001"))), isCompany: true, name: unwrap(Name.create("Supplier A")), @@ -284,3 +285,118 @@ test("SupplierUpdater propaga el fallo obligatorio de Audit después de persisti assert.equal(counters.updates, 1); assert.equal(audit.mutations.length, 1); }); + +test("SupplierStatusChanger persiste y audita activate/deactivate, pero omite no-ops", async () => { + const counters = { creates: 0, updates: 0 }; + const audit = buildAudit(); + const changer = new SupplierStatusChanger(buildRepository(counters), audit.audit); + const inactiveSupplier = buildSupplier(SupplierStatus.inactive()); + + const activateResult = await changer.changeStatus({ + supplier: inactiveSupplier, + action: "activate", + transaction: undefined, + }); + assert.equal(activateResult.isSuccess, true); + assert.equal(inactiveSupplier.isActive, true); + assert.equal(counters.updates, 1); + assert.deepEqual(audit.mutations[0], { + action: "supplier.activated", + target: { + type: "supplier", + id: inactiveSupplier.id.toPrimitive(), + label: "Supplier A", + }, + before: { + status: "inactive", + reference: "SUP-001", + isCompany: true, + name: "Supplier A", + tradeName: "Supplier Trading", + tin: "B12345678", + address: { + street: "Main street 1", + street2: null, + city: "Madrid", + province: "Madrid", + postalCode: "28001", + country: "ES", + }, + emailPrimary: "supplier@example.test", + emailSecondary: null, + phonePrimary: null, + phoneSecondary: null, + mobilePrimary: null, + mobileSecondary: null, + fax: null, + website: "https://supplier.example.test", + languageCode: "es", + currencyCode: "EUR", + }, + after: { + status: "active", + reference: "SUP-001", + isCompany: true, + name: "Supplier A", + tradeName: "Supplier Trading", + tin: "B12345678", + address: { + street: "Main street 1", + street2: null, + city: "Madrid", + province: "Madrid", + postalCode: "28001", + country: "ES", + }, + emailPrimary: "supplier@example.test", + emailSecondary: null, + phonePrimary: null, + phoneSecondary: null, + mobilePrimary: null, + mobileSecondary: null, + fax: null, + website: "https://supplier.example.test", + languageCode: "es", + currencyCode: "EUR", + }, + }); + + const repeatedActivateResult = await changer.changeStatus({ + supplier: inactiveSupplier, + action: "activate", + transaction: undefined, + }); + assert.equal(repeatedActivateResult.isSuccess, true); + assert.equal(counters.updates, 1); + assert.equal(audit.mutations.length, 1); + + const deactivateResult = await changer.changeStatus({ + supplier: inactiveSupplier, + action: "deactivate", + transaction: undefined, + }); + assert.equal(deactivateResult.isSuccess, true); + assert.equal(inactiveSupplier.isActive, false); + assert.equal(counters.updates, 2); + assert.equal(audit.mutations[1]?.action, "supplier.deactivated"); + assert.equal((audit.mutations[1]?.before as { status: string }).status, "active"); + assert.equal((audit.mutations[1]?.after as { status: string }).status, "inactive"); +}); + +test("SupplierStatusChanger propaga el fallo obligatorio de Audit", async () => { + const counters = { creates: 0, updates: 0 }; + const changer = new SupplierStatusChanger( + buildRepository(counters), + buildAudit(new Error("audit unavailable")).audit + ); + + const result = await changer.changeStatus({ + supplier: buildSupplier(), + action: "deactivate", + transaction: undefined, + }); + + assert.equal(result.isFailure, true); + assert.equal(result.error.message, "audit unavailable"); + assert.equal(counters.updates, 1); +}); diff --git a/modules/suppliers/src/api/application/services/supplier-status-changer.service.ts b/modules/suppliers/src/api/application/services/supplier-status-changer.service.ts new file mode 100644 index 00000000..f206dacd --- /dev/null +++ b/modules/suppliers/src/api/application/services/supplier-status-changer.service.ts @@ -0,0 +1,61 @@ +import type { IAudit } from "@repo/rdx-audit"; +import { Result } from "@repo/rdx-utils"; + +import type { Supplier } from "../../domain"; +import { SUPPLIER_AUDIT_ACTIONS, toSupplierAuditState, toSupplierAuditTarget } from "../audit"; +import type { ISupplierRepository } from "../repositories"; + +export type SupplierStatusAction = "activate" | "deactivate"; + +export interface ISupplierStatusChanger { + changeStatus(params: { + supplier: Supplier; + action: SupplierStatusAction; + transaction: unknown; + }): Promise>; +} + +export class SupplierStatusChanger implements ISupplierStatusChanger { + public constructor( + private readonly repository: ISupplierRepository, + private readonly audit: IAudit + ) {} + + public async changeStatus(params: { + supplier: Supplier; + action: SupplierStatusAction; + transaction: unknown; + }): Promise> { + const before = toSupplierAuditState(params.supplier); + const statusResult = + params.action === "activate" ? params.supplier.activate() : params.supplier.deactivate(); + + if (statusResult.isFailure) { + return Result.fail(statusResult.error); + } + + if (!statusResult.data) { + return Result.ok(params.supplier); + } + + const persistResult = await this.repository.update(params.supplier, params.transaction); + if (persistResult.isFailure) { + return Result.fail(persistResult.error); + } + + const auditResult = await this.audit.recordMutation({ + action: + params.action === "activate" + ? SUPPLIER_AUDIT_ACTIONS.ACTIVATED + : SUPPLIER_AUDIT_ACTIONS.DEACTIVATED, + target: toSupplierAuditTarget(params.supplier), + before, + after: toSupplierAuditState(params.supplier), + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + + return Result.ok(params.supplier); + } +} diff --git a/modules/suppliers/src/api/application/use-cases/change-supplier-status.use-case.ts b/modules/suppliers/src/api/application/use-cases/change-supplier-status.use-case.ts new file mode 100644 index 00000000..c591c58f --- /dev/null +++ b/modules/suppliers/src/api/application/use-cases/change-supplier-status.use-case.ts @@ -0,0 +1,51 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { ISupplierFinder, ISupplierStatusChanger, SupplierStatusAction } from "../services"; +import type { ISupplierFullSnapshotBuilder } from "../snapshot-builders"; + +type ChangeSupplierStatusUseCaseDeps = { + finder: ISupplierFinder; + statusChanger: ISupplierStatusChanger; + fullSnapshotBuilder: ISupplierFullSnapshotBuilder; + transactionManager: ITransactionManager; + action: SupplierStatusAction; +}; + +export class ChangeSupplierStatusUseCase { + public constructor(private readonly deps: ChangeSupplierStatusUseCaseDeps) {} + + public execute(params: { companyId: UniqueID; supplierId: string }) { + const supplierIdResult = UniqueID.create(params.supplierId); + if (supplierIdResult.isFailure) { + return Result.fail(supplierIdResult.error); + } + + return this.deps.transactionManager.complete(async (transaction: unknown) => { + try { + const supplierResult = await this.deps.finder.findSupplierById( + params.companyId, + supplierIdResult.data, + transaction + ); + if (supplierResult.isFailure) { + return Result.fail(supplierResult.error); + } + + const statusResult = await this.deps.statusChanger.changeStatus({ + supplier: supplierResult.data, + action: this.deps.action, + transaction, + }); + if (statusResult.isFailure) { + return Result.fail(statusResult.error); + } + + return Result.ok(this.deps.fullSnapshotBuilder.toOutput(statusResult.data)); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/suppliers/src/api/application/use-cases/index.ts b/modules/suppliers/src/api/application/use-cases/index.ts index 3b720f2a..c0aa9b28 100644 --- a/modules/suppliers/src/api/application/use-cases/index.ts +++ b/modules/suppliers/src/api/application/use-cases/index.ts @@ -1,3 +1,4 @@ +export * from "./change-supplier-status.use-case"; export * from "./create-supplier.command"; export * from "./create-supplier.use-case"; export * from "./get-supplier.use-case"; diff --git a/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts index afa9ccdc..92a8b7ea 100644 --- a/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts +++ b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts @@ -1,13 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - CurrencyCode, - LanguageCode, - Name, - Street, - UniqueID, -} from "@repo/rdx-ddd"; +import { CurrencyCode, LanguageCode, Name, Street, UniqueID } from "@repo/rdx-ddd"; import { Maybe } from "@repo/rdx-utils"; import { Supplier } from "./supplier.aggregate"; @@ -61,3 +55,51 @@ test("crea Supplier activo por defecto y actualiza su dirección", () => { assert.equal(updateResult.isSuccess, true); assert.equal(supplierResult.data.address.street.unwrap().toPrimitive(), "Calle de prueba"); }); + +test("activa y desactiva Supplier sin persistir transiciones repetidas", () => { + const supplierResult = Supplier.create({ + companyId: UniqueID.generateNewID(), + reference: Maybe.none(), + isCompany: true, + name: Name.create("Proveedor de estado").data, + tradeName: Maybe.none(), + tin: Maybe.none(), + address: { + street: Maybe.none(), + street2: Maybe.none(), + city: Maybe.none(), + postalCode: Maybe.none(), + province: Maybe.none(), + country: Maybe.none(), + }, + emailPrimary: Maybe.none(), + emailSecondary: Maybe.none(), + phonePrimary: Maybe.none(), + phoneSecondary: Maybe.none(), + mobilePrimary: Maybe.none(), + mobileSecondary: Maybe.none(), + fax: Maybe.none(), + website: Maybe.none(), + languageCode: LanguageCode.create("es").data, + currencyCode: CurrencyCode.create("EUR").data, + }); + assert.equal(supplierResult.isSuccess, true); + + const alreadyActive = supplierResult.data.activate(); + assert.equal(alreadyActive.isSuccess, true); + assert.equal(alreadyActive.data, false); + + const deactivateResult = supplierResult.data.deactivate(); + assert.equal(deactivateResult.isSuccess, true); + assert.equal(deactivateResult.data, true); + assert.equal(supplierResult.data.isActive, false); + + const alreadyInactive = supplierResult.data.deactivate(); + assert.equal(alreadyInactive.isSuccess, true); + assert.equal(alreadyInactive.data, false); + + const activateResult = supplierResult.data.activate(); + assert.equal(activateResult.isSuccess, true); + assert.equal(activateResult.data, true); + assert.equal(supplierResult.data.isActive, true); +}); diff --git a/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts index 6fe6adca..f5448cf3 100644 --- a/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts +++ b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts @@ -271,14 +271,22 @@ export class Supplier extends AggregateRoot implements IS ); } - public activate(): Result { + public activate(): Result { + if (this.props.status.isActive()) { + return Result.ok(false); + } + this.props.status = SupplierStatus.active(); - return Result.ok(this); + return Result.ok(true); } - public deactivate(): Result { + public deactivate(): Result { + if (this.props.status.isInactive()) { + return Result.ok(false); + } + this.props.status = SupplierStatus.inactive(); - return Result.ok(this); + return Result.ok(true); } // ------------------------- diff --git a/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts b/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts index 87709290..fa4b6c99 100644 --- a/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts +++ b/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts @@ -1,10 +1,12 @@ import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; import { + type ChangeSupplierStatusUseCase, type CreateSupplierUseCase, type GetSupplierByIdUseCase, type ListSuppliersUseCase, type UpdateSupplierUseCase, + buildChangeSupplierStatusUseCase, buildCreateSupplierUseCase, buildGetSupplierByIdUseCase, buildListSuppliersUseCase, @@ -12,6 +14,7 @@ import { buildSupplierFinder, buildSupplierInputMappers, buildSupplierSnapshotBuilders, + buildSupplierStatusChanger, buildSupplierUpdater, buildUpdateSupplierUseCase, } from "../../application"; @@ -25,6 +28,8 @@ export type SuppliersInternalDeps = { getSupplierById: () => GetSupplierByIdUseCase; createSupplier: () => CreateSupplierUseCase; updateSupplier: () => UpdateSupplierUseCase; + activateSupplier: () => ChangeSupplierStatusUseCase; + deactivateSupplier: () => ChangeSupplierStatusUseCase; }; }; @@ -42,6 +47,7 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter const finder = buildSupplierFinder({ repository }); const creator = buildSupplierCreator({ repository, audit }); const updater = buildSupplierUpdater({ repository, audit }); + const statusChanger = buildSupplierStatusChanger({ repository, audit }); const snapshotBuilders = buildSupplierSnapshotBuilders(); @@ -78,6 +84,24 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter fullSnapshotBuilder: snapshotBuilders.full, transactionManager, }), + + activateSupplier: () => + buildChangeSupplierStatusUseCase({ + finder, + statusChanger, + fullSnapshotBuilder: snapshotBuilders.full, + transactionManager, + action: "activate", + }), + + deactivateSupplier: () => + buildChangeSupplierStatusUseCase({ + finder, + statusChanger, + fullSnapshotBuilder: snapshotBuilders.full, + transactionManager, + action: "deactivate", + }), }, }; } diff --git a/modules/suppliers/src/api/infrastructure/express/controllers/change-supplier-status.controller.ts b/modules/suppliers/src/api/infrastructure/express/controllers/change-supplier-status.controller.ts new file mode 100644 index 00000000..2200e845 --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/express/controllers/change-supplier-status.controller.ts @@ -0,0 +1,31 @@ +import { + ExpressController, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { ChangeSupplierStatusUseCase } from "../../../application"; +import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; + +export class ChangeSupplierStatusController extends ExpressController { + public constructor(private readonly useCase: ChangeSupplierStatusUseCase) { + super(); + this.errorMapper = suppliersApiErrorMapper; + this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard()); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + const { supplier_id: supplierId } = this.req.params; + if (!(companyId && supplierId)) { + return this.invalidInputError("Supplier ID missing"); + } + + const result = await this.useCase.execute({ companyId, supplierId }); + + return result.match( + (data) => this.ok(data), + (error) => this.handleError(error) + ); + } +} diff --git a/modules/suppliers/src/api/infrastructure/express/controllers/index.ts b/modules/suppliers/src/api/infrastructure/express/controllers/index.ts index 83ef6001..c6625ffa 100644 --- a/modules/suppliers/src/api/infrastructure/express/controllers/index.ts +++ b/modules/suppliers/src/api/infrastructure/express/controllers/index.ts @@ -1,3 +1,4 @@ +export * from "./change-supplier-status.controller"; export * from "./create-supplier.controller"; export * from "./get-supplier.controller"; export * from "./list-suppliers.controller"; diff --git a/modules/suppliers/src/api/infrastructure/express/suppliers.routes.test.ts b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.test.ts index 66b81995..1e5fddc9 100644 --- a/modules/suppliers/src/api/infrastructure/express/suppliers.routes.test.ts +++ b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.test.ts @@ -3,6 +3,8 @@ import test from "node:test"; import type { StartParams } from "@erp/core/api"; +import { UpdateSupplierByIdRequestSchema } from "../../../common"; + import { suppliersRouter } from "./suppliers.routes"; type ExpressRouteLayer = { @@ -62,4 +64,17 @@ test("registra PATCH como única operación de actualización", () => { ), true ); + assert.equal( + registeredRouter.stack.some( + (layer) => layer.route?.path === "/:supplier_id/activate" && layer.route.methods.post + ), + true + ); + assert.equal( + registeredRouter.stack.some( + (layer) => layer.route?.path === "/:supplier_id/deactivate" && layer.route.methods.post + ), + true + ); + assert.equal(UpdateSupplierByIdRequestSchema.safeParse({ status: "inactive" }).success, false); }); diff --git a/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts index 74ed0c83..25a31987 100644 --- a/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts +++ b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts @@ -3,6 +3,7 @@ import { requireIdentityTenant } from "@erp/identity/api"; import { type NextFunction, type Request, type Response, Router } from "express"; import { + ChangeSupplierStatusParamsRequestSchema, CreateSupplierRequestSchema, GetSupplierByIdRequestSchema, SupplierListRequestSchema, @@ -12,6 +13,7 @@ import { import type { SuppliersInternalDeps } from "../di"; import { + ChangeSupplierStatusController, CreateSupplierController, GetSupplierController, ListSuppliersController, @@ -83,5 +85,23 @@ export const suppliersRouter = (params: StartParams) => { return controller.execute(req, res, next); } ); + + router.post( + "/:supplier_id/activate", + validateRequest(ChangeSupplierStatusParamsRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new ChangeSupplierStatusController(deps.useCases.activateSupplier()); + return controller.execute(req, res, next); + } + ); + + router.post( + "/:supplier_id/deactivate", + validateRequest(ChangeSupplierStatusParamsRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new ChangeSupplierStatusController(deps.useCases.deactivateSupplier()); + return controller.execute(req, res, next); + } + ); app.use(`${config.server.apiBasePath}/suppliers`, router); }; diff --git a/modules/suppliers/src/common/dto/request/change-supplier-status.request.dto.ts b/modules/suppliers/src/common/dto/request/change-supplier-status.request.dto.ts new file mode 100644 index 00000000..d27944a7 --- /dev/null +++ b/modules/suppliers/src/common/dto/request/change-supplier-status.request.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const ChangeSupplierStatusParamsRequestSchema = z.object({ + supplier_id: z.uuid(), +}); + +export type ChangeSupplierStatusParamsRequestDTO = z.infer< + typeof ChangeSupplierStatusParamsRequestSchema +>; diff --git a/modules/suppliers/src/common/dto/request/index.ts b/modules/suppliers/src/common/dto/request/index.ts index 01c4180f..25b28c3f 100644 --- a/modules/suppliers/src/common/dto/request/index.ts +++ b/modules/suppliers/src/common/dto/request/index.ts @@ -1,3 +1,4 @@ +export * from "./change-supplier-status.request.dto"; export * from "./create-supplier.request.dto"; export * from "./get-supplier-by-id.request.dto"; export * from "./supplier-list.request.dto"; diff --git a/modules/suppliers/src/common/dto/request/update-supplier-by-id.request.dto.ts b/modules/suppliers/src/common/dto/request/update-supplier-by-id.request.dto.ts index f67cc62e..a961b3fa 100644 --- a/modules/suppliers/src/common/dto/request/update-supplier-by-id.request.dto.ts +++ b/modules/suppliers/src/common/dto/request/update-supplier-by-id.request.dto.ts @@ -4,33 +4,35 @@ export const UpdateSupplierByIdParamsRequestSchema = z.object({ supplier_id: z.uuid(), }); -export const UpdateSupplierByIdRequestSchema = z.object({ - reference: z.string().optional(), +export const UpdateSupplierByIdRequestSchema = z + .object({ + reference: z.string().optional(), - is_company: z.boolean().optional(), - name: z.string().optional(), - trade_name: z.string().optional(), - tin: z.string().optional(), + is_company: z.boolean().optional(), + name: z.string().optional(), + trade_name: z.string().optional(), + tin: z.string().optional(), - street: z.string().optional(), - street2: z.string().optional(), - city: z.string().optional(), - province: z.string().optional(), - postal_code: z.string().optional(), - country: z.string().optional(), + street: z.string().optional(), + street2: z.string().optional(), + city: z.string().optional(), + province: z.string().optional(), + postal_code: z.string().optional(), + country: z.string().optional(), - email_primary: z.string().optional(), - email_secondary: z.string().optional(), - phone_primary: z.string().optional(), - phone_secondary: z.string().optional(), - mobile_primary: z.string().optional(), - mobile_secondary: z.string().optional(), + email_primary: z.string().optional(), + email_secondary: z.string().optional(), + phone_primary: z.string().optional(), + phone_secondary: z.string().optional(), + mobile_primary: z.string().optional(), + mobile_secondary: z.string().optional(), - fax: z.string().optional(), - website: z.string().optional(), + fax: z.string().optional(), + website: z.string().optional(), - language_code: z.string().optional(), - currency_code: z.string().optional(), -}); + language_code: z.string().optional(), + currency_code: z.string().optional(), + }) + .strict(); export type UpdateSupplierByIdRequestDTO = Partial>;