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.
This commit is contained in:
David Arranz 2026-08-25 11:16:47 +02:00
parent e23a28cb8b
commit 2fba3b1108
18 changed files with 871 additions and 39 deletions

View File

@ -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);
});

View File

@ -36,9 +36,11 @@
"dependencies": { "dependencies": {
"@erp/auth": "workspace:*", "@erp/auth": "workspace:*",
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@erp/identity": "workspace:*",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@repo/i18next": "workspace:*", "@repo/i18next": "workspace:*",
"@repo/rdx-criteria": "workspace:*", "@repo/rdx-criteria": "workspace:*",
"@repo/rdx-audit": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-logger": "workspace:*", "@repo/rdx-logger": "workspace:*",
"@repo/rdx-ui": "workspace:*", "@repo/rdx-ui": "workspace:*",
@ -55,4 +57,4 @@
"use-debounce": "^10.1.1", "use-debounce": "^10.1.1",
"zod": "^4.3.6" "zod": "^4.3.6"
} }
} }

View File

@ -0,0 +1 @@
export * from "./supplier-audit";

View File

@ -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 = <T extends { toPrimitive(): string }>(value: {
match: <R>(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(),
});

View File

@ -1,12 +1,16 @@
import type { IAudit } from "@repo/rdx-audit";
import type { ISupplierRepository } from "../repositories"; import type { ISupplierRepository } from "../repositories";
import { type ISupplierCreator, SupplierCreator } from "../services"; import { type ISupplierCreator, SupplierCreator } from "../services";
export const buildSupplierCreator = (params: { export const buildSupplierCreator = (params: {
repository: ISupplierRepository; repository: ISupplierRepository;
audit: IAudit;
}): ISupplierCreator => { }): ISupplierCreator => {
const { repository } = params; const { repository, audit } = params;
return new SupplierCreator({ return new SupplierCreator({
repository, repository,
audit,
}); });
}; };

View File

@ -1,12 +1,16 @@
import type { IAudit } from "@repo/rdx-audit";
import type { ISupplierRepository } from "../repositories"; import type { ISupplierRepository } from "../repositories";
import { type ISupplierUpdater, SupplierUpdater } from "../services"; import { type ISupplierUpdater, SupplierUpdater } from "../services";
export const buildSupplierUpdater = (params: { export const buildSupplierUpdater = (params: {
repository: ISupplierRepository; repository: ISupplierRepository;
audit: IAudit;
}): ISupplierUpdater => { }): ISupplierUpdater => {
const { repository } = params; const { repository, audit } = params;
return new SupplierUpdater({ return new SupplierUpdater({
repository, repository,
audit,
}); });
}; };

View File

@ -1,3 +1,4 @@
export * from "./audit";
export * from "./di"; export * from "./di";
export * from "./models"; export * from "./models";
export * from "./repositories"; export * from "./repositories";

View File

@ -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 = <T>(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);
});

View File

@ -1,8 +1,10 @@
import { DuplicateEntityError } from "@erp/core/api"; import { DuplicateEntityError } from "@erp/core/api";
import type { IAudit } from "@repo/rdx-audit";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { type ISupplierCreateProps, Supplier } from "../../domain"; import { type ISupplierCreateProps, Supplier } from "../../domain";
import { SUPPLIER_AUDIT_ACTIONS, toSupplierAuditState, toSupplierAuditTarget } from "../audit";
import type { ISupplierRepository } from "../repositories"; import type { ISupplierRepository } from "../repositories";
import { SupplierNotExistsInCompanySpecification } from "../specs"; import { SupplierNotExistsInCompanySpecification } from "../specs";
@ -17,13 +19,16 @@ export interface ISupplierCreator {
type SupplierCreatorDeps = { type SupplierCreatorDeps = {
repository: ISupplierRepository; repository: ISupplierRepository;
audit: IAudit;
}; };
export class SupplierCreator implements ISupplierCreator { export class SupplierCreator implements ISupplierCreator {
private readonly repository: ISupplierRepository; private readonly repository: ISupplierRepository;
private readonly audit: IAudit;
constructor(deps: SupplierCreatorDeps) { constructor(deps: SupplierCreatorDeps) {
this.repository = deps.repository; this.repository = deps.repository;
this.audit = deps.audit;
} }
async create(params: { async create(params: {
@ -69,6 +74,16 @@ export class SupplierCreator implements ISupplierCreator {
return Result.fail(saveResult.error); 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); return Result.ok(newSupplier);
} }
} }

View File

@ -1,6 +1,8 @@
import type { IAudit } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { Supplier, SupplierPatchProps } from "../../domain"; import type { Supplier, SupplierPatchProps } from "../../domain";
import { SUPPLIER_AUDIT_ACTIONS, toSupplierAuditState, toSupplierAuditTarget } from "../audit";
import type { ISupplierRepository } from "../repositories"; import type { ISupplierRepository } from "../repositories";
export interface ISupplierUpdater { export interface ISupplierUpdater {
@ -13,13 +15,16 @@ export interface ISupplierUpdater {
type SupplierUpdaterDeps = { type SupplierUpdaterDeps = {
repository: ISupplierRepository; repository: ISupplierRepository;
audit: IAudit;
}; };
export class SupplierUpdater implements ISupplierUpdater { export class SupplierUpdater implements ISupplierUpdater {
private readonly repository: ISupplierRepository; private readonly repository: ISupplierRepository;
private readonly audit: IAudit;
constructor(deps: SupplierUpdaterDeps) { constructor(deps: SupplierUpdaterDeps) {
this.repository = deps.repository; this.repository = deps.repository;
this.audit = deps.audit;
} }
async update(params: { async update(params: {
@ -28,6 +33,7 @@ export class SupplierUpdater implements ISupplierUpdater {
transaction: unknown; transaction: unknown;
}): Promise<Result<Supplier, Error>> { }): Promise<Result<Supplier, Error>> {
const { supplier, props, transaction } = params; const { supplier, props, transaction } = params;
const before = toSupplierAuditState(supplier);
// Aplicar cambios en el agregado // Aplicar cambios en el agregado
const updateResult = supplier.update(props); const updateResult = supplier.update(props);
@ -36,6 +42,10 @@ export class SupplierUpdater implements ISupplierUpdater {
return Result.fail(updateResult.error); return Result.fail(updateResult.error);
} }
if (!updateResult.data) {
return Result.ok(supplier);
}
// Persistir cambios // Persistir cambios
const saveResult = await this.repository.update(supplier, transaction); const saveResult = await this.repository.update(supplier, transaction);
@ -43,6 +53,16 @@ export class SupplierUpdater implements ISupplierUpdater {
return Result.fail(saveResult.error); 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); return Result.ok(supplier);
} }
} }

View File

@ -3,6 +3,7 @@ import test from "node:test";
import type { ITransactionManager } from "@erp/core/api"; import type { ITransactionManager } from "@erp/core/api";
import { EntityNotFoundError } from "@erp/core/api"; import { EntityNotFoundError } from "@erp/core/api";
import type { IAudit } from "@repo/rdx-audit";
import { import {
CurrencyCode, CurrencyCode,
EmailAddress, EmailAddress,
@ -15,6 +16,7 @@ import { Maybe, Result } from "@repo/rdx-utils";
import { Supplier, type SupplierPatchProps, SupplierStatus } from "../../domain"; import { Supplier, type SupplierPatchProps, SupplierStatus } from "../../domain";
import { SupplierUpdater } from "../services"; import { SupplierUpdater } from "../services";
import { UpdateSupplierUseCase } from "./update-supplier.use-case"; import { UpdateSupplierUseCase } from "./update-supplier.use-case";
const buildTransactionManager = (): ITransactionManager => ({ const buildTransactionManager = (): ITransactionManager => ({
@ -25,6 +27,13 @@ const buildTransactionManager = (): ITransactionManager => ({
rollback: async () => undefined, 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 buildSupplier = (companyId: UniqueID) => {
const supplierId = UniqueID.generateNewID(); const supplierId = UniqueID.generateNewID();
const name = Name.create("Proveedor original"); const name = Name.create("Proveedor original");
@ -89,6 +98,7 @@ const buildUseCase = (params: {
getByTINInCompany: async () => Result.fail(new Error("unused")), getByTINInCompany: async () => Result.fail(new Error("unused")),
findByCriteriaInCompany: async () => Result.fail(new Error("unused")), findByCriteriaInCompany: async () => Result.fail(new Error("unused")),
}, },
audit: buildAudit(),
}); });
const useCase = new UpdateSupplierUseCase({ const useCase = new UpdateSupplierUseCase({
@ -98,7 +108,8 @@ const buildUseCase = (params: {
finder: { finder: {
findSupplierById: async (companyId) => { findSupplierById: async (companyId) => {
finderCompanyId = 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.ok(params.supplier)
: Result.fail(new EntityNotFoundError("Supplier", "id", "missing")); : Result.fail(new EntityNotFoundError("Supplier", "id", "missing"));
}, },

View File

@ -5,9 +5,9 @@ import {
type LanguageCode, type LanguageCode,
type Name, type Name,
type PhoneNumber, type PhoneNumber,
PostalAddress,
type PostalAddressPatchProps, type PostalAddressPatchProps,
type PostalAddressProps, type PostalAddressProps,
PostalAddress,
type TINNumber, type TINNumber,
type URLAddress, type URLAddress,
type UniqueID, type UniqueID,
@ -56,7 +56,7 @@ export type SupplierPatchProps = Partial<
// Supplier // Supplier
export interface ISupplier { export interface ISupplier {
// comportamiento // comportamiento
update(partialSupplier: SupplierPatchProps): Result<Supplier, Error>; update(partialSupplier: SupplierPatchProps): Result<boolean, Error>;
// propiedades (getters) // propiedades (getters)
readonly isIndividual: boolean; readonly isIndividual: boolean;
@ -144,12 +144,95 @@ export class Supplier extends AggregateRoot<SupplierInternalProps> implements IS
// BEHAVIOUR // BEHAVIOUR
// ------------------------- // -------------------------
public update(partialSupplier: SupplierPatchProps): Result<Supplier, Error> { public update(partialSupplier: SupplierPatchProps): Result<boolean, Error> {
const { address: partialAddress, ...rest } = partialSupplier; const { address: partialAddress, ...rest } = partialSupplier;
const nextProps: SupplierInternalProps = { const nextProps: SupplierInternalProps = { ...this.props };
...this.props, let hasChanges = false;
...rest,
}; 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) { if (partialAddress) {
const addressResult = PostalAddress.create({ const addressResult = PostalAddress.create({
@ -161,12 +244,31 @@ export class Supplier extends AggregateRoot<SupplierInternalProps> implements IS
return Result.fail(addressResult.error); 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<T extends { equals(other: T): boolean }>(
current: Maybe<T>,
next: Maybe<T>
): boolean {
return current.match(
(currentValue) =>
next.match(
(nextValue) => currentValue.equals(nextValue),
() => false
),
() => next.isNone()
);
} }
public activate(): Result<Supplier, Error> { public activate(): Result<Supplier, Error> {

View File

@ -8,6 +8,9 @@ import { suppliersAPIModule } from "./index";
test("compone suppliers sin publicar servicios de infraestructura", async () => { test("compone suppliers sin publicar servicios de infraestructura", async () => {
const params: ModuleParams = { const params: ModuleParams = {
database: {}, database: {},
transactionContext: {} as never,
operationContext: {} as never,
audit: {} as never,
getService: <T>() => undefined as T, getService: <T>() => undefined as T,
logger: { logger: {
info: () => undefined, info: () => undefined,

View File

@ -6,7 +6,7 @@ import { buildSuppliersDependencies } from "./infrastructure/di";
export const suppliersAPIModule: IModuleServer = { export const suppliersAPIModule: IModuleServer = {
name: "suppliers", name: "suppliers",
version: "1.0.0", version: "1.0.0",
dependencies: [], dependencies: ["identity", "companies"],
/** /**
* Fase de SETUP * Fase de SETUP

View File

@ -8,12 +8,12 @@ import {
buildCreateSupplierUseCase, buildCreateSupplierUseCase,
buildGetSupplierByIdUseCase, buildGetSupplierByIdUseCase,
buildListSuppliersUseCase, buildListSuppliersUseCase,
buildSupplierUpdater,
buildUpdateSupplierUseCase,
buildSupplierCreator, buildSupplierCreator,
buildSupplierFinder, buildSupplierFinder,
buildSupplierInputMappers, buildSupplierInputMappers,
buildSupplierSnapshotBuilders, buildSupplierSnapshotBuilders,
buildSupplierUpdater,
buildUpdateSupplierUseCase,
} from "../../application"; } from "../../application";
import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di"; import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di";
@ -29,7 +29,7 @@ export type SuppliersInternalDeps = {
}; };
export function buildSuppliersDependencies(params: ModuleParams): SuppliersInternalDeps { export function buildSuppliersDependencies(params: ModuleParams): SuppliersInternalDeps {
const { database, transactionContext } = params; const { audit, database, transactionContext } = params;
// Infrastructure // Infrastructure
const transactionManager = buildTransactionManager(database, transactionContext); const transactionManager = buildTransactionManager(database, transactionContext);
@ -40,8 +40,8 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter
// Application helpers // Application helpers
const inputMappers = buildSupplierInputMappers(); const inputMappers = buildSupplierInputMappers();
const finder = buildSupplierFinder({ repository }); const finder = buildSupplierFinder({ repository });
const creator = buildSupplierCreator({ repository }); const creator = buildSupplierCreator({ repository, audit });
const updater = buildSupplierUpdater({ repository }); const updater = buildSupplierUpdater({ repository, audit });
const snapshotBuilders = buildSupplierSnapshotBuilders(); const snapshotBuilders = buildSupplierSnapshotBuilders();

View File

@ -30,7 +30,17 @@ test("registra PATCH como única operación de actualización", () => {
apiBasePath: "/api", apiBasePath: "/api",
}, },
}, },
getService: <T>() => undefined as T, transactionContext: {} as never,
operationContext: {} as never,
audit: {} as never,
getService: <T>() =>
({
auth: {
tenantRequired: () => [(_req: unknown, _res: unknown, next: () => void) => next()],
},
companyAccess: {},
finder: {},
}) as T,
getInternal: <T>() => ({ useCases: {} }) as T, getInternal: <T>() => ({ useCases: {} }) as T,
}; };

View File

@ -1,5 +1,5 @@
import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api"; import { type StartParams, validateRequest } from "@erp/core/api";
import { type RequestWithAuth, type StartParams, validateRequest } from "@erp/core/api"; import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express"; import { type NextFunction, type Request, type Response, Router } from "express";
import { import {
@ -30,22 +30,8 @@ export const suppliersRouter = (params: StartParams) => {
// ---------------------------------------------- // ----------------------------------------------
// 🔐 Autenticación + Tenancy para TODO el router // Mantiene el mismo pipeline de identidad y tenant que los módulos HTTP actuales.
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") { router.use(...requireIdentityTenant(params));
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
]);
// ---------------------------------------------- // ----------------------------------------------

View File

@ -1136,12 +1136,18 @@ importers:
'@erp/core': '@erp/core':
specifier: workspace:* specifier: workspace:*
version: link:../core version: link:../core
'@erp/identity':
specifier: workspace:*
version: link:../identity
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.2.2 specifier: ^5.2.2
version: 5.4.0(react-hook-form@7.76.1(react@19.2.6)) version: 5.4.0(react-hook-form@7.76.1(react@19.2.6))
'@repo/i18next': '@repo/i18next':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/i18n version: link:../../packages/i18n
'@repo/rdx-audit':
specifier: workspace:*
version: link:../../packages/rdx-audit
'@repo/rdx-criteria': '@repo/rdx-criteria':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-criteria version: link:../../packages/rdx-criteria