feat(audit): implement customer audit functionality and integrate with services
This commit is contained in:
parent
71b79cc4b1
commit
0bcccd5e80
314
apps/server/src/testing/http/customer-audit.integration.test.ts
Normal file
314
apps/server/src/testing/http/customer-audit.integration.test.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import { type IModuleServer, SequelizeTransactionManager } from "@erp/core/api";
|
||||
import type { ICustomerPublicServices } from "@erp/customers/api";
|
||||
import { Country, CurrencyCode, LanguageCode, Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize";
|
||||
|
||||
import { CustomerStatus } from "../../../../../modules/customers/src/api/domain";
|
||||
import { CustomerModel } from "../../../../../modules/customers/src/api/infrastructure/persistence/sequelize/models/sequelize-customer.model";
|
||||
import { requireIdentityTenantMembership } from "../../../../../modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares";
|
||||
|
||||
import {
|
||||
buildCompanyHeaders,
|
||||
createTestAccount,
|
||||
createTestCompany,
|
||||
createTestMembership,
|
||||
loginByApi,
|
||||
} from "./http-test-fixtures";
|
||||
import { createHttpTestRuntime } from "./http-test-runtime";
|
||||
|
||||
const publicCustomerCreateProbe: IModuleServer = {
|
||||
name: "customer-public-create-probe",
|
||||
version: "1.0.0",
|
||||
dependencies: ["customers", "identity"],
|
||||
start: (params) => {
|
||||
const customerServices = params.getService<ICustomerPublicServices>("customers:general");
|
||||
|
||||
params.app.post(
|
||||
"/api/v1/__test/customers/public-create",
|
||||
...requireIdentityTenantMembership(params),
|
||||
async (request: Request, response: Response) => {
|
||||
const companyIdResult = UniqueID.create(request.header("x-company-id") ?? "");
|
||||
if (companyIdResult.isFailure) {
|
||||
response.status(400).json({ success: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const transactionManager = new SequelizeTransactionManager(
|
||||
params.database,
|
||||
params.transactionContext
|
||||
);
|
||||
const customerId = UniqueID.generateNewID();
|
||||
try {
|
||||
const result = await transactionManager.complete(async (transaction) => {
|
||||
const customerResult = await customerServices.createCustomer(
|
||||
customerId,
|
||||
{
|
||||
companyId: companyIdResult.data,
|
||||
status: CustomerStatus.createActive(),
|
||||
reference: Maybe.none(),
|
||||
isCompany: true,
|
||||
name: Name.create("FactuGES customer").data,
|
||||
tradeName: Maybe.none(),
|
||||
tin: Maybe.none(),
|
||||
address: {
|
||||
street: Maybe.none(),
|
||||
street2: Maybe.none(),
|
||||
city: Maybe.none(),
|
||||
province: Maybe.none(),
|
||||
postalCode: Maybe.none(),
|
||||
country: Maybe.some(Country.create("ES").data),
|
||||
},
|
||||
emailPrimary: Maybe.none(),
|
||||
emailSecondary: Maybe.none(),
|
||||
phonePrimary: Maybe.none(),
|
||||
phoneSecondary: Maybe.none(),
|
||||
mobilePrimary: Maybe.none(),
|
||||
mobileSecondary: Maybe.none(),
|
||||
fax: Maybe.none(),
|
||||
website: Maybe.none(),
|
||||
legalRecord: Maybe.none(),
|
||||
paymentMethodId: Maybe.none(),
|
||||
paymentTermId: Maybe.none(),
|
||||
taxRegimeCode: Maybe.none(),
|
||||
usesEquivalenceSurcharge: false,
|
||||
usesRetention: false,
|
||||
languageCode: LanguageCode.create("es").data,
|
||||
currencyCode: CurrencyCode.create("EUR").data,
|
||||
},
|
||||
{ companyId: companyIdResult.data, transaction }
|
||||
);
|
||||
if (customerResult.isFailure) {
|
||||
return customerResult;
|
||||
}
|
||||
|
||||
if (request.query.rollback === "true") {
|
||||
return Result.fail(new Error("forced public customer rollback"));
|
||||
}
|
||||
|
||||
return Result.ok({ customerId: customerId.toPrimitive() });
|
||||
});
|
||||
|
||||
response
|
||||
.status(result.isSuccess ? 201 : 409)
|
||||
.json(result.isSuccess ? result.data : { success: false });
|
||||
} catch {
|
||||
response.status(409).json({ success: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
test("customers registra create, update y el create público en MariaDB", async (t) => {
|
||||
const runtime = await createHttpTestRuntime({ testModules: [publicCustomerCreateProbe] });
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const account = await createTestAccount(runtime.database, {
|
||||
email: `customer-audit-${randomUUID()}@example.test`,
|
||||
password: "Password123!",
|
||||
});
|
||||
const company = await createTestCompany(runtime.database, {
|
||||
legalName: "Customer audit company",
|
||||
});
|
||||
await createTestMembership(runtime.database, {
|
||||
accountId: account.id,
|
||||
companyId: company.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 customerId = randomUUID();
|
||||
|
||||
const createResponse = await runtime.request("/api/v1/customers", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||
"content-type": "application/json",
|
||||
"x-correlation-id": "customer-create-correlation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: customerId,
|
||||
is_company: true,
|
||||
name: "Acme",
|
||||
tin: "B12345678",
|
||||
address: { country: "ES", postal_code: "28001" },
|
||||
contact: { email_primary: "contact@acme.test" },
|
||||
uses_retention: false,
|
||||
language_code: "es",
|
||||
currency_code: "EUR",
|
||||
payment_term_id: null,
|
||||
}),
|
||||
});
|
||||
assert.equal(createResponse.status, 201);
|
||||
|
||||
const createdEvent = await AuditEventModel.findOne({
|
||||
where: { action: "customer.created", target_id: customerId },
|
||||
});
|
||||
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, "customer-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, "customer");
|
||||
assert.equal(createdEvent.target_label, "Acme");
|
||||
|
||||
const updateResponse = await runtime.request(`/api/v1/customers/${customerId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||
"content-type": "application/json",
|
||||
"x-correlation-id": "customer-update-correlation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Acme updated",
|
||||
uses_retention: true,
|
||||
address: { postal_code: "08001" },
|
||||
contact: { email_primary: "billing@acme.test" },
|
||||
}),
|
||||
});
|
||||
assert.equal(updateResponse.status, 200);
|
||||
|
||||
const updatedEvent = await AuditEventModel.findOne({
|
||||
where: { action: "customer.updated", target_id: customerId },
|
||||
});
|
||||
assert.ok(updatedEvent);
|
||||
assert.equal(updatedEvent.target_label, "Acme updated");
|
||||
assert.equal(updatedEvent.operation_id, updateResponse.headers.get("x-request-id"));
|
||||
assert.equal(updatedEvent.correlation_id, "customer-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: "contact@acme.test",
|
||||
after: "billing@acme.test",
|
||||
},
|
||||
{ path: "name", kind: "change", before: "Acme", after: "Acme updated" },
|
||||
{ path: "usesRetention", kind: "change", before: false, after: true },
|
||||
]
|
||||
);
|
||||
|
||||
const auditEventsBeforeNoop = await AuditEventModel.count({
|
||||
where: { action: "customer.updated", target_id: customerId },
|
||||
});
|
||||
const noopResponse = await runtime.request(`/api/v1/customers/${customerId}`, {
|
||||
method: "PUT",
|
||||
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: "customer.updated", target_id: customerId } }),
|
||||
auditEventsBeforeNoop
|
||||
);
|
||||
|
||||
const auditEventsBeforeIdenticalUpdate = await AuditEventModel.count({
|
||||
where: { action: "customer.updated", target_id: customerId },
|
||||
});
|
||||
const identicalUpdateResponse = await runtime.request(`/api/v1/customers/${customerId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Acme updated",
|
||||
uses_retention: true,
|
||||
language_code: "es",
|
||||
}),
|
||||
});
|
||||
assert.equal(identicalUpdateResponse.status, 200);
|
||||
const unchangedCustomer = await CustomerModel.findByPk(customerId);
|
||||
assert.ok(unchangedCustomer);
|
||||
assert.equal(unchangedCustomer.name, "Acme updated");
|
||||
assert.equal(unchangedCustomer.uses_retention, true);
|
||||
assert.equal(unchangedCustomer.language_code, "es");
|
||||
assert.equal(
|
||||
await AuditEventModel.count({ where: { action: "customer.updated", target_id: customerId } }),
|
||||
auditEventsBeforeIdenticalUpdate
|
||||
);
|
||||
|
||||
const auditEventsBeforeNotFound = await AuditEventModel.count();
|
||||
const notFoundResponse = await runtime.request(`/api/v1/customers/${randomUUID()}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: "Missing" }),
|
||||
});
|
||||
assert.equal(notFoundResponse.status, 404);
|
||||
assert.equal(await AuditEventModel.count(), auditEventsBeforeNotFound);
|
||||
|
||||
const publicCreateResponse = await runtime.request("/api/v1/__test/customers/public-create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||
"x-correlation-id": "customer-public-create-correlation",
|
||||
},
|
||||
});
|
||||
assert.equal(publicCreateResponse.status, 201);
|
||||
const publicCustomerId = (publicCreateResponse.body as { customerId: string }).customerId;
|
||||
assert.ok(await CustomerModel.findByPk(publicCustomerId));
|
||||
const publicEvent = await AuditEventModel.findOne({
|
||||
where: { action: "customer.created", target_id: publicCustomerId },
|
||||
});
|
||||
assert.ok(publicEvent);
|
||||
assert.equal(publicEvent.correlation_id, "customer-public-create-correlation");
|
||||
assert.equal(publicEvent.scope_id, company.id);
|
||||
assert.equal(publicEvent.actor_id, account.id);
|
||||
|
||||
const auditChangesBeforeRollback = await AuditChangeModel.count();
|
||||
const rollbackResponse = await runtime.request(
|
||||
"/api/v1/__test/customers/public-create?rollback=true",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||
"x-correlation-id": "customer-public-rollback-correlation",
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(rollbackResponse.status, 409);
|
||||
assert.equal(
|
||||
await CustomerModel.count({ where: { company_id: company.id, name: "FactuGES customer" } }),
|
||||
1
|
||||
);
|
||||
assert.equal(
|
||||
await AuditEventModel.count({
|
||||
where: { correlation_id: "customer-public-rollback-correlation" },
|
||||
}),
|
||||
0
|
||||
);
|
||||
assert.equal(await AuditChangeModel.count(), auditChangesBeforeRollback);
|
||||
});
|
||||
@ -41,6 +41,7 @@
|
||||
"@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:*",
|
||||
@ -57,4 +58,4 @@
|
||||
"use-debounce": "^10.1.1",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
City,
|
||||
Country,
|
||||
CurrencyCode,
|
||||
EmailAddress,
|
||||
LanguageCode,
|
||||
Name,
|
||||
PhoneNumber,
|
||||
PostalCode,
|
||||
Province,
|
||||
Street,
|
||||
TINNumber,
|
||||
URLAddress,
|
||||
UniqueID,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
import { Customer, CustomerStatus } from "../../domain";
|
||||
|
||||
import { toCustomerAuditState, toCustomerAuditTarget } from "./customer-audit";
|
||||
|
||||
const unwrap = <T>(result: { data: T; isSuccess: boolean }): T => {
|
||||
assert.equal(result.isSuccess, true);
|
||||
return result.data;
|
||||
};
|
||||
|
||||
const buildCustomer = () =>
|
||||
unwrap(
|
||||
Customer.create(
|
||||
{
|
||||
companyId: UniqueID.generateNewID(),
|
||||
status: CustomerStatus.createActive(),
|
||||
reference: Maybe.some(unwrap(Name.create("C-001"))),
|
||||
isCompany: true,
|
||||
name: unwrap(Name.create("Acme")),
|
||||
tradeName: Maybe.some(unwrap(Name.create("Acme 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("primary@example.test"))),
|
||||
emailSecondary: Maybe.none(),
|
||||
phonePrimary: Maybe.some(unwrap(PhoneNumber.create("+34910000000"))),
|
||||
phoneSecondary: Maybe.none(),
|
||||
mobilePrimary: Maybe.some(unwrap(PhoneNumber.create("+34600000000"))),
|
||||
mobileSecondary: Maybe.none(),
|
||||
fax: Maybe.some(unwrap(PhoneNumber.create("+34910000001"))),
|
||||
website: Maybe.some(unwrap(URLAddress.create("https://example.test"))),
|
||||
legalRecord: Maybe.none(),
|
||||
paymentMethodId: Maybe.some(UniqueID.generateNewID()),
|
||||
paymentTermId: Maybe.some(UniqueID.generateNewID()),
|
||||
taxRegimeCode: Maybe.some("RE"),
|
||||
usesEquivalenceSurcharge: true,
|
||||
usesRetention: true,
|
||||
languageCode: unwrap(LanguageCode.create("es")),
|
||||
currencyCode: unwrap(CurrencyCode.create("EUR")),
|
||||
},
|
||||
UniqueID.generateNewID()
|
||||
)
|
||||
);
|
||||
|
||||
test("proyecta Customer a un estado histórico portable completo sin legalRecord", () => {
|
||||
const customer = buildCustomer();
|
||||
|
||||
assert.deepEqual(toCustomerAuditState(customer), {
|
||||
status: "active",
|
||||
reference: "C-001",
|
||||
isCompany: true,
|
||||
name: "Acme",
|
||||
tradeName: "Acme Trading",
|
||||
tin: "B12345678",
|
||||
address: {
|
||||
street: "Main street 1",
|
||||
street2: null,
|
||||
city: "Madrid",
|
||||
province: "Madrid",
|
||||
postalCode: "28001",
|
||||
country: "ES",
|
||||
},
|
||||
emailPrimary: "primary@example.test",
|
||||
emailSecondary: null,
|
||||
phonePrimary: "+34910000000",
|
||||
phoneSecondary: null,
|
||||
mobilePrimary: "+34600000000",
|
||||
mobileSecondary: null,
|
||||
fax: "+34910000001",
|
||||
website: "https://example.test",
|
||||
paymentMethodId: customer.paymentMethodId.unwrap().toPrimitive(),
|
||||
paymentTermId: customer.paymentTermId.unwrap().toPrimitive(),
|
||||
taxRegimeCode: "RE",
|
||||
usesEquivalenceSurcharge: true,
|
||||
usesRetention: true,
|
||||
languageCode: "es",
|
||||
currencyCode: "EUR",
|
||||
});
|
||||
assert.equal("legalRecord" in toCustomerAuditState(customer), false);
|
||||
assert.deepEqual(toCustomerAuditTarget(customer), {
|
||||
type: "customer",
|
||||
id: customer.id.toPrimitive(),
|
||||
label: "Acme",
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,94 @@
|
||||
import type { AuditMutationTarget, AuditState } from "@repo/rdx-audit";
|
||||
|
||||
import type { Customer } from "../../domain";
|
||||
|
||||
export const CUSTOMER_AUDIT_ACTIONS = {
|
||||
CREATED: "customer.created",
|
||||
UPDATED: "customer.updated",
|
||||
} as const;
|
||||
|
||||
type CustomerAuditState = 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;
|
||||
paymentMethodId: string | null;
|
||||
paymentTermId: string | null;
|
||||
taxRegimeCode: string | null;
|
||||
usesEquivalenceSurcharge: boolean;
|
||||
usesRetention: boolean;
|
||||
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; `legalRecord` se excluye deliberadamente en v1. */
|
||||
export const toCustomerAuditState = (customer: Customer): CustomerAuditState => {
|
||||
const status: CustomerAuditState["status"] = customer.isActive ? "active" : "inactive";
|
||||
|
||||
return {
|
||||
status,
|
||||
reference: toNullablePrimitive(customer.reference),
|
||||
isCompany: customer.isCompany,
|
||||
name: customer.name.toPrimitive(),
|
||||
tradeName: toNullablePrimitive(customer.tradeName),
|
||||
tin: toNullablePrimitive(customer.tin),
|
||||
address: {
|
||||
street: toNullablePrimitive(customer.address.getProps().street),
|
||||
street2: toNullablePrimitive(customer.address.getProps().street2),
|
||||
city: toNullablePrimitive(customer.address.getProps().city),
|
||||
province: toNullablePrimitive(customer.address.getProps().province),
|
||||
postalCode: toNullablePrimitive(customer.address.getProps().postalCode),
|
||||
country: toNullablePrimitive(customer.address.getProps().country),
|
||||
},
|
||||
emailPrimary: toNullablePrimitive(customer.emailPrimary),
|
||||
emailSecondary: toNullablePrimitive(customer.emailSecondary),
|
||||
phonePrimary: toNullablePrimitive(customer.phonePrimary),
|
||||
phoneSecondary: toNullablePrimitive(customer.phoneSecondary),
|
||||
mobilePrimary: toNullablePrimitive(customer.mobilePrimary),
|
||||
mobileSecondary: toNullablePrimitive(customer.mobileSecondary),
|
||||
fax: toNullablePrimitive(customer.fax),
|
||||
website: toNullablePrimitive(customer.website),
|
||||
paymentMethodId: toNullablePrimitive(customer.paymentMethodId),
|
||||
paymentTermId: toNullablePrimitive(customer.paymentTermId),
|
||||
taxRegimeCode: customer.taxRegimeCode.match(
|
||||
(code) => code,
|
||||
() => null
|
||||
),
|
||||
usesEquivalenceSurcharge: customer.usesEquivalenceSurcharge,
|
||||
usesRetention: customer.usesRetention,
|
||||
languageCode: customer.languageCode.toPrimitive(),
|
||||
currencyCode: customer.currencyCode.toPrimitive(),
|
||||
} satisfies AuditState;
|
||||
};
|
||||
|
||||
export const toCustomerAuditTarget = (customer: Customer): AuditMutationTarget => ({
|
||||
type: "customer",
|
||||
id: customer.id.toPrimitive(),
|
||||
label: customer.name.toPrimitive(),
|
||||
});
|
||||
@ -1,12 +1,16 @@
|
||||
import type { IAudit } from "@repo/rdx-audit";
|
||||
|
||||
import type { ICustomerRepository } from "../repositories";
|
||||
import { CustomerCreator, type ICustomerCreator } from "../services";
|
||||
|
||||
export const buildCustomerCreator = (params: {
|
||||
repository: ICustomerRepository;
|
||||
audit: IAudit;
|
||||
}): ICustomerCreator => {
|
||||
const { repository } = params;
|
||||
const { repository, audit } = params;
|
||||
|
||||
return new CustomerCreator({
|
||||
repository,
|
||||
audit,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import type { IAudit } from "@repo/rdx-audit";
|
||||
|
||||
import type { ICustomerRepository } from "../repositories";
|
||||
import { CustomerUpdater, type ICustomerUpdater } from "../services";
|
||||
|
||||
export const buildCustomerUpdater = (params: {
|
||||
repository: ICustomerRepository;
|
||||
audit: IAudit;
|
||||
}): ICustomerUpdater => {
|
||||
const { repository } = params;
|
||||
const { repository, audit } = params;
|
||||
|
||||
return new CustomerUpdater({
|
||||
repository,
|
||||
audit,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
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 { Customer, type ICustomerCreateProps } from "../../domain";
|
||||
import {
|
||||
CUSTOMER_AUDIT_ACTIONS,
|
||||
toCustomerAuditState,
|
||||
toCustomerAuditTarget,
|
||||
} from "../audit/customer-audit";
|
||||
import type { ICustomerRepository } from "../repositories";
|
||||
import { CustomerNotExistsInCompanySpecification } from "../specs";
|
||||
|
||||
@ -17,13 +23,16 @@ export interface ICustomerCreator {
|
||||
|
||||
type CustomerCreatorDeps = {
|
||||
repository: ICustomerRepository;
|
||||
audit: IAudit;
|
||||
};
|
||||
|
||||
export class CustomerCreator implements ICustomerCreator {
|
||||
private readonly repository: ICustomerRepository;
|
||||
private readonly audit: IAudit;
|
||||
|
||||
constructor(deps: CustomerCreatorDeps) {
|
||||
this.repository = deps.repository;
|
||||
this.audit = deps.audit;
|
||||
}
|
||||
|
||||
async create(params: {
|
||||
@ -69,6 +78,16 @@ export class CustomerCreator implements ICustomerCreator {
|
||||
return Result.fail(saveResult.error);
|
||||
}
|
||||
|
||||
const auditResult = await this.audit.recordMutation({
|
||||
action: CUSTOMER_AUDIT_ACTIONS.CREATED,
|
||||
target: toCustomerAuditTarget(newCustomer),
|
||||
before: {},
|
||||
after: toCustomerAuditState(newCustomer),
|
||||
});
|
||||
if (auditResult.isFailure) {
|
||||
return Result.fail(auditResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(newCustomer);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import type { IAudit } from "@repo/rdx-audit";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Customer, CustomerPatchProps } from "../../domain";
|
||||
import {
|
||||
CUSTOMER_AUDIT_ACTIONS,
|
||||
toCustomerAuditState,
|
||||
toCustomerAuditTarget,
|
||||
} from "../audit/customer-audit";
|
||||
import type { ICustomerRepository } from "../repositories";
|
||||
|
||||
export interface ICustomerUpdater {
|
||||
@ -15,13 +21,16 @@ export interface ICustomerUpdater {
|
||||
|
||||
type CustomerUpdaterDeps = {
|
||||
repository: ICustomerRepository;
|
||||
audit: IAudit;
|
||||
};
|
||||
|
||||
export class CustomerUpdater implements ICustomerUpdater {
|
||||
private readonly repository: ICustomerRepository;
|
||||
private readonly audit: IAudit;
|
||||
|
||||
constructor(deps: CustomerUpdaterDeps) {
|
||||
this.repository = deps.repository;
|
||||
this.audit = deps.audit;
|
||||
}
|
||||
|
||||
async update(params: {
|
||||
@ -40,6 +49,7 @@ export class CustomerUpdater implements ICustomerUpdater {
|
||||
}
|
||||
|
||||
const customer = existingResult.data;
|
||||
const before = toCustomerAuditState(customer);
|
||||
|
||||
// Aplicar cambios en el agregado
|
||||
const updateResult = customer.update(props);
|
||||
@ -48,6 +58,20 @@ export class CustomerUpdater implements ICustomerUpdater {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
|
||||
if (Object.keys(props).length === 0) {
|
||||
const auditResult = await this.audit.recordMutation({
|
||||
action: CUSTOMER_AUDIT_ACTIONS.UPDATED,
|
||||
target: toCustomerAuditTarget(customer),
|
||||
before,
|
||||
after: toCustomerAuditState(customer),
|
||||
});
|
||||
if (auditResult.isFailure) {
|
||||
return Result.fail(auditResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(customer);
|
||||
}
|
||||
|
||||
// Persistir cambios
|
||||
const saveResult = await this.repository.update(customer, transaction);
|
||||
|
||||
@ -55,6 +79,16 @@ export class CustomerUpdater implements ICustomerUpdater {
|
||||
return Result.fail(saveResult.error);
|
||||
}
|
||||
|
||||
const auditResult = await this.audit.recordMutation({
|
||||
action: CUSTOMER_AUDIT_ACTIONS.UPDATED,
|
||||
target: toCustomerAuditTarget(customer),
|
||||
before,
|
||||
after: toCustomerAuditState(customer),
|
||||
});
|
||||
if (auditResult.isFailure) {
|
||||
return Result.fail(auditResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(customer);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { SetupParams } from "@erp/core/api";
|
||||
import type { ModuleParams } from "@erp/core/api";
|
||||
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
@ -16,18 +16,18 @@ import { buildCustomerRepository } from "./customer-repositories.di";
|
||||
import type { CustomersInternalDeps } from "./customers.di";
|
||||
|
||||
export function buildCustomerPublicServices(
|
||||
params: SetupParams,
|
||||
deps: CustomersInternalDeps
|
||||
params: ModuleParams,
|
||||
_deps: CustomersInternalDeps
|
||||
): ICustomerPublicServices {
|
||||
const { database } = params;
|
||||
const catalogs = resolveCustomerCatalogsDeps(params);
|
||||
const _catalogs = resolveCustomerCatalogsDeps(params);
|
||||
|
||||
// Infrastructure
|
||||
const persistenceMappers = buildCustomerPersistenceMappers();
|
||||
const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
|
||||
|
||||
const finder = buildCustomerFinder({ repository });
|
||||
const creator = buildCustomerCreator({ repository });
|
||||
const creator = buildCustomerCreator({ repository, audit: params.audit });
|
||||
|
||||
return {
|
||||
findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => {
|
||||
@ -53,7 +53,7 @@ export function buildCustomerPublicServices(
|
||||
|
||||
return Result.ok(customerResult.data);
|
||||
},
|
||||
|
||||
|
||||
createCustomer: async (
|
||||
id: UniqueID,
|
||||
props: ICustomerCreateProps,
|
||||
|
||||
@ -69,8 +69,8 @@ export function buildCustomersDependencies(params: ModuleParams): CustomersInter
|
||||
});
|
||||
|
||||
const finder = buildCustomerFinder({ repository });
|
||||
const creator = buildCustomerCreator({ repository });
|
||||
const updater = buildCustomerUpdater({ repository });
|
||||
const creator = buildCustomerCreator({ repository, audit: params.audit });
|
||||
const updater = buildCustomerUpdater({ repository, audit: params.audit });
|
||||
const defaultPaymentTermResolver = new CustomerDefaultPaymentTermResolver({
|
||||
companyFinder: companies.company.finder,
|
||||
paymentResolver: catalogResolvers.paymentResolver,
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
import {
|
||||
EntityNotFoundError,
|
||||
InfrastructureRepositoryError,
|
||||
SequelizeRepository,
|
||||
translateSequelizeError,
|
||||
} from "@erp/core/api";
|
||||
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
||||
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
||||
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
|
||||
import { type Collection, Result } from "@repo/rdx-utils";
|
||||
@ -68,18 +63,14 @@ export class CustomerRepository
|
||||
|
||||
const { id, ...updatePayload } = dtoResult.data;
|
||||
|
||||
const [affected] = await CustomerModel.update(updatePayload, {
|
||||
await CustomerModel.update(updatePayload, {
|
||||
where: { id /*, version */ },
|
||||
transaction,
|
||||
//individualHooks: true,
|
||||
});
|
||||
|
||||
if (affected === 0) {
|
||||
return Result.fail(
|
||||
new InfrastructureRepositoryError("Concurrency conflict or not found update customer")
|
||||
);
|
||||
}
|
||||
|
||||
// CustomerUpdater ya comprobó existencia y scope antes de persistir.
|
||||
// MariaDB devuelve 0 cuando el estado recibido es idéntico al almacenado.
|
||||
return Result.ok();
|
||||
} catch (err: unknown) {
|
||||
return Result.fail(translateSequelizeError(err));
|
||||
|
||||
@ -831,6 +831,9 @@ importers:
|
||||
'@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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user