From 2fba3b11082fd752bc08683b5c1639646fbab99a Mon Sep 17 00:00:00 2001 From: david Date: Tue, 25 Aug 2026 11:16:47 +0200 Subject: [PATCH] feat: integrate audit functionality for supplier creation and updates - Added @repo/rdx-audit as a dependency to the suppliers module. - Implemented audit recording in SupplierCreator and SupplierUpdater services. - Created supplier-audit module to handle audit actions and state transformations. - Updated supplier aggregate to reflect changes in update method return type. - Added integration tests for supplier audit functionality, ensuring correct audit records are created on supplier creation and updates. - Refactored dependency injection to include audit service in supplier-related use cases. --- .../http/supplier-audit.integration.test.ts | 300 ++++++++++++++++++ modules/suppliers/package.json | 4 +- .../src/api/application/audit/index.ts | 1 + .../api/application/audit/supplier-audit.ts | 81 +++++ .../api/application/di/supplier-creator.di.ts | 6 +- .../api/application/di/supplier-updater.di.ts | 6 +- .../suppliers/src/api/application/index.ts | 1 + .../services/supplier-audit.services.test.ts | 286 +++++++++++++++++ .../services/supplier-creator.service.ts | 15 + .../services/supplier-updater.service.ts | 20 ++ .../update-supplier.use-case.test.ts | 13 +- .../domain/aggregates/supplier.aggregate.ts | 122 ++++++- modules/suppliers/src/api/index.test.ts | 3 + modules/suppliers/src/api/index.ts | 2 +- .../src/api/infrastructure/di/suppliers.di.ts | 10 +- .../express/suppliers.routes.test.ts | 12 +- .../express/suppliers.routes.ts | 22 +- pnpm-lock.yaml | 6 + 18 files changed, 871 insertions(+), 39 deletions(-) create mode 100644 apps/server/src/testing/http/supplier-audit.integration.test.ts create mode 100644 modules/suppliers/src/api/application/audit/index.ts create mode 100644 modules/suppliers/src/api/application/audit/supplier-audit.ts create mode 100644 modules/suppliers/src/api/application/services/supplier-audit.services.test.ts diff --git a/apps/server/src/testing/http/supplier-audit.integration.test.ts b/apps/server/src/testing/http/supplier-audit.integration.test.ts new file mode 100644 index 00000000..859d76a1 --- /dev/null +++ b/apps/server/src/testing/http/supplier-audit.integration.test.ts @@ -0,0 +1,300 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { SequelizeTransactionManager } from "@erp/core/api"; +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize"; + +import { SupplierUpdater } from "../../../../../modules/suppliers/src/api/application/services/supplier-updater.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 registra create/update, omite no-ops y revierte Supplier + Audit en MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + + const account = await createTestAccount(runtime.database, { + email: `supplier-audit-${randomUUID()}@example.test`, + password: "Password123!", + }); + const company = await createTestCompany(runtime.database, { + legalName: "Supplier audit company", + }); + const otherCompany = await createTestCompany(runtime.database, { + legalName: "Other supplier audit 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 createResponse = await runtime.request("/api/v1/suppliers", { + method: "POST", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + "x-correlation-id": "supplier-create-correlation", + }, + body: JSON.stringify({ + id: supplierId, + reference: "SUP-001", + is_company: true, + name: "Supplier A", + trade_name: "Supplier Trading", + tin: "B12345678", + street: "Main street 1", + city: "Madrid", + province: "Madrid", + postal_code: "28001", + country: "ES", + email_primary: "supplier@example.test", + language_code: "es", + currency_code: "EUR", + }), + }); + assert.equal(createResponse.status, 201); + + const createdEvent = await AuditEventModel.findOne({ + where: { action: "supplier.created", target_id: supplierId }, + }); + assert.ok(createdEvent); + assert.equal(createdEvent.source_type, "application"); + assert.equal(createdEvent.source_id, "uecko-erp"); + assert.equal(createdEvent.origin, "http"); + assert.equal(createdEvent.operation_id, createResponse.headers.get("x-request-id")); + assert.equal(createdEvent.correlation_id, "supplier-create-correlation"); + assert.equal(createdEvent.actor_type, "user"); + assert.equal(createdEvent.actor_id, account.id); + assert.equal(createdEvent.scope_type, "company"); + assert.equal(createdEvent.scope_id, company.id); + assert.equal(createdEvent.target_type, "supplier"); + assert.equal(createdEvent.target_label, "Supplier A"); + + const updateResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + "x-correlation-id": "supplier-update-correlation", + }, + body: JSON.stringify({ + email_primary: "billing@supplier.example.test", + postal_code: "08001", + }), + }); + assert.equal(updateResponse.status, 200); + + const updatedEvent = await AuditEventModel.findOne({ + where: { action: "supplier.updated", target_id: supplierId }, + }); + assert.ok(updatedEvent); + assert.equal(updatedEvent.operation_id, updateResponse.headers.get("x-request-id")); + assert.equal(updatedEvent.correlation_id, "supplier-update-correlation"); + const updatedChanges = await AuditChangeModel.findAll({ + where: { audit_event_id: updatedEvent.id }, + order: [["position", "ASC"]], + }); + assert.deepEqual( + updatedChanges.map((change) => ({ + path: change.path, + kind: change.kind, + before: change.before_value, + after: change.after_value, + })), + [ + { + path: "address.postalCode", + kind: "change", + before: "28001", + after: "08001", + }, + { + path: "emailPrimary", + kind: "change", + before: "supplier@example.test", + after: "billing@supplier.example.test", + }, + ] + ); + + const [identicalAffected] = await SupplierModel.update( + { name: "Supplier A" }, + { + where: { id: supplierId, company_id: company.id }, + silent: true, + } + ); + assert.equal(identicalAffected, 0); + + const originalUpdate = SupplierModel.update; + let noOpUpdateCalls = 0; + (SupplierModel as unknown as { update: typeof SupplierModel.update }).update = async ( + ...params + ) => { + noOpUpdateCalls += 1; + return originalUpdate.call(SupplierModel, ...params); + }; + try { + const eventsBeforeNoop = await AuditEventModel.count({ + where: { action: "supplier.updated", target_id: supplierId }, + }); + const changesBeforeNoop = await AuditChangeModel.count(); + const emptyPatchResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({}), + }); + assert.equal(emptyPatchResponse.status, 200); + + const identicalPatchResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ + name: "Supplier A", + language_code: "es", + currency_code: "EUR", + }), + }); + assert.equal(identicalPatchResponse.status, 200); + assert.equal(noOpUpdateCalls, 0); + assert.equal( + await AuditEventModel.count({ where: { action: "supplier.updated", target_id: supplierId } }), + eventsBeforeNoop + ); + assert.equal(await AuditChangeModel.count(), changesBeforeNoop); + } finally { + (SupplierModel as unknown as { update: typeof SupplierModel.update }).update = originalUpdate; + } + + const eventsBeforeRejectedRequests = await AuditEventModel.count(); + const crossCompanyResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: otherCompany.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ name: "Forbidden supplier" }), + }); + assert.equal(crossCompanyResponse.status, 404); + + const notFoundResponse = await runtime.request(`/api/v1/suppliers/${randomUUID()}`, { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ name: "Missing supplier" }), + }); + assert.equal(notFoundResponse.status, 404); + + const invalidPatchResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ name: "" }), + }); + assert.notEqual(invalidPatchResponse.status, 200); + assert.equal(await AuditEventModel.count(), eventsBeforeRejectedRequests); + + const duplicateCreateResponse = await runtime.request("/api/v1/suppliers", { + method: "POST", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ id: supplierId, is_company: true, name: "Duplicate supplier" }), + }); + assert.notEqual(duplicateCreateResponse.status, 201); + assert.equal(await AuditEventModel.count(), eventsBeforeRejectedRequests); + + const supplierIdResult = UniqueID.create(supplierId); + const companyIdResult = UniqueID.create(company.id); + const rollbackName = Name.create("Supplier rollback"); + assert.equal(supplierIdResult.isSuccess, true); + assert.equal(companyIdResult.isSuccess, true); + assert.equal(rollbackName.isSuccess, true); + const repository = new SupplierRepository( + new SequelizeSupplierDomainMapper(), + new SequelizeSupplierSummaryMapper(), + runtime.database + ); + const updater = new SupplierUpdater({ repository, audit: runtime.audit }); + const transactionManager = new SequelizeTransactionManager( + runtime.database, + runtime.transactionContext + ); + const changesBeforeRollback = await AuditChangeModel.count(); + + await assert.rejects( + () => + runtime.operationContext.run( + { + operationId: "supplier-rollback-operation", + correlationId: "supplier-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 updateResult = await updater.update({ + supplier: supplierResult.data, + props: { name: rollbackName.data }, + transaction, + }); + assert.equal(updateResult.isSuccess, true); + return Result.fail(new Error("forced supplier rollback")); + }) + ), + /forced supplier rollback/ + ); + + const persistedSupplier = await SupplierModel.findByPk(supplierId); + assert.ok(persistedSupplier); + assert.equal(persistedSupplier.name, "Supplier A"); + assert.equal( + await AuditEventModel.count({ where: { operation_id: "supplier-rollback-operation" } }), + 0 + ); + assert.equal(await AuditChangeModel.count(), changesBeforeRollback); +}); diff --git a/modules/suppliers/package.json b/modules/suppliers/package.json index 001af2af..eabc4340 100644 --- a/modules/suppliers/package.json +++ b/modules/suppliers/package.json @@ -36,9 +36,11 @@ "dependencies": { "@erp/auth": "workspace:*", "@erp/core": "workspace:*", + "@erp/identity": "workspace:*", "@hookform/resolvers": "^5.2.2", "@repo/i18next": "workspace:*", "@repo/rdx-criteria": "workspace:*", + "@repo/rdx-audit": "workspace:*", "@repo/rdx-ddd": "workspace:*", "@repo/rdx-logger": "workspace:*", "@repo/rdx-ui": "workspace:*", @@ -55,4 +57,4 @@ "use-debounce": "^10.1.1", "zod": "^4.3.6" } -} \ No newline at end of file +} diff --git a/modules/suppliers/src/api/application/audit/index.ts b/modules/suppliers/src/api/application/audit/index.ts new file mode 100644 index 00000000..93ba02b1 --- /dev/null +++ b/modules/suppliers/src/api/application/audit/index.ts @@ -0,0 +1 @@ +export * from "./supplier-audit"; diff --git a/modules/suppliers/src/api/application/audit/supplier-audit.ts b/modules/suppliers/src/api/application/audit/supplier-audit.ts new file mode 100644 index 00000000..962dac92 --- /dev/null +++ b/modules/suppliers/src/api/application/audit/supplier-audit.ts @@ -0,0 +1,81 @@ +import type { AuditMutationTarget, AuditState } from "@repo/rdx-audit"; + +import type { Supplier } from "../../domain"; + +export const SUPPLIER_AUDIT_ACTIONS = { + CREATED: "supplier.created", + UPDATED: "supplier.updated", +} as const; + +type SupplierAuditState = Readonly<{ + status: "active" | "inactive"; + reference: string | null; + isCompany: boolean; + name: string; + tradeName: string | null; + tin: string | null; + address: Readonly<{ + street: string | null; + street2: string | null; + city: string | null; + province: string | null; + postalCode: string | null; + country: string | null; + }>; + emailPrimary: string | null; + emailSecondary: string | null; + phonePrimary: string | null; + phoneSecondary: string | null; + mobilePrimary: string | null; + mobileSecondary: string | null; + fax: string | null; + website: string | null; + languageCode: string; + currencyCode: string; +}>; + +const toNullablePrimitive = (value: { + match: (some: (item: T) => R, none: () => R) => R; +}): string | null => + value.match( + (item) => item.toPrimitive(), + () => null + ); + +/** Proyección histórica explícita del Supplier, sin infraestructura ni referencias de compras. */ +export const toSupplierAuditState = (supplier: Supplier): SupplierAuditState => { + const status: SupplierAuditState["status"] = supplier.isActive ? "active" : "inactive"; + + return { + status, + reference: toNullablePrimitive(supplier.reference), + isCompany: supplier.isCompany, + name: supplier.name.toPrimitive(), + tradeName: toNullablePrimitive(supplier.tradeName), + tin: toNullablePrimitive(supplier.tin), + address: { + street: toNullablePrimitive(supplier.address.getProps().street), + street2: toNullablePrimitive(supplier.address.getProps().street2), + city: toNullablePrimitive(supplier.address.getProps().city), + province: toNullablePrimitive(supplier.address.getProps().province), + postalCode: toNullablePrimitive(supplier.address.getProps().postalCode), + country: toNullablePrimitive(supplier.address.getProps().country), + }, + emailPrimary: toNullablePrimitive(supplier.emailPrimary), + emailSecondary: toNullablePrimitive(supplier.emailSecondary), + phonePrimary: toNullablePrimitive(supplier.phonePrimary), + phoneSecondary: toNullablePrimitive(supplier.phoneSecondary), + mobilePrimary: toNullablePrimitive(supplier.mobilePrimary), + mobileSecondary: toNullablePrimitive(supplier.mobileSecondary), + fax: toNullablePrimitive(supplier.fax), + website: toNullablePrimitive(supplier.website), + languageCode: supplier.languageCode.toPrimitive(), + currencyCode: supplier.currencyCode.toPrimitive(), + } satisfies AuditState; +}; + +export const toSupplierAuditTarget = (supplier: Supplier): AuditMutationTarget => ({ + type: "supplier", + id: supplier.id.toPrimitive(), + label: supplier.name.toPrimitive(), +}); diff --git a/modules/suppliers/src/api/application/di/supplier-creator.di.ts b/modules/suppliers/src/api/application/di/supplier-creator.di.ts index ff2a2a15..a8383839 100644 --- a/modules/suppliers/src/api/application/di/supplier-creator.di.ts +++ b/modules/suppliers/src/api/application/di/supplier-creator.di.ts @@ -1,12 +1,16 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { ISupplierRepository } from "../repositories"; import { type ISupplierCreator, SupplierCreator } from "../services"; export const buildSupplierCreator = (params: { repository: ISupplierRepository; + audit: IAudit; }): ISupplierCreator => { - const { repository } = params; + const { repository, audit } = params; return new SupplierCreator({ repository, + audit, }); }; diff --git a/modules/suppliers/src/api/application/di/supplier-updater.di.ts b/modules/suppliers/src/api/application/di/supplier-updater.di.ts index 96b1abef..5c4d08b3 100644 --- a/modules/suppliers/src/api/application/di/supplier-updater.di.ts +++ b/modules/suppliers/src/api/application/di/supplier-updater.di.ts @@ -1,12 +1,16 @@ +import type { IAudit } from "@repo/rdx-audit"; + import type { ISupplierRepository } from "../repositories"; import { type ISupplierUpdater, SupplierUpdater } from "../services"; export const buildSupplierUpdater = (params: { repository: ISupplierRepository; + audit: IAudit; }): ISupplierUpdater => { - const { repository } = params; + const { repository, audit } = params; return new SupplierUpdater({ repository, + audit, }); }; diff --git a/modules/suppliers/src/api/application/index.ts b/modules/suppliers/src/api/application/index.ts index 21fd64a4..1b6712de 100644 --- a/modules/suppliers/src/api/application/index.ts +++ b/modules/suppliers/src/api/application/index.ts @@ -1,3 +1,4 @@ +export * from "./audit"; export * from "./di"; export * from "./models"; export * from "./repositories"; 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 new file mode 100644 index 00000000..37511442 --- /dev/null +++ b/modules/suppliers/src/api/application/services/supplier-audit.services.test.ts @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { IAudit, RecordAuditMutationShortcutInput } from "@repo/rdx-audit"; +import { + City, + Country, + CurrencyCode, + EmailAddress, + LanguageCode, + Name, + PostalCode, + Province, + Street, + TINNumber, + URLAddress, + UniqueID, +} from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { type ISupplierCreateProps, Supplier, SupplierStatus } from "../../domain"; +import type { ISupplierRepository } from "../repositories"; + +import { SupplierCreator } from "./supplier-creator.service"; +import { SupplierUpdater } from "./supplier-updater.service"; + +const unwrap = (result: { data: T; isSuccess: boolean }): T => { + assert.equal(result.isSuccess, true); + return result.data; +}; + +const buildSupplier = () => + unwrap( + Supplier.create( + { + companyId: UniqueID.generateNewID(), + status: SupplierStatus.active(), + reference: Maybe.some(unwrap(Name.create("SUP-001"))), + isCompany: true, + name: unwrap(Name.create("Supplier A")), + tradeName: Maybe.some(unwrap(Name.create("Supplier Trading"))), + tin: Maybe.some(unwrap(TINNumber.create("B12345678"))), + address: { + street: Maybe.some(unwrap(Street.create("Main street 1"))), + street2: Maybe.none(), + city: Maybe.some(unwrap(City.create("Madrid"))), + province: Maybe.some(unwrap(Province.create("Madrid"))), + postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), + country: Maybe.some(unwrap(Country.create("ES"))), + }, + emailPrimary: Maybe.some(unwrap(EmailAddress.create("supplier@example.test"))), + emailSecondary: Maybe.none(), + phonePrimary: Maybe.none(), + phoneSecondary: Maybe.none(), + mobilePrimary: Maybe.none(), + mobileSecondary: Maybe.none(), + fax: Maybe.none(), + website: Maybe.some(unwrap(URLAddress.create("https://supplier.example.test"))), + languageCode: unwrap(LanguageCode.create("es")), + currencyCode: unwrap(CurrencyCode.create("EUR")), + }, + UniqueID.generateNewID() + ) + ); + +const toCreateProps = (supplier: Supplier): ISupplierCreateProps => ({ + companyId: supplier.companyId, + status: SupplierStatus.active(), + reference: supplier.reference, + isCompany: supplier.isCompany, + name: supplier.name, + tradeName: supplier.tradeName, + tin: supplier.tin, + address: supplier.address.getProps(), + emailPrimary: supplier.emailPrimary, + emailSecondary: supplier.emailSecondary, + phonePrimary: supplier.phonePrimary, + phoneSecondary: supplier.phoneSecondary, + mobilePrimary: supplier.mobilePrimary, + mobileSecondary: supplier.mobileSecondary, + fax: supplier.fax, + website: supplier.website, + languageCode: supplier.languageCode, + currencyCode: supplier.currencyCode, +}); + +const buildRepository = (counters: { creates: number; updates: number }): ISupplierRepository => ({ + create: async () => { + counters.creates += 1; + return Result.ok(); + }, + update: async () => { + counters.updates += 1; + return Result.ok(); + }, + existsByIdInCompany: async () => Result.ok(false), + getByIdInCompany: async () => Result.fail(new Error("unused")), + getByTINInCompany: async () => Result.fail(new Error("unused")), + findByCriteriaInCompany: async () => Result.fail(new Error("unused")), +}); + +const buildAudit = (failure?: Error) => { + const mutations: RecordAuditMutationShortcutInput[] = []; + const audit = { + record: async () => Result.ok(), + recordEvent: async () => Result.ok(), + recordMutation: async (input: RecordAuditMutationShortcutInput) => { + mutations.push(input); + return failure ? Result.fail(failure as never) : Result.ok(); + }, + } satisfies IAudit; + + return { audit, mutations }; +}; + +test("SupplierCreator registra supplier.created con el estado histórico completo", async () => { + const supplier = buildSupplier(); + const counters = { creates: 0, updates: 0 }; + const audit = buildAudit(); + const creator = new SupplierCreator({ + repository: buildRepository(counters), + audit: audit.audit, + }); + + const result = await creator.create({ + companyId: supplier.companyId, + id: supplier.id, + props: toCreateProps(supplier), + }); + + assert.equal(result.isSuccess, true); + assert.equal(counters.creates, 1); + assert.deepEqual(audit.mutations[0], { + action: "supplier.created", + target: { type: "supplier", id: supplier.id.toPrimitive(), label: "Supplier A" }, + before: {}, + 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", + }, + }); +}); + +test("SupplierUpdater persiste y audita sólo cambios semánticos", async () => { + const supplier = buildSupplier(); + const counters = { creates: 0, updates: 0 }; + const audit = buildAudit(); + const updater = new SupplierUpdater({ + repository: buildRepository(counters), + audit: audit.audit, + }); + const name = unwrap(Name.create("Supplier B")); + const email = unwrap(EmailAddress.create("billing@supplier.example.test")); + const postalCode = unwrap(PostalCode.create("08001")); + const languageCode = unwrap(LanguageCode.create("en")); + const currencyCode = unwrap(CurrencyCode.create("USD")); + + const updateResult = await updater.update({ + supplier, + props: { + name, + emailPrimary: Maybe.some(email), + address: { postalCode: Maybe.some(postalCode) }, + languageCode, + currencyCode, + }, + transaction: undefined, + }); + + assert.equal(updateResult.isSuccess, true); + assert.equal(counters.updates, 1); + assert.equal(audit.mutations.length, 1); + assert.equal(audit.mutations[0]?.action, "supplier.updated"); + assert.equal(audit.mutations[0]?.target.label, "Supplier B"); + assert.deepEqual(audit.mutations[0]?.before, { + 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 mutation = audit.mutations[0]; + assert.ok(mutation); + assert.deepEqual(mutation.after, { + status: "active", + reference: "SUP-001", + isCompany: true, + name: "Supplier B", + tradeName: "Supplier Trading", + tin: "B12345678", + address: { + street: "Main street 1", + street2: null, + city: "Madrid", + province: "Madrid", + postalCode: "08001", + country: "ES", + }, + emailPrimary: "billing@supplier.example.test", + emailSecondary: null, + phonePrimary: null, + phoneSecondary: null, + mobilePrimary: null, + mobileSecondary: null, + fax: null, + website: "https://supplier.example.test", + languageCode: "en", + currencyCode: "USD", + }); + + const emptyPatchResult = await updater.update({ supplier, props: {}, transaction: undefined }); + assert.equal(emptyPatchResult.isSuccess, true); + assert.equal(counters.updates, 1); + assert.equal(audit.mutations.length, 1); + + const identicalPatchResult = await updater.update({ + supplier, + props: { name, languageCode, currencyCode }, + transaction: undefined, + }); + assert.equal(identicalPatchResult.isSuccess, true); + assert.equal(counters.updates, 1); + assert.equal(audit.mutations.length, 1); +}); + +test("SupplierUpdater propaga el fallo obligatorio de Audit después de persistir", async () => { + const supplier = buildSupplier(); + const counters = { creates: 0, updates: 0 }; + const audit = buildAudit(new Error("audit unavailable")); + const updater = new SupplierUpdater({ + repository: buildRepository(counters), + audit: audit.audit, + }); + + const result = await updater.update({ + supplier, + props: { name: unwrap(Name.create("Supplier unavailable")) }, + transaction: undefined, + }); + + assert.equal(result.isFailure, true); + assert.equal(result.error.message, "audit unavailable"); + assert.equal(counters.updates, 1); + assert.equal(audit.mutations.length, 1); +}); diff --git a/modules/suppliers/src/api/application/services/supplier-creator.service.ts b/modules/suppliers/src/api/application/services/supplier-creator.service.ts index 2ea53756..7b97b8be 100644 --- a/modules/suppliers/src/api/application/services/supplier-creator.service.ts +++ b/modules/suppliers/src/api/application/services/supplier-creator.service.ts @@ -1,8 +1,10 @@ import { DuplicateEntityError } from "@erp/core/api"; +import type { IAudit } from "@repo/rdx-audit"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { type ISupplierCreateProps, Supplier } from "../../domain"; +import { SUPPLIER_AUDIT_ACTIONS, toSupplierAuditState, toSupplierAuditTarget } from "../audit"; import type { ISupplierRepository } from "../repositories"; import { SupplierNotExistsInCompanySpecification } from "../specs"; @@ -17,13 +19,16 @@ export interface ISupplierCreator { type SupplierCreatorDeps = { repository: ISupplierRepository; + audit: IAudit; }; export class SupplierCreator implements ISupplierCreator { private readonly repository: ISupplierRepository; + private readonly audit: IAudit; constructor(deps: SupplierCreatorDeps) { this.repository = deps.repository; + this.audit = deps.audit; } async create(params: { @@ -69,6 +74,16 @@ export class SupplierCreator implements ISupplierCreator { return Result.fail(saveResult.error); } + const auditResult = await this.audit.recordMutation({ + action: SUPPLIER_AUDIT_ACTIONS.CREATED, + target: toSupplierAuditTarget(newSupplier), + before: {}, + after: toSupplierAuditState(newSupplier), + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(newSupplier); } } diff --git a/modules/suppliers/src/api/application/services/supplier-updater.service.ts b/modules/suppliers/src/api/application/services/supplier-updater.service.ts index 6b8e2df1..1223ddf7 100644 --- a/modules/suppliers/src/api/application/services/supplier-updater.service.ts +++ b/modules/suppliers/src/api/application/services/supplier-updater.service.ts @@ -1,6 +1,8 @@ +import type { IAudit } from "@repo/rdx-audit"; import { Result } from "@repo/rdx-utils"; import type { Supplier, SupplierPatchProps } from "../../domain"; +import { SUPPLIER_AUDIT_ACTIONS, toSupplierAuditState, toSupplierAuditTarget } from "../audit"; import type { ISupplierRepository } from "../repositories"; export interface ISupplierUpdater { @@ -13,13 +15,16 @@ export interface ISupplierUpdater { type SupplierUpdaterDeps = { repository: ISupplierRepository; + audit: IAudit; }; export class SupplierUpdater implements ISupplierUpdater { private readonly repository: ISupplierRepository; + private readonly audit: IAudit; constructor(deps: SupplierUpdaterDeps) { this.repository = deps.repository; + this.audit = deps.audit; } async update(params: { @@ -28,6 +33,7 @@ export class SupplierUpdater implements ISupplierUpdater { transaction: unknown; }): Promise> { const { supplier, props, transaction } = params; + const before = toSupplierAuditState(supplier); // Aplicar cambios en el agregado const updateResult = supplier.update(props); @@ -36,6 +42,10 @@ export class SupplierUpdater implements ISupplierUpdater { return Result.fail(updateResult.error); } + if (!updateResult.data) { + return Result.ok(supplier); + } + // Persistir cambios const saveResult = await this.repository.update(supplier, transaction); @@ -43,6 +53,16 @@ export class SupplierUpdater implements ISupplierUpdater { return Result.fail(saveResult.error); } + const auditResult = await this.audit.recordMutation({ + action: SUPPLIER_AUDIT_ACTIONS.UPDATED, + target: toSupplierAuditTarget(supplier), + before, + after: toSupplierAuditState(supplier), + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(supplier); } } diff --git a/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts b/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts index 2d520d43..3cad1322 100644 --- a/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts +++ b/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import type { ITransactionManager } from "@erp/core/api"; import { EntityNotFoundError } from "@erp/core/api"; +import type { IAudit } from "@repo/rdx-audit"; import { CurrencyCode, EmailAddress, @@ -15,6 +16,7 @@ import { Maybe, Result } from "@repo/rdx-utils"; import { Supplier, type SupplierPatchProps, SupplierStatus } from "../../domain"; import { SupplierUpdater } from "../services"; + import { UpdateSupplierUseCase } from "./update-supplier.use-case"; const buildTransactionManager = (): ITransactionManager => ({ @@ -25,6 +27,13 @@ const buildTransactionManager = (): ITransactionManager => ({ rollback: async () => undefined, }); +const buildAudit = (): IAudit => + ({ + record: async () => Result.ok(), + recordEvent: async () => Result.ok(), + recordMutation: async () => Result.ok(), + }) as IAudit; + const buildSupplier = (companyId: UniqueID) => { const supplierId = UniqueID.generateNewID(); const name = Name.create("Proveedor original"); @@ -89,6 +98,7 @@ const buildUseCase = (params: { getByTINInCompany: async () => Result.fail(new Error("unused")), findByCriteriaInCompany: async () => Result.fail(new Error("unused")), }, + audit: buildAudit(), }); const useCase = new UpdateSupplierUseCase({ @@ -98,7 +108,8 @@ const buildUseCase = (params: { finder: { findSupplierById: async (companyId) => { finderCompanyId = companyId; - return params.supplier && companyId.toPrimitive() === params.supplier.companyId.toPrimitive() + return params.supplier && + companyId.toPrimitive() === params.supplier.companyId.toPrimitive() ? Result.ok(params.supplier) : Result.fail(new EntityNotFoundError("Supplier", "id", "missing")); }, diff --git a/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts index 72418e18..6fe6adca 100644 --- a/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts +++ b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts @@ -5,9 +5,9 @@ import { type LanguageCode, type Name, type PhoneNumber, + PostalAddress, type PostalAddressPatchProps, type PostalAddressProps, - PostalAddress, type TINNumber, type URLAddress, type UniqueID, @@ -56,7 +56,7 @@ export type SupplierPatchProps = Partial< // Supplier export interface ISupplier { // comportamiento - update(partialSupplier: SupplierPatchProps): Result; + update(partialSupplier: SupplierPatchProps): Result; // propiedades (getters) readonly isIndividual: boolean; @@ -144,12 +144,95 @@ export class Supplier extends AggregateRoot implements IS // BEHAVIOUR // ------------------------- - public update(partialSupplier: SupplierPatchProps): Result { + public update(partialSupplier: SupplierPatchProps): Result { const { address: partialAddress, ...rest } = partialSupplier; - const nextProps: SupplierInternalProps = { - ...this.props, - ...rest, - }; + const nextProps: SupplierInternalProps = { ...this.props }; + let hasChanges = false; + + if ( + rest.reference !== undefined && + !Supplier.sameMaybeValueObject(this.reference, rest.reference) + ) { + nextProps.reference = rest.reference; + hasChanges = true; + } + if (rest.isCompany !== undefined && this.isCompany !== rest.isCompany) { + nextProps.isCompany = rest.isCompany; + hasChanges = true; + } + if (rest.name !== undefined && !this.name.equals(rest.name)) { + nextProps.name = rest.name; + hasChanges = true; + } + if ( + rest.tradeName !== undefined && + !Supplier.sameMaybeValueObject(this.tradeName, rest.tradeName) + ) { + nextProps.tradeName = rest.tradeName; + hasChanges = true; + } + if (rest.tin !== undefined && !Supplier.sameMaybeValueObject(this.tin, rest.tin)) { + nextProps.tin = rest.tin; + hasChanges = true; + } + if ( + rest.emailPrimary !== undefined && + !Supplier.sameMaybeValueObject(this.emailPrimary, rest.emailPrimary) + ) { + nextProps.emailPrimary = rest.emailPrimary; + hasChanges = true; + } + if ( + rest.emailSecondary !== undefined && + !Supplier.sameMaybeValueObject(this.emailSecondary, rest.emailSecondary) + ) { + nextProps.emailSecondary = rest.emailSecondary; + hasChanges = true; + } + if ( + rest.phonePrimary !== undefined && + !Supplier.sameMaybeValueObject(this.phonePrimary, rest.phonePrimary) + ) { + nextProps.phonePrimary = rest.phonePrimary; + hasChanges = true; + } + if ( + rest.phoneSecondary !== undefined && + !Supplier.sameMaybeValueObject(this.phoneSecondary, rest.phoneSecondary) + ) { + nextProps.phoneSecondary = rest.phoneSecondary; + hasChanges = true; + } + if ( + rest.mobilePrimary !== undefined && + !Supplier.sameMaybeValueObject(this.mobilePrimary, rest.mobilePrimary) + ) { + nextProps.mobilePrimary = rest.mobilePrimary; + hasChanges = true; + } + if ( + rest.mobileSecondary !== undefined && + !Supplier.sameMaybeValueObject(this.mobileSecondary, rest.mobileSecondary) + ) { + nextProps.mobileSecondary = rest.mobileSecondary; + hasChanges = true; + } + if (rest.fax !== undefined && !Supplier.sameMaybeValueObject(this.fax, rest.fax)) { + nextProps.fax = rest.fax; + hasChanges = true; + } + if (rest.website !== undefined && !Supplier.sameMaybeValueObject(this.website, rest.website)) { + nextProps.website = rest.website; + hasChanges = true; + } + if (rest.languageCode !== undefined && !this.languageCode.equals(rest.languageCode)) { + nextProps.languageCode = rest.languageCode; + hasChanges = true; + } + if (rest.currencyCode !== undefined && !this.currencyCode.equals(rest.currencyCode)) { + nextProps.currencyCode = rest.currencyCode; + hasChanges = true; + } if (partialAddress) { const addressResult = PostalAddress.create({ @@ -161,12 +244,31 @@ export class Supplier extends AggregateRoot implements IS return Result.fail(addressResult.error); } - nextProps.address = addressResult.data; + if (!this.address.equals(addressResult.data)) { + nextProps.address = addressResult.data; + hasChanges = true; + } } - Object.assign(this.props, nextProps); + if (hasChanges) { + Object.assign(this.props, nextProps); + } - return Result.ok(this); + return Result.ok(hasChanges); + } + + private static sameMaybeValueObject( + current: Maybe, + next: Maybe + ): boolean { + return current.match( + (currentValue) => + next.match( + (nextValue) => currentValue.equals(nextValue), + () => false + ), + () => next.isNone() + ); } public activate(): Result { diff --git a/modules/suppliers/src/api/index.test.ts b/modules/suppliers/src/api/index.test.ts index 481cefdf..3642af4c 100644 --- a/modules/suppliers/src/api/index.test.ts +++ b/modules/suppliers/src/api/index.test.ts @@ -8,6 +8,9 @@ import { suppliersAPIModule } from "./index"; test("compone suppliers sin publicar servicios de infraestructura", async () => { const params: ModuleParams = { database: {}, + transactionContext: {} as never, + operationContext: {} as never, + audit: {} as never, getService: () => undefined as T, logger: { info: () => undefined, diff --git a/modules/suppliers/src/api/index.ts b/modules/suppliers/src/api/index.ts index f93255ee..f5b8b39e 100644 --- a/modules/suppliers/src/api/index.ts +++ b/modules/suppliers/src/api/index.ts @@ -6,7 +6,7 @@ import { buildSuppliersDependencies } from "./infrastructure/di"; export const suppliersAPIModule: IModuleServer = { name: "suppliers", version: "1.0.0", - dependencies: [], + dependencies: ["identity", "companies"], /** * Fase de SETUP diff --git a/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts b/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts index ca4b7e11..87709290 100644 --- a/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts +++ b/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts @@ -8,12 +8,12 @@ import { buildCreateSupplierUseCase, buildGetSupplierByIdUseCase, buildListSuppliersUseCase, - buildSupplierUpdater, - buildUpdateSupplierUseCase, buildSupplierCreator, buildSupplierFinder, buildSupplierInputMappers, buildSupplierSnapshotBuilders, + buildSupplierUpdater, + buildUpdateSupplierUseCase, } from "../../application"; import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di"; @@ -29,7 +29,7 @@ export type SuppliersInternalDeps = { }; export function buildSuppliersDependencies(params: ModuleParams): SuppliersInternalDeps { - const { database, transactionContext } = params; + const { audit, database, transactionContext } = params; // Infrastructure const transactionManager = buildTransactionManager(database, transactionContext); @@ -40,8 +40,8 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter // Application helpers const inputMappers = buildSupplierInputMappers(); const finder = buildSupplierFinder({ repository }); - const creator = buildSupplierCreator({ repository }); - const updater = buildSupplierUpdater({ repository }); + const creator = buildSupplierCreator({ repository, audit }); + const updater = buildSupplierUpdater({ repository, audit }); const snapshotBuilders = buildSupplierSnapshotBuilders(); 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 5ec2a235..66b81995 100644 --- a/modules/suppliers/src/api/infrastructure/express/suppliers.routes.test.ts +++ b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.test.ts @@ -30,7 +30,17 @@ test("registra PATCH como única operación de actualización", () => { apiBasePath: "/api", }, }, - getService: () => undefined as T, + transactionContext: {} as never, + operationContext: {} as never, + audit: {} as never, + getService: () => + ({ + auth: { + tenantRequired: () => [(_req: unknown, _res: unknown, next: () => void) => next()], + }, + companyAccess: {}, + finder: {}, + }) as T, getInternal: () => ({ useCases: {} }) as T, }; diff --git a/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts index 18ff6ccc..74ed0c83 100644 --- a/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts +++ b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts @@ -1,5 +1,5 @@ -import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api"; -import { type RequestWithAuth, type StartParams, validateRequest } from "@erp/core/api"; +import { type StartParams, validateRequest } from "@erp/core/api"; +import { requireIdentityTenant } from "@erp/identity/api"; import { type NextFunction, type Request, type Response, Router } from "express"; import { @@ -30,22 +30,8 @@ export const suppliersRouter = (params: StartParams) => { // ---------------------------------------------- - // 🔐 Autenticación + Tenancy para TODO el router - if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") { - router.use( - (req: Request, res: Response, next: NextFunction) => - mockUser(req as RequestWithAuth, res, next) // Debe ir antes de las rutas protegidas - ); - } - - //router.use(/*authenticateJWT(),*/ enforceTenant() /*checkTabContext*/); - router.use([ - (req: Request, res: Response, next: NextFunction) => - requireAuthenticated()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas - - (req: Request, res: Response, next: NextFunction) => - requireCompanyContext()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas - ]); + // Mantiene el mismo pipeline de identidad y tenant que los módulos HTTP actuales. + router.use(...requireIdentityTenant(params)); // ---------------------------------------------- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3192fe3..cfad68be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1136,12 +1136,18 @@ importers: '@erp/core': specifier: workspace:* version: link:../core + '@erp/identity': + specifier: workspace:* + version: link:../identity '@hookform/resolvers': specifier: ^5.2.2 version: 5.4.0(react-hook-form@7.76.1(react@19.2.6)) '@repo/i18next': specifier: workspace:* version: link:../../packages/i18n + '@repo/rdx-audit': + specifier: workspace:* + version: link:../../packages/rdx-audit '@repo/rdx-criteria': specifier: workspace:* version: link:../../packages/rdx-criteria