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": {
"@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"
}
}
}

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

View File

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

View File

@ -1,3 +1,4 @@
export * from "./audit";
export * from "./di";
export * from "./models";
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 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);
}
}

View File

@ -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<Result<Supplier, Error>> {
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);
}
}

View File

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

View File

@ -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<Supplier, Error>;
update(partialSupplier: SupplierPatchProps): Result<boolean, Error>;
// propiedades (getters)
readonly isIndividual: boolean;
@ -144,12 +144,95 @@ export class Supplier extends AggregateRoot<SupplierInternalProps> implements IS
// BEHAVIOUR
// -------------------------
public update(partialSupplier: SupplierPatchProps): Result<Supplier, Error> {
public update(partialSupplier: SupplierPatchProps): Result<boolean, Error> {
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<SupplierInternalProps> 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<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> {

View File

@ -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: <T>() => undefined as T,
logger: {
info: () => undefined,

View File

@ -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

View File

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

View File

@ -30,7 +30,17 @@ test("registra PATCH como única operación de actualización", () => {
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,
};

View File

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

View File

@ -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