feat(audit): integrate company audit functionality and related tests
This commit is contained in:
parent
0bcccd5e80
commit
e23a28cb8b
247
apps/server/src/testing/http/company-audit.integration.test.ts
Normal file
247
apps/server/src/testing/http/company-audit.integration.test.ts
Normal 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);
|
||||||
|
});
|
||||||
@ -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:*",
|
||||||
|
|||||||
@ -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(),
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user