feat(audit): integrate company audit functionality and related tests

This commit is contained in:
David Arranz 2026-08-25 10:37:12 +02:00
parent 0bcccd5e80
commit e23a28cb8b
11 changed files with 691 additions and 17 deletions

View File

@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import { SequelizeTransactionManager } from "@erp/core/api";
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize";
import {
CompanyFinder,
CompanyUpdater,
} from "../../../../../modules/companies/src/api/application";
import {
SequelizeCompanyDomainMapper,
SequelizeCompanyRepository,
} from "../../../../../modules/companies/src/api/infrastructure/persistence";
import { CompanyModel } from "../../../../../modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model";
import {
buildCompanyHeaders,
createTestAccount,
createTestCompany,
createTestMembership,
createTestPaymentTerm,
loginByApi,
} from "./http-test-fixtures";
import { createHttpTestRuntime } from "./http-test-runtime";
test("companies registra create, updates, no-ops y rollback con MariaDB", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createTestAccount(runtime.database, {
email: `company-audit-${randomUUID()}@example.test`,
password: "Password123!",
});
const loginResponse = await loginByApi({
request: runtime.request,
email: account.email,
password: account.password,
});
assert.equal(loginResponse.status, 200);
const accessToken = (loginResponse.body as { access_token: string }).access_token;
const createdCompanyId = randomUUID();
const createResponse = await runtime.request("/api/v1/companies", {
method: "POST",
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
"x-correlation-id": "company-create-correlation",
},
body: JSON.stringify({
id: createdCompanyId,
legal_name: "Created company",
slug: `created-company-${randomUUID()}`,
tin: "B12345678",
email: "created@example.test",
phone: "+34911111111",
}),
});
assert.equal(createResponse.status, 201);
const createdEvent = await AuditEventModel.findOne({
where: { action: "company.created", target_id: createdCompanyId },
});
assert.ok(createdEvent);
assert.equal(createdEvent.scope_type, null);
assert.equal(createdEvent.scope_id, null);
assert.equal(createdEvent.actor_id, account.id);
assert.equal(createdEvent.correlation_id, "company-create-correlation");
const company = await createTestCompany(runtime.database, {
legalName: "Original company",
email: "old@example.test",
});
await createTestMembership(runtime.database, {
accountId: account.id,
companyId: company.id,
});
const paymentTerm = await createTestPaymentTerm(runtime.database, {
companyId: company.id,
name: "30 days",
});
const updateResponse = await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers: {
...buildCompanyHeaders({ accessToken, companyId: company.id }),
"content-type": "application/json",
"x-correlation-id": "company-update-correlation",
},
body: JSON.stringify({
legal_name: "Updated company",
email: "new@example.test",
default_payment_term_id: paymentTerm.id,
}),
});
assert.equal(updateResponse.status, 200);
const updatedEvent = await AuditEventModel.findOne({
where: { action: "company.updated", target_id: company.id },
order: [["occurred_at", "DESC"]],
});
assert.ok(updatedEvent);
assert.equal(updatedEvent.target_type, "company");
assert.equal(updatedEvent.target_label, "Updated company");
assert.equal(updatedEvent.source_type, "application");
assert.equal(updatedEvent.source_id, "uecko-erp");
assert.equal(updatedEvent.origin, "http");
assert.equal(updatedEvent.operation_id, updateResponse.headers.get("x-request-id"));
assert.equal(updatedEvent.correlation_id, "company-update-correlation");
assert.equal(updatedEvent.actor_type, "user");
assert.equal(updatedEvent.actor_id, account.id);
assert.equal(updatedEvent.scope_type, "company");
assert.equal(updatedEvent.scope_id, company.id);
assert.equal(updatedEvent.target_id, company.id);
const updatedChanges = await AuditChangeModel.findAll({
where: { audit_event_id: updatedEvent.id },
order: [["position", "ASC"]],
});
assert.deepEqual(
updatedChanges.map((change) => ({
path: change.path,
kind: change.kind,
before: change.before_value,
after: change.after_value,
})),
[
{
path: "defaultPaymentTermId",
kind: "change",
before: null,
after: paymentTerm.id,
},
{
path: "email",
kind: "change",
before: "old@example.test",
after: "new@example.test",
},
{
path: "legalName",
kind: "change",
before: "Original company",
after: "Updated company",
},
]
);
const updateEventsBeforeNoop = await AuditEventModel.count({
where: { action: "company.updated", target_id: company.id },
});
const noopResponse = await runtime.request("/api/v1/companies/current", {
method: "PATCH",
headers: {
...buildCompanyHeaders({ accessToken, companyId: company.id }),
"content-type": "application/json",
},
body: JSON.stringify({}),
});
assert.equal(noopResponse.status, 200);
assert.equal(
await AuditEventModel.count({ where: { action: "company.updated", target_id: company.id } }),
updateEventsBeforeNoop
);
const legacyUpdateResponse = await runtime.request(`/api/v1/companies/${company.id}`, {
method: "PATCH",
headers: {
...buildCompanyHeaders({ accessToken, companyId: company.id }),
"content-type": "application/json",
},
body: JSON.stringify({ slug: `updated-company-${randomUUID()}` }),
});
assert.equal(legacyUpdateResponse.status, 200);
const otherCompany = await createTestCompany(runtime.database, {
legalName: "Other company",
});
const eventsBeforeCrossCompany = await AuditEventModel.count();
const crossCompanyResponse = await runtime.request(`/api/v1/companies/${otherCompany.id}`, {
method: "PATCH",
headers: {
...buildCompanyHeaders({ accessToken, companyId: company.id }),
"content-type": "application/json",
},
body: JSON.stringify({ legal_name: "Must not change" }),
});
assert.equal(crossCompanyResponse.status, 403);
assert.equal((await CompanyModel.findByPk(otherCompany.id))?.legal_name, "Other company");
assert.equal(await AuditEventModel.count(), eventsBeforeCrossCompany);
const repository = new SequelizeCompanyRepository(
new SequelizeCompanyDomainMapper(),
runtime.database
);
const updater = new CompanyUpdater({
finder: new CompanyFinder(repository),
repository,
audit: runtime.audit,
});
const transactionManager = new SequelizeTransactionManager(
runtime.database,
runtime.transactionContext
);
const rollbackName = Name.create("Rolled back company name");
assert.equal(rollbackName.isSuccess, true);
const changesBeforeRollback = await AuditChangeModel.count();
await assert.rejects(
() =>
runtime.operationContext.run(
{
operationId: "company-rollback-operation",
correlationId: "company-rollback-correlation",
origin: "http",
actor: { type: "user", id: account.id, label: account.email },
scope: { type: "company", id: company.id, label: null },
},
() =>
transactionManager.complete(async (transaction) => {
const updateResult = await updater.update({
id: UniqueID.create(company.id).data,
patchProps: {
legalName: rollbackName.data,
updatedAt: UtcDate.today(),
},
transaction,
});
assert.equal(updateResult.isSuccess, true);
return Result.fail(new Error("forced company rollback"));
})
),
/forced company rollback/
);
assert.equal((await CompanyModel.findByPk(company.id))?.legal_name, "Updated company");
assert.equal(
await AuditEventModel.count({ where: { operation_id: "company-rollback-operation" } }),
0
);
assert.equal(await AuditChangeModel.count(), changesBeforeRollback);
});

View File

@ -36,6 +36,7 @@
"@erp/identity": "workspace:*", "@erp/identity": "workspace:*",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@repo/i18next": "workspace:*", "@repo/i18next": "workspace:*",
"@repo/rdx-audit": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-ui": "workspace:*", "@repo/rdx-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*", "@repo/rdx-utils": "workspace:*",
@ -49,4 +50,4 @@
"sequelize": "^6.37.8", "sequelize": "^6.37.8",
"zod": "^4.3.6" "zod": "^4.3.6"
} }
} }

View File

@ -0,0 +1,54 @@
import type { AuditMutationTarget, AuditState } from "@repo/rdx-audit";
import type { Company } from "../../../domain";
export const COMPANY_AUDIT_ACTIONS = {
CREATED: "company.created",
UPDATED: "company.updated",
ENABLED: "company.enabled",
DISABLED: "company.disabled",
} as const;
type CompanyAuditState = Readonly<{
legalName: string;
tradeName: string | null;
tin: string | null;
slug: string;
email: string | null;
phone: string | null;
website: string | null;
defaultPaymentTermId: string | null;
status: "active" | "disabled";
}>;
const toNullablePrimitive = <T extends { toPrimitive(): string }>(value: {
match: <R>(some: (item: T) => R, none: () => R) => R;
}): string | null =>
value.match(
(item) => item.toPrimitive(),
() => null
);
/** Proyeccion historica estable del agregado, sin timestamps ni infraestructura. */
export const toCompanyAuditState = (company: Company): CompanyAuditState => {
const status: CompanyAuditState["status"] = company.status.isActive() ? "active" : "disabled";
return {
legalName: company.legalName.toPrimitive(),
tradeName: toNullablePrimitive(company.tradeName),
tin: toNullablePrimitive(company.tin),
slug: company.slug.toPrimitive(),
email: toNullablePrimitive(company.email),
phone: toNullablePrimitive(company.phone),
website: toNullablePrimitive(company.website),
defaultPaymentTermId: toNullablePrimitive(company.defaultPaymentTermId),
status,
} satisfies AuditState;
};
/** Conserva el nombre legal visible del recurso en el momento de la mutacion. */
export const toCompanyAuditTarget = (company: Company): AuditMutationTarget => ({
type: "company",
id: company.id.toPrimitive(),
label: company.legalName.toPrimitive(),
});

View File

@ -0,0 +1,294 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { IAudit, RecordAuditMutationShortcutInput } from "@repo/rdx-audit";
import {
EmailAddress,
Name,
PhoneNumber,
TextValue,
URLAddress,
UniqueID,
UtcDate,
} from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import { Company, CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain";
import type { ICompanyRepository } from "../contracts";
import { CompanyCreator } from "./company-creator";
import { CompanyFinder } from "./company-finder";
import { CompanyStatusChanger } from "./company-status-changer";
import { CompanyUpdater } from "./company-updater";
const buildCompany = (status = CompanyStatus.createActive()) => {
const now = UtcDate.createFromISO("2026-08-16");
const legalName = Name.create("Rodax Labs");
const slug = CompanySlug.create("rodax-labs");
const tin = TextValue.create("B12345678");
const email = EmailAddress.create("hello@rodax.test");
const phone = PhoneNumber.create("+34911111111");
const website = URLAddress.create("https://rodax.test");
assert.equal(now.isSuccess, true);
assert.equal(legalName.isSuccess, true);
assert.equal(slug.isSuccess, true);
assert.equal(tin.isSuccess, true);
assert.equal(email.isSuccess, true);
assert.equal(phone.isSuccess, true);
assert.equal(website.isSuccess, true);
const companyResult = Company.create(
{
legalName: legalName.data,
tradeName: Maybe.none(),
tin: Maybe.some(tin.data),
slug: slug.data,
email: Maybe.some(email.data),
phone: Maybe.some(phone.data),
website: Maybe.some(website.data),
defaultPaymentTermId: Maybe.none(),
status,
createdAt: now.data,
updatedAt: now.data,
},
UniqueID.generateNewID()
);
assert.equal(companyResult.isSuccess, true);
return companyResult.data;
};
const toCreateProps = (company: Company): ICompanyCreateProps => ({
legalName: company.legalName,
tradeName: company.tradeName,
tin: company.tin,
slug: company.slug,
email: company.email,
phone: company.phone,
website: company.website,
defaultPaymentTermId: company.defaultPaymentTermId,
status: company.status,
createdAt: company.createdAt,
updatedAt: company.updatedAt,
});
const buildRepository = (company: Company, counters: { saves: number; updates: number }) =>
({
save: async () => {
counters.saves += 1;
return Result.ok();
},
update: async () => {
counters.updates += 1;
return Result.ok();
},
findById: async () => Result.ok(Maybe.some(company)),
findBySlug: async () => Result.ok(Maybe.none()),
existsActiveById: async () => Result.ok(true),
findActiveByIds: async () => Result.ok(new Collection([])),
}) satisfies ICompanyRepository;
const buildAudit = (failure?: Error) => {
const mutations: RecordAuditMutationShortcutInput[] = [];
const audit = {
record: async () => Result.ok(),
recordEvent: async () => Result.ok(),
recordMutation: async (input: RecordAuditMutationShortcutInput) => {
mutations.push(input);
return failure ? Result.fail(failure as never) : Result.ok();
},
} satisfies IAudit;
return { audit, mutations };
};
test("CompanyCreator registra el estado completo y propaga un fallo de audit", async () => {
const company = buildCompany();
const counters = { saves: 0, updates: 0 };
const repository = buildRepository(company, counters);
const successfulAudit = buildAudit();
const creator = new CompanyCreator(repository, successfulAudit.audit);
const result = await creator.create({ id: company.id, props: toCreateProps(company) });
assert.equal(result.isSuccess, true);
assert.equal(counters.saves, 1);
assert.equal(successfulAudit.mutations.length, 1);
assert.deepEqual(successfulAudit.mutations[0], {
action: "company.created",
target: {
type: "company",
id: company.id.toPrimitive(),
label: "Rodax Labs",
},
before: {},
after: {
legalName: "Rodax Labs",
tradeName: null,
tin: "B12345678",
slug: "rodax-labs",
email: "hello@rodax.test",
phone: "+34911111111",
website: "https://rodax.test",
defaultPaymentTermId: null,
status: "active",
},
});
const failedAudit = buildAudit(new Error("audit unavailable"));
const failureResult = await new CompanyCreator(repository, failedAudit.audit).create({
id: UniqueID.generateNewID(),
props: toCreateProps(company),
});
assert.equal(failureResult.isFailure, true);
assert.equal(failureResult.error.message, "audit unavailable");
});
test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () => {
const company = buildCompany();
const counters = { saves: 0, updates: 0 };
const repository = buildRepository(company, counters);
const audit = buildAudit();
const updater = new CompanyUpdater({
finder: new CompanyFinder(repository),
repository,
audit: audit.audit,
});
const updatedName = Name.create("Rodax Updated");
const updatedEmail = EmailAddress.create("updated@rodax.test");
const defaultPaymentTermId = UniqueID.generateNewID();
assert.equal(updatedName.isSuccess, true);
assert.equal(updatedEmail.isSuccess, true);
const updateResult = await updater.update({
id: company.id,
patchProps: {
legalName: updatedName.data,
email: Maybe.some(updatedEmail.data),
defaultPaymentTermId: Maybe.some(defaultPaymentTermId),
updatedAt: UtcDate.today(),
},
});
assert.equal(updateResult.isSuccess, true);
assert.equal(counters.updates, 1);
assert.equal(audit.mutations.length, 1);
assert.deepEqual(audit.mutations[0]?.before, {
legalName: "Rodax Labs",
tradeName: null,
tin: "B12345678",
slug: "rodax-labs",
email: "hello@rodax.test",
phone: "+34911111111",
website: "https://rodax.test",
defaultPaymentTermId: null,
status: "active",
});
assert.deepEqual(audit.mutations[0]?.after, {
legalName: "Rodax Updated",
tradeName: null,
tin: "B12345678",
slug: "rodax-labs",
email: "updated@rodax.test",
phone: "+34911111111",
website: "https://rodax.test",
defaultPaymentTermId: defaultPaymentTermId.toPrimitive(),
status: "active",
});
const noopResult = await updater.update({
id: company.id,
patchProps: { updatedAt: UtcDate.today() },
});
assert.equal(noopResult.isSuccess, true);
assert.equal(counters.updates, 1);
assert.equal(audit.mutations.length, 1);
const failedAuditCompany = buildCompany();
const failedAuditCounters = { saves: 0, updates: 0 };
const failedAuditRepository = buildRepository(failedAuditCompany, failedAuditCounters);
const failedAudit = buildAudit(new Error("audit unavailable"));
const failedAuditUpdater = new CompanyUpdater({
finder: new CompanyFinder(failedAuditRepository),
repository: failedAuditRepository,
audit: failedAudit.audit,
});
const auditFailureResult = await failedAuditUpdater.update({
id: failedAuditCompany.id,
patchProps: {
legalName: updatedName.data,
updatedAt: UtcDate.today(),
},
});
assert.equal(auditFailureResult.isFailure, true);
assert.equal(auditFailureResult.error.message, "audit unavailable");
const disabledCompany = buildCompany(CompanyStatus.createDisabled());
const disabledCounters = { saves: 0, updates: 0 };
const disabledRepository = buildRepository(disabledCompany, disabledCounters);
const disabledAudit = buildAudit();
const disabledUpdater = new CompanyUpdater({
finder: new CompanyFinder(disabledRepository),
repository: disabledRepository,
audit: disabledAudit.audit,
});
const disabledResult = await disabledUpdater.update({
id: disabledCompany.id,
patchProps: {
legalName: updatedName.data,
updatedAt: UtcDate.today(),
},
});
assert.equal(disabledResult.isFailure, true);
assert.equal(disabledCounters.updates, 0);
assert.equal(disabledAudit.mutations.length, 0);
});
test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () => {
const company = buildCompany(CompanyStatus.createDisabled());
const counters = { saves: 0, updates: 0 };
const repository = buildRepository(company, counters);
const audit = buildAudit();
const changer = new CompanyStatusChanger(repository, audit.audit);
const enableResult = await changer.changeStatus({
company,
action: "enable",
updatedAt: UtcDate.today(),
});
assert.equal(enableResult.isSuccess, true);
assert.equal(counters.updates, 1);
assert.equal(audit.mutations.length, 1);
assert.equal(audit.mutations[0]?.action, "company.enabled");
assert.equal(audit.mutations[0]?.before.status, "disabled");
assert.equal(audit.mutations[0]?.after.status, "active");
const noopResult = await changer.changeStatus({
company,
action: "enable",
updatedAt: UtcDate.today(),
});
assert.equal(noopResult.isSuccess, true);
assert.equal(counters.updates, 1);
assert.equal(audit.mutations.length, 1);
const disableResult = await changer.changeStatus({
company,
action: "disable",
updatedAt: UtcDate.today(),
});
assert.equal(disableResult.isSuccess, true);
assert.equal(counters.updates, 2);
assert.equal(audit.mutations.length, 2);
assert.equal(audit.mutations[1]?.action, "company.disabled");
assert.equal(audit.mutations[1]?.before.status, "active");
assert.equal(audit.mutations[1]?.after.status, "disabled");
const disabledNoopResult = await changer.changeStatus({
company,
action: "disable",
updatedAt: UtcDate.today(),
});
assert.equal(disabledNoopResult.isSuccess, true);
assert.equal(counters.updates, 2);
assert.equal(audit.mutations.length, 2);
});

View File

@ -1,7 +1,13 @@
import type { IAudit } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { Company, ICompanyCreateProps } from "../../../domain"; import type { Company, ICompanyCreateProps } from "../../../domain";
import { Company as CompanyAggregate, CompanySlugAlreadyExistsError } from "../../../domain"; import { Company as CompanyAggregate, CompanySlugAlreadyExistsError } from "../../../domain";
import {
COMPANY_AUDIT_ACTIONS,
toCompanyAuditState,
toCompanyAuditTarget,
} from "../audit/company-audit";
import type { ICompanyRepository } from "../contracts"; import type { ICompanyRepository } from "../contracts";
export interface ICompanyCreator { export interface ICompanyCreator {
@ -13,7 +19,10 @@ export interface ICompanyCreator {
} }
export class CompanyCreator implements ICompanyCreator { export class CompanyCreator implements ICompanyCreator {
public constructor(private readonly repository: ICompanyRepository) {} public constructor(
private readonly repository: ICompanyRepository,
private readonly audit: IAudit
) {}
public async create(params: { public async create(params: {
id: Company["id"]; id: Company["id"];
@ -43,6 +52,16 @@ export class CompanyCreator implements ICompanyCreator {
return Result.fail(saveResult.error); return Result.fail(saveResult.error);
} }
const auditResult = await this.audit.recordMutation({
action: COMPANY_AUDIT_ACTIONS.CREATED,
target: toCompanyAuditTarget(companyResult.data),
before: {},
after: toCompanyAuditState(companyResult.data),
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
return Result.ok(companyResult.data); return Result.ok(companyResult.data);
} }
} }

View File

@ -88,7 +88,10 @@ test("rechaza un payment term de otra company", async () => {
}); });
assert.equal(result.isFailure, true); assert.equal(result.isFailure, true);
assert.match(result.error.message, /not found/i); assert.equal(
result.error.message,
"Default payment term is not assignable to the active company."
);
}); });
test("rechaza un payment term inactivo como nueva asignacion", async () => { test("rechaza un payment term inactivo como nueva asignacion", async () => {
@ -110,7 +113,10 @@ test("rechaza un payment term inactivo como nueva asignacion", async () => {
}); });
assert.equal(result.isFailure, true); assert.equal(result.isFailure, true);
assert.match(result.error.message, /must be active/i); assert.equal(
result.error.message,
"Default payment term is not assignable to the active company."
);
}); });
test("rechaza un id inexistente", async () => { test("rechaza un id inexistente", async () => {
@ -128,5 +134,8 @@ test("rechaza un id inexistente", async () => {
}); });
assert.equal(result.isFailure, true); assert.equal(result.isFailure, true);
assert.match(result.error.message, /not found/i); assert.equal(
result.error.message,
"Default payment term is not assignable to the active company."
);
}); });

View File

@ -1,6 +1,12 @@
import type { IAudit } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { Company } from "../../../domain"; import type { Company } from "../../../domain";
import {
COMPANY_AUDIT_ACTIONS,
toCompanyAuditState,
toCompanyAuditTarget,
} from "../audit/company-audit";
import type { ICompanyRepository } from "../contracts"; import type { ICompanyRepository } from "../contracts";
export interface ICompanyStatusChanger { export interface ICompanyStatusChanger {
@ -13,7 +19,10 @@ export interface ICompanyStatusChanger {
} }
export class CompanyStatusChanger implements ICompanyStatusChanger { export class CompanyStatusChanger implements ICompanyStatusChanger {
public constructor(private readonly repository: ICompanyRepository) {} public constructor(
private readonly repository: ICompanyRepository,
private readonly audit: IAudit
) {}
public async changeStatus(params: { public async changeStatus(params: {
company: Company; company: Company;
@ -21,6 +30,7 @@ export class CompanyStatusChanger implements ICompanyStatusChanger {
updatedAt: Company["updatedAt"]; updatedAt: Company["updatedAt"];
transaction?: unknown; transaction?: unknown;
}): Promise<Result<Company, Error>> { }): Promise<Result<Company, Error>> {
const before = toCompanyAuditState(params.company);
const statusResult = const statusResult =
params.action === "enable" params.action === "enable"
? params.company.enable(params.updatedAt) ? params.company.enable(params.updatedAt)
@ -30,11 +40,26 @@ export class CompanyStatusChanger implements ICompanyStatusChanger {
return Result.fail(statusResult.error); return Result.fail(statusResult.error);
} }
if (!statusResult.data) {
return Result.ok(params.company);
}
const persistResult = await this.repository.update(params.company, params.transaction); const persistResult = await this.repository.update(params.company, params.transaction);
if (persistResult.isFailure) { if (persistResult.isFailure) {
return Result.fail(persistResult.error); return Result.fail(persistResult.error);
} }
const auditResult = await this.audit.recordMutation({
action:
params.action === "enable" ? COMPANY_AUDIT_ACTIONS.ENABLED : COMPANY_AUDIT_ACTIONS.DISABLED,
target: toCompanyAuditTarget(params.company),
before,
after: toCompanyAuditState(params.company),
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
return Result.ok(params.company); return Result.ok(params.company);
} }
} }

View File

@ -1,8 +1,15 @@
import type { IAudit } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { Company, CompanyPatchProps } from "../../../domain"; import type { Company, CompanyPatchProps } from "../../../domain";
import { CompanySlugAlreadyExistsError } from "../../../domain"; import { CompanySlugAlreadyExistsError } from "../../../domain";
import {
COMPANY_AUDIT_ACTIONS,
toCompanyAuditState,
toCompanyAuditTarget,
} from "../audit/company-audit";
import type { ICompanyRepository } from "../contracts"; import type { ICompanyRepository } from "../contracts";
import type { ICompanyFinder } from "./company-finder"; import type { ICompanyFinder } from "./company-finder";
export interface ICompanyUpdater { export interface ICompanyUpdater {
@ -18,6 +25,7 @@ export class CompanyUpdater implements ICompanyUpdater {
private readonly deps: { private readonly deps: {
finder: ICompanyFinder; finder: ICompanyFinder;
repository: ICompanyRepository; repository: ICompanyRepository;
audit: IAudit;
} }
) {} ) {}
@ -32,6 +40,7 @@ export class CompanyUpdater implements ICompanyUpdater {
} }
const company = companyResult.data; const company = companyResult.data;
const before = toCompanyAuditState(company);
if (params.patchProps.slug && !company.slug.equals(params.patchProps.slug)) { if (params.patchProps.slug && !company.slug.equals(params.patchProps.slug)) {
const existingBySlug = await this.deps.repository.findBySlug( const existingBySlug = await this.deps.repository.findBySlug(
@ -42,10 +51,7 @@ export class CompanyUpdater implements ICompanyUpdater {
return Result.fail(existingBySlug.error); return Result.fail(existingBySlug.error);
} }
if ( if (existingBySlug.data.isSome() && !existingBySlug.data.unwrap().id.equals(company.id)) {
existingBySlug.data.isSome() &&
!existingBySlug.data.unwrap().id.equals(company.id)
) {
return Result.fail( return Result.fail(
new CompanySlugAlreadyExistsError( new CompanySlugAlreadyExistsError(
`Company slug "${params.patchProps.slug.toPrimitive()}" already exists.` `Company slug "${params.patchProps.slug.toPrimitive()}" already exists.`
@ -59,11 +65,25 @@ export class CompanyUpdater implements ICompanyUpdater {
return Result.fail(updateResult.error); return Result.fail(updateResult.error);
} }
if (!updateResult.data) {
return Result.ok(company);
}
const persistResult = await this.deps.repository.update(company, params.transaction); const persistResult = await this.deps.repository.update(company, params.transaction);
if (persistResult.isFailure) { if (persistResult.isFailure) {
return Result.fail(persistResult.error); return Result.fail(persistResult.error);
} }
const auditResult = await this.deps.audit.recordMutation({
action: COMPANY_AUDIT_ACTIONS.UPDATED,
target: toCompanyAuditTarget(company),
before,
after: toCompanyAuditState(company),
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
return Result.ok(company); return Result.ok(company);
} }
} }

View File

@ -1,11 +1,11 @@
import { buildTransactionManager, type ModuleParams, type SetupParams } from "@erp/core/api"; import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api";
import type { IIdentityPublicServices } from "@erp/identity/api"; import type { IIdentityPublicServices } from "@erp/identity/api";
import type { Sequelize } from "sequelize"; import type { Sequelize } from "sequelize";
import { import {
CompanyPaymentTermResolver,
CompanyCreator, CompanyCreator,
CompanyFinder, CompanyFinder,
CompanyPaymentTermResolver,
CompanyPublicFinder, CompanyPublicFinder,
CompanyPublicModelMapper, CompanyPublicModelMapper,
CompanyStatusChanger, CompanyStatusChanger,
@ -22,6 +22,7 @@ import {
buildCompanyPublicServices, buildCompanyPublicServices,
} from "../../application"; } from "../../application";
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence"; import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
import { resolveCompanyCatalogsDeps } from "./company-catalog-deps.di"; import { resolveCompanyCatalogsDeps } from "./company-catalog-deps.di";
export type CompaniesInternalDeps = { export type CompaniesInternalDeps = {
@ -40,7 +41,7 @@ export type CompaniesInternalDeps = {
}; };
export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInternalDeps => { export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInternalDeps => {
const { database, getService, transactionContext } = params; const { audit, database, getService, transactionContext } = params;
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext); const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
const responseMapper = new CompanyPublicModelMapper(); const responseMapper = new CompanyPublicModelMapper();
const identityServices = getService<IIdentityPublicServices>("identity:general"); const identityServices = getService<IIdentityPublicServices>("identity:general");
@ -48,12 +49,12 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database); const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
const finder = new CompanyFinder(repository); const finder = new CompanyFinder(repository);
const creator = new CompanyCreator(repository); const creator = new CompanyCreator(repository, audit);
const paymentTermResolver = new CompanyPaymentTermResolver({ const paymentTermResolver = new CompanyPaymentTermResolver({
paymentTermFinder: catalogsDeps.paymentTerm.finder, paymentTermFinder: catalogsDeps.paymentTerm.finder,
}); });
const updater = new CompanyUpdater({ finder, repository }); const updater = new CompanyUpdater({ finder, repository, audit });
const statusChanger = new CompanyStatusChanger(repository); const statusChanger = new CompanyStatusChanger(repository, audit);
const publicFinder = new CompanyPublicFinder({ const publicFinder = new CompanyPublicFinder({
repository, repository,
mapper: responseMapper, mapper: responseMapper,

View File

@ -26,12 +26,13 @@ import {
} from "./controllers"; } from "./controllers";
export const companiesRouter = (params: StartParams) => { export const companiesRouter = (params: StartParams) => {
const { app, config, getInternal, getService } = params; const { app, config, getInternal, getService, operationContext } = params;
const deps = getInternal<CompaniesInternalDeps>("companies"); const deps = getInternal<CompaniesInternalDeps>("companies");
const identityServices = getService<IIdentityPublicServices>("identity:general"); const identityServices = getService<IIdentityPublicServices>("identity:general");
const requireCurrentCompanyContext = requireCompanyContext({ const requireCurrentCompanyContext = requireCompanyContext({
companyAccessChecker: identityServices.companyAccess, companyAccessChecker: identityServices.companyAccess,
companyFinder: deps.publicFinder, companyFinder: deps.publicFinder,
operationContext,
}); });
const router = Router({ mergeParams: true }); const router = Router({ mergeParams: true });

View File

@ -522,6 +522,9 @@ importers:
'@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-ddd': '@repo/rdx-ddd':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-ddd version: link:../../packages/rdx-ddd