From e23a28cb8be6eaa14697db299e093142955aa1e2 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 25 Aug 2026 10:37:12 +0200 Subject: [PATCH] feat(audit): integrate company audit functionality and related tests --- .../http/company-audit.integration.test.ts | 247 +++++++++++++++ modules/companies/package.json | 3 +- .../companies/audit/company-audit.ts | 54 ++++ .../services/company-audit.services.test.ts | 294 ++++++++++++++++++ .../companies/services/company-creator.ts | 21 +- .../company-payment-term-resolver.test.ts | 15 +- .../services/company-status-changer.ts | 27 +- .../companies/services/company-updater.ts | 28 +- .../src/api/infrastructure/di/companies.di.ts | 13 +- .../express/companies.routes.ts | 3 +- pnpm-lock.yaml | 3 + 11 files changed, 691 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/testing/http/company-audit.integration.test.ts create mode 100644 modules/companies/src/api/application/companies/audit/company-audit.ts create mode 100644 modules/companies/src/api/application/companies/services/company-audit.services.test.ts diff --git a/apps/server/src/testing/http/company-audit.integration.test.ts b/apps/server/src/testing/http/company-audit.integration.test.ts new file mode 100644 index 00000000..7323fd90 --- /dev/null +++ b/apps/server/src/testing/http/company-audit.integration.test.ts @@ -0,0 +1,247 @@ +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, UtcDate } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize"; + +import { + CompanyFinder, + CompanyUpdater, +} from "../../../../../modules/companies/src/api/application"; +import { + SequelizeCompanyDomainMapper, + SequelizeCompanyRepository, +} from "../../../../../modules/companies/src/api/infrastructure/persistence"; +import { CompanyModel } from "../../../../../modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model"; + +import { + buildCompanyHeaders, + createTestAccount, + createTestCompany, + createTestMembership, + createTestPaymentTerm, + loginByApi, +} from "./http-test-fixtures"; +import { createHttpTestRuntime } from "./http-test-runtime"; + +test("companies registra create, updates, no-ops y rollback con MariaDB", async (t) => { + const runtime = await createHttpTestRuntime(); + t.after(async () => runtime.stop()); + + const account = await createTestAccount(runtime.database, { + email: `company-audit-${randomUUID()}@example.test`, + password: "Password123!", + }); + 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 createdCompanyId = randomUUID(); + const createResponse = await runtime.request("/api/v1/companies", { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "x-correlation-id": "company-create-correlation", + }, + body: JSON.stringify({ + id: createdCompanyId, + legal_name: "Created company", + slug: `created-company-${randomUUID()}`, + tin: "B12345678", + email: "created@example.test", + phone: "+34911111111", + }), + }); + assert.equal(createResponse.status, 201); + + const createdEvent = await AuditEventModel.findOne({ + where: { action: "company.created", target_id: createdCompanyId }, + }); + assert.ok(createdEvent); + assert.equal(createdEvent.scope_type, null); + assert.equal(createdEvent.scope_id, null); + assert.equal(createdEvent.actor_id, account.id); + assert.equal(createdEvent.correlation_id, "company-create-correlation"); + + const company = await createTestCompany(runtime.database, { + legalName: "Original company", + email: "old@example.test", + }); + await createTestMembership(runtime.database, { + accountId: account.id, + companyId: company.id, + }); + const paymentTerm = await createTestPaymentTerm(runtime.database, { + companyId: company.id, + name: "30 days", + }); + + const updateResponse = await runtime.request("/api/v1/companies/current", { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + "x-correlation-id": "company-update-correlation", + }, + body: JSON.stringify({ + legal_name: "Updated company", + email: "new@example.test", + default_payment_term_id: paymentTerm.id, + }), + }); + assert.equal(updateResponse.status, 200); + + const updatedEvent = await AuditEventModel.findOne({ + where: { action: "company.updated", target_id: company.id }, + order: [["occurred_at", "DESC"]], + }); + assert.ok(updatedEvent); + assert.equal(updatedEvent.target_type, "company"); + assert.equal(updatedEvent.target_label, "Updated company"); + assert.equal(updatedEvent.source_type, "application"); + assert.equal(updatedEvent.source_id, "uecko-erp"); + assert.equal(updatedEvent.origin, "http"); + assert.equal(updatedEvent.operation_id, updateResponse.headers.get("x-request-id")); + assert.equal(updatedEvent.correlation_id, "company-update-correlation"); + assert.equal(updatedEvent.actor_type, "user"); + assert.equal(updatedEvent.actor_id, account.id); + assert.equal(updatedEvent.scope_type, "company"); + assert.equal(updatedEvent.scope_id, company.id); + assert.equal(updatedEvent.target_id, company.id); + + 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: "defaultPaymentTermId", + kind: "change", + before: null, + after: paymentTerm.id, + }, + { + path: "email", + kind: "change", + before: "old@example.test", + after: "new@example.test", + }, + { + path: "legalName", + kind: "change", + before: "Original company", + after: "Updated company", + }, + ] + ); + + const updateEventsBeforeNoop = await AuditEventModel.count({ + where: { action: "company.updated", target_id: company.id }, + }); + const noopResponse = await runtime.request("/api/v1/companies/current", { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({}), + }); + assert.equal(noopResponse.status, 200); + assert.equal( + await AuditEventModel.count({ where: { action: "company.updated", target_id: company.id } }), + updateEventsBeforeNoop + ); + + const legacyUpdateResponse = await runtime.request(`/api/v1/companies/${company.id}`, { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ slug: `updated-company-${randomUUID()}` }), + }); + assert.equal(legacyUpdateResponse.status, 200); + + const otherCompany = await createTestCompany(runtime.database, { + legalName: "Other company", + }); + const eventsBeforeCrossCompany = await AuditEventModel.count(); + const crossCompanyResponse = await runtime.request(`/api/v1/companies/${otherCompany.id}`, { + method: "PATCH", + headers: { + ...buildCompanyHeaders({ accessToken, companyId: company.id }), + "content-type": "application/json", + }, + body: JSON.stringify({ legal_name: "Must not change" }), + }); + assert.equal(crossCompanyResponse.status, 403); + assert.equal((await CompanyModel.findByPk(otherCompany.id))?.legal_name, "Other company"); + assert.equal(await AuditEventModel.count(), eventsBeforeCrossCompany); + + const repository = new SequelizeCompanyRepository( + new SequelizeCompanyDomainMapper(), + runtime.database + ); + const updater = new CompanyUpdater({ + finder: new CompanyFinder(repository), + repository, + audit: runtime.audit, + }); + const transactionManager = new SequelizeTransactionManager( + runtime.database, + runtime.transactionContext + ); + const rollbackName = Name.create("Rolled back company name"); + assert.equal(rollbackName.isSuccess, true); + const changesBeforeRollback = await AuditChangeModel.count(); + + await assert.rejects( + () => + runtime.operationContext.run( + { + operationId: "company-rollback-operation", + correlationId: "company-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 updateResult = await updater.update({ + id: UniqueID.create(company.id).data, + patchProps: { + legalName: rollbackName.data, + updatedAt: UtcDate.today(), + }, + transaction, + }); + assert.equal(updateResult.isSuccess, true); + return Result.fail(new Error("forced company rollback")); + }) + ), + /forced company rollback/ + ); + + assert.equal((await CompanyModel.findByPk(company.id))?.legal_name, "Updated company"); + assert.equal( + await AuditEventModel.count({ where: { operation_id: "company-rollback-operation" } }), + 0 + ); + assert.equal(await AuditChangeModel.count(), changesBeforeRollback); +}); diff --git a/modules/companies/package.json b/modules/companies/package.json index 2f36d07c..6dd22b56 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -36,6 +36,7 @@ "@erp/identity": "workspace:*", "@hookform/resolvers": "^5.2.2", "@repo/i18next": "workspace:*", + "@repo/rdx-audit": "workspace:*", "@repo/rdx-ddd": "workspace:*", "@repo/rdx-ui": "workspace:*", "@repo/rdx-utils": "workspace:*", @@ -49,4 +50,4 @@ "sequelize": "^6.37.8", "zod": "^4.3.6" } -} \ No newline at end of file +} diff --git a/modules/companies/src/api/application/companies/audit/company-audit.ts b/modules/companies/src/api/application/companies/audit/company-audit.ts new file mode 100644 index 00000000..193eae10 --- /dev/null +++ b/modules/companies/src/api/application/companies/audit/company-audit.ts @@ -0,0 +1,54 @@ +import type { AuditMutationTarget, AuditState } from "@repo/rdx-audit"; + +import type { Company } from "../../../domain"; + +export const COMPANY_AUDIT_ACTIONS = { + CREATED: "company.created", + UPDATED: "company.updated", + ENABLED: "company.enabled", + DISABLED: "company.disabled", +} as const; + +type CompanyAuditState = Readonly<{ + legalName: string; + tradeName: string | null; + tin: string | null; + slug: string; + email: string | null; + phone: string | null; + website: string | null; + defaultPaymentTermId: string | null; + status: "active" | "disabled"; +}>; + +const toNullablePrimitive = (value: { + match: (some: (item: T) => R, none: () => R) => R; +}): string | null => + value.match( + (item) => item.toPrimitive(), + () => null + ); + +/** Proyeccion historica estable del agregado, sin timestamps ni infraestructura. */ +export const toCompanyAuditState = (company: Company): CompanyAuditState => { + const status: CompanyAuditState["status"] = company.status.isActive() ? "active" : "disabled"; + + return { + legalName: company.legalName.toPrimitive(), + tradeName: toNullablePrimitive(company.tradeName), + tin: toNullablePrimitive(company.tin), + slug: company.slug.toPrimitive(), + email: toNullablePrimitive(company.email), + phone: toNullablePrimitive(company.phone), + website: toNullablePrimitive(company.website), + defaultPaymentTermId: toNullablePrimitive(company.defaultPaymentTermId), + status, + } satisfies AuditState; +}; + +/** Conserva el nombre legal visible del recurso en el momento de la mutacion. */ +export const toCompanyAuditTarget = (company: Company): AuditMutationTarget => ({ + type: "company", + id: company.id.toPrimitive(), + label: company.legalName.toPrimitive(), +}); diff --git a/modules/companies/src/api/application/companies/services/company-audit.services.test.ts b/modules/companies/src/api/application/companies/services/company-audit.services.test.ts new file mode 100644 index 00000000..d7f2788a --- /dev/null +++ b/modules/companies/src/api/application/companies/services/company-audit.services.test.ts @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { IAudit, RecordAuditMutationShortcutInput } from "@repo/rdx-audit"; +import { + EmailAddress, + Name, + PhoneNumber, + TextValue, + URLAddress, + UniqueID, + UtcDate, +} from "@repo/rdx-ddd"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import { Company, CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain"; +import type { ICompanyRepository } from "../contracts"; + +import { CompanyCreator } from "./company-creator"; +import { CompanyFinder } from "./company-finder"; +import { CompanyStatusChanger } from "./company-status-changer"; +import { CompanyUpdater } from "./company-updater"; + +const buildCompany = (status = CompanyStatus.createActive()) => { + const now = UtcDate.createFromISO("2026-08-16"); + const legalName = Name.create("Rodax Labs"); + const slug = CompanySlug.create("rodax-labs"); + const tin = TextValue.create("B12345678"); + const email = EmailAddress.create("hello@rodax.test"); + const phone = PhoneNumber.create("+34911111111"); + const website = URLAddress.create("https://rodax.test"); + + assert.equal(now.isSuccess, true); + assert.equal(legalName.isSuccess, true); + assert.equal(slug.isSuccess, true); + assert.equal(tin.isSuccess, true); + assert.equal(email.isSuccess, true); + assert.equal(phone.isSuccess, true); + assert.equal(website.isSuccess, true); + + const companyResult = Company.create( + { + legalName: legalName.data, + tradeName: Maybe.none(), + tin: Maybe.some(tin.data), + slug: slug.data, + email: Maybe.some(email.data), + phone: Maybe.some(phone.data), + website: Maybe.some(website.data), + defaultPaymentTermId: Maybe.none(), + status, + createdAt: now.data, + updatedAt: now.data, + }, + UniqueID.generateNewID() + ); + assert.equal(companyResult.isSuccess, true); + return companyResult.data; +}; + +const toCreateProps = (company: Company): ICompanyCreateProps => ({ + legalName: company.legalName, + tradeName: company.tradeName, + tin: company.tin, + slug: company.slug, + email: company.email, + phone: company.phone, + website: company.website, + defaultPaymentTermId: company.defaultPaymentTermId, + status: company.status, + createdAt: company.createdAt, + updatedAt: company.updatedAt, +}); + +const buildRepository = (company: Company, counters: { saves: number; updates: number }) => + ({ + save: async () => { + counters.saves += 1; + return Result.ok(); + }, + update: async () => { + counters.updates += 1; + return Result.ok(); + }, + findById: async () => Result.ok(Maybe.some(company)), + findBySlug: async () => Result.ok(Maybe.none()), + existsActiveById: async () => Result.ok(true), + findActiveByIds: async () => Result.ok(new Collection([])), + }) satisfies ICompanyRepository; + +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("CompanyCreator registra el estado completo y propaga un fallo de audit", async () => { + const company = buildCompany(); + const counters = { saves: 0, updates: 0 }; + const repository = buildRepository(company, counters); + const successfulAudit = buildAudit(); + const creator = new CompanyCreator(repository, successfulAudit.audit); + + const result = await creator.create({ id: company.id, props: toCreateProps(company) }); + + assert.equal(result.isSuccess, true); + assert.equal(counters.saves, 1); + assert.equal(successfulAudit.mutations.length, 1); + assert.deepEqual(successfulAudit.mutations[0], { + action: "company.created", + target: { + type: "company", + id: company.id.toPrimitive(), + label: "Rodax Labs", + }, + before: {}, + after: { + legalName: "Rodax Labs", + tradeName: null, + tin: "B12345678", + slug: "rodax-labs", + email: "hello@rodax.test", + phone: "+34911111111", + website: "https://rodax.test", + defaultPaymentTermId: null, + status: "active", + }, + }); + + const failedAudit = buildAudit(new Error("audit unavailable")); + const failureResult = await new CompanyCreator(repository, failedAudit.audit).create({ + id: UniqueID.generateNewID(), + props: toCreateProps(company), + }); + assert.equal(failureResult.isFailure, true); + assert.equal(failureResult.error.message, "audit unavailable"); +}); + +test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () => { + const company = buildCompany(); + const counters = { saves: 0, updates: 0 }; + const repository = buildRepository(company, counters); + const audit = buildAudit(); + const updater = new CompanyUpdater({ + finder: new CompanyFinder(repository), + repository, + audit: audit.audit, + }); + const updatedName = Name.create("Rodax Updated"); + const updatedEmail = EmailAddress.create("updated@rodax.test"); + const defaultPaymentTermId = UniqueID.generateNewID(); + assert.equal(updatedName.isSuccess, true); + assert.equal(updatedEmail.isSuccess, true); + + const updateResult = await updater.update({ + id: company.id, + patchProps: { + legalName: updatedName.data, + email: Maybe.some(updatedEmail.data), + defaultPaymentTermId: Maybe.some(defaultPaymentTermId), + updatedAt: UtcDate.today(), + }, + }); + assert.equal(updateResult.isSuccess, true); + assert.equal(counters.updates, 1); + assert.equal(audit.mutations.length, 1); + assert.deepEqual(audit.mutations[0]?.before, { + legalName: "Rodax Labs", + tradeName: null, + tin: "B12345678", + slug: "rodax-labs", + email: "hello@rodax.test", + phone: "+34911111111", + website: "https://rodax.test", + defaultPaymentTermId: null, + status: "active", + }); + assert.deepEqual(audit.mutations[0]?.after, { + legalName: "Rodax Updated", + tradeName: null, + tin: "B12345678", + slug: "rodax-labs", + email: "updated@rodax.test", + phone: "+34911111111", + website: "https://rodax.test", + defaultPaymentTermId: defaultPaymentTermId.toPrimitive(), + status: "active", + }); + + const noopResult = await updater.update({ + id: company.id, + patchProps: { updatedAt: UtcDate.today() }, + }); + assert.equal(noopResult.isSuccess, true); + assert.equal(counters.updates, 1); + assert.equal(audit.mutations.length, 1); + + const failedAuditCompany = buildCompany(); + const failedAuditCounters = { saves: 0, updates: 0 }; + const failedAuditRepository = buildRepository(failedAuditCompany, failedAuditCounters); + const failedAudit = buildAudit(new Error("audit unavailable")); + const failedAuditUpdater = new CompanyUpdater({ + finder: new CompanyFinder(failedAuditRepository), + repository: failedAuditRepository, + audit: failedAudit.audit, + }); + const auditFailureResult = await failedAuditUpdater.update({ + id: failedAuditCompany.id, + patchProps: { + legalName: updatedName.data, + updatedAt: UtcDate.today(), + }, + }); + assert.equal(auditFailureResult.isFailure, true); + assert.equal(auditFailureResult.error.message, "audit unavailable"); + + const disabledCompany = buildCompany(CompanyStatus.createDisabled()); + const disabledCounters = { saves: 0, updates: 0 }; + const disabledRepository = buildRepository(disabledCompany, disabledCounters); + const disabledAudit = buildAudit(); + const disabledUpdater = new CompanyUpdater({ + finder: new CompanyFinder(disabledRepository), + repository: disabledRepository, + audit: disabledAudit.audit, + }); + const disabledResult = await disabledUpdater.update({ + id: disabledCompany.id, + patchProps: { + legalName: updatedName.data, + updatedAt: UtcDate.today(), + }, + }); + assert.equal(disabledResult.isFailure, true); + assert.equal(disabledCounters.updates, 0); + assert.equal(disabledAudit.mutations.length, 0); +}); + +test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () => { + const company = buildCompany(CompanyStatus.createDisabled()); + const counters = { saves: 0, updates: 0 }; + const repository = buildRepository(company, counters); + const audit = buildAudit(); + const changer = new CompanyStatusChanger(repository, audit.audit); + + const enableResult = await changer.changeStatus({ + company, + action: "enable", + updatedAt: UtcDate.today(), + }); + assert.equal(enableResult.isSuccess, true); + assert.equal(counters.updates, 1); + assert.equal(audit.mutations.length, 1); + assert.equal(audit.mutations[0]?.action, "company.enabled"); + assert.equal(audit.mutations[0]?.before.status, "disabled"); + assert.equal(audit.mutations[0]?.after.status, "active"); + + const noopResult = await changer.changeStatus({ + company, + action: "enable", + updatedAt: UtcDate.today(), + }); + assert.equal(noopResult.isSuccess, true); + assert.equal(counters.updates, 1); + assert.equal(audit.mutations.length, 1); + + const disableResult = await changer.changeStatus({ + company, + action: "disable", + updatedAt: UtcDate.today(), + }); + assert.equal(disableResult.isSuccess, true); + assert.equal(counters.updates, 2); + assert.equal(audit.mutations.length, 2); + assert.equal(audit.mutations[1]?.action, "company.disabled"); + assert.equal(audit.mutations[1]?.before.status, "active"); + assert.equal(audit.mutations[1]?.after.status, "disabled"); + + const disabledNoopResult = await changer.changeStatus({ + company, + action: "disable", + updatedAt: UtcDate.today(), + }); + assert.equal(disabledNoopResult.isSuccess, true); + assert.equal(counters.updates, 2); + assert.equal(audit.mutations.length, 2); +}); diff --git a/modules/companies/src/api/application/companies/services/company-creator.ts b/modules/companies/src/api/application/companies/services/company-creator.ts index 3e5bb827..a0015758 100644 --- a/modules/companies/src/api/application/companies/services/company-creator.ts +++ b/modules/companies/src/api/application/companies/services/company-creator.ts @@ -1,7 +1,13 @@ +import type { IAudit } from "@repo/rdx-audit"; import { Result } from "@repo/rdx-utils"; import type { Company, ICompanyCreateProps } from "../../../domain"; import { Company as CompanyAggregate, CompanySlugAlreadyExistsError } from "../../../domain"; +import { + COMPANY_AUDIT_ACTIONS, + toCompanyAuditState, + toCompanyAuditTarget, +} from "../audit/company-audit"; import type { ICompanyRepository } from "../contracts"; export interface ICompanyCreator { @@ -13,7 +19,10 @@ export interface ICompanyCreator { } export class CompanyCreator implements ICompanyCreator { - public constructor(private readonly repository: ICompanyRepository) {} + public constructor( + private readonly repository: ICompanyRepository, + private readonly audit: IAudit + ) {} public async create(params: { id: Company["id"]; @@ -43,6 +52,16 @@ export class CompanyCreator implements ICompanyCreator { return Result.fail(saveResult.error); } + const auditResult = await this.audit.recordMutation({ + action: COMPANY_AUDIT_ACTIONS.CREATED, + target: toCompanyAuditTarget(companyResult.data), + before: {}, + after: toCompanyAuditState(companyResult.data), + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(companyResult.data); } } diff --git a/modules/companies/src/api/application/companies/services/company-payment-term-resolver.test.ts b/modules/companies/src/api/application/companies/services/company-payment-term-resolver.test.ts index 383076c7..5a18d3dd 100644 --- a/modules/companies/src/api/application/companies/services/company-payment-term-resolver.test.ts +++ b/modules/companies/src/api/application/companies/services/company-payment-term-resolver.test.ts @@ -88,7 +88,10 @@ test("rechaza un payment term de otra company", async () => { }); assert.equal(result.isFailure, true); - assert.match(result.error.message, /not found/i); + assert.equal( + result.error.message, + "Default payment term is not assignable to the active company." + ); }); test("rechaza un payment term inactivo como nueva asignacion", async () => { @@ -110,7 +113,10 @@ test("rechaza un payment term inactivo como nueva asignacion", async () => { }); assert.equal(result.isFailure, true); - assert.match(result.error.message, /must be active/i); + assert.equal( + result.error.message, + "Default payment term is not assignable to the active company." + ); }); test("rechaza un id inexistente", async () => { @@ -128,5 +134,8 @@ test("rechaza un id inexistente", async () => { }); assert.equal(result.isFailure, true); - assert.match(result.error.message, /not found/i); + assert.equal( + result.error.message, + "Default payment term is not assignable to the active company." + ); }); diff --git a/modules/companies/src/api/application/companies/services/company-status-changer.ts b/modules/companies/src/api/application/companies/services/company-status-changer.ts index 3fed7507..86abc426 100644 --- a/modules/companies/src/api/application/companies/services/company-status-changer.ts +++ b/modules/companies/src/api/application/companies/services/company-status-changer.ts @@ -1,6 +1,12 @@ +import type { IAudit } from "@repo/rdx-audit"; import { Result } from "@repo/rdx-utils"; import type { Company } from "../../../domain"; +import { + COMPANY_AUDIT_ACTIONS, + toCompanyAuditState, + toCompanyAuditTarget, +} from "../audit/company-audit"; import type { ICompanyRepository } from "../contracts"; export interface ICompanyStatusChanger { @@ -13,7 +19,10 @@ export interface ICompanyStatusChanger { } export class CompanyStatusChanger implements ICompanyStatusChanger { - public constructor(private readonly repository: ICompanyRepository) {} + public constructor( + private readonly repository: ICompanyRepository, + private readonly audit: IAudit + ) {} public async changeStatus(params: { company: Company; @@ -21,6 +30,7 @@ export class CompanyStatusChanger implements ICompanyStatusChanger { updatedAt: Company["updatedAt"]; transaction?: unknown; }): Promise> { + const before = toCompanyAuditState(params.company); const statusResult = params.action === "enable" ? params.company.enable(params.updatedAt) @@ -30,11 +40,26 @@ export class CompanyStatusChanger implements ICompanyStatusChanger { return Result.fail(statusResult.error); } + if (!statusResult.data) { + return Result.ok(params.company); + } + const persistResult = await this.repository.update(params.company, params.transaction); if (persistResult.isFailure) { return Result.fail(persistResult.error); } + const auditResult = await this.audit.recordMutation({ + action: + params.action === "enable" ? COMPANY_AUDIT_ACTIONS.ENABLED : COMPANY_AUDIT_ACTIONS.DISABLED, + target: toCompanyAuditTarget(params.company), + before, + after: toCompanyAuditState(params.company), + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(params.company); } } diff --git a/modules/companies/src/api/application/companies/services/company-updater.ts b/modules/companies/src/api/application/companies/services/company-updater.ts index bfbba137..c135ecc6 100644 --- a/modules/companies/src/api/application/companies/services/company-updater.ts +++ b/modules/companies/src/api/application/companies/services/company-updater.ts @@ -1,8 +1,15 @@ +import type { IAudit } from "@repo/rdx-audit"; import { Result } from "@repo/rdx-utils"; import type { Company, CompanyPatchProps } from "../../../domain"; import { CompanySlugAlreadyExistsError } from "../../../domain"; +import { + COMPANY_AUDIT_ACTIONS, + toCompanyAuditState, + toCompanyAuditTarget, +} from "../audit/company-audit"; import type { ICompanyRepository } from "../contracts"; + import type { ICompanyFinder } from "./company-finder"; export interface ICompanyUpdater { @@ -18,6 +25,7 @@ export class CompanyUpdater implements ICompanyUpdater { private readonly deps: { finder: ICompanyFinder; repository: ICompanyRepository; + audit: IAudit; } ) {} @@ -32,6 +40,7 @@ export class CompanyUpdater implements ICompanyUpdater { } const company = companyResult.data; + const before = toCompanyAuditState(company); if (params.patchProps.slug && !company.slug.equals(params.patchProps.slug)) { const existingBySlug = await this.deps.repository.findBySlug( @@ -42,10 +51,7 @@ export class CompanyUpdater implements ICompanyUpdater { return Result.fail(existingBySlug.error); } - if ( - existingBySlug.data.isSome() && - !existingBySlug.data.unwrap().id.equals(company.id) - ) { + if (existingBySlug.data.isSome() && !existingBySlug.data.unwrap().id.equals(company.id)) { return Result.fail( new CompanySlugAlreadyExistsError( `Company slug "${params.patchProps.slug.toPrimitive()}" already exists.` @@ -59,11 +65,25 @@ export class CompanyUpdater implements ICompanyUpdater { return Result.fail(updateResult.error); } + if (!updateResult.data) { + return Result.ok(company); + } + const persistResult = await this.deps.repository.update(company, params.transaction); if (persistResult.isFailure) { return Result.fail(persistResult.error); } + const auditResult = await this.deps.audit.recordMutation({ + action: COMPANY_AUDIT_ACTIONS.UPDATED, + target: toCompanyAuditTarget(company), + before, + after: toCompanyAuditState(company), + }); + if (auditResult.isFailure) { + return Result.fail(auditResult.error); + } + return Result.ok(company); } } diff --git a/modules/companies/src/api/infrastructure/di/companies.di.ts b/modules/companies/src/api/infrastructure/di/companies.di.ts index 0de8ba46..37a1983f 100644 --- a/modules/companies/src/api/infrastructure/di/companies.di.ts +++ b/modules/companies/src/api/infrastructure/di/companies.di.ts @@ -1,11 +1,11 @@ -import { buildTransactionManager, type ModuleParams, type SetupParams } from "@erp/core/api"; +import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api"; import type { IIdentityPublicServices } from "@erp/identity/api"; import type { Sequelize } from "sequelize"; import { - CompanyPaymentTermResolver, CompanyCreator, CompanyFinder, + CompanyPaymentTermResolver, CompanyPublicFinder, CompanyPublicModelMapper, CompanyStatusChanger, @@ -22,6 +22,7 @@ import { buildCompanyPublicServices, } from "../../application"; import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence"; + import { resolveCompanyCatalogsDeps } from "./company-catalog-deps.di"; export type CompaniesInternalDeps = { @@ -40,7 +41,7 @@ export type CompaniesInternalDeps = { }; export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInternalDeps => { - const { database, getService, transactionContext } = params; + const { audit, database, getService, transactionContext } = params; const transactionManager = buildTransactionManager(database as Sequelize, transactionContext); const responseMapper = new CompanyPublicModelMapper(); const identityServices = getService("identity:general"); @@ -48,12 +49,12 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database); const finder = new CompanyFinder(repository); - const creator = new CompanyCreator(repository); + const creator = new CompanyCreator(repository, audit); const paymentTermResolver = new CompanyPaymentTermResolver({ paymentTermFinder: catalogsDeps.paymentTerm.finder, }); - const updater = new CompanyUpdater({ finder, repository }); - const statusChanger = new CompanyStatusChanger(repository); + const updater = new CompanyUpdater({ finder, repository, audit }); + const statusChanger = new CompanyStatusChanger(repository, audit); const publicFinder = new CompanyPublicFinder({ repository, mapper: responseMapper, diff --git a/modules/companies/src/api/infrastructure/express/companies.routes.ts b/modules/companies/src/api/infrastructure/express/companies.routes.ts index 540de480..3e128f77 100644 --- a/modules/companies/src/api/infrastructure/express/companies.routes.ts +++ b/modules/companies/src/api/infrastructure/express/companies.routes.ts @@ -26,12 +26,13 @@ import { } from "./controllers"; export const companiesRouter = (params: StartParams) => { - const { app, config, getInternal, getService } = params; + const { app, config, getInternal, getService, operationContext } = params; const deps = getInternal("companies"); const identityServices = getService("identity:general"); const requireCurrentCompanyContext = requireCompanyContext({ companyAccessChecker: identityServices.companyAccess, companyFinder: deps.publicFinder, + operationContext, }); const router = Router({ mergeParams: true }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 401ca420..d3192fe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -522,6 +522,9 @@ importers: '@repo/i18next': specifier: workspace:* version: link:../../packages/i18n + '@repo/rdx-audit': + specifier: workspace:* + version: link:../../packages/rdx-audit '@repo/rdx-ddd': specifier: workspace:* version: link:../../packages/rdx-ddd