feat(onboarding): implement initial onboarding use cases and controllers
- Add GetInitialOnboardingStatusUseCase to retrieve onboarding status. - Implement CompleteInitialOnboardingUseCase for completing onboarding. - Create controllers for handling onboarding requests in Express. - Introduce new repository methods for managing onboarding data. - Set up dependency injection for onboarding services and use cases. - Add tests for onboarding use cases and repository methods. - Define request DTOs for onboarding operations with validation. - Update module composition to include onboarding functionalities.
This commit is contained in:
parent
dac22372b9
commit
bf1dd43c78
34
AGENTS.md
34
AGENTS.md
@ -412,6 +412,18 @@ export interface IIdentityTenantCompanyFinder {
|
|||||||
|
|
||||||
\- Mantener inmutabilidad en valores y estructuras cuando no exista necesidad de mutación.
|
\- Mantener inmutabilidad en valores y estructuras cuando no exista necesidad de mutación.
|
||||||
|
|
||||||
|
**## Referencias arquitectónicas normativas**
|
||||||
|
|
||||||
|
Para reglas completas, consultar y no duplicar:
|
||||||
|
|
||||||
|
- [Arquitectura backend](docs/architecture/backend-architecture.md)
|
||||||
|
- [Inyección de dependencias](docs/architecture/dependency-injection.md)
|
||||||
|
- [Flujo HTTP y entrada a Application](docs/architecture/http-request-flow.md)
|
||||||
|
- [DTOs y schemas HTTP](docs/architecture/dto-guidelines.md)
|
||||||
|
- [Pruebas backend por capa](docs/architecture/testing-guidelines.md)
|
||||||
|
|
||||||
|
Las reglas resumidas siguientes son recordatorios operativos; las SSOT anteriores prevalecen.
|
||||||
|
|
||||||
**## Zod y DTOs**
|
**## Zod y DTOs**
|
||||||
|
|
||||||
Zod es la fuente única de verdad para los contratos HTTP.
|
Zod es la fuente única de verdad para los contratos HTTP.
|
||||||
@ -478,29 +490,15 @@ No se permite:
|
|||||||
|
|
||||||
**### Flujo de entrada**
|
**### Flujo de entrada**
|
||||||
|
|
||||||
Respetar:
|
Para el flujo normal, usar:
|
||||||
|
|
||||||
\`\`\`text
|
\`\`\`text
|
||||||
|
|
||||||
RequestDTO
|
RequestDTO -> UseCase({ dto, context }) -> InputMapper -> Value Objects / Props
|
||||||
|
|
||||||
-> RequestMapper
|
|
||||||
|
|
||||||
-> Command
|
|
||||||
|
|
||||||
-> PropsMapper
|
|
||||||
|
|
||||||
-> Aggregate o Entity
|
|
||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
No mapear directamente:
|
`RequestMapper -> Command` es opcional y solo procede cuando existe adaptación HTTP propia, por ejemplo para combinar body, params o query. No crear uno solo para renombrar campos que Application volverá a transformar.
|
||||||
|
|
||||||
\`\`\`text
|
|
||||||
|
|
||||||
DTO -> Aggregate
|
|
||||||
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**### Casos de uso**
|
**### Casos de uso**
|
||||||
|
|
||||||
@ -866,4 +864,4 @@ En caso de conflicto, aplicar este orden:
|
|||||||
|
|
||||||
5\. preferencias generales del agente.
|
5\. preferencias generales del agente.
|
||||||
|
|
||||||
Si una instrucción contradice la arquitectura o genera un riesgo relevante, señalarlo antes de introducir el cambio.
|
Si una instrucción contradice la arquitectura o genera un riesgo relevante, señalarlo antes de introducir el cambio.
|
||||||
|
|||||||
@ -46,6 +46,7 @@
|
|||||||
"@erp/document-series": "workspace:*",
|
"@erp/document-series": "workspace:*",
|
||||||
"@erp/identity": "workspace:*",
|
"@erp/identity": "workspace:*",
|
||||||
"@erp/factuges": "workspace:*",
|
"@erp/factuges": "workspace:*",
|
||||||
|
"@erp/onboarding": "workspace:*",
|
||||||
"@erp/suppliers": "workspace:*",
|
"@erp/suppliers": "workspace:*",
|
||||||
"@repo/rdx-logger": "workspace:*",
|
"@repo/rdx-logger": "workspace:*",
|
||||||
"@repo/rdx-audit": "workspace:*",
|
"@repo/rdx-audit": "workspace:*",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import customersAPIModule from "@erp/customers/api";
|
|||||||
import documentSeriesAPIModule from "@erp/document-series/api";
|
import documentSeriesAPIModule from "@erp/document-series/api";
|
||||||
import factuGESAPIModule from "@erp/factuges/api";
|
import factuGESAPIModule from "@erp/factuges/api";
|
||||||
import identityAPIModule from "@erp/identity/api";
|
import identityAPIModule from "@erp/identity/api";
|
||||||
|
import onboardingAPIModule from "@erp/onboarding/api";
|
||||||
import suppliersAPIModule from "@erp/suppliers/api";
|
import suppliersAPIModule from "@erp/suppliers/api";
|
||||||
|
|
||||||
// Aquí hay que registrar los módulos que
|
// Aquí hay que registrar los módulos que
|
||||||
@ -22,4 +23,5 @@ export const registerModules = () => {
|
|||||||
registerModule(customerInvoicesAPIModule);
|
registerModule(customerInvoicesAPIModule);
|
||||||
registerModule(factuGESAPIModule);
|
registerModule(factuGESAPIModule);
|
||||||
registerModule(suppliersAPIModule);
|
registerModule(suppliersAPIModule);
|
||||||
|
registerModule(onboardingAPIModule);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { CreateCompanyInputMapper } from "./create-company-input.mapper";
|
||||||
|
|
||||||
|
const mapper = new CreateCompanyInputMapper();
|
||||||
|
const dto = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
legal_name: "Rodax Labs",
|
||||||
|
trade_name: "Rodax",
|
||||||
|
tin: "B12345678",
|
||||||
|
slug: "rodax-labs",
|
||||||
|
email: "hello@rodax.test",
|
||||||
|
phone: "+34911111111",
|
||||||
|
website: "https://rodax.test",
|
||||||
|
address: {
|
||||||
|
street: "Main 1",
|
||||||
|
street_2: null,
|
||||||
|
city: "Madrid",
|
||||||
|
postal_code: "28001",
|
||||||
|
province: "Madrid",
|
||||||
|
country_code: "ES",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
test("maps complete create input and optional address", () => {
|
||||||
|
const result = mapper.map(dto);
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
if (result.isFailure) return;
|
||||||
|
assert.equal(result.data.id.toPrimitive(), dto.id);
|
||||||
|
assert.equal(result.data.props.legalName.toPrimitive(), dto.legal_name);
|
||||||
|
assert.equal(result.data.props.tradeName.unwrap().toPrimitive(), dto.trade_name);
|
||||||
|
assert.equal(result.data.props.address.unwrap().city.unwrap().toPrimitive(), "Madrid");
|
||||||
|
assert.equal(result.data.props.defaultPaymentTermId.isNone(), true);
|
||||||
|
});
|
||||||
|
test("maps absent and null address to none", () => {
|
||||||
|
for (const address of [undefined, null]) {
|
||||||
|
const result = mapper.map({ ...dto, address });
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
if (result.isSuccess) assert.equal(result.data.props.address.isNone(), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("returns failure for invalid value object", () => {
|
||||||
|
const result = mapper.map({ ...dto, email: "invalid" });
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
});
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import type { PostalAddress } from "@repo/rdx-ddd";
|
||||||
|
import {
|
||||||
|
EmailAddress,
|
||||||
|
Name,
|
||||||
|
PhoneNumber,
|
||||||
|
TextValue,
|
||||||
|
URLAddress,
|
||||||
|
UniqueID,
|
||||||
|
UtcDateTime,
|
||||||
|
maybeFromNullableResult,
|
||||||
|
} from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { CreateCompanyRequestDTO } from "../../../../common";
|
||||||
|
import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain";
|
||||||
|
|
||||||
|
import { toPostalAddress } from "./company-address.mapper";
|
||||||
|
|
||||||
|
export interface ICreateCompanyInputMapper {
|
||||||
|
map(dto: CreateCompanyRequestDTO): Result<CreateCompanyInput, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateCompanyInput = { id: UniqueID; props: ICompanyCreateProps };
|
||||||
|
|
||||||
|
export class CreateCompanyInputMapper implements ICreateCompanyInputMapper {
|
||||||
|
public map(dto: CreateCompanyRequestDTO): Result<CreateCompanyInput, Error> {
|
||||||
|
const idResult = UniqueID.create(dto.id);
|
||||||
|
if (idResult.isFailure) return Result.fail(idResult.error);
|
||||||
|
const legalNameResult = Name.create(dto.legal_name);
|
||||||
|
if (legalNameResult.isFailure) return Result.fail(legalNameResult.error);
|
||||||
|
const tradeNameResult = maybeFromNullableResult(dto.trade_name ?? null, (value) =>
|
||||||
|
Name.create(value)
|
||||||
|
);
|
||||||
|
if (tradeNameResult.isFailure) return Result.fail(tradeNameResult.error);
|
||||||
|
const tinResult = maybeFromNullableResult(dto.tin ?? null, (value) => TextValue.create(value));
|
||||||
|
if (tinResult.isFailure) return Result.fail(tinResult.error);
|
||||||
|
const slugResult = CompanySlug.create(dto.slug);
|
||||||
|
if (slugResult.isFailure) return Result.fail(slugResult.error);
|
||||||
|
const emailResult = maybeFromNullableResult(dto.email ?? null, (value) =>
|
||||||
|
EmailAddress.create(value)
|
||||||
|
);
|
||||||
|
if (emailResult.isFailure) return Result.fail(emailResult.error);
|
||||||
|
const phoneResult = maybeFromNullableResult(dto.phone ?? null, (value) =>
|
||||||
|
PhoneNumber.create(value)
|
||||||
|
);
|
||||||
|
if (phoneResult.isFailure) return Result.fail(phoneResult.error);
|
||||||
|
const websiteResult = maybeFromNullableResult(dto.website ?? null, (value) =>
|
||||||
|
URLAddress.create(value)
|
||||||
|
);
|
||||||
|
if (websiteResult.isFailure) return Result.fail(websiteResult.error);
|
||||||
|
const addressResult = this.mapAddress(dto);
|
||||||
|
if (addressResult.isFailure) return Result.fail(addressResult.error);
|
||||||
|
const now = UtcDateTime.now();
|
||||||
|
return Result.ok({
|
||||||
|
id: idResult.data,
|
||||||
|
props: {
|
||||||
|
legalName: legalNameResult.data,
|
||||||
|
tradeName: tradeNameResult.data,
|
||||||
|
tin: tinResult.data,
|
||||||
|
slug: slugResult.data,
|
||||||
|
email: emailResult.data,
|
||||||
|
phone: phoneResult.data,
|
||||||
|
website: websiteResult.data,
|
||||||
|
address: addressResult.data,
|
||||||
|
defaultPaymentTermId: Maybe.none(),
|
||||||
|
status: CompanyStatus.createActive(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapAddress(dto: CreateCompanyRequestDTO): Result<Maybe<PostalAddress>, Error> {
|
||||||
|
if (dto.address === undefined || dto.address === null) return Result.ok(Maybe.none());
|
||||||
|
const result = toPostalAddress(dto.address);
|
||||||
|
return result.isFailure ? Result.fail(result.error) : Result.ok(Maybe.some(result.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { UpdateCompanyInputMapper } from "./update-company-input.mapper";
|
||||||
|
|
||||||
|
const mapper = new UpdateCompanyInputMapper();
|
||||||
|
const address = {
|
||||||
|
street: "Main 1",
|
||||||
|
street_2: null,
|
||||||
|
city: "Madrid",
|
||||||
|
postal_code: "28001",
|
||||||
|
province: "Madrid",
|
||||||
|
country_code: "ES",
|
||||||
|
};
|
||||||
|
test("preserves omitted PATCH fields", () => {
|
||||||
|
const result = mapper.map({});
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
if (result.isSuccess) {
|
||||||
|
assert.equal(result.data.patchProps.address, undefined);
|
||||||
|
assert.equal(result.data.defaultPaymentTermId, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("maps nullable address and payment term PATCH states", () => {
|
||||||
|
const cleared = mapper.map({ address: null, default_payment_term_id: null });
|
||||||
|
assert.equal(cleared.isSuccess, true);
|
||||||
|
if (cleared.isSuccess) {
|
||||||
|
assert.equal(cleared.data.patchProps.address?.isNone(), true);
|
||||||
|
assert.equal(cleared.data.defaultPaymentTermId?.isNone(), true);
|
||||||
|
}
|
||||||
|
const value = mapper.map({
|
||||||
|
address,
|
||||||
|
default_payment_term_id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
slug: "rodax",
|
||||||
|
});
|
||||||
|
assert.equal(value.isSuccess, true);
|
||||||
|
if (value.isSuccess) {
|
||||||
|
assert.equal(value.data.patchProps.address?.unwrap().city.unwrap().toPrimitive(), "Madrid");
|
||||||
|
assert.equal(
|
||||||
|
value.data.defaultPaymentTermId?.unwrap().toPrimitive(),
|
||||||
|
"11111111-1111-4111-8111-111111111111"
|
||||||
|
);
|
||||||
|
assert.equal(value.data.patchProps.slug?.toPrimitive(), "rodax");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("returns failure for invalid slug", () => {
|
||||||
|
assert.equal(mapper.map({ slug: "" }).isFailure, true);
|
||||||
|
});
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
EmailAddress,
|
||||||
|
Name,
|
||||||
|
PhoneNumber,
|
||||||
|
TextValue,
|
||||||
|
URLAddress,
|
||||||
|
UniqueID,
|
||||||
|
UtcDateTime,
|
||||||
|
maybeFromNullableResult,
|
||||||
|
} from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { UpdateCompanyRequestDTO } from "../../../../common";
|
||||||
|
import { type CompanyPatchProps, CompanySlug } from "../../../domain";
|
||||||
|
|
||||||
|
import { toPostalAddress } from "./company-address.mapper";
|
||||||
|
|
||||||
|
export type UpdateCompanyInput = {
|
||||||
|
patchProps: CompanyPatchProps;
|
||||||
|
defaultPaymentTermId: Maybe<UniqueID> | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IUpdateCompanyInputMapper {
|
||||||
|
map(dto: UpdateCompanyRequestDTO): Result<UpdateCompanyInput, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCompanyInputMapper implements IUpdateCompanyInputMapper {
|
||||||
|
public map(dto: UpdateCompanyRequestDTO): Result<UpdateCompanyInput, Error> {
|
||||||
|
const patchProps: CompanyPatchProps = { updatedAt: UtcDateTime.now() };
|
||||||
|
const fieldResults = [
|
||||||
|
this.mapLegalName(patchProps, dto),
|
||||||
|
this.mapTradeName(patchProps, dto),
|
||||||
|
this.mapTin(patchProps, dto),
|
||||||
|
this.mapSlug(patchProps, dto),
|
||||||
|
this.mapEmail(patchProps, dto),
|
||||||
|
this.mapPhone(patchProps, dto),
|
||||||
|
this.mapWebsite(patchProps, dto),
|
||||||
|
this.mapAddress(patchProps, dto),
|
||||||
|
this.mapPaymentTermPatch(patchProps, dto),
|
||||||
|
];
|
||||||
|
for (const result of fieldResults) if (result.isFailure) return Result.fail(result.error);
|
||||||
|
const paymentTermResult = this.mapPaymentTermId(dto);
|
||||||
|
return paymentTermResult.isFailure
|
||||||
|
? Result.fail(paymentTermResult.error)
|
||||||
|
: Result.ok({ patchProps, defaultPaymentTermId: paymentTermResult.data });
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapLegalName(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.legal_name === undefined) return Result.ok(undefined);
|
||||||
|
const r = Name.create(d.legal_name);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.legalName = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private mapTradeName(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.trade_name === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.trade_name, Name.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.tradeName = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private mapTin(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.tin === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.tin, TextValue.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.tin = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private mapSlug(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.slug === undefined) return Result.ok(undefined);
|
||||||
|
const r = CompanySlug.create(d.slug);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.slug = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private mapEmail(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.email === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.email, EmailAddress.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.email = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private mapPhone(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.phone === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.phone, PhoneNumber.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.phone = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private mapWebsite(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.website === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.website, URLAddress.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.website = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private mapAddress(p: CompanyPatchProps, d: UpdateCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.address === undefined) return Result.ok(undefined);
|
||||||
|
if (d.address === null) {
|
||||||
|
p.address = Maybe.none();
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
const r = toPostalAddress(d.address);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.address = Maybe.some(r.data);
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private mapPaymentTermPatch(
|
||||||
|
p: CompanyPatchProps,
|
||||||
|
d: UpdateCompanyRequestDTO
|
||||||
|
): Result<void, Error> {
|
||||||
|
if (d.default_payment_term_id === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.default_payment_term_id, UniqueID.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.defaultPaymentTermId = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private mapPaymentTermId(d: UpdateCompanyRequestDTO): Result<Maybe<UniqueID> | undefined, Error> {
|
||||||
|
if (d.default_payment_term_id === undefined) return Result.ok(undefined);
|
||||||
|
if (d.default_payment_term_id === null) return Result.ok(Maybe.none());
|
||||||
|
const r = UniqueID.create(d.default_payment_term_id);
|
||||||
|
return r.isFailure ? Result.fail(r.error) : Result.ok(Maybe.some(r.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { UpdateCurrentCompanyInputMapper } from "./update-current-company-input.mapper";
|
||||||
|
|
||||||
|
const mapper = new UpdateCurrentCompanyInputMapper();
|
||||||
|
const address = {
|
||||||
|
street: "Main 1",
|
||||||
|
street_2: null,
|
||||||
|
city: "Madrid",
|
||||||
|
postal_code: "28001",
|
||||||
|
province: "Madrid",
|
||||||
|
country_code: "ES",
|
||||||
|
};
|
||||||
|
test("maps current-company PATCH tri-state without slug", () => {
|
||||||
|
const omitted = mapper.map({});
|
||||||
|
assert.equal(omitted.isSuccess, true);
|
||||||
|
if (omitted.isSuccess) assert.equal(omitted.data.patchProps.address, undefined);
|
||||||
|
const cleared = mapper.map({ address: null, default_payment_term_id: null });
|
||||||
|
assert.equal(cleared.isSuccess, true);
|
||||||
|
if (cleared.isSuccess) assert.equal(cleared.data.patchProps.address?.isNone(), true);
|
||||||
|
const value = mapper.map({
|
||||||
|
address,
|
||||||
|
default_payment_term_id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
email: "hello@rodax.test",
|
||||||
|
});
|
||||||
|
assert.equal(value.isSuccess, true);
|
||||||
|
if (value.isSuccess) {
|
||||||
|
assert.equal(value.data.patchProps.email?.unwrap().toPrimitive(), "hello@rodax.test");
|
||||||
|
assert.equal(value.data.defaultPaymentTermId?.isSome(), true);
|
||||||
|
assert.equal("slug" in value.data.patchProps, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("returns failure for invalid email", () => {
|
||||||
|
assert.equal(mapper.map({ email: "invalid" }).isFailure, true);
|
||||||
|
});
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
EmailAddress,
|
||||||
|
Name,
|
||||||
|
PhoneNumber,
|
||||||
|
TextValue,
|
||||||
|
URLAddress,
|
||||||
|
UniqueID,
|
||||||
|
UtcDateTime,
|
||||||
|
maybeFromNullableResult,
|
||||||
|
} from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { UpdateCurrentCompanyRequestDTO } from "../../../../common";
|
||||||
|
import type { CompanyPatchProps } from "../../../domain";
|
||||||
|
|
||||||
|
import { toPostalAddress } from "./company-address.mapper";
|
||||||
|
|
||||||
|
export type UpdateCurrentCompanyInput = {
|
||||||
|
patchProps: CompanyPatchProps;
|
||||||
|
defaultPaymentTermId: Maybe<UniqueID> | undefined;
|
||||||
|
};
|
||||||
|
export interface IUpdateCurrentCompanyInputMapper {
|
||||||
|
map(dto: UpdateCurrentCompanyRequestDTO): Result<UpdateCurrentCompanyInput, Error>;
|
||||||
|
}
|
||||||
|
export class UpdateCurrentCompanyInputMapper implements IUpdateCurrentCompanyInputMapper {
|
||||||
|
public map(dto: UpdateCurrentCompanyRequestDTO): Result<UpdateCurrentCompanyInput, Error> {
|
||||||
|
const patchProps: CompanyPatchProps = { updatedAt: UtcDateTime.now() };
|
||||||
|
const results = [
|
||||||
|
this.legalName(patchProps, dto),
|
||||||
|
this.tradeName(patchProps, dto),
|
||||||
|
this.tin(patchProps, dto),
|
||||||
|
this.email(patchProps, dto),
|
||||||
|
this.phone(patchProps, dto),
|
||||||
|
this.website(patchProps, dto),
|
||||||
|
this.address(patchProps, dto),
|
||||||
|
this.paymentTermPatch(patchProps, dto),
|
||||||
|
];
|
||||||
|
for (const result of results) if (result.isFailure) return Result.fail(result.error);
|
||||||
|
const paymentTerm = this.paymentTermId(dto);
|
||||||
|
return paymentTerm.isFailure
|
||||||
|
? Result.fail(paymentTerm.error)
|
||||||
|
: Result.ok({ patchProps, defaultPaymentTermId: paymentTerm.data });
|
||||||
|
}
|
||||||
|
private legalName(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.legal_name === undefined) return Result.ok(undefined);
|
||||||
|
const r = Name.create(d.legal_name);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.legalName = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private tradeName(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.trade_name === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.trade_name, Name.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.tradeName = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private tin(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.tin === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.tin, TextValue.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.tin = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private email(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.email === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.email, EmailAddress.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.email = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private phone(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.phone === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.phone, PhoneNumber.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.phone = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private website(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.website === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.website, URLAddress.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.website = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private address(p: CompanyPatchProps, d: UpdateCurrentCompanyRequestDTO): Result<void, Error> {
|
||||||
|
if (d.address === undefined) return Result.ok(undefined);
|
||||||
|
if (d.address === null) {
|
||||||
|
p.address = Maybe.none();
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
const r = toPostalAddress(d.address);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.address = Maybe.some(r.data);
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private paymentTermPatch(
|
||||||
|
p: CompanyPatchProps,
|
||||||
|
d: UpdateCurrentCompanyRequestDTO
|
||||||
|
): Result<void, Error> {
|
||||||
|
if (d.default_payment_term_id === undefined) return Result.ok(undefined);
|
||||||
|
const r = maybeFromNullableResult(d.default_payment_term_id, UniqueID.create);
|
||||||
|
if (r.isFailure) return Result.fail(r.error);
|
||||||
|
p.defaultPaymentTermId = r.data;
|
||||||
|
return Result.ok(undefined);
|
||||||
|
}
|
||||||
|
private paymentTermId(
|
||||||
|
d: UpdateCurrentCompanyRequestDTO
|
||||||
|
): Result<Maybe<UniqueID> | undefined, Error> {
|
||||||
|
if (d.default_payment_term_id === undefined) return Result.ok(undefined);
|
||||||
|
if (d.default_payment_term_id === null) return Result.ok(Maybe.none());
|
||||||
|
const r = UniqueID.create(d.default_payment_term_id);
|
||||||
|
return r.isFailure ? Result.fail(r.error) : Result.ok(Maybe.some(r.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { City, CountryCode, Name, PostalAddress, PostalCode, Province, Street, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
import { City, CountryCode, Name, PostalAddress, PostalCode, Province, Street, UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||||
import { Maybe } from "@repo/rdx-utils";
|
import { Maybe } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { Company, CompanySlug, CompanyStatus } from "../../../../domain";
|
import { Company, CompanySlug, CompanyStatus } from "../../../../domain";
|
||||||
@ -9,7 +9,7 @@ import { CompanyPublicModelMapper } from "./company-public-model.mapper";
|
|||||||
|
|
||||||
const unwrap = <T>(result: { data: T }): T => result.data;
|
const unwrap = <T>(result: { data: T }): T => result.data;
|
||||||
const buildAddress = (street2 = Maybe.some(unwrap(Street.create("Suite 2")))) => unwrap(PostalAddress.create({ street: Maybe.some(unwrap(Street.create("Street 1"))), street2, city: Maybe.some(unwrap(City.create("Madrid"))), postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), province: Maybe.some(unwrap(Province.create("Madrid"))), countryCode: Maybe.some(unwrap(CountryCode.create("ES"))) }));
|
const buildAddress = (street2 = Maybe.some(unwrap(Street.create("Suite 2")))) => unwrap(PostalAddress.create({ street: Maybe.some(unwrap(Street.create("Street 1"))), street2, city: Maybe.some(unwrap(City.create("Madrid"))), postalCode: Maybe.some(unwrap(PostalCode.create("28001"))), province: Maybe.some(unwrap(Province.create("Madrid"))), countryCode: Maybe.some(unwrap(CountryCode.create("ES"))) }));
|
||||||
const buildCompany = (address = Maybe.none<PostalAddress>()) => unwrap(Company.create({ legalName: unwrap(Name.create("Rodax")), tradeName: Maybe.none(), tin: Maybe.none(), slug: unwrap(CompanySlug.create("rodax")), email: Maybe.none(), phone: Maybe.none(), website: Maybe.none(), address, defaultPaymentTermId: Maybe.none(), status: CompanyStatus.createActive(), createdAt: unwrap(UtcDate.createFromISO("2026-01-01")), updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")) }, UniqueID.generateNewID()));
|
const buildCompany = (address = Maybe.none<PostalAddress>()) => unwrap(Company.create({ legalName: unwrap(Name.create("Rodax")), tradeName: Maybe.none(), tin: Maybe.none(), slug: unwrap(CompanySlug.create("rodax")), email: Maybe.none(), phone: Maybe.none(), website: Maybe.none(), address, defaultPaymentTermId: Maybe.none(), status: CompanyStatus.createActive(), createdAt: unwrap(UtcDateTime.createFromISO("2026-01-01T00:00:00.000Z")), updatedAt: unwrap(UtcDateTime.createFromISO("2026-01-01T00:00:00.000Z")) }, UniqueID.generateNewID()));
|
||||||
const mapper = new CompanyPublicModelMapper();
|
const mapper = new CompanyPublicModelMapper();
|
||||||
|
|
||||||
test("maps Company address to public primitives", () => {
|
test("maps Company address to public primitives", () => {
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
import type { ICompanyPublicFinder } from "./company-public-finder";
|
import type { ICompanyPublicFinder } from "./company-public-finder";
|
||||||
|
|
||||||
|
import type { IInitialCompanyCreator } from "./initial-company-creator";
|
||||||
|
|
||||||
export interface ICompanyPublicServices {
|
export interface ICompanyPublicServices {
|
||||||
finder: ICompanyPublicFinder;
|
finder: ICompanyPublicFinder;
|
||||||
|
initialCompanyCreator: IInitialCompanyCreator;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildCompanyPublicServices = (
|
export const buildCompanyPublicServices = (
|
||||||
finder: ICompanyPublicFinder
|
finder: ICompanyPublicFinder,
|
||||||
|
initialCompanyCreator: IInitialCompanyCreator
|
||||||
): ICompanyPublicServices => ({
|
): ICompanyPublicServices => ({
|
||||||
finder,
|
finder,
|
||||||
|
initialCompanyCreator,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
Street,
|
Street,
|
||||||
TextValue,
|
TextValue,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
UtcDate,
|
UtcDateTime,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -71,8 +71,8 @@ function setup(error?: Error, duplicate = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("crea con id, slug, status y fechas internos y persiste dirección completa", async (t) => {
|
test("crea con id, slug, status y fechas internos y persiste dirección completa", async (t) => {
|
||||||
const now = UtcDate.createFromISO("2026-09-07").data;
|
const now = UtcDateTime.createFromISO("2026-09-07T00:00:00.000Z").data;
|
||||||
t.mock.method(UtcDate, "today", () => now);
|
t.mock.method(UtcDateTime, "now", () => now);
|
||||||
const { capability, saved } = setup();
|
const { capability, saved } = setup();
|
||||||
const params = input();
|
const params = input();
|
||||||
const result = await capability.create(params);
|
const result = await capability.create(params);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type {
|
|||||||
TextValue,
|
TextValue,
|
||||||
URLAddress,
|
URLAddress,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { CompanySlug, CompanyStatus } from "../../../../domain";
|
import { CompanySlug, CompanyStatus } from "../../../../domain";
|
||||||
@ -56,7 +56,7 @@ export class InitialCompanyCreator implements IInitialCompanyCreator {
|
|||||||
// El ID interno mantiene el slug estable y distingue empresas con el mismo nombre.
|
// El ID interno mantiene el slug estable y distingue empresas con el mismo nombre.
|
||||||
const slug = CompanySlug.create(`company-${id.toPrimitive()}`);
|
const slug = CompanySlug.create(`company-${id.toPrimitive()}`);
|
||||||
if (slug.isFailure) return Result.fail(slug.error);
|
if (slug.isFailure) return Result.fail(slug.error);
|
||||||
const now = UtcDate.today();
|
const now = UtcDateTime.now();
|
||||||
const created = await this.creator.create({
|
const created = await this.creator.create({
|
||||||
id,
|
id,
|
||||||
transaction: params.transaction,
|
transaction: params.transaction,
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
TextValue,
|
TextValue,
|
||||||
URLAddress,
|
URLAddress,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
UtcDate,
|
UtcDateTime,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ import { CompanyStatusChanger } from "./company-status-changer";
|
|||||||
import { CompanyUpdater } from "./company-updater";
|
import { CompanyUpdater } from "./company-updater";
|
||||||
|
|
||||||
const buildCompany = (status = CompanyStatus.createActive()) => {
|
const buildCompany = (status = CompanyStatus.createActive()) => {
|
||||||
const now = UtcDate.createFromISO("2026-08-16");
|
const now = UtcDateTime.createFromISO("2026-08-16T00:00:00.000Z");
|
||||||
const legalName = Name.create("Rodax Labs");
|
const legalName = Name.create("Rodax Labs");
|
||||||
const slug = CompanySlug.create("rodax-labs");
|
const slug = CompanySlug.create("rodax-labs");
|
||||||
const tin = TextValue.create("B12345678");
|
const tin = TextValue.create("B12345678");
|
||||||
@ -167,7 +167,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
|
|||||||
legalName: updatedName.data,
|
legalName: updatedName.data,
|
||||||
email: Maybe.some(updatedEmail.data),
|
email: Maybe.some(updatedEmail.data),
|
||||||
defaultPaymentTermId: Maybe.some(defaultPaymentTermId),
|
defaultPaymentTermId: Maybe.some(defaultPaymentTermId),
|
||||||
updatedAt: UtcDate.today(),
|
updatedAt: UtcDateTime.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(updateResult.isSuccess, true);
|
assert.equal(updateResult.isSuccess, true);
|
||||||
@ -198,7 +198,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
|
|||||||
|
|
||||||
const noopResult = await updater.update({
|
const noopResult = await updater.update({
|
||||||
id: company.id,
|
id: company.id,
|
||||||
patchProps: { updatedAt: UtcDate.today() },
|
patchProps: { updatedAt: UtcDateTime.now() },
|
||||||
});
|
});
|
||||||
assert.equal(noopResult.isSuccess, true);
|
assert.equal(noopResult.isSuccess, true);
|
||||||
assert.equal(counters.updates, 1);
|
assert.equal(counters.updates, 1);
|
||||||
@ -217,7 +217,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
|
|||||||
id: failedAuditCompany.id,
|
id: failedAuditCompany.id,
|
||||||
patchProps: {
|
patchProps: {
|
||||||
legalName: updatedName.data,
|
legalName: updatedName.data,
|
||||||
updatedAt: UtcDate.today(),
|
updatedAt: UtcDateTime.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(auditFailureResult.isFailure, true);
|
assert.equal(auditFailureResult.isFailure, true);
|
||||||
@ -236,7 +236,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
|
|||||||
id: disabledCompany.id,
|
id: disabledCompany.id,
|
||||||
patchProps: {
|
patchProps: {
|
||||||
legalName: updatedName.data,
|
legalName: updatedName.data,
|
||||||
updatedAt: UtcDate.today(),
|
updatedAt: UtcDateTime.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(disabledResult.isFailure, true);
|
assert.equal(disabledResult.isFailure, true);
|
||||||
@ -254,7 +254,7 @@ test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () =>
|
|||||||
const enableResult = await changer.changeStatus({
|
const enableResult = await changer.changeStatus({
|
||||||
company,
|
company,
|
||||||
action: "enable",
|
action: "enable",
|
||||||
updatedAt: UtcDate.today(),
|
updatedAt: UtcDateTime.now(),
|
||||||
});
|
});
|
||||||
assert.equal(enableResult.isSuccess, true);
|
assert.equal(enableResult.isSuccess, true);
|
||||||
assert.equal(counters.updates, 1);
|
assert.equal(counters.updates, 1);
|
||||||
@ -266,7 +266,7 @@ test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () =>
|
|||||||
const noopResult = await changer.changeStatus({
|
const noopResult = await changer.changeStatus({
|
||||||
company,
|
company,
|
||||||
action: "enable",
|
action: "enable",
|
||||||
updatedAt: UtcDate.today(),
|
updatedAt: UtcDateTime.now(),
|
||||||
});
|
});
|
||||||
assert.equal(noopResult.isSuccess, true);
|
assert.equal(noopResult.isSuccess, true);
|
||||||
assert.equal(counters.updates, 1);
|
assert.equal(counters.updates, 1);
|
||||||
@ -275,7 +275,7 @@ test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () =>
|
|||||||
const disableResult = await changer.changeStatus({
|
const disableResult = await changer.changeStatus({
|
||||||
company,
|
company,
|
||||||
action: "disable",
|
action: "disable",
|
||||||
updatedAt: UtcDate.today(),
|
updatedAt: UtcDateTime.now(),
|
||||||
});
|
});
|
||||||
assert.equal(disableResult.isSuccess, true);
|
assert.equal(disableResult.isSuccess, true);
|
||||||
assert.equal(counters.updates, 2);
|
assert.equal(counters.updates, 2);
|
||||||
@ -287,7 +287,7 @@ test("CompanyStatusChanger audita cambios de estado y omite no-ops", async () =>
|
|||||||
const disabledNoopResult = await changer.changeStatus({
|
const disabledNoopResult = await changer.changeStatus({
|
||||||
company,
|
company,
|
||||||
action: "disable",
|
action: "disable",
|
||||||
updatedAt: UtcDate.today(),
|
updatedAt: UtcDateTime.now(),
|
||||||
});
|
});
|
||||||
assert.equal(disabledNoopResult.isSuccess, true);
|
assert.equal(disabledNoopResult.isSuccess, true);
|
||||||
assert.equal(counters.updates, 2);
|
assert.equal(counters.updates, 2);
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
|
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
|
||||||
|
import { CreateCompanyInputMapper } from "../mappers/create-company-input.mapper";
|
||||||
import { CompanyPublicModelMapper } from "../public";
|
import { CompanyPublicModelMapper } from "../public";
|
||||||
|
|
||||||
import { CreateCompanyUseCase } from "./create-company.use-case";
|
import { CreateCompanyUseCase } from "./create-company.use-case";
|
||||||
@ -76,6 +77,7 @@ test("create devuelve la company sin default payment term", async () => {
|
|||||||
const company = buildCompany();
|
const company = buildCompany();
|
||||||
|
|
||||||
const useCase = new CreateCompanyUseCase({
|
const useCase = new CreateCompanyUseCase({
|
||||||
|
inputMapper: new CreateCompanyInputMapper(),
|
||||||
creator: {
|
creator: {
|
||||||
create: async () => Result.ok(company),
|
create: async () => Result.ok(company),
|
||||||
},
|
},
|
||||||
@ -116,6 +118,7 @@ test("create serializes the company public address", async () => {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
const useCase = new CreateCompanyUseCase({
|
const useCase = new CreateCompanyUseCase({
|
||||||
|
inputMapper: new CreateCompanyInputMapper(),
|
||||||
creator: { create: async () => Result.ok(company) },
|
creator: { create: async () => Result.ok(company) },
|
||||||
responseMapper: new CompanyPublicModelMapper(),
|
responseMapper: new CompanyPublicModelMapper(),
|
||||||
transactionManager: buildTransactionManager(),
|
transactionManager: buildTransactionManager(),
|
||||||
@ -147,6 +150,7 @@ test("create maps a complete request address to Company props", async () => {
|
|||||||
const company = buildCompany();
|
const company = buildCompany();
|
||||||
let capturedAddress: typeof company.address | undefined;
|
let capturedAddress: typeof company.address | undefined;
|
||||||
const useCase = new CreateCompanyUseCase({
|
const useCase = new CreateCompanyUseCase({
|
||||||
|
inputMapper: new CreateCompanyInputMapper(),
|
||||||
creator: {
|
creator: {
|
||||||
create: async ({ props }) => {
|
create: async ({ props }) => {
|
||||||
capturedAddress = props.address;
|
capturedAddress = props.address;
|
||||||
@ -182,3 +186,22 @@ test("create maps a complete request address to Company props", async () => {
|
|||||||
assert.equal(capturedAddress?.unwrap().street2.isNone(), true);
|
assert.equal(capturedAddress?.unwrap().street2.isNone(), true);
|
||||||
assert.equal(capturedAddress?.unwrap().countryCode.unwrap().toPrimitive(), "ES");
|
assert.equal(capturedAddress?.unwrap().countryCode.unwrap().toPrimitive(), "ES");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("create returns an input mapper failure without calling the creator", async () => {
|
||||||
|
let creatorCalled = false;
|
||||||
|
const expectedError = new Error("invalid input");
|
||||||
|
const useCase = new CreateCompanyUseCase({
|
||||||
|
inputMapper: { map: () => Result.fail(expectedError) },
|
||||||
|
creator: {
|
||||||
|
create: async () => {
|
||||||
|
creatorCalled = true;
|
||||||
|
return Result.fail(expectedError);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseMapper: new CompanyPublicModelMapper(),
|
||||||
|
transactionManager: buildTransactionManager(),
|
||||||
|
});
|
||||||
|
const result = await useCase.execute({ dto: {} as never });
|
||||||
|
assert.equal(result.error, expectedError);
|
||||||
|
assert.equal(creatorCalled, false);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,137 +1,38 @@
|
|||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
import type { PostalAddress } from "@repo/rdx-ddd";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import {
|
|
||||||
EmailAddress,
|
|
||||||
Name,
|
|
||||||
PhoneNumber,
|
|
||||||
TextValue,
|
|
||||||
URLAddress,
|
|
||||||
UniqueID,
|
|
||||||
UtcDateTime,
|
|
||||||
maybeFromNullableResult,
|
|
||||||
} from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
|
|
||||||
import type { CompanyResponseDTO, CreateCompanyRequestDTO } from "../../../../common";
|
import type { CompanyResponseDTO, CreateCompanyRequestDTO } from "../../../../common";
|
||||||
import { CompanySlug, CompanyStatus, type ICompanyCreateProps } from "../../../domain";
|
|
||||||
import { toPostalAddress } from "../mappers/company-address.mapper";
|
|
||||||
import { toCompanyResponseDTO } from "../mappers/company-response.mapper";
|
import { toCompanyResponseDTO } from "../mappers/company-response.mapper";
|
||||||
|
import type { ICreateCompanyInputMapper } from "../mappers/create-company-input.mapper";
|
||||||
import type { CompanyPublicModelMapper } from "../public";
|
import type { CompanyPublicModelMapper } from "../public";
|
||||||
import type { ICompanyCreator } from "../services";
|
import type { ICompanyCreator } from "../services";
|
||||||
|
|
||||||
export class CreateCompanyUseCase {
|
export class CreateCompanyUseCase {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
creator: ICompanyCreator;
|
creator: ICompanyCreator;
|
||||||
|
inputMapper: ICreateCompanyInputMapper;
|
||||||
responseMapper: CompanyPublicModelMapper;
|
responseMapper: CompanyPublicModelMapper;
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public execute(params: {
|
public execute(params: {
|
||||||
dto: CreateCompanyRequestDTO;
|
dto: CreateCompanyRequestDTO;
|
||||||
}): Promise<Result<CompanyResponseDTO, Error>> {
|
}): Promise<Result<CompanyResponseDTO, Error>> {
|
||||||
const mappedResult = mapCreateCompanyProps(params.dto);
|
const input = this.deps.inputMapper.map(params.dto);
|
||||||
if (mappedResult.isFailure) {
|
if (input.isFailure) return Promise.resolve(Result.fail(input.error));
|
||||||
return Promise.resolve(Result.fail(mappedResult.error));
|
return this.deps.transactionManager.complete(async (transaction) => {
|
||||||
}
|
|
||||||
|
|
||||||
const { id, props } = mappedResult.data;
|
|
||||||
|
|
||||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
|
||||||
try {
|
try {
|
||||||
const createResult = await this.deps.creator.create({ id, props, transaction });
|
const result = await this.deps.creator.create({
|
||||||
if (createResult.isFailure) {
|
id: input.data.id,
|
||||||
return Result.fail(createResult.error);
|
props: input.data.props,
|
||||||
}
|
transaction,
|
||||||
|
});
|
||||||
return Result.ok(
|
return result.isFailure
|
||||||
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(createResult.data))
|
? Result.fail(result.error)
|
||||||
);
|
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return Result.fail(error as Error);
|
return Result.fail(error as Error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCreateCompanyProps(dto: CreateCompanyRequestDTO) {
|
|
||||||
const idResult = UniqueID.create(dto.id);
|
|
||||||
if (idResult.isFailure) {
|
|
||||||
return Result.fail(idResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const legalNameResult = Name.create(dto.legal_name);
|
|
||||||
if (legalNameResult.isFailure) {
|
|
||||||
return Result.fail(legalNameResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tradeNameResult = maybeFromNullableResult(dto.trade_name ?? null, (value) =>
|
|
||||||
Name.create(value)
|
|
||||||
);
|
|
||||||
if (tradeNameResult.isFailure) {
|
|
||||||
return Result.fail(tradeNameResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tinResult = maybeFromNullableResult(dto.tin ?? null, (value) => TextValue.create(value));
|
|
||||||
if (tinResult.isFailure) {
|
|
||||||
return Result.fail(tinResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const slugResult = CompanySlug.create(dto.slug);
|
|
||||||
if (slugResult.isFailure) {
|
|
||||||
return Result.fail(slugResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailResult = maybeFromNullableResult(dto.email ?? null, (value) =>
|
|
||||||
EmailAddress.create(value)
|
|
||||||
);
|
|
||||||
if (emailResult.isFailure) {
|
|
||||||
return Result.fail(emailResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const phoneResult = maybeFromNullableResult(dto.phone ?? null, (value) =>
|
|
||||||
PhoneNumber.create(value)
|
|
||||||
);
|
|
||||||
if (phoneResult.isFailure) {
|
|
||||||
return Result.fail(phoneResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const websiteResult = maybeFromNullableResult(dto.website ?? null, (value) =>
|
|
||||||
URLAddress.create(value)
|
|
||||||
);
|
|
||||||
if (websiteResult.isFailure) {
|
|
||||||
return Result.fail(websiteResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
let address: Maybe<PostalAddress> = Maybe.none();
|
|
||||||
if (dto.address !== undefined && dto.address !== null) {
|
|
||||||
const addressResult = toPostalAddress(dto.address);
|
|
||||||
if (addressResult.isFailure) {
|
|
||||||
return Result.fail(addressResult.error);
|
|
||||||
}
|
|
||||||
address = Maybe.some(addressResult.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = UtcDateTime.now();
|
|
||||||
|
|
||||||
const props: ICompanyCreateProps = {
|
|
||||||
legalName: legalNameResult.data,
|
|
||||||
tradeName: tradeNameResult.data,
|
|
||||||
tin: tinResult.data,
|
|
||||||
slug: slugResult.data,
|
|
||||||
email: emailResult.data,
|
|
||||||
phone: phoneResult.data,
|
|
||||||
website: websiteResult.data,
|
|
||||||
address,
|
|
||||||
defaultPaymentTermId: Maybe.none(),
|
|
||||||
status: CompanyStatus.createActive(),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Result.ok({
|
|
||||||
id: idResult.data,
|
|
||||||
props,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { IIdentityPublicServices } from "@erp/identity/api";
|
|
||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
|
import type { IIdentityPublicServices } from "@erp/identity/api";
|
||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -10,14 +10,16 @@ import type { CompanyPublicModelMapper } from "../public";
|
|||||||
export class FindAvailableCompaniesForAccountUseCase {
|
export class FindAvailableCompaniesForAccountUseCase {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
identityServices: IIdentityPublicServices;
|
identityServices: Pick<IIdentityPublicServices, "companyAccess">;
|
||||||
repository: ICompanyRepository;
|
repository: ICompanyRepository;
|
||||||
responseMapper: CompanyPublicModelMapper;
|
responseMapper: CompanyPublicModelMapper;
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public execute(params: { accountId: string }): Promise<Result<AvailableCompaniesResponseDTO, Error>> {
|
public execute(params: {
|
||||||
|
accountId: string;
|
||||||
|
}): Promise<Result<AvailableCompaniesResponseDTO, Error>> {
|
||||||
const accountIdResult = UniqueID.create(params.accountId);
|
const accountIdResult = UniqueID.create(params.accountId);
|
||||||
if (accountIdResult.isFailure) {
|
if (accountIdResult.isFailure) {
|
||||||
return Promise.resolve(Result.fail(accountIdResult.error));
|
return Promise.resolve(Result.fail(accountIdResult.error));
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { Company, type CompanyPatchProps, CompanySlug, CompanyStatus } from "../../../domain";
|
import { Company, type CompanyPatchProps, CompanySlug, CompanyStatus } from "../../../domain";
|
||||||
|
import { UpdateCompanyInputMapper } from "../mappers/update-company-input.mapper";
|
||||||
import { CompanyPublicModelMapper } from "../public";
|
import { CompanyPublicModelMapper } from "../public";
|
||||||
|
|
||||||
import { UpdateCompanyUseCase } from "./update-company.use-case";
|
import { UpdateCompanyUseCase } from "./update-company.use-case";
|
||||||
@ -72,6 +73,7 @@ test("update permite limpiar el default payment term con null", async () => {
|
|||||||
let receivedId: Maybe<UniqueID> | undefined;
|
let receivedId: Maybe<UniqueID> | undefined;
|
||||||
|
|
||||||
const useCase = new UpdateCompanyUseCase({
|
const useCase = new UpdateCompanyUseCase({
|
||||||
|
inputMapper: new UpdateCompanyInputMapper(),
|
||||||
updater: {
|
updater: {
|
||||||
update: async ({ patchProps }) => {
|
update: async ({ patchProps }) => {
|
||||||
receivedPatch = patchProps;
|
receivedPatch = patchProps;
|
||||||
@ -100,7 +102,7 @@ test("update permite limpiar el default payment term con null", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
assert.equal(receivedPatch?.defaultPaymentTermId?.isNone(), true);
|
assert.equal((receivedPatch as CompanyPatchProps | null)?.defaultPaymentTermId?.isNone(), true);
|
||||||
assert.equal(receivedId?.isNone(), true);
|
assert.equal(receivedId?.isNone(), true);
|
||||||
assert.equal(result.data.default_payment_term_id, null);
|
assert.equal(result.data.default_payment_term_id, null);
|
||||||
});
|
});
|
||||||
@ -109,6 +111,7 @@ test("update company maps the address tri-state", async () => {
|
|||||||
const company = buildCompany();
|
const company = buildCompany();
|
||||||
const captured: Array<unknown> = [];
|
const captured: Array<unknown> = [];
|
||||||
const useCase = new UpdateCompanyUseCase({
|
const useCase = new UpdateCompanyUseCase({
|
||||||
|
inputMapper: new UpdateCompanyInputMapper(),
|
||||||
updater: {
|
updater: {
|
||||||
update: async ({ patchProps }) => {
|
update: async ({ patchProps }) => {
|
||||||
captured.push(patchProps.address);
|
captured.push(patchProps.address);
|
||||||
@ -150,3 +153,26 @@ test("update company maps the address tri-state", async () => {
|
|||||||
"ES"
|
"ES"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("update returns an input mapper failure without calling services", async () => {
|
||||||
|
let updaterCalled = false;
|
||||||
|
const expectedError = new Error("invalid input");
|
||||||
|
const useCase = new UpdateCompanyUseCase({
|
||||||
|
inputMapper: { map: () => Result.fail(expectedError) },
|
||||||
|
updater: {
|
||||||
|
update: async () => {
|
||||||
|
updaterCalled = true;
|
||||||
|
return Result.fail(expectedError);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paymentTermResolver: { ensureAssignable: async () => Result.fail(expectedError) },
|
||||||
|
responseMapper: new CompanyPublicModelMapper(),
|
||||||
|
transactionManager: buildTransactionManager(),
|
||||||
|
});
|
||||||
|
const result = await useCase.execute({
|
||||||
|
company_id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
dto: {},
|
||||||
|
});
|
||||||
|
assert.equal(result.error, expectedError);
|
||||||
|
assert.equal(updaterCalled, false);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,188 +1,51 @@
|
|||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
import {
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
EmailAddress,
|
import { Result } from "@repo/rdx-utils";
|
||||||
Name,
|
|
||||||
PhoneNumber,
|
|
||||||
TextValue,
|
|
||||||
URLAddress,
|
|
||||||
UniqueID,
|
|
||||||
UtcDateTime,
|
|
||||||
maybeFromNullableResult,
|
|
||||||
} from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
|
|
||||||
import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../common";
|
import type { CompanyResponseDTO, UpdateCompanyRequestDTO } from "../../../../common";
|
||||||
import { type CompanyPatchProps, CompanySlug } from "../../../domain";
|
|
||||||
import { toPostalAddress } from "../mappers/company-address.mapper";
|
|
||||||
import { toCompanyResponseDTO } from "../mappers/company-response.mapper";
|
import { toCompanyResponseDTO } from "../mappers/company-response.mapper";
|
||||||
|
import type { IUpdateCompanyInputMapper } from "../mappers/update-company-input.mapper";
|
||||||
import type { CompanyPublicModelMapper } from "../public";
|
import type { CompanyPublicModelMapper } from "../public";
|
||||||
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
|
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
|
||||||
|
|
||||||
export class UpdateCompanyUseCase {
|
export class UpdateCompanyUseCase {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
updater: ICompanyUpdater;
|
updater: ICompanyUpdater;
|
||||||
|
inputMapper: IUpdateCompanyInputMapper;
|
||||||
paymentTermResolver: ICompanyPaymentTermResolver;
|
paymentTermResolver: ICompanyPaymentTermResolver;
|
||||||
responseMapper: CompanyPublicModelMapper;
|
responseMapper: CompanyPublicModelMapper;
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public execute(params: {
|
public execute(params: {
|
||||||
company_id: string;
|
company_id: string;
|
||||||
dto: UpdateCompanyRequestDTO;
|
dto: UpdateCompanyRequestDTO;
|
||||||
}): Promise<Result<CompanyResponseDTO, Error>> {
|
}): Promise<Result<CompanyResponseDTO, Error>> {
|
||||||
const companyIdResult = UniqueID.create(params.company_id);
|
const companyId = UniqueID.create(params.company_id);
|
||||||
if (companyIdResult.isFailure) {
|
if (companyId.isFailure) return Promise.resolve(Result.fail(companyId.error));
|
||||||
return Promise.resolve(Result.fail(companyIdResult.error));
|
const input = this.deps.inputMapper.map(params.dto);
|
||||||
}
|
if (input.isFailure) return Promise.resolve(Result.fail(input.error));
|
||||||
|
return this.deps.transactionManager.complete(async (transaction) => {
|
||||||
const patchPropsResult = mapUpdateCompanyProps(params.dto);
|
|
||||||
if (patchPropsResult.isFailure) {
|
|
||||||
return Promise.resolve(Result.fail(patchPropsResult.error));
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPaymentTermIdResult = mapDefaultPaymentTermId(params.dto);
|
|
||||||
if (defaultPaymentTermIdResult.isFailure) {
|
|
||||||
return Promise.resolve(Result.fail(defaultPaymentTermIdResult.error));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
|
||||||
try {
|
try {
|
||||||
if (defaultPaymentTermIdResult.data !== undefined) {
|
if (input.data.defaultPaymentTermId !== undefined) {
|
||||||
const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({
|
const result = await this.deps.paymentTermResolver.ensureAssignable({
|
||||||
companyId: companyIdResult.data,
|
companyId: companyId.data,
|
||||||
id: defaultPaymentTermIdResult.data,
|
id: input.data.defaultPaymentTermId,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
if (result.isFailure) return Result.fail(result.error);
|
||||||
if (ensureResult.isFailure) {
|
|
||||||
return Result.fail(ensureResult.error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const result = await this.deps.updater.update({
|
||||||
const updateResult = await this.deps.updater.update({
|
id: companyId.data,
|
||||||
id: companyIdResult.data,
|
patchProps: input.data.patchProps,
|
||||||
patchProps: patchPropsResult.data,
|
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
return result.isFailure
|
||||||
if (updateResult.isFailure) {
|
? Result.fail(result.error)
|
||||||
return Result.fail(updateResult.error);
|
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(
|
|
||||||
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data))
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return Result.fail(error as Error);
|
return Result.fail(error as Error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapUpdateCompanyProps(dto: UpdateCompanyRequestDTO): Result<CompanyPatchProps, Error> {
|
|
||||||
const patchProps: CompanyPatchProps = {
|
|
||||||
updatedAt: UtcDateTime.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (dto.legal_name !== undefined) {
|
|
||||||
const legalNameResult = Name.create(dto.legal_name);
|
|
||||||
if (legalNameResult.isFailure) {
|
|
||||||
return Result.fail(legalNameResult.error);
|
|
||||||
}
|
|
||||||
patchProps.legalName = legalNameResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.trade_name !== undefined) {
|
|
||||||
const tradeNameResult = maybeFromNullableResult(dto.trade_name, (value) => Name.create(value));
|
|
||||||
if (tradeNameResult.isFailure) {
|
|
||||||
return Result.fail(tradeNameResult.error);
|
|
||||||
}
|
|
||||||
patchProps.tradeName = tradeNameResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.tin !== undefined) {
|
|
||||||
const tinResult = maybeFromNullableResult(dto.tin, (value) => TextValue.create(value));
|
|
||||||
if (tinResult.isFailure) {
|
|
||||||
return Result.fail(tinResult.error);
|
|
||||||
}
|
|
||||||
patchProps.tin = tinResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.slug !== undefined) {
|
|
||||||
const slugResult = CompanySlug.create(dto.slug);
|
|
||||||
if (slugResult.isFailure) {
|
|
||||||
return Result.fail(slugResult.error);
|
|
||||||
}
|
|
||||||
patchProps.slug = slugResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.email !== undefined) {
|
|
||||||
const emailResult = maybeFromNullableResult(dto.email, (value) => EmailAddress.create(value));
|
|
||||||
if (emailResult.isFailure) {
|
|
||||||
return Result.fail(emailResult.error);
|
|
||||||
}
|
|
||||||
patchProps.email = emailResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.phone !== undefined) {
|
|
||||||
const phoneResult = maybeFromNullableResult(dto.phone, (value) => PhoneNumber.create(value));
|
|
||||||
if (phoneResult.isFailure) {
|
|
||||||
return Result.fail(phoneResult.error);
|
|
||||||
}
|
|
||||||
patchProps.phone = phoneResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.website !== undefined) {
|
|
||||||
const websiteResult = maybeFromNullableResult(dto.website, (value) => URLAddress.create(value));
|
|
||||||
if (websiteResult.isFailure) {
|
|
||||||
return Result.fail(websiteResult.error);
|
|
||||||
}
|
|
||||||
patchProps.website = websiteResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.address !== undefined) {
|
|
||||||
if (dto.address === null) {
|
|
||||||
patchProps.address = Maybe.none();
|
|
||||||
} else {
|
|
||||||
const addressResult = toPostalAddress(dto.address);
|
|
||||||
if (addressResult.isFailure) {
|
|
||||||
return Result.fail(addressResult.error);
|
|
||||||
}
|
|
||||||
patchProps.address = Maybe.some(addressResult.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.default_payment_term_id !== undefined) {
|
|
||||||
const defaultPaymentTermIdResult = maybeFromNullableResult(
|
|
||||||
dto.default_payment_term_id,
|
|
||||||
(value) => UniqueID.create(value)
|
|
||||||
);
|
|
||||||
if (defaultPaymentTermIdResult.isFailure) {
|
|
||||||
return Result.fail(defaultPaymentTermIdResult.error);
|
|
||||||
}
|
|
||||||
patchProps.defaultPaymentTermId = defaultPaymentTermIdResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(patchProps);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapDefaultPaymentTermId(
|
|
||||||
dto: UpdateCompanyRequestDTO
|
|
||||||
): Result<Maybe<UniqueID> | undefined, Error> {
|
|
||||||
if (dto.default_payment_term_id === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.default_payment_term_id === null) {
|
|
||||||
return Result.ok(Maybe.none());
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPaymentTermIdResult = UniqueID.create(dto.default_payment_term_id);
|
|
||||||
if (defaultPaymentTermIdResult.isFailure) {
|
|
||||||
return Result.fail(defaultPaymentTermIdResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(Maybe.some(defaultPaymentTermIdResult.data));
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,11 +9,12 @@ import {
|
|||||||
TextValue,
|
TextValue,
|
||||||
URLAddress,
|
URLAddress,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
UtcDate,
|
UtcDateTime,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
|
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
|
||||||
|
import { UpdateCurrentCompanyInputMapper } from "../mappers/update-current-company-input.mapper";
|
||||||
import { CompanyPublicModelMapper } from "../public";
|
import { CompanyPublicModelMapper } from "../public";
|
||||||
|
|
||||||
import { UpdateCurrentCompanyUseCase } from "./update-current-company.use-case";
|
import { UpdateCurrentCompanyUseCase } from "./update-current-company.use-case";
|
||||||
@ -27,7 +28,7 @@ const buildTransactionManager = (): ITransactionManager => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildCompany = () => {
|
const buildCompany = () => {
|
||||||
const now = UtcDate.createFromISO("2026-08-16");
|
const now = UtcDateTime.createFromISO("2026-08-16T00:00:00.000Z");
|
||||||
const id = UniqueID.generateNewID();
|
const id = UniqueID.generateNewID();
|
||||||
const legalName = Name.create("Rodax Labs");
|
const legalName = Name.create("Rodax Labs");
|
||||||
const tradeName = Name.create("Rodax");
|
const tradeName = Name.create("Rodax");
|
||||||
@ -74,6 +75,7 @@ test("update current company permite limpiar el default payment term con null",
|
|||||||
let receivedId: Maybe<UniqueID> | undefined;
|
let receivedId: Maybe<UniqueID> | undefined;
|
||||||
|
|
||||||
const useCase = new UpdateCurrentCompanyUseCase({
|
const useCase = new UpdateCurrentCompanyUseCase({
|
||||||
|
inputMapper: new UpdateCurrentCompanyInputMapper(),
|
||||||
updater: {
|
updater: {
|
||||||
update: async ({ patchProps }) => {
|
update: async ({ patchProps }) => {
|
||||||
receivedDefaultPaymentTermId = patchProps.defaultPaymentTermId;
|
receivedDefaultPaymentTermId = patchProps.defaultPaymentTermId;
|
||||||
@ -115,6 +117,7 @@ test("update current company no permite cambiar el slug", async () => {
|
|||||||
let receivedPatch: Record<string, unknown> | null = null;
|
let receivedPatch: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
const useCase = new UpdateCurrentCompanyUseCase({
|
const useCase = new UpdateCurrentCompanyUseCase({
|
||||||
|
inputMapper: new UpdateCurrentCompanyInputMapper(),
|
||||||
updater: {
|
updater: {
|
||||||
update: async ({ patchProps }) => {
|
update: async ({ patchProps }) => {
|
||||||
receivedPatch = patchProps;
|
receivedPatch = patchProps;
|
||||||
@ -145,6 +148,7 @@ test("update current company preserves omitted address and maps null or a replac
|
|||||||
const company = buildCompany();
|
const company = buildCompany();
|
||||||
const captured: Array<unknown> = [];
|
const captured: Array<unknown> = [];
|
||||||
const useCase = new UpdateCurrentCompanyUseCase({
|
const useCase = new UpdateCurrentCompanyUseCase({
|
||||||
|
inputMapper: new UpdateCurrentCompanyInputMapper(),
|
||||||
updater: {
|
updater: {
|
||||||
update: async ({ patchProps }) => {
|
update: async ({ patchProps }) => {
|
||||||
captured.push(patchProps.address);
|
captured.push(patchProps.address);
|
||||||
@ -186,3 +190,26 @@ test("update current company preserves omitted address and maps null or a replac
|
|||||||
"ES"
|
"ES"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("update current returns an input mapper failure without calling services", async () => {
|
||||||
|
let updaterCalled = false;
|
||||||
|
const expectedError = new Error("invalid input");
|
||||||
|
const useCase = new UpdateCurrentCompanyUseCase({
|
||||||
|
inputMapper: { map: () => Result.fail(expectedError) },
|
||||||
|
updater: {
|
||||||
|
update: async () => {
|
||||||
|
updaterCalled = true;
|
||||||
|
return Result.fail(expectedError);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paymentTermResolver: { ensureAssignable: async () => Result.fail(expectedError) },
|
||||||
|
responseMapper: new CompanyPublicModelMapper(),
|
||||||
|
transactionManager: buildTransactionManager(),
|
||||||
|
});
|
||||||
|
const result = await useCase.execute({
|
||||||
|
companyId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
dto: {},
|
||||||
|
});
|
||||||
|
assert.equal(result.error, expectedError);
|
||||||
|
assert.equal(updaterCalled, false);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,266 +1,51 @@
|
|||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
import {
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
EmailAddress,
|
import { Result } from "@repo/rdx-utils";
|
||||||
Name,
|
|
||||||
PhoneNumber,
|
|
||||||
TextValue,
|
|
||||||
URLAddress,
|
|
||||||
UniqueID,
|
|
||||||
UtcDate,
|
|
||||||
maybeFromNullableResult,
|
|
||||||
} from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
|
|
||||||
import type { CompanyResponseDTO, UpdateCurrentCompanyRequestDTO } from "../../../../common";
|
import type { CompanyResponseDTO, UpdateCurrentCompanyRequestDTO } from "../../../../common";
|
||||||
import type { CompanyPatchProps } from "../../../domain";
|
|
||||||
import { toPostalAddress } from "../mappers/company-address.mapper";
|
|
||||||
import { toCompanyResponseDTO } from "../mappers/company-response.mapper";
|
import { toCompanyResponseDTO } from "../mappers/company-response.mapper";
|
||||||
|
import type { IUpdateCurrentCompanyInputMapper } from "../mappers/update-current-company-input.mapper";
|
||||||
import type { CompanyPublicModelMapper } from "../public";
|
import type { CompanyPublicModelMapper } from "../public";
|
||||||
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
|
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
|
||||||
|
|
||||||
export class UpdateCurrentCompanyUseCase {
|
export class UpdateCurrentCompanyUseCase {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
updater: ICompanyUpdater;
|
updater: ICompanyUpdater;
|
||||||
|
inputMapper: IUpdateCurrentCompanyInputMapper;
|
||||||
paymentTermResolver: ICompanyPaymentTermResolver;
|
paymentTermResolver: ICompanyPaymentTermResolver;
|
||||||
responseMapper: CompanyPublicModelMapper;
|
responseMapper: CompanyPublicModelMapper;
|
||||||
transactionManager: ITransactionManager;
|
transactionManager: ITransactionManager;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public execute(params: {
|
public execute(params: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
dto: UpdateCurrentCompanyRequestDTO;
|
dto: UpdateCurrentCompanyRequestDTO;
|
||||||
}): Promise<Result<CompanyResponseDTO, Error>> {
|
}): Promise<Result<CompanyResponseDTO, Error>> {
|
||||||
const companyIdResult = UniqueID.create(params.companyId);
|
const companyId = UniqueID.create(params.companyId);
|
||||||
if (companyIdResult.isFailure) {
|
if (companyId.isFailure) return Promise.resolve(Result.fail(companyId.error));
|
||||||
return Promise.resolve(Result.fail(companyIdResult.error));
|
const input = this.deps.inputMapper.map(params.dto);
|
||||||
}
|
if (input.isFailure) return Promise.resolve(Result.fail(input.error));
|
||||||
|
return this.deps.transactionManager.complete(async (transaction) => {
|
||||||
const patchPropsResult = mapUpdateCurrentCompanyProps(params.dto);
|
|
||||||
if (patchPropsResult.isFailure) {
|
|
||||||
return Promise.resolve(Result.fail(patchPropsResult.error));
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPaymentTermIdResult = mapDefaultPaymentTermId(params.dto);
|
|
||||||
if (defaultPaymentTermIdResult.isFailure) {
|
|
||||||
return Promise.resolve(Result.fail(defaultPaymentTermIdResult.error));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
|
||||||
try {
|
try {
|
||||||
if (defaultPaymentTermIdResult.data !== undefined) {
|
if (input.data.defaultPaymentTermId !== undefined) {
|
||||||
const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({
|
const result = await this.deps.paymentTermResolver.ensureAssignable({
|
||||||
companyId: companyIdResult.data,
|
companyId: companyId.data,
|
||||||
id: defaultPaymentTermIdResult.data,
|
id: input.data.defaultPaymentTermId,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
if (ensureResult.isFailure) {
|
if (result.isFailure) return Result.fail(result.error);
|
||||||
return Result.fail(ensureResult.error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const result = await this.deps.updater.update({
|
||||||
const updateResult = await this.deps.updater.update({
|
id: companyId.data,
|
||||||
id: companyIdResult.data,
|
patchProps: input.data.patchProps,
|
||||||
patchProps: patchPropsResult.data,
|
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
if (updateResult.isFailure) {
|
return result.isFailure
|
||||||
return Result.fail(updateResult.error);
|
? Result.fail(result.error)
|
||||||
}
|
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
|
||||||
|
|
||||||
return Result.ok(
|
|
||||||
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data))
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return Result.fail(error as Error);
|
return Result.fail(error as Error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapUpdateCurrentCompanyProps(
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<CompanyPatchProps, Error> {
|
|
||||||
const patchProps: CompanyPatchProps = {
|
|
||||||
updatedAt: UtcDate.today(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const assignments = [
|
|
||||||
assignLegalNamePatch(patchProps, dto),
|
|
||||||
assignTradeNamePatch(patchProps, dto),
|
|
||||||
assignTinPatch(patchProps, dto),
|
|
||||||
assignEmailPatch(patchProps, dto),
|
|
||||||
assignPhonePatch(patchProps, dto),
|
|
||||||
assignWebsitePatch(patchProps, dto),
|
|
||||||
assignDefaultPaymentTermPatch(patchProps, dto),
|
|
||||||
assignAddressPatch(patchProps, dto),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const assignment of assignments) {
|
|
||||||
if (assignment.isFailure) {
|
|
||||||
return Result.fail(assignment.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(patchProps);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignLegalNamePatch(
|
|
||||||
patchProps: CompanyPatchProps,
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<void, Error> {
|
|
||||||
if (dto.legal_name === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = Name.create(dto.legal_name);
|
|
||||||
if (result.isFailure) {
|
|
||||||
return Result.fail(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
patchProps.legalName = result.data;
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignTradeNamePatch(
|
|
||||||
patchProps: CompanyPatchProps,
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<void, Error> {
|
|
||||||
if (dto.trade_name === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = maybeFromNullableResult(dto.trade_name, (value) => Name.create(value));
|
|
||||||
if (result.isFailure) {
|
|
||||||
return Result.fail(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
patchProps.tradeName = result.data;
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignTinPatch(
|
|
||||||
patchProps: CompanyPatchProps,
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<void, Error> {
|
|
||||||
if (dto.tin === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = maybeFromNullableResult(dto.tin, (value) => TextValue.create(value));
|
|
||||||
if (result.isFailure) {
|
|
||||||
return Result.fail(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
patchProps.tin = result.data;
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignEmailPatch(
|
|
||||||
patchProps: CompanyPatchProps,
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<void, Error> {
|
|
||||||
if (dto.email === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = maybeFromNullableResult(dto.email, (value) => EmailAddress.create(value));
|
|
||||||
if (result.isFailure) {
|
|
||||||
return Result.fail(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
patchProps.email = result.data;
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignPhonePatch(
|
|
||||||
patchProps: CompanyPatchProps,
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<void, Error> {
|
|
||||||
if (dto.phone === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = maybeFromNullableResult(dto.phone, (value) => PhoneNumber.create(value));
|
|
||||||
if (result.isFailure) {
|
|
||||||
return Result.fail(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
patchProps.phone = result.data;
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignWebsitePatch(
|
|
||||||
patchProps: CompanyPatchProps,
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<void, Error> {
|
|
||||||
if (dto.website === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = maybeFromNullableResult(dto.website, (value) => URLAddress.create(value));
|
|
||||||
if (result.isFailure) {
|
|
||||||
return Result.fail(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
patchProps.website = result.data;
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignAddressPatch(
|
|
||||||
patchProps: CompanyPatchProps,
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<void, Error> {
|
|
||||||
if (dto.address === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
if (dto.address === null) {
|
|
||||||
patchProps.address = Maybe.none();
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
const addressResult = toPostalAddress(dto.address);
|
|
||||||
if (addressResult.isFailure) {
|
|
||||||
return Result.fail(addressResult.error);
|
|
||||||
}
|
|
||||||
patchProps.address = Maybe.some(addressResult.data);
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignDefaultPaymentTermPatch(
|
|
||||||
patchProps: CompanyPatchProps,
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<void, Error> {
|
|
||||||
if (dto.default_payment_term_id === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = maybeFromNullableResult(dto.default_payment_term_id, (value) =>
|
|
||||||
UniqueID.create(value)
|
|
||||||
);
|
|
||||||
if (result.isFailure) {
|
|
||||||
return Result.fail(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
patchProps.defaultPaymentTermId = result.data;
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapDefaultPaymentTermId(
|
|
||||||
dto: UpdateCurrentCompanyRequestDTO
|
|
||||||
): Result<Maybe<UniqueID> | undefined, Error> {
|
|
||||||
if (dto.default_payment_term_id === undefined) {
|
|
||||||
return Result.ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.default_payment_term_id === null) {
|
|
||||||
return Result.ok(Maybe.none());
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPaymentTermIdResult = UniqueID.create(dto.default_payment_term_id);
|
|
||||||
if (defaultPaymentTermIdResult.isFailure) {
|
|
||||||
return Result.fail(defaultPaymentTermIdResult.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(Maybe.some(defaultPaymentTermIdResult.data));
|
|
||||||
}
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
Province,
|
Province,
|
||||||
Street,
|
Street,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
UtcDate,
|
UtcDateTime,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Maybe } from "@repo/rdx-utils";
|
import { Maybe } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -41,8 +41,8 @@ const props = (value = Maybe.none<PostalAddress>()) => ({
|
|||||||
address: value,
|
address: value,
|
||||||
defaultPaymentTermId: Maybe.none(),
|
defaultPaymentTermId: Maybe.none(),
|
||||||
status: CompanyStatus.createActive(),
|
status: CompanyStatus.createActive(),
|
||||||
createdAt: unwrap(UtcDate.createFromISO("2026-01-01")),
|
createdAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
|
||||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")),
|
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
|
||||||
});
|
});
|
||||||
const company = (value = Maybe.none<PostalAddress>()) =>
|
const company = (value = Maybe.none<PostalAddress>()) =>
|
||||||
unwrap(Company.create(props(value), UniqueID.generateNewID()));
|
unwrap(Company.create(props(value), UniqueID.generateNewID()));
|
||||||
@ -65,7 +65,8 @@ test("Company address update preserves, sets, replaces, clears and compares prim
|
|||||||
const initial = address("Street A");
|
const initial = address("Street A");
|
||||||
const target = company(Maybe.some(initial));
|
const target = company(Maybe.some(initial));
|
||||||
assert.equal(
|
assert.equal(
|
||||||
target.update({ updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")) }).data,
|
target.update({ updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")) })
|
||||||
|
.data,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
const equivalent = address("Street A");
|
const equivalent = address("Street A");
|
||||||
@ -73,26 +74,28 @@ test("Company address update preserves, sets, replaces, clears and compares prim
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
target.update({
|
target.update({
|
||||||
address: Maybe.some(equivalent),
|
address: Maybe.some(equivalent),
|
||||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")),
|
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
|
||||||
}).data,
|
}).data,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
target.update({
|
target.update({
|
||||||
address: Maybe.some(address("Street B")),
|
address: Maybe.some(address("Street B")),
|
||||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")),
|
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
|
||||||
}).data,
|
}).data,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
target.update({ address: Maybe.none(), updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")) })
|
target.update({
|
||||||
.data,
|
address: Maybe.none(),
|
||||||
|
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
|
||||||
|
}).data,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
target.update({
|
target.update({
|
||||||
address: Maybe.some(address()),
|
address: Maybe.some(address()),
|
||||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")),
|
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
|
||||||
}).data,
|
}).data,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
type TextValue,
|
type TextValue,
|
||||||
type URLAddress,
|
type URLAddress,
|
||||||
type UniqueID,
|
type UniqueID,
|
||||||
type UtcDate,
|
type UtcDateTime,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -26,8 +26,8 @@ export interface ICompanyCreateProps {
|
|||||||
address: Maybe<PostalAddress>;
|
address: Maybe<PostalAddress>;
|
||||||
defaultPaymentTermId: Maybe<UniqueID>;
|
defaultPaymentTermId: Maybe<UniqueID>;
|
||||||
status: CompanyStatus;
|
status: CompanyStatus;
|
||||||
createdAt: UtcDate;
|
createdAt: UtcDateTime;
|
||||||
updatedAt: UtcDate;
|
updatedAt: UtcDateTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyPatchProps = Partial<{
|
export type CompanyPatchProps = Partial<{
|
||||||
@ -41,7 +41,7 @@ export type CompanyPatchProps = Partial<{
|
|||||||
address: Maybe<PostalAddress>;
|
address: Maybe<PostalAddress>;
|
||||||
defaultPaymentTermId: Maybe<UniqueID>;
|
defaultPaymentTermId: Maybe<UniqueID>;
|
||||||
}> & {
|
}> & {
|
||||||
updatedAt: UtcDate;
|
updatedAt: UtcDateTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompanyInternalProps = ICompanyCreateProps;
|
export type CompanyInternalProps = ICompanyCreateProps;
|
||||||
@ -124,11 +124,11 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
|||||||
return this.props.status;
|
return this.props.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get createdAt(): UtcDate {
|
public get createdAt(): UtcDateTime {
|
||||||
return this.props.createdAt;
|
return this.props.createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get updatedAt(): UtcDate {
|
public get updatedAt(): UtcDateTime {
|
||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,7 +204,7 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
|||||||
return Result.ok(true);
|
return Result.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public disable(updatedAt: UtcDate): Result<boolean, Error> {
|
public disable(updatedAt: UtcDateTime): Result<boolean, Error> {
|
||||||
if (this.status.isDisabled()) {
|
if (this.status.isDisabled()) {
|
||||||
return Result.ok(false);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
@ -214,7 +214,7 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
|||||||
return Result.ok(true);
|
return Result.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public enable(updatedAt: UtcDate): Result<boolean, Error> {
|
public enable(updatedAt: UtcDateTime): Result<boolean, Error> {
|
||||||
if (this.status.isActive()) {
|
if (this.status.isActive()) {
|
||||||
return Result.ok(false);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api";
|
import type { CatalogsPublicServicesType } from "@erp/catalogs/api";
|
||||||
import type { IIdentityCompanyAccessChecker } from "@erp/identity/module";
|
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
|
||||||
|
import type { IIdentityPublicServices } from "@erp/identity/api";
|
||||||
import type { Sequelize } from "sequelize";
|
import type { Sequelize } from "sequelize";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -17,43 +18,49 @@ import {
|
|||||||
GetCompanyByIdUseCase,
|
GetCompanyByIdUseCase,
|
||||||
GetCurrentCompanyUseCase,
|
GetCurrentCompanyUseCase,
|
||||||
type ICompanyPublicServices,
|
type ICompanyPublicServices,
|
||||||
|
InitialCompanyCreator,
|
||||||
UpdateCompanyUseCase,
|
UpdateCompanyUseCase,
|
||||||
UpdateCurrentCompanyUseCase,
|
UpdateCurrentCompanyUseCase,
|
||||||
buildCompanyPublicServices,
|
buildCompanyPublicServices,
|
||||||
} from "../../application";
|
} from "../../application";
|
||||||
|
import { CreateCompanyInputMapper } from "../../application/companies/mappers/create-company-input.mapper";
|
||||||
|
import { UpdateCompanyInputMapper } from "../../application/companies/mappers/update-company-input.mapper";
|
||||||
|
import { UpdateCurrentCompanyInputMapper } from "../../application/companies/mappers/update-current-company-input.mapper";
|
||||||
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
|
import { SequelizeCompanyDomainMapper, SequelizeCompanyRepository } from "../persistence";
|
||||||
|
|
||||||
import { resolveCompanyCatalogsDeps } from "./company-catalog-deps.di";
|
|
||||||
|
|
||||||
export type CompaniesInternalDeps = {
|
export type CompaniesInternalDeps = {
|
||||||
repository: SequelizeCompanyRepository;
|
companyAccess: IIdentityPublicServices["companyAccess"];
|
||||||
publicFinder: CompanyPublicFinder;
|
publicFinder: CompanyPublicFinder;
|
||||||
useCases: {
|
useCases: {
|
||||||
createCompany: () => CreateCompanyUseCase;
|
createCompany: CreateCompanyUseCase;
|
||||||
getCompanyById: () => GetCompanyByIdUseCase;
|
getCompanyById: GetCompanyByIdUseCase;
|
||||||
getCurrentCompany: () => GetCurrentCompanyUseCase;
|
getCurrentCompany: GetCurrentCompanyUseCase;
|
||||||
findAvailableCompaniesForAccount: () => FindAvailableCompaniesForAccountUseCase;
|
findAvailableCompaniesForAccount: FindAvailableCompaniesForAccountUseCase;
|
||||||
updateCompany: () => UpdateCompanyUseCase;
|
updateCompany: UpdateCompanyUseCase;
|
||||||
updateCurrentCompany: () => UpdateCurrentCompanyUseCase;
|
updateCurrentCompany: UpdateCurrentCompanyUseCase;
|
||||||
enableCompany: () => EnableCompanyUseCase;
|
enableCompany: EnableCompanyUseCase;
|
||||||
disableCompany: () => DisableCompanyUseCase;
|
disableCompany: DisableCompanyUseCase;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInternalDeps => {
|
export type CompaniesDependencies = {
|
||||||
const { audit, database, getService, transactionContext } = params;
|
internal: CompaniesInternalDeps;
|
||||||
|
publicServices: ICompanyPublicServices;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildCompaniesDependencies = (params: ModuleParams): CompaniesDependencies => {
|
||||||
|
const { audit, database, 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 companyAccess = getService<{ companyAccess: IIdentityCompanyAccessChecker }>(
|
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
|
||||||
"identity:general"
|
const paymentTermServices =
|
||||||
).companyAccess;
|
params.getService<CatalogsPublicServicesType["paymentTerms"]>("catalogs:paymentTerms");
|
||||||
const catalogsDeps = resolveCompanyCatalogsDeps(params);
|
|
||||||
|
|
||||||
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, audit);
|
const creator = new CompanyCreator(repository, audit);
|
||||||
const paymentTermResolver = new CompanyPaymentTermResolver({
|
const paymentTermResolver = new CompanyPaymentTermResolver({
|
||||||
paymentTermFinder: catalogsDeps.paymentTerm.finder,
|
paymentTermFinder: paymentTermServices.finder,
|
||||||
});
|
});
|
||||||
const updater = new CompanyUpdater({ finder, repository, audit });
|
const updater = new CompanyUpdater({ finder, repository, audit });
|
||||||
const statusChanger = new CompanyStatusChanger(repository, audit);
|
const statusChanger = new CompanyStatusChanger(repository, audit);
|
||||||
@ -62,70 +69,66 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
|||||||
mapper: responseMapper,
|
mapper: responseMapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const initialCompanyCreator = new InitialCompanyCreator(creator);
|
||||||
|
const createCompanyInputMapper = new CreateCompanyInputMapper();
|
||||||
|
const updateCompanyInputMapper = new UpdateCompanyInputMapper();
|
||||||
|
const updateCurrentCompanyInputMapper = new UpdateCurrentCompanyInputMapper();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repository,
|
internal: {
|
||||||
publicFinder,
|
companyAccess: identityServices.companyAccess,
|
||||||
useCases: {
|
publicFinder,
|
||||||
createCompany: () =>
|
useCases: {
|
||||||
new CreateCompanyUseCase({
|
createCompany: new CreateCompanyUseCase({
|
||||||
creator,
|
creator,
|
||||||
|
inputMapper: createCompanyInputMapper,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
getCompanyById: () =>
|
getCompanyById: new GetCompanyByIdUseCase({
|
||||||
new GetCompanyByIdUseCase({
|
|
||||||
finder,
|
finder,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
getCurrentCompany: () =>
|
getCurrentCompany: new GetCurrentCompanyUseCase({
|
||||||
new GetCurrentCompanyUseCase({
|
|
||||||
finder,
|
finder,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
findAvailableCompaniesForAccount: () =>
|
findAvailableCompaniesForAccount: new FindAvailableCompaniesForAccountUseCase({
|
||||||
new FindAvailableCompaniesForAccountUseCase({
|
identityServices: { companyAccess: identityServices.companyAccess },
|
||||||
identityServices: { companyAccess },
|
|
||||||
repository,
|
repository,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
updateCompany: () =>
|
updateCompany: new UpdateCompanyUseCase({
|
||||||
new UpdateCompanyUseCase({
|
|
||||||
updater,
|
updater,
|
||||||
|
inputMapper: updateCompanyInputMapper,
|
||||||
paymentTermResolver,
|
paymentTermResolver,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
updateCurrentCompany: () =>
|
updateCurrentCompany: new UpdateCurrentCompanyUseCase({
|
||||||
new UpdateCurrentCompanyUseCase({
|
|
||||||
updater,
|
updater,
|
||||||
|
inputMapper: updateCurrentCompanyInputMapper,
|
||||||
paymentTermResolver,
|
paymentTermResolver,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
enableCompany: () =>
|
enableCompany: new EnableCompanyUseCase({
|
||||||
new EnableCompanyUseCase({
|
|
||||||
finder,
|
finder,
|
||||||
changer: statusChanger,
|
changer: statusChanger,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
disableCompany: () =>
|
disableCompany: new DisableCompanyUseCase({
|
||||||
new DisableCompanyUseCase({
|
|
||||||
finder,
|
finder,
|
||||||
changer: statusChanger,
|
changer: statusChanger,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
publicServices: buildCompanyPublicServices(publicFinder, initialCompanyCreator),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildCompaniesPublicServices = (
|
|
||||||
_params: SetupParams,
|
|
||||||
deps: CompaniesInternalDeps
|
|
||||||
): ICompanyPublicServices => {
|
|
||||||
return buildCompanyPublicServices(deps.publicFinder);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
import type { CatalogsPublicServicesType } from "@erp/catalogs/api";
|
|
||||||
import type { ModuleParams } from "@erp/core/api";
|
|
||||||
|
|
||||||
export function resolveCompanyCatalogsDeps(params: ModuleParams): {
|
|
||||||
paymentTerm: {
|
|
||||||
finder: CatalogsPublicServicesType["paymentTerms"]["finder"];
|
|
||||||
};
|
|
||||||
} {
|
|
||||||
const paymentTerm =
|
|
||||||
params.getService<CatalogsPublicServicesType["paymentTerms"]>("catalogs:paymentTerms");
|
|
||||||
|
|
||||||
if (!paymentTerm?.finder) {
|
|
||||||
throw new Error("Missing public service: catalogs:paymentTerms.finder");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
paymentTerm: {
|
|
||||||
finder: paymentTerm.finder,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { type StartParams, validateRequest } from "@erp/core/api";
|
import { type StartParams, validateRequest } from "@erp/core/api";
|
||||||
import type { IIdentityPublicServices } from "@erp/identity/api";
|
|
||||||
import { requireCompanyContext, requireIdentityAuthenticated } from "@erp/identity/api";
|
import { requireCompanyContext, requireIdentityAuthenticated } from "@erp/identity/api";
|
||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
@ -26,11 +25,10 @@ import {
|
|||||||
} from "./controllers";
|
} from "./controllers";
|
||||||
|
|
||||||
export const companiesRouter = (params: StartParams) => {
|
export const companiesRouter = (params: StartParams) => {
|
||||||
const { app, config, getInternal, getService, operationContext } = params;
|
const { app, config, getInternal, operationContext } = params;
|
||||||
const deps = getInternal<CompaniesInternalDeps>("companies");
|
const deps = getInternal<CompaniesInternalDeps>("companies");
|
||||||
const identityServices = getService<IIdentityPublicServices>("identity:general");
|
|
||||||
const requireCurrentCompanyContext = requireCompanyContext({
|
const requireCurrentCompanyContext = requireCompanyContext({
|
||||||
companyAccessChecker: identityServices.companyAccess,
|
companyAccessChecker: deps.companyAccess,
|
||||||
companyFinder: deps.publicFinder,
|
companyFinder: deps.publicFinder,
|
||||||
operationContext,
|
operationContext,
|
||||||
});
|
});
|
||||||
@ -43,14 +41,14 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
"/",
|
"/",
|
||||||
validateRequest(CreateCompanyRequestSchema, "body"),
|
validateRequest(CreateCompanyRequestSchema, "body"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new CreateCompanyController(deps.useCases.createCompany());
|
const controller = new CreateCompanyController(deps.useCases.createCompany);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get("/available", (req: Request, res: Response, next: NextFunction) => {
|
router.get("/available", (req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new FindAvailableCompaniesController(
|
const controller = new FindAvailableCompaniesController(
|
||||||
deps.useCases.findAvailableCompaniesForAccount()
|
deps.useCases.findAvailableCompaniesForAccount
|
||||||
);
|
);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
});
|
});
|
||||||
@ -59,7 +57,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
"/current",
|
"/current",
|
||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new GetCurrentCompanyController(deps.useCases.getCurrentCompany());
|
const controller = new GetCurrentCompanyController(deps.useCases.getCurrentCompany);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -69,7 +67,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
validateRequest(UpdateCurrentCompanyRequestSchema, "body"),
|
validateRequest(UpdateCurrentCompanyRequestSchema, "body"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany());
|
const controller = new UpdateCurrentCompanyController(deps.useCases.updateCurrentCompany);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -79,7 +77,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
validateRequest(GetCompanyByIdRequestSchema, "params"),
|
validateRequest(GetCompanyByIdRequestSchema, "params"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById());
|
const controller = new GetCompanyByIdController(deps.useCases.getCompanyById);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -90,7 +88,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
|
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
|
||||||
validateRequest(UpdateCompanyRequestSchema, "body"),
|
validateRequest(UpdateCompanyRequestSchema, "body"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new UpdateCompanyController(deps.useCases.updateCompany());
|
const controller = new UpdateCompanyController(deps.useCases.updateCompany);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -100,7 +98,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new EnableCompanyController(deps.useCases.enableCompany());
|
const controller = new EnableCompanyController(deps.useCases.enableCompany);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -110,7 +108,7 @@ export const companiesRouter = (params: StartParams) => {
|
|||||||
requireCurrentCompanyContext,
|
requireCurrentCompanyContext,
|
||||||
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const controller = new DisableCompanyController(deps.useCases.disableCompany());
|
const controller = new DisableCompanyController(deps.useCases.disableCompany);
|
||||||
return controller.execute(req, res, next);
|
return controller.execute(req, res, next);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
Province,
|
Province,
|
||||||
Street,
|
Street,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
UtcDate,
|
UtcDateTime,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Maybe } from "@repo/rdx-utils";
|
import { Maybe } from "@repo/rdx-utils";
|
||||||
|
|
||||||
@ -47,8 +47,8 @@ const company = (address = Maybe.none<PostalAddress>()) =>
|
|||||||
address,
|
address,
|
||||||
defaultPaymentTermId: Maybe.none(),
|
defaultPaymentTermId: Maybe.none(),
|
||||||
status: CompanyStatus.createActive(),
|
status: CompanyStatus.createActive(),
|
||||||
createdAt: unwrap(UtcDate.createFromISO("2026-01-01")),
|
createdAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
|
||||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")),
|
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
|
||||||
},
|
},
|
||||||
UniqueID.generateNewID()
|
UniqueID.generateNewID()
|
||||||
)
|
)
|
||||||
@ -65,8 +65,8 @@ const row = (patch: Record<string, string | null> = {}) =>
|
|||||||
website: null,
|
website: null,
|
||||||
default_payment_term_id: null,
|
default_payment_term_id: null,
|
||||||
status: "active",
|
status: "active",
|
||||||
created_at: "2026-01-01T00:00:00.000Z",
|
created_at: "2026-09-08T09:37:24.321Z",
|
||||||
updated_at: "2026-01-01T00:00:00.000Z",
|
updated_at: "2026-09-08T09:37:24.321Z",
|
||||||
address_street: null,
|
address_street: null,
|
||||||
address_street_2: null,
|
address_street_2: null,
|
||||||
address_city: null,
|
address_city: null,
|
||||||
|
|||||||
@ -36,18 +36,27 @@ function mapAddress(source: CompanyModel): Result<Maybe<PostalAddress>, Error> {
|
|||||||
source.address_province,
|
source.address_province,
|
||||||
source.address_country_code,
|
source.address_country_code,
|
||||||
];
|
];
|
||||||
if (values.every((value) => value === null) && source.address_street_2 === null)
|
if (values.every((value) => value === null) && source.address_street_2 === null) {
|
||||||
return Result.ok(Maybe.none());
|
return Result.ok(Maybe.none());
|
||||||
if (values.some((value) => value === null))
|
}
|
||||||
|
|
||||||
|
if (values.some((value) => value === null)) {
|
||||||
return Result.fail(new Error("Company address persistence is partial."));
|
return Result.fail(new Error("Company address persistence is partial."));
|
||||||
|
}
|
||||||
|
|
||||||
const street = Street.create(source.address_street as string);
|
const street = Street.create(source.address_street as string);
|
||||||
const city = City.create(source.address_city as string);
|
const city = City.create(source.address_city as string);
|
||||||
const postalCode = PostalCode.create(source.address_postal_code as string);
|
const postalCode = PostalCode.create(source.address_postal_code as string);
|
||||||
const province = Province.create(source.address_province as string);
|
const province = Province.create(source.address_province as string);
|
||||||
const countryCode = CountryCode.create(source.address_country_code as string);
|
const countryCode = CountryCode.create(source.address_country_code as string);
|
||||||
const street2 = maybeFromNullableResult(source.address_street_2, (value) => Street.create(value));
|
const street2 = maybeFromNullableResult(source.address_street_2, (value) => Street.create(value));
|
||||||
for (const result of [street, city, postalCode, province, countryCode, street2])
|
|
||||||
if (result.isFailure) return Result.fail(result.error);
|
for (const result of [street, city, postalCode, province, countryCode, street2]) {
|
||||||
|
if (result.isFailure) {
|
||||||
|
return Result.fail(result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const address = PostalAddress.create({
|
const address = PostalAddress.create({
|
||||||
street: Maybe.some(street.data),
|
street: Maybe.some(street.data),
|
||||||
street2: street2.data,
|
street2: street2.data,
|
||||||
@ -56,6 +65,7 @@ function mapAddress(source: CompanyModel): Result<Maybe<PostalAddress>, Error> {
|
|||||||
province: Maybe.some(province.data),
|
province: Maybe.some(province.data),
|
||||||
countryCode: Maybe.some(countryCode.data),
|
countryCode: Maybe.some(countryCode.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
return address.isFailure ? Result.fail(address.error) : Result.ok(Maybe.some(address.data));
|
return address.isFailure ? Result.fail(address.error) : Result.ok(Maybe.some(address.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,8 +183,8 @@ export class SequelizeCompanyDomainMapper extends SequelizeDomainMapper<
|
|||||||
value.toPrimitive()
|
value.toPrimitive()
|
||||||
),
|
),
|
||||||
status: source.status.toPrimitive(),
|
status: source.status.toPrimitive(),
|
||||||
created_at: source.createdAt.toISOString(),
|
created_at: source.createdAt.toDate(),
|
||||||
updated_at: source.updatedAt.toISOString(),
|
updated_at: source.updatedAt.toDate(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,8 +28,9 @@ export class CompanyModel extends Model<
|
|||||||
declare address_country_code: string | null;
|
declare address_country_code: string | null;
|
||||||
declare default_payment_term_id: string | null;
|
declare default_payment_term_id: string | null;
|
||||||
declare status: string;
|
declare status: string;
|
||||||
declare created_at: string;
|
declare created_at: Date;
|
||||||
declare updated_at: string;
|
declare updated_at: Date;
|
||||||
|
declare deleted_at: Date;
|
||||||
|
|
||||||
static associate(_database: Sequelize) {}
|
static associate(_database: Sequelize) {}
|
||||||
|
|
||||||
@ -101,6 +102,10 @@ export default (database: Sequelize) => {
|
|||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
|
deleted_at: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize: database,
|
sequelize: database,
|
||||||
@ -109,7 +114,13 @@ export default (database: Sequelize) => {
|
|||||||
charset: "utf8mb4",
|
charset: "utf8mb4",
|
||||||
collate: "utf8mb4_unicode_ci",
|
collate: "utf8mb4_unicode_ci",
|
||||||
underscored: true,
|
underscored: true,
|
||||||
timestamps: false,
|
|
||||||
|
timestamps: true,
|
||||||
|
|
||||||
|
createdAt: "created_at",
|
||||||
|
updatedAt: "updated_at",
|
||||||
|
deletedAt: "deleted_at",
|
||||||
|
|
||||||
indexes: [
|
indexes: [
|
||||||
{
|
{
|
||||||
name: "uq_companies_slug",
|
name: "uq_companies_slug",
|
||||||
|
|||||||
@ -1,17 +1,71 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CompanyCreator,
|
||||||
|
CreateCompanyUseCase,
|
||||||
|
DisableCompanyUseCase,
|
||||||
|
EnableCompanyUseCase,
|
||||||
|
FindAvailableCompaniesForAccountUseCase,
|
||||||
|
GetCompanyByIdUseCase,
|
||||||
|
GetCurrentCompanyUseCase,
|
||||||
|
InitialCompanyCreator,
|
||||||
|
UpdateCompanyUseCase,
|
||||||
|
UpdateCurrentCompanyUseCase,
|
||||||
|
} from "./application";
|
||||||
import { companiesModule } from "./module";
|
import { companiesModule } from "./module";
|
||||||
|
|
||||||
test("companiesModule compone el finder público de compañías sin HTTP", async () => {
|
test("companiesModule compone el finder público de compañías sin HTTP", async (t) => {
|
||||||
|
const create = t.mock.method(CompanyCreator.prototype, "create", async () =>
|
||||||
|
Result.fail(new Error("not called during setup"))
|
||||||
|
);
|
||||||
const setup = await companiesModule.setup?.({
|
const setup = await companiesModule.setup?.({
|
||||||
database: {},
|
database: {},
|
||||||
transactionContext: {},
|
transactionContext: {},
|
||||||
audit: { recordMutation: async () => ({ isFailure: false }) },
|
audit: { recordMutation: async () => ({ isFailure: false }) },
|
||||||
getService: () => ({ finder: {} }),
|
getService: (name: string) => {
|
||||||
|
if (name === "identity:general") return { companyAccess: {} };
|
||||||
|
if (name === "catalogs:paymentTerms") return { finder: {} };
|
||||||
|
throw new Error(`Unexpected public service: ${name}`);
|
||||||
|
},
|
||||||
logger: { info: () => undefined },
|
logger: { info: () => undefined },
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const general = setup?.services?.general as { finder: unknown };
|
const general = setup?.services?.general as {
|
||||||
|
finder: unknown;
|
||||||
|
initialCompanyCreator: InitialCompanyCreator;
|
||||||
|
};
|
||||||
assert.equal(typeof general.finder, "object");
|
assert.equal(typeof general.finder, "object");
|
||||||
|
assert.ok(general.initialCompanyCreator instanceof InitialCompanyCreator);
|
||||||
|
assert.equal(typeof general.initialCompanyCreator.create, "function");
|
||||||
|
assert.equal(create.mock.callCount(), 0);
|
||||||
|
|
||||||
|
const internal = setup?.internal as {
|
||||||
|
useCases: {
|
||||||
|
createCompany: unknown;
|
||||||
|
getCompanyById: unknown;
|
||||||
|
getCurrentCompany: unknown;
|
||||||
|
findAvailableCompaniesForAccount: unknown;
|
||||||
|
updateCompany: unknown;
|
||||||
|
updateCurrentCompany: unknown;
|
||||||
|
enableCompany: unknown;
|
||||||
|
disableCompany: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.ok(internal.useCases.createCompany instanceof CreateCompanyUseCase);
|
||||||
|
assert.ok(internal.useCases.getCompanyById instanceof GetCompanyByIdUseCase);
|
||||||
|
assert.ok(internal.useCases.getCurrentCompany instanceof GetCurrentCompanyUseCase);
|
||||||
|
assert.ok(
|
||||||
|
internal.useCases.findAvailableCompaniesForAccount instanceof
|
||||||
|
FindAvailableCompaniesForAccountUseCase
|
||||||
|
);
|
||||||
|
assert.ok(internal.useCases.updateCompany instanceof UpdateCompanyUseCase);
|
||||||
|
assert.ok(internal.useCases.updateCurrentCompany instanceof UpdateCurrentCompanyUseCase);
|
||||||
|
assert.ok(internal.useCases.enableCompany instanceof EnableCompanyUseCase);
|
||||||
|
assert.ok(internal.useCases.disableCompany instanceof DisableCompanyUseCase);
|
||||||
|
assert.equal("repository" in internal, false);
|
||||||
|
assert.equal("initialCompanyCreator" in internal, false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import type { IModuleServer } from "@erp/core/api";
|
import type { IModuleServer } from "@erp/core/api";
|
||||||
|
|
||||||
import type { ICompanyPublicServices } from "./application";
|
import type { ICompanyPublicServices } from "./application";
|
||||||
import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di";
|
import { buildCompaniesDependencies } from "./infrastructure/di";
|
||||||
import { models } from "./infrastructure/persistence/sequelize";
|
import { models } from "./infrastructure/persistence/sequelize";
|
||||||
|
|
||||||
export type { ICompanyPublicServices } from "./application";
|
export type { ICompanyPublicServices } from "./application";
|
||||||
export * from "./infrastructure/persistence/sequelize";
|
export * from "./application/companies/public/services/initial-company-creator";
|
||||||
|
|
||||||
export type CompaniesPublicServicesType = ICompanyPublicServices;
|
export type CompaniesPublicServicesType = ICompanyPublicServices;
|
||||||
|
|
||||||
@ -15,8 +15,8 @@ export const companiesModule: IModuleServer = {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["identity", "catalogs"],
|
dependencies: ["identity", "catalogs"],
|
||||||
async setup(params) {
|
async setup(params) {
|
||||||
const internal = buildCompaniesDependencies(params);
|
const { internal, publicServices } = buildCompaniesDependencies(params);
|
||||||
const general: ICompanyPublicServices = buildCompaniesPublicServices(params, internal);
|
const general: ICompanyPublicServices = publicServices;
|
||||||
return { models, services: { general }, internal };
|
return { models, services: { general }, internal };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1157,7 +1157,7 @@
|
|||||||
"form_fields": {
|
"form_fields": {
|
||||||
"proformas": {
|
"proformas": {
|
||||||
"status": {
|
"status": {
|
||||||
"label": "Estado",
|
"label": "Status",
|
||||||
"placeholder": "",
|
"placeholder": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
},
|
||||||
@ -1223,6 +1223,11 @@
|
|||||||
"clear_label": "No payment term",
|
"clear_label": "No payment term",
|
||||||
"inactive": "Inactive"
|
"inactive": "Inactive"
|
||||||
},
|
},
|
||||||
|
"retention_percentage": {
|
||||||
|
"label": "Retention",
|
||||||
|
"placeholder": "Select a percentage",
|
||||||
|
"description": "Retention percentage"
|
||||||
|
},
|
||||||
"item": {
|
"item": {
|
||||||
"quantity": {
|
"quantity": {
|
||||||
"label": "Quantity",
|
"label": "Quantity",
|
||||||
@ -1405,4 +1410,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1215,6 +1215,11 @@
|
|||||||
"clear_label": "Sin plazo de pago",
|
"clear_label": "Sin plazo de pago",
|
||||||
"inactive": "Inactivo"
|
"inactive": "Inactivo"
|
||||||
},
|
},
|
||||||
|
"retention_percentage": {
|
||||||
|
"label": "Retención",
|
||||||
|
"placeholder": "Selecciona porcentaje",
|
||||||
|
"description": "Porcentaje de retención"
|
||||||
|
},
|
||||||
"item": {
|
"item": {
|
||||||
"quantity": {
|
"quantity": {
|
||||||
"label": "Cantidad",
|
"label": "Cantidad",
|
||||||
@ -1397,4 +1402,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -140,7 +140,7 @@ export const LineEditor = <TLine,>({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[40px] font-semibold text-muted-foreground">#</TableHead>
|
<TableHead className="w-10 font-semibold text-muted-foreground">#</TableHead>
|
||||||
|
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
@ -151,7 +151,7 @@ export const LineEditor = <TLine,>({
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<TableHead className="w-[50px] font-semibold text-muted-foreground text-center">
|
<TableHead className="w-12.5 font-semibold text-muted-foreground text-center">
|
||||||
Acciones
|
Acciones
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export const ProformaUpdateTaxEditor = ({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:[&>[data-slot=field-label]]:min-w-0 min-[480px]:[&>[data-slot=field-label]]:flex-auto min-[480px]:[&>[button]]:ml-auto min-[480px]:[&>[button]]:min-w-[11rem] min-[480px]:[&>[button]]:flex-none [&>[data-slot=field-description]]:basis-full [&>[data-slot=field-description]]:w-full [&>[data-slot=field-description]]:pl-0 [&>[data-slot=field-error]]:basis-full [&>[data-slot=field-error]]:w-full [&>[data-slot=field-error]]:pl-0"
|
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:*:data-[slot=field-label]:min-w-0 min-[480px]:*:data-[slot=field-label]:flex-auto min-[480px]:*:[[button]]:ml-auto min-[480px]:*:[[button]]:min-w-44 min-[480px]:*:[[button]]:flex-none *:data-[slot=field-description]:basis-full *:data-[slot=field-description]:w-full *:data-[slot=field-description]:pl-0 *:data-[slot=field-error]:basis-full *:data-[slot=field-error]:w-full *:data-[slot=field-error]:pl-0"
|
||||||
description={
|
description={
|
||||||
taxCtrl.usesSingleTax
|
taxCtrl.usesSingleTax
|
||||||
? t("form_fields.proformas.default_tax_percentage.description", "")
|
? t("form_fields.proformas.default_tax_percentage.description", "")
|
||||||
@ -133,7 +133,7 @@ export const ProformaUpdateTaxEditor = ({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:[&>[data-slot=field-label]]:min-w-0 min-[480px]:[&>[data-slot=field-label]]:flex-auto min-[480px]:[&>[button]]:ml-auto min-[480px]:[&>[button]]:min-w-[11rem] min-[480px]:[&>[button]]:flex-none [&>[data-slot=field-description]]:basis-full [&>[data-slot=field-description]]:w-full [&>[data-slot=field-description]]:pl-0 [&>[data-slot=field-error]]:basis-full [&>[data-slot=field-error]]:w-full [&>[data-slot=field-error]]:pl-0"
|
className="md:col-span-12 md:col-start-1 min-[480px]:flex-row min-[480px]:items-center min-[480px]:*:w-auto min-[480px]:*:data-[slot=field-label]:min-w-0 min-[480px]:*:data-[slot=field-label]:flex-auto min-[480px]:*:[[button]]:ml-auto min-[480px]:*:[[button]]:min-w-44 min-[480px]:*:[[button]]:flex-none *:data-[slot=field-description]:basis-full *:data-[slot=field-description]:w-full *:data-[slot=field-description]:pl-0 *:data-[slot=field-error]:basis-full *:data-[slot=field-error]:w-full *:data-[slot=field-error]:pl-0"
|
||||||
deserialize={(value) => (value === null || value === "" ? null : Number(value))}
|
deserialize={(value) => (value === null || value === "" ? null : Number(value))}
|
||||||
disabled={disabled || !taxCtrl.hasRetentionPercentage}
|
disabled={disabled || !taxCtrl.hasRetentionPercentage}
|
||||||
inputClassName="bg-background"
|
inputClassName="bg-background"
|
||||||
@ -147,8 +147,8 @@ export const ProformaUpdateTaxEditor = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"form_fields.proformas.default_tax_percentage.placeholder",
|
"form_fields.proformas.retention_percentage.placeholder",
|
||||||
"Selecciona IVA"
|
"Selecciona porcentaje"
|
||||||
)}
|
)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
serialize={(value) => (typeof value === "number" ? String(value) : "")}
|
serialize={(value) => (typeof value === "number" ? String(value) : "")}
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
DataTableColumnHeader,
|
DataTableColumnHeader,
|
||||||
InitialsAvatar,
|
InitialsAvatar,
|
||||||
RdxBadge,
|
|
||||||
type RdxRowAction,
|
type RdxRowAction,
|
||||||
RdxRowActions,
|
RdxRowActions,
|
||||||
} from "@repo/rdx-ui/components";
|
} from "@repo/rdx-ui/components";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components";
|
import { Checkbox } from "@repo/shadcn-ui/components";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Building2Icon, EyeIcon, PencilIcon, Trash2Icon, UserIcon } from "lucide-react";
|
import { EyeIcon, PencilIcon, Trash2Icon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import type { CustomerListRow } from "../../../../shared";
|
import { type CustomerListRow, CustomerTypeBadge } from "../../../../shared";
|
||||||
import { AddressCell, ContactCell } from "../../components";
|
import { AddressCell, ContactCell } from "../../components";
|
||||||
|
|
||||||
type GridActionHandlers = {
|
type GridActionHandlers = {
|
||||||
@ -98,19 +97,11 @@ export function useCustomersGridColumns(
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span className="font-mono">{customer.tin}</span>
|
<span className="font-mono">{customer.tin}</span>
|
||||||
<RdxBadge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
|
<CustomerTypeBadge
|
||||||
{customer.isCompany ? (
|
companyLabel={t("fields.customer_type.options.company")}
|
||||||
<>
|
individualLabel={t("fields.customer_type.options.individual")}
|
||||||
<Building2Icon className="size-3" />
|
isCompany={customer.isCompany}
|
||||||
{t("fields.customer_type.options.company")}
|
/>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<UserIcon className="size-3" />
|
|
||||||
{t("fields.customer_type.options.individual")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</RdxBadge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { RdxBadge } from "@repo/rdx-ui/components";
|
||||||
|
import { Building2Icon, UserIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type CustomerTypeBadgeProps = {
|
||||||
|
isCompany: boolean;
|
||||||
|
companyLabel: string;
|
||||||
|
individualLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CustomerTypeBadge({
|
||||||
|
isCompany,
|
||||||
|
companyLabel,
|
||||||
|
individualLabel,
|
||||||
|
}: CustomerTypeBadgeProps) {
|
||||||
|
return (
|
||||||
|
<RdxBadge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
|
||||||
|
{isCompany ? (
|
||||||
|
<>
|
||||||
|
<Building2Icon className="size-3" />
|
||||||
|
{companyLabel}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserIcon className="size-3" />
|
||||||
|
{individualLabel}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</RdxBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from "./customer-status-badge";
|
export * from "./customer-status-badge";
|
||||||
|
export * from "./customer-type-badge";
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
import type { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { Company } from "../../../domain";
|
import type { Company } from "../../../domain";
|
||||||
|
|||||||
@ -3,9 +3,13 @@ import type { Collection, Maybe, Result } from "@repo/rdx-utils";
|
|||||||
import type { CompanyMembership } from "../../../domain";
|
import type { CompanyMembership } from "../../../domain";
|
||||||
|
|
||||||
export interface ICompanyMembershipRepository {
|
export interface ICompanyMembershipRepository {
|
||||||
|
/** La escritura de membership y roles requiere transacción externa; revertirla ante Result.fail. */
|
||||||
save(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
|
save(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
|
||||||
|
/** Requiere la misma transacción externa para datos y asignaciones; revertirla ante Result.fail. */
|
||||||
update(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
|
update(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
|
||||||
|
/** currentRead carga membership y roles desde el estado actual de la transacción. */
|
||||||
findByAccountIdAndCompanyId(params: {
|
findByAccountIdAndCompanyId(params: {
|
||||||
|
currentRead?: boolean;
|
||||||
accountId: CompanyMembership["accountId"];
|
accountId: CompanyMembership["accountId"];
|
||||||
companyId: CompanyMembership["companyId"];
|
companyId: CompanyMembership["companyId"];
|
||||||
transaction?: unknown;
|
transaction?: unknown;
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./company-access-checker";
|
export * from "./company-access-checker";
|
||||||
export * from "./create-identity-company-access";
|
export * from "./create-identity-company-access";
|
||||||
|
export * from "./initial-company-admin-assigner";
|
||||||
|
|||||||
@ -0,0 +1,262 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { DuplicateEntityError } from "@erp/core/api";
|
||||||
|
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { IdentityPermissionCodes } from "../../../../common/permissions";
|
||||||
|
import { CompanyMembership, CompanyMembershipStatus, Role, RoleCode } from "../../../domain";
|
||||||
|
import { EffectivePermissionsResolver } from "../../permissions";
|
||||||
|
import type { IRoleRepository } from "../../roles/contracts";
|
||||||
|
import { CompanyAdminRoleEnsurer } from "../../roles/services";
|
||||||
|
import type { ICompanyMembershipRepository } from "../contracts";
|
||||||
|
|
||||||
|
import { InitialCompanyAdminAssigner } from "./initial-company-admin-assigner";
|
||||||
|
|
||||||
|
const accountId = UniqueID.generateNewID();
|
||||||
|
const companyId = UniqueID.generateNewID();
|
||||||
|
const adminId = UniqueID.generateNewID();
|
||||||
|
const otherId = UniqueID.generateNewID();
|
||||||
|
const transaction = {};
|
||||||
|
const params = { accountId, companyId, transaction };
|
||||||
|
function role(id = adminId) {
|
||||||
|
return Role.create(
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
code: RoleCode.create(id.equals(adminId) ? "admin" : "other").data,
|
||||||
|
name: Name.create("Administrator").data,
|
||||||
|
isSystem: id.equals(adminId),
|
||||||
|
permissionCodes: id.equals(adminId) ? Object.values(IdentityPermissionCodes) : ["extra:read"],
|
||||||
|
createdAt: UtcDate.today(),
|
||||||
|
updatedAt: UtcDate.today(),
|
||||||
|
},
|
||||||
|
id
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
function member(ids = [adminId], status = CompanyMembershipStatus.createActive()) {
|
||||||
|
return CompanyMembership.create(
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
roleIds: ids,
|
||||||
|
status,
|
||||||
|
createdAt: UtcDate.today(),
|
||||||
|
updatedAt: UtcDate.today(),
|
||||||
|
},
|
||||||
|
UniqueID.generateNewID()
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
function setup(
|
||||||
|
initial?: CompanyMembership,
|
||||||
|
failure?: string,
|
||||||
|
winner?: CompanyMembership,
|
||||||
|
concurrent = false
|
||||||
|
) {
|
||||||
|
let state = initial;
|
||||||
|
let admin: Role | undefined = initial || winner || concurrent ? role() : undefined;
|
||||||
|
let saves = 0;
|
||||||
|
let updates = 0;
|
||||||
|
let roleSaves = 0;
|
||||||
|
let resolutions = 0;
|
||||||
|
const error = new Error(failure);
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
let arrivals = 0;
|
||||||
|
let release: () => void = () => {};
|
||||||
|
const barrier = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const memberships: ICompanyMembershipRepository = {
|
||||||
|
findByAccountIdAndCompanyId: async (p) => {
|
||||||
|
calls.push(p.transaction);
|
||||||
|
if (concurrent && arrivals < 2) {
|
||||||
|
arrivals++;
|
||||||
|
if (arrivals === 2) release();
|
||||||
|
await barrier;
|
||||||
|
return Result.ok(Maybe.none<CompanyMembership>());
|
||||||
|
}
|
||||||
|
if (failure === "find") return Result.fail(error);
|
||||||
|
return Result.ok(state ? Maybe.some(state) : Maybe.none<CompanyMembership>());
|
||||||
|
},
|
||||||
|
save: async (m, tx) => {
|
||||||
|
calls.push(tx);
|
||||||
|
saves++;
|
||||||
|
if (failure === "save") return Result.fail(error);
|
||||||
|
if (failure === "unpersisted") return Result.ok();
|
||||||
|
if (failure === "duplicate") {
|
||||||
|
state = winner;
|
||||||
|
return Result.fail(
|
||||||
|
new DuplicateEntityError("Membership", "accountId", accountId.toPrimitive())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state)
|
||||||
|
return Result.fail(
|
||||||
|
new DuplicateEntityError("Membership", "accountId", accountId.toPrimitive())
|
||||||
|
);
|
||||||
|
state = m;
|
||||||
|
return Result.ok();
|
||||||
|
},
|
||||||
|
update: async (m, tx) => {
|
||||||
|
calls.push(tx);
|
||||||
|
updates++;
|
||||||
|
if (failure === "update") return Result.fail(error);
|
||||||
|
state = m;
|
||||||
|
return Result.ok();
|
||||||
|
},
|
||||||
|
findByAccountId: async () => Result.ok(new Collection<CompanyMembership>([])),
|
||||||
|
findByCompanyId: async () => Result.ok(new Collection<CompanyMembership>([])),
|
||||||
|
};
|
||||||
|
const roles: IRoleRepository = {
|
||||||
|
findByCodeInCompany: async (p) => {
|
||||||
|
calls.push(p.transaction);
|
||||||
|
return Result.ok(admin ? Maybe.some(admin) : Maybe.none<Role>());
|
||||||
|
},
|
||||||
|
save: async (r, tx) => {
|
||||||
|
calls.push(tx);
|
||||||
|
admin = r;
|
||||||
|
roleSaves++;
|
||||||
|
return Result.ok();
|
||||||
|
},
|
||||||
|
update: async () => Result.ok(),
|
||||||
|
delete: async () => Result.ok(),
|
||||||
|
findByIdInCompany: async () => Result.ok(Maybe.none<Role>()),
|
||||||
|
findByCompanyId: async () => Result.ok(new Collection<Role>([])),
|
||||||
|
findByIdsInCompany: async (p) => {
|
||||||
|
calls.push(p.transaction);
|
||||||
|
const values = p.roleIds.map((id) => (admin?.id.equals(id) ? admin : role(id)));
|
||||||
|
return Result.ok(new Collection(values));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ensurer = new CompanyAdminRoleEnsurer(roles);
|
||||||
|
const resolver = new EffectivePermissionsResolver({ memberships, roles });
|
||||||
|
const service = new InitialCompanyAdminAssigner({
|
||||||
|
memberships,
|
||||||
|
adminRoles: {
|
||||||
|
ensure: async (p) => {
|
||||||
|
calls.push(p.transaction);
|
||||||
|
return failure === "ensure" ? Result.fail(error) : ensurer.ensure(p);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
resolve: async (p) => {
|
||||||
|
resolutions++;
|
||||||
|
calls.push(p.transaction);
|
||||||
|
if (failure === "resolve") return Result.fail(error);
|
||||||
|
if (failure === "permissions") return Result.ok(new Collection<string>([]));
|
||||||
|
return resolver.resolve(p);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
error,
|
||||||
|
calls,
|
||||||
|
get state() {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
get admin() {
|
||||||
|
return admin;
|
||||||
|
},
|
||||||
|
get saves() {
|
||||||
|
return saves;
|
||||||
|
},
|
||||||
|
get updates() {
|
||||||
|
return updates;
|
||||||
|
},
|
||||||
|
get roleSaves() {
|
||||||
|
return roleSaves;
|
||||||
|
},
|
||||||
|
get resolutions() {
|
||||||
|
return resolutions;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
test("crea membership activa, verifica permisos y es idempotente con estado real", async () => {
|
||||||
|
const s = setup();
|
||||||
|
assert.equal((await s.service.assign(params)).isSuccess, true);
|
||||||
|
assert.equal((await s.service.assign(params)).isSuccess, true);
|
||||||
|
assert.equal(s.saves, 1);
|
||||||
|
assert.equal(s.updates, 0);
|
||||||
|
assert.equal(s.roleSaves, 1);
|
||||||
|
assert.equal(s.resolutions, 2);
|
||||||
|
assert.ok(s.state?.status.isActive());
|
||||||
|
assert.deepEqual(
|
||||||
|
s.state?.roleIds.map((id) => id.toPrimitive()),
|
||||||
|
[s.admin?.id.toPrimitive()]
|
||||||
|
);
|
||||||
|
assert.ok(s.state?.id);
|
||||||
|
assert.equal(s.state.createdAt.toPrimitive(), UtcDate.today().toPrimitive());
|
||||||
|
for (const tx of s.calls) assert.equal(tx, transaction);
|
||||||
|
});
|
||||||
|
test("activa admin no escribe membership", async () => {
|
||||||
|
const s = setup(member());
|
||||||
|
assert.equal((await s.service.assign(params)).isSuccess, true);
|
||||||
|
assert.equal(s.saves + s.updates, 0);
|
||||||
|
});
|
||||||
|
for (const status of [
|
||||||
|
CompanyMembershipStatus.createActive(),
|
||||||
|
CompanyMembershipStatus.createDisabled(),
|
||||||
|
CompanyMembershipStatus.createInvited(),
|
||||||
|
]) {
|
||||||
|
test(`estado ${status.toPrimitive()} conserva otros roles y añade admin`, async () => {
|
||||||
|
const s = setup(member([otherId], status));
|
||||||
|
assert.equal((await s.service.assign(params)).isSuccess, true);
|
||||||
|
assert.equal(s.updates, 1);
|
||||||
|
assert.ok(s.state?.status.isActive());
|
||||||
|
assert.deepEqual(
|
||||||
|
s.state?.roleIds.map((id) => id.toPrimitive()),
|
||||||
|
[otherId.toPrimitive(), adminId.toPrimitive()]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("reactiva membership sin roles mediante dominio", async () => {
|
||||||
|
const s = setup(member([], CompanyMembershipStatus.createDisabled()));
|
||||||
|
assert.equal((await s.service.assign(params)).isSuccess, true);
|
||||||
|
});
|
||||||
|
for (const failure of ["ensure", "find", "save", "update", "resolve"]) {
|
||||||
|
test(`propaga error en ${failure}`, async () => {
|
||||||
|
const s = setup(failure === "update" ? member([otherId]) : undefined, failure);
|
||||||
|
assert.equal((await s.service.assign(params)).error, s.error);
|
||||||
|
if (failure === "ensure" || failure === "find") assert.equal(s.saves + s.updates, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("permisos insuficientes impiden success", async () => {
|
||||||
|
assert.equal((await setup(undefined, "permissions").service.assign(params)).isFailure, true);
|
||||||
|
});
|
||||||
|
for (const hasAdmin of [true, false]) {
|
||||||
|
test(`duplicate relee ganador con admin=${hasAdmin}`, async () => {
|
||||||
|
const winner = member(hasAdmin ? [adminId, otherId] : [otherId]);
|
||||||
|
const s = setup(undefined, "duplicate", winner);
|
||||||
|
assert.equal((await s.service.assign(params)).isSuccess, true);
|
||||||
|
assert.equal(s.updates, hasAdmin ? 0 : 1);
|
||||||
|
assert.ok(s.state?.roleIds.some((id) => id.equals(otherId)));
|
||||||
|
assert.ok(s.state?.roleIds.some((id) => id.equals(s.admin?.id ?? adminId)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("duplicate sin ganador falla", async () => {
|
||||||
|
assert.equal((await setup(undefined, "duplicate").service.assign(params)).isFailure, true);
|
||||||
|
});
|
||||||
|
test("transacción ausente falla antes de I/O", async () => {
|
||||||
|
const s = setup();
|
||||||
|
assert.equal((await s.service.assign({ ...params, transaction: undefined })).isFailure, true);
|
||||||
|
assert.equal(s.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dos asignaciones concurrentes convergen mediante barrera determinista", async () => {
|
||||||
|
const s = setup(undefined, undefined, undefined, true);
|
||||||
|
const results = await Promise.all([s.service.assign(params), s.service.assign(params)]);
|
||||||
|
assert.ok(results.every((result) => result.isSuccess));
|
||||||
|
assert.equal(s.saves, 2);
|
||||||
|
assert.equal(s.roleSaves, 0);
|
||||||
|
assert.equal(s.resolutions, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
s.state?.roleIds.map((id) => id.toPrimitive()),
|
||||||
|
[adminId.toPrimitive()]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("save nominal sin persistencia falla la postcondición", async () => {
|
||||||
|
const s = setup(undefined, "unpersisted");
|
||||||
|
assert.equal((await s.service.assign(params)).isFailure, true);
|
||||||
|
assert.equal(s.resolutions, 0);
|
||||||
|
});
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
import { isDuplicateEntityError } from "@erp/core/api";
|
||||||
|
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { CompanyMembership, CompanyMembershipStatus } from "../../../domain";
|
||||||
|
import type { IEffectivePermissionsResolver } from "../../permissions";
|
||||||
|
import type { ICompanyAdminRoleEnsurer } from "../../roles/services";
|
||||||
|
import type { ICompanyMembershipRepository } from "../contracts";
|
||||||
|
|
||||||
|
export interface AssignInitialCompanyAdminParams {
|
||||||
|
accountId: UniqueID;
|
||||||
|
companyId: UniqueID;
|
||||||
|
transaction: unknown;
|
||||||
|
}
|
||||||
|
export interface IInitialCompanyAdminAssigner {
|
||||||
|
assign(params: AssignInitialCompanyAdminParams): Promise<Result<void, Error>>;
|
||||||
|
}
|
||||||
|
/** Participa en la transacción externa; el caller debe revertirla ante cualquier fallo. */
|
||||||
|
export class InitialCompanyAdminAssigner implements IInitialCompanyAdminAssigner {
|
||||||
|
public constructor(
|
||||||
|
private readonly deps: {
|
||||||
|
memberships: ICompanyMembershipRepository;
|
||||||
|
adminRoles: ICompanyAdminRoleEnsurer;
|
||||||
|
permissions: IEffectivePermissionsResolver;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async assign(params: AssignInitialCompanyAdminParams): Promise<Result<void, Error>> {
|
||||||
|
if (!params.transaction)
|
||||||
|
return Result.fail(new Error("Initial admin assignment requires an external transaction."));
|
||||||
|
const admin = await this.deps.adminRoles.ensure({
|
||||||
|
companyId: params.companyId,
|
||||||
|
transaction: params.transaction,
|
||||||
|
});
|
||||||
|
if (admin.isFailure) return Result.fail(admin.error);
|
||||||
|
const assigned = await this.ensureMembership(admin.data.id, params);
|
||||||
|
if (assigned.isFailure) return assigned;
|
||||||
|
const persisted = await this.deps.memberships.findByAccountIdAndCompanyId({
|
||||||
|
...params,
|
||||||
|
currentRead: true,
|
||||||
|
});
|
||||||
|
if (persisted.isFailure) return Result.fail(persisted.error);
|
||||||
|
if (persisted.data.isNone())
|
||||||
|
return Result.fail(new Error("Initial admin membership was not persisted."));
|
||||||
|
const membership = persisted.data.unwrap();
|
||||||
|
if (
|
||||||
|
!(membership.status.isActive() && membership.roleIds.some((id) => id.equals(admin.data.id)))
|
||||||
|
) {
|
||||||
|
return Result.fail(new Error("Initial admin membership postcondition failed."));
|
||||||
|
}
|
||||||
|
const effective = await this.deps.permissions.resolve({ ...params, currentRead: true });
|
||||||
|
if (effective.isFailure) return Result.fail(effective.error);
|
||||||
|
const permissions = new Set(effective.data.getAll());
|
||||||
|
if (admin.data.permissionCodes.some((code) => !permissions.has(code))) {
|
||||||
|
return Result.fail(new Error("Initial admin effective permissions postcondition failed."));
|
||||||
|
}
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureMembership(
|
||||||
|
adminId: UniqueID,
|
||||||
|
params: AssignInitialCompanyAdminParams
|
||||||
|
): Promise<Result<void, Error>> {
|
||||||
|
const found = await this.deps.memberships.findByAccountIdAndCompanyId(params);
|
||||||
|
if (found.isFailure) return Result.fail(found.error);
|
||||||
|
if (found.data.isSome()) return this.complete(found.data.unwrap(), adminId, params);
|
||||||
|
const now = UtcDate.today();
|
||||||
|
const created = CompanyMembership.create(
|
||||||
|
{
|
||||||
|
accountId: params.accountId,
|
||||||
|
companyId: params.companyId,
|
||||||
|
roleIds: [adminId],
|
||||||
|
status: CompanyMembershipStatus.createActive(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
UniqueID.generateNewID()
|
||||||
|
);
|
||||||
|
if (created.isFailure) return Result.fail(created.error);
|
||||||
|
const saved = await this.deps.memberships.save(created.data, params.transaction);
|
||||||
|
if (saved.isSuccess) return saved;
|
||||||
|
if (!isDuplicateEntityError(saved.error)) return Result.fail(saved.error);
|
||||||
|
const winner = await this.deps.memberships.findByAccountIdAndCompanyId({
|
||||||
|
...params,
|
||||||
|
currentRead: true,
|
||||||
|
});
|
||||||
|
if (winner.isFailure) return Result.fail(winner.error);
|
||||||
|
if (winner.data.isNone()) return Result.fail(saved.error);
|
||||||
|
return this.complete(winner.data.unwrap(), adminId, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async complete(
|
||||||
|
membership: CompanyMembership,
|
||||||
|
adminId: UniqueID,
|
||||||
|
params: AssignInitialCompanyAdminParams
|
||||||
|
): Promise<Result<void, Error>> {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
membership.accountId.equals(params.accountId) &&
|
||||||
|
membership.companyId.equals(params.companyId)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Result.fail(new Error("Membership does not match the requested account and company."));
|
||||||
|
}
|
||||||
|
const now = UtcDate.today();
|
||||||
|
const roles = membership.roleIds;
|
||||||
|
if (!roles.some((id) => id.equals(adminId))) roles.push(adminId);
|
||||||
|
const replaced = membership.replaceRoles({ roleIds: roles, updatedAt: now });
|
||||||
|
if (replaced.isFailure) return Result.fail(replaced.error);
|
||||||
|
const enabled = membership.enable(now);
|
||||||
|
if (enabled.isFailure) return Result.fail(enabled.error);
|
||||||
|
if (replaced.data || enabled.data)
|
||||||
|
return this.deps.memberships.update(membership, params.transaction);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from "./contracts";
|
export * from "./contracts";
|
||||||
|
export * from "./services/effective-permissions-resolver";
|
||||||
|
|||||||
@ -0,0 +1,178 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { CompanyMembership, CompanyMembershipStatus, Role, RoleCode } from "../../../domain";
|
||||||
|
import type { ICompanyMembershipRepository } from "../../company-memberships/contracts";
|
||||||
|
import type { IRoleRepository } from "../../roles/contracts";
|
||||||
|
|
||||||
|
import { EffectivePermissionsResolver } from "./effective-permissions-resolver";
|
||||||
|
|
||||||
|
const accountId = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
|
||||||
|
const companyId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
||||||
|
const firstId = UniqueID.create("33333333-3333-4333-8333-333333333333").data;
|
||||||
|
const secondId = UniqueID.create("44444444-4444-4444-8444-444444444444").data;
|
||||||
|
const date = UtcDate.createFromISO("2026-01-01").data;
|
||||||
|
|
||||||
|
function membership(roleIds = [firstId], status = CompanyMembershipStatus.createActive()) {
|
||||||
|
return CompanyMembership.rehydrate(
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
roleIds,
|
||||||
|
status,
|
||||||
|
createdAt: date,
|
||||||
|
updatedAt: date,
|
||||||
|
},
|
||||||
|
accountId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function role(id = firstId, permissionCodes = ["invoices:read"], company = companyId) {
|
||||||
|
return Role.create(
|
||||||
|
{
|
||||||
|
companyId: company,
|
||||||
|
code: RoleCode.create("billing-editor").data,
|
||||||
|
name: Name.create("Billing").data,
|
||||||
|
permissionCodes,
|
||||||
|
isSystem: false,
|
||||||
|
createdAt: date,
|
||||||
|
updatedAt: date,
|
||||||
|
},
|
||||||
|
id
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
function setup(
|
||||||
|
member: CompanyMembership | null = membership(),
|
||||||
|
assignedRoles = [role()],
|
||||||
|
membershipError?: Error,
|
||||||
|
roleError?: Error
|
||||||
|
) {
|
||||||
|
const membershipCalls: Parameters<
|
||||||
|
ICompanyMembershipRepository["findByAccountIdAndCompanyId"]
|
||||||
|
>[0][] = [];
|
||||||
|
const roleCalls: Parameters<IRoleRepository["findByIdsInCompany"]>[0][] = [];
|
||||||
|
const memberships: ICompanyMembershipRepository = {
|
||||||
|
save: async () => Result.ok(),
|
||||||
|
update: async () => Result.ok(),
|
||||||
|
findByAccountIdAndCompanyId: async (params) => {
|
||||||
|
membershipCalls.push(params);
|
||||||
|
return membershipError
|
||||||
|
? Result.fail(membershipError)
|
||||||
|
: Result.ok(member === null ? Maybe.none<CompanyMembership>() : Maybe.some(member));
|
||||||
|
},
|
||||||
|
findByAccountId: async () => Result.ok(new Collection<CompanyMembership>([])),
|
||||||
|
findByCompanyId: async () => Result.ok(new Collection<CompanyMembership>([])),
|
||||||
|
};
|
||||||
|
const roles: IRoleRepository = {
|
||||||
|
findByCodeInCompany: async () => Result.ok(Maybe.none<Role>()),
|
||||||
|
save: async () => Result.ok(),
|
||||||
|
update: async () => Result.ok(),
|
||||||
|
delete: async () => Result.ok(),
|
||||||
|
findByIdInCompany: async () => Result.ok(Maybe.none<Role>()),
|
||||||
|
findByCompanyId: async () => Result.ok(new Collection<Role>([])),
|
||||||
|
findByIdsInCompany: async (params) => {
|
||||||
|
roleCalls.push(params);
|
||||||
|
return roleError ? Result.fail(roleError) : Result.ok(new Collection(assignedRoles));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
resolver: new EffectivePermissionsResolver({ memberships, roles }),
|
||||||
|
membershipCalls,
|
||||||
|
roleCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
test("un rol devuelve sus permisos", async () => {
|
||||||
|
const result = await setup().resolver.resolve({ accountId, companyId });
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.deepEqual(result.data.getAll(), ["invoices:read"]);
|
||||||
|
});
|
||||||
|
test("varios roles unen permisos sin duplicados y con orden estable", async () => {
|
||||||
|
const subject = setup(membership([firstId, secondId]), [
|
||||||
|
role(secondId, ["invoices:write", "invoices:read"]),
|
||||||
|
role(),
|
||||||
|
]);
|
||||||
|
const result = await subject.resolver.resolve({ accountId, companyId });
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.deepEqual(result.data.getAll(), ["invoices:read", "invoices:write"]);
|
||||||
|
});
|
||||||
|
test("sin membership deniega sin consultar roles", async () => {
|
||||||
|
const subject = setup(null);
|
||||||
|
const result = await subject.resolver.resolve({ accountId, companyId });
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(subject.roleCalls.length, 0);
|
||||||
|
});
|
||||||
|
for (const [label, member] of [
|
||||||
|
["disabled", membership([firstId], CompanyMembershipStatus.createDisabled())],
|
||||||
|
["invited", membership([firstId], CompanyMembershipStatus.createInvited())],
|
||||||
|
["activa sin roles", membership([])],
|
||||||
|
["roles duplicados", membership([firstId, firstId])],
|
||||||
|
] as const) {
|
||||||
|
test(`membership ${label} propaga el error del dominio sin consultar roles`, async () => {
|
||||||
|
const subject = setup(member);
|
||||||
|
const result = await subject.resolver.resolve({ accountId, companyId });
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(result.error.constructor, member.canAccessCompany().error.constructor);
|
||||||
|
assert.equal(subject.roleCalls.length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [label, assigned] of [
|
||||||
|
["ausentes", []],
|
||||||
|
["incompletos", [role()]],
|
||||||
|
["de otra compañía", [role(firstId, ["invoices:read"], accountId), role(secondId)]],
|
||||||
|
["duplicados", [role(), role()]],
|
||||||
|
["inesperados", [role(), role(accountId)]],
|
||||||
|
] as const) {
|
||||||
|
test(`roles ${label} fallan sin devolver permisos parciales`, async () => {
|
||||||
|
const result = await setup(membership([firstId, secondId]), [...assigned]).resolver.resolve({
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("membership de otro contexto falla antes de consultar roles", async () => {
|
||||||
|
const subject = setup();
|
||||||
|
const result = await subject.resolver.resolve({ accountId: secondId, companyId });
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(subject.roleCalls.length, 0);
|
||||||
|
});
|
||||||
|
test("propaga error del repositorio de memberships", async () => {
|
||||||
|
const error = new Error("membership failure");
|
||||||
|
const subject = setup(membership(), [role()], error);
|
||||||
|
const result = await subject.resolver.resolve({ accountId, companyId });
|
||||||
|
assert.equal(result.error, error);
|
||||||
|
assert.equal(subject.roleCalls.length, 0);
|
||||||
|
});
|
||||||
|
test("propaga error del repositorio de roles", async () => {
|
||||||
|
const error = new Error("role failure");
|
||||||
|
const result = await setup(membership(), [role()], undefined, error).resolver.resolve({
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
|
assert.equal(result.error, error);
|
||||||
|
});
|
||||||
|
test("propaga la misma transacción y el contexto a ambos repositorios", async () => {
|
||||||
|
const transaction = {};
|
||||||
|
const subject = setup();
|
||||||
|
await subject.resolver.resolve({ accountId, companyId, transaction });
|
||||||
|
assert.deepEqual(subject.membershipCalls, [{ accountId, companyId, transaction }]);
|
||||||
|
assert.deepEqual(subject.roleCalls, [{ roleIds: [firstId], companyId, transaction }]);
|
||||||
|
assert.equal(subject.membershipCalls[0].transaction, transaction);
|
||||||
|
assert.equal(subject.roleCalls[0].transaction, transaction);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lectura actual se propaga a membership y roles", async () => {
|
||||||
|
const subject = setup();
|
||||||
|
const transaction = {};
|
||||||
|
const result = await subject.resolver.resolve({
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
transaction,
|
||||||
|
currentRead: true,
|
||||||
|
});
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(subject.membershipCalls[0].currentRead, true);
|
||||||
|
assert.equal(subject.roleCalls[0].currentRead, true);
|
||||||
|
});
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import type { PermissionCode } from "@erp/core";
|
||||||
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Collection, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { CompanyMembershipCannotAccessCompanyError } from "../../../domain";
|
||||||
|
import type { ICompanyMembershipRepository } from "../../company-memberships/contracts";
|
||||||
|
import type { IRoleRepository } from "../../roles/contracts";
|
||||||
|
|
||||||
|
export interface ResolveEffectivePermissionsParams {
|
||||||
|
accountId: UniqueID;
|
||||||
|
companyId: UniqueID;
|
||||||
|
transaction?: unknown;
|
||||||
|
/** Comprueba el estado actual tras escrituras o conflictos concurrentes. */
|
||||||
|
currentRead?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEffectivePermissionsResolver {
|
||||||
|
resolve(
|
||||||
|
params: ResolveEffectivePermissionsParams
|
||||||
|
): Promise<Result<Collection<PermissionCode>, Error>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EffectivePermissionsResolver implements IEffectivePermissionsResolver {
|
||||||
|
public constructor(
|
||||||
|
private readonly deps: {
|
||||||
|
memberships: ICompanyMembershipRepository;
|
||||||
|
roles: IRoleRepository;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async resolve(
|
||||||
|
params: ResolveEffectivePermissionsParams
|
||||||
|
): Promise<Result<Collection<PermissionCode>, Error>> {
|
||||||
|
const found = await this.deps.memberships.findByAccountIdAndCompanyId(params);
|
||||||
|
if (found.isFailure) return Result.fail(found.error);
|
||||||
|
if (found.data.isNone()) {
|
||||||
|
return Result.fail(
|
||||||
|
new CompanyMembershipCannotAccessCompanyError("Company membership is required.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const membership = found.data.unwrap();
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
membership.accountId.equals(params.accountId) &&
|
||||||
|
membership.companyId.equals(params.companyId)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Result.fail(new Error("Membership does not match the requested account and company."));
|
||||||
|
}
|
||||||
|
const access = membership.canAccessCompany();
|
||||||
|
if (access.isFailure) return Result.fail(access.error);
|
||||||
|
|
||||||
|
const roleIds = membership.roleIds;
|
||||||
|
const foundRoles = await this.deps.roles.findByIdsInCompany({
|
||||||
|
roleIds,
|
||||||
|
...(params.currentRead ? { currentRead: true } : {}),
|
||||||
|
companyId: params.companyId,
|
||||||
|
transaction: params.transaction,
|
||||||
|
});
|
||||||
|
if (foundRoles.isFailure) return Result.fail(foundRoles.error);
|
||||||
|
const roles = foundRoles.data.getAll();
|
||||||
|
const expected = new Set(roleIds.map((id) => id.toPrimitive()));
|
||||||
|
const received = new Set(roles.map((role) => role.id.toPrimitive()));
|
||||||
|
if (
|
||||||
|
roles.length !== expected.size ||
|
||||||
|
received.size !== expected.size ||
|
||||||
|
roles.some(
|
||||||
|
(role) => !(expected.has(role.id.toPrimitive()) && role.companyId.equals(params.companyId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Result.fail(
|
||||||
|
new Error("Membership roles are missing, duplicated or outside the requested company.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const permissions = new Set<PermissionCode>();
|
||||||
|
for (const role of roles) {
|
||||||
|
for (const permission of role.permissionCodes) permissions.add(permission);
|
||||||
|
}
|
||||||
|
return Result.ok(new Collection([...permissions].sort()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,13 @@ import type { Collection, Maybe, Result } from "@repo/rdx-utils";
|
|||||||
import type { Role } from "../../../domain";
|
import type { Role } from "../../../domain";
|
||||||
|
|
||||||
export interface IRoleRepository {
|
export interface IRoleRepository {
|
||||||
|
/** currentRead evita releer un snapshot anterior tras un conflicto concurrente. */
|
||||||
|
findByCodeInCompany(params: {
|
||||||
|
companyId: Role["companyId"];
|
||||||
|
code: Role["code"];
|
||||||
|
transaction?: unknown;
|
||||||
|
currentRead?: boolean;
|
||||||
|
}): Promise<Result<Maybe<Role>, Error>>;
|
||||||
save(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
|
save(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
|
||||||
update(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
|
update(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
|
||||||
delete(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
|
delete(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
|
||||||
@ -13,6 +20,7 @@ export interface IRoleRepository {
|
|||||||
transaction?: unknown;
|
transaction?: unknown;
|
||||||
}): Promise<Result<Maybe<Role>, Error>>;
|
}): Promise<Result<Maybe<Role>, Error>>;
|
||||||
findByIdsInCompany(params: {
|
findByIdsInCompany(params: {
|
||||||
|
currentRead?: boolean;
|
||||||
roleIds: UniqueID[];
|
roleIds: UniqueID[];
|
||||||
companyId: Role["companyId"];
|
companyId: Role["companyId"];
|
||||||
transaction?: unknown;
|
transaction?: unknown;
|
||||||
|
|||||||
@ -0,0 +1,131 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { DuplicateEntityError } from "@erp/core/api";
|
||||||
|
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { IdentityPermissionCodes } from "../../../../common/permissions";
|
||||||
|
import { Role, RoleCode } from "../../../domain";
|
||||||
|
import type { IRoleRepository } from "../contracts";
|
||||||
|
|
||||||
|
import { CompanyAdminRoleEnsurer } from "./company-admin-role-ensurer";
|
||||||
|
|
||||||
|
const companyId = UniqueID.generateNewID();
|
||||||
|
function admin(
|
||||||
|
isSystem = true,
|
||||||
|
permissionCodes: string[] = Object.values(IdentityPermissionCodes)
|
||||||
|
) {
|
||||||
|
return Role.create(
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
code: RoleCode.create("admin").data,
|
||||||
|
name: Name.create("Administrator").data,
|
||||||
|
isSystem,
|
||||||
|
permissionCodes,
|
||||||
|
createdAt: UtcDate.today(),
|
||||||
|
updatedAt: UtcDate.today(),
|
||||||
|
},
|
||||||
|
UniqueID.generateNewID()
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
function setup(
|
||||||
|
reads: Result<Maybe<Role>, Error>[] = [Result.ok(Maybe.none())],
|
||||||
|
saveResult: Result<void, Error> = Result.ok()
|
||||||
|
) {
|
||||||
|
const lookups: Parameters<IRoleRepository["findByCodeInCompany"]>[0][] = [];
|
||||||
|
const writes: { role: Role; transaction?: unknown }[] = [];
|
||||||
|
const repository: IRoleRepository = {
|
||||||
|
findByCodeInCompany: async (params) => {
|
||||||
|
lookups.push(params);
|
||||||
|
const result = reads.shift();
|
||||||
|
assert.ok(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
save: async (role, transaction) => {
|
||||||
|
writes.push({ role, transaction });
|
||||||
|
return saveResult;
|
||||||
|
},
|
||||||
|
update: async () => {
|
||||||
|
assert.fail("No debe modificar roles");
|
||||||
|
},
|
||||||
|
delete: async () => {
|
||||||
|
assert.fail("No debe borrar roles");
|
||||||
|
},
|
||||||
|
findByIdInCompany: async () => Result.ok(Maybe.none()),
|
||||||
|
findByIdsInCompany: async () => Result.ok(new Collection<Role>([])),
|
||||||
|
findByCompanyId: async () => Result.ok(new Collection<Role>([])),
|
||||||
|
};
|
||||||
|
return { service: new CompanyAdminRoleEnsurer(repository), writes, lookups };
|
||||||
|
}
|
||||||
|
test("crea admin de sistema con SSOT exacta, ID y fechas internos", async () => {
|
||||||
|
const subject = setup();
|
||||||
|
const result = await subject.service.ensure({ companyId });
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
const role = result.data;
|
||||||
|
assert.equal(role.code.toPrimitive(), "admin");
|
||||||
|
assert.equal(role.name.toPrimitive(), "Administrator");
|
||||||
|
assert.equal(role.isSystem, true);
|
||||||
|
assert.deepEqual(role.permissionCodes, Object.values(IdentityPermissionCodes));
|
||||||
|
assert.equal(UniqueID.create(role.id.toPrimitive()).isSuccess, true);
|
||||||
|
assert.equal(role.id.equals(companyId), false);
|
||||||
|
assert.equal(role.createdAt.toPrimitive(), UtcDate.today().toPrimitive());
|
||||||
|
assert.equal(role.updatedAt.toPrimitive(), role.createdAt.toPrimitive());
|
||||||
|
assert.equal(subject.writes[0].role, role);
|
||||||
|
});
|
||||||
|
test("existente válido es idempotente, independientemente del orden de permisos", async () => {
|
||||||
|
const existing = admin(true, Object.values(IdentityPermissionCodes).reverse());
|
||||||
|
const subject = setup([Result.ok(Maybe.some(existing)), Result.ok(Maybe.some(existing))]);
|
||||||
|
assert.equal((await subject.service.ensure({ companyId })).data, existing);
|
||||||
|
assert.equal((await subject.service.ensure({ companyId })).data, existing);
|
||||||
|
assert.equal(subject.writes.length, 0);
|
||||||
|
});
|
||||||
|
for (const [label, existing] of [
|
||||||
|
["no-system", admin(false)],
|
||||||
|
["permisos incorrectos", admin(true, ["identity:roles:read"])],
|
||||||
|
] as const) {
|
||||||
|
test(`existente ${label} falla sin mutar`, async () => {
|
||||||
|
const subject = setup([Result.ok(Maybe.some(existing))]);
|
||||||
|
assert.equal((await subject.service.ensure({ companyId })).isFailure, true);
|
||||||
|
assert.equal(subject.writes.length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const duplicate = () => new DuplicateEntityError("Role", "code", "admin");
|
||||||
|
for (const valid of [true, false]) {
|
||||||
|
test(`duplicate relee y valida ganador: válido=${valid}`, async () => {
|
||||||
|
const winner = admin(valid);
|
||||||
|
const subject = setup(
|
||||||
|
[Result.ok(Maybe.none()), Result.ok(Maybe.some(winner))],
|
||||||
|
Result.fail(duplicate())
|
||||||
|
);
|
||||||
|
const transaction = {};
|
||||||
|
const result = await subject.service.ensure({ companyId, transaction });
|
||||||
|
assert.equal(result.isSuccess, valid);
|
||||||
|
if (valid) assert.equal(result.data, winner);
|
||||||
|
assert.equal(subject.lookups.length, 2);
|
||||||
|
for (const lookup of subject.lookups) {
|
||||||
|
assert.equal(lookup.companyId, companyId);
|
||||||
|
assert.equal(lookup.code.toPrimitive(), "admin");
|
||||||
|
assert.equal(lookup.transaction, transaction);
|
||||||
|
}
|
||||||
|
assert.equal(subject.lookups[1].currentRead, true);
|
||||||
|
assert.equal(subject.writes[0].transaction, transaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("duplicate sin ganador conserva error", async () => {
|
||||||
|
const error = duplicate();
|
||||||
|
const subject = setup([Result.ok(Maybe.none()), Result.ok(Maybe.none())], Result.fail(error));
|
||||||
|
assert.equal((await subject.service.ensure({ companyId })).error, error);
|
||||||
|
});
|
||||||
|
for (const phase of ["lookup", "save", "reread"]) {
|
||||||
|
test(`propaga error de repository en ${phase}`, async () => {
|
||||||
|
const error = new Error(phase);
|
||||||
|
const subject =
|
||||||
|
phase === "lookup"
|
||||||
|
? setup([Result.fail(error)])
|
||||||
|
: phase === "save"
|
||||||
|
? setup([Result.ok(Maybe.none())], Result.fail(error))
|
||||||
|
: setup([Result.ok(Maybe.none()), Result.fail(error)], Result.fail(duplicate()));
|
||||||
|
assert.equal((await subject.service.ensure({ companyId })).error, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { isDuplicateEntityError } from "@erp/core/api";
|
||||||
|
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { IdentityPermissionCodes } from "../../../../common/permissions";
|
||||||
|
import { Role, RoleCode } from "../../../domain";
|
||||||
|
import type { IRoleRepository } from "../contracts";
|
||||||
|
|
||||||
|
export const COMPANY_ADMIN_ROLE_CODE = "admin";
|
||||||
|
export interface EnsureCompanyAdminRoleParams {
|
||||||
|
companyId: UniqueID;
|
||||||
|
transaction?: unknown;
|
||||||
|
}
|
||||||
|
export interface ICompanyAdminRoleEnsurer {
|
||||||
|
ensure(params: EnsureCompanyAdminRoleParams): Promise<Result<Role, Error>>;
|
||||||
|
}
|
||||||
|
/** Asegura el rol administrativo de Identity sin asignarlo ni modificar roles existentes. */
|
||||||
|
export class CompanyAdminRoleEnsurer implements ICompanyAdminRoleEnsurer {
|
||||||
|
public constructor(private readonly roles: IRoleRepository) {}
|
||||||
|
|
||||||
|
public async ensure(params: EnsureCompanyAdminRoleParams): Promise<Result<Role, Error>> {
|
||||||
|
const code = RoleCode.create(COMPANY_ADMIN_ROLE_CODE);
|
||||||
|
if (code.isFailure) return Result.fail(code.error);
|
||||||
|
const lookup = { ...params, code: code.data };
|
||||||
|
const found = await this.roles.findByCodeInCompany(lookup);
|
||||||
|
if (found.isFailure) return Result.fail(found.error);
|
||||||
|
if (found.data.isSome()) return this.validate(found.data.unwrap(), params.companyId);
|
||||||
|
const name = Name.create("Administrator");
|
||||||
|
if (name.isFailure) return Result.fail(name.error);
|
||||||
|
const now = UtcDate.today();
|
||||||
|
const created = Role.create(
|
||||||
|
{
|
||||||
|
companyId: params.companyId,
|
||||||
|
code: code.data,
|
||||||
|
name: name.data,
|
||||||
|
permissionCodes: Object.values(IdentityPermissionCodes),
|
||||||
|
isSystem: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
UniqueID.generateNewID()
|
||||||
|
);
|
||||||
|
if (created.isFailure) return Result.fail(created.error);
|
||||||
|
const saved = await this.roles.save(created.data, params.transaction);
|
||||||
|
if (saved.isSuccess) return created;
|
||||||
|
if (!isDuplicateEntityError(saved.error)) return Result.fail(saved.error);
|
||||||
|
const winner = await this.roles.findByCodeInCompany({ ...lookup, currentRead: true });
|
||||||
|
if (winner.isFailure) return Result.fail(winner.error);
|
||||||
|
if (winner.data.isNone()) return Result.fail(saved.error);
|
||||||
|
return this.validate(winner.data.unwrap(), params.companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validate(role: Role, companyId: UniqueID): Result<Role, Error> {
|
||||||
|
const expected = new Set<string>(Object.values(IdentityPermissionCodes));
|
||||||
|
const actual = role.permissionCodes;
|
||||||
|
if (
|
||||||
|
!role.companyId.equals(companyId) ||
|
||||||
|
role.code.toPrimitive() !== COMPANY_ADMIN_ROLE_CODE ||
|
||||||
|
!role.isSystem ||
|
||||||
|
actual.length !== expected.size ||
|
||||||
|
new Set(actual).size !== expected.size ||
|
||||||
|
actual.some((code) => !expected.has(code))
|
||||||
|
) {
|
||||||
|
return Result.fail(
|
||||||
|
new Error("Existing company admin role does not match the system definition.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Result.ok(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,2 +1,3 @@
|
|||||||
|
export * from "./company-admin-role-ensurer";
|
||||||
export * from "./role-permission-validator";
|
export * from "./role-permission-validator";
|
||||||
export * from "./unknown-permission-code.error";
|
export * from "./unknown-permission-code.error";
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import type { IIdentityCompanyAccessChecker } from "../company-memberships";
|
import type {
|
||||||
|
IIdentityCompanyAccessChecker,
|
||||||
|
IInitialCompanyAdminAssigner,
|
||||||
|
} from "../company-memberships";
|
||||||
|
|
||||||
import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface";
|
import type { IIdentityAuthPublicServices } from "./identity-auth-public-services.interface";
|
||||||
|
|
||||||
export interface IIdentityPublicServices {
|
export interface IIdentityPublicServices {
|
||||||
auth: IIdentityAuthPublicServices;
|
auth: IIdentityAuthPublicServices;
|
||||||
companyAccess: IIdentityCompanyAccessChecker;
|
companyAccess: IIdentityCompanyAccessChecker;
|
||||||
|
initialCompanyAdminAssigner: IInitialCompanyAdminAssigner;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
import type { IIdentityCompanyAccessChecker } from "../../application";
|
import type {
|
||||||
|
IIdentityCompanyAccessChecker,
|
||||||
|
IInitialCompanyAdminAssigner,
|
||||||
|
} from "../../application";
|
||||||
|
|
||||||
import type { IdentityInternalDeps } from "./identity.di";
|
import type { IdentityInternalDeps } from "./identity.di";
|
||||||
|
|
||||||
export interface IIdentityNeutralPublicServices {
|
export interface IIdentityNeutralPublicServices {
|
||||||
companyAccess: IIdentityCompanyAccessChecker;
|
companyAccess: IIdentityCompanyAccessChecker;
|
||||||
|
initialCompanyAdminAssigner: IInitialCompanyAdminAssigner;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildIdentityNeutralPublicServices(
|
export function buildIdentityNeutralPublicServices(
|
||||||
deps: IdentityInternalDeps
|
deps: IdentityInternalDeps
|
||||||
): IIdentityNeutralPublicServices {
|
): IIdentityNeutralPublicServices {
|
||||||
return { companyAccess: deps.companyAccess.checker };
|
return {
|
||||||
|
companyAccess: deps.companyAccess.checker,
|
||||||
|
initialCompanyAdminAssigner: deps.initialCompanyAdminAssigner,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
|
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CompanyAdminRoleEnsurer,
|
||||||
|
EffectivePermissionsResolver,
|
||||||
type IIdentityCompanyAccessChecker,
|
type IIdentityCompanyAccessChecker,
|
||||||
IdentityCompanyAccessChecker,
|
IdentityCompanyAccessChecker,
|
||||||
|
InitialCompanyAdminAssigner,
|
||||||
} from "../../application";
|
} from "../../application";
|
||||||
import type { IAccountRepository } from "../../application/accounts";
|
import type { IAccountRepository } from "../../application/accounts";
|
||||||
import {
|
import {
|
||||||
@ -31,8 +34,10 @@ import {
|
|||||||
SequelizeAccountDomainMapper,
|
SequelizeAccountDomainMapper,
|
||||||
SequelizeAccountRepository,
|
SequelizeAccountRepository,
|
||||||
SequelizeCompanyMembershipAccessRepository,
|
SequelizeCompanyMembershipAccessRepository,
|
||||||
|
SequelizeCompanyMembershipRepository,
|
||||||
SequelizeRefreshTokenDomainMapper,
|
SequelizeRefreshTokenDomainMapper,
|
||||||
SequelizeRefreshTokenRepository,
|
SequelizeRefreshTokenRepository,
|
||||||
|
SequelizeRoleRepository,
|
||||||
} from "../persistence";
|
} from "../persistence";
|
||||||
import {
|
import {
|
||||||
BcryptPasswordHasher,
|
BcryptPasswordHasher,
|
||||||
@ -43,6 +48,7 @@ import {
|
|||||||
} from "../security";
|
} from "../security";
|
||||||
|
|
||||||
export type IdentityInternalDeps = {
|
export type IdentityInternalDeps = {
|
||||||
|
initialCompanyAdminAssigner: InitialCompanyAdminAssigner;
|
||||||
auth: {
|
auth: {
|
||||||
refreshTokenExpiration: string;
|
refreshTokenExpiration: string;
|
||||||
authenticateUserDependencies: {
|
authenticateUserDependencies: {
|
||||||
@ -88,6 +94,16 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
|
|||||||
);
|
);
|
||||||
const companyAccessChecker = new IdentityCompanyAccessChecker(companyMembershipAccessRepository);
|
const companyAccessChecker = new IdentityCompanyAccessChecker(companyMembershipAccessRepository);
|
||||||
|
|
||||||
|
const memberships = new SequelizeCompanyMembershipRepository();
|
||||||
|
const roles = new SequelizeRoleRepository();
|
||||||
|
const adminRoles = new CompanyAdminRoleEnsurer(roles);
|
||||||
|
const permissions = new EffectivePermissionsResolver({ memberships, roles });
|
||||||
|
const initialCompanyAdminAssigner = new InitialCompanyAdminAssigner({
|
||||||
|
memberships,
|
||||||
|
adminRoles,
|
||||||
|
permissions,
|
||||||
|
});
|
||||||
|
|
||||||
const passwordHasher = new BcryptPasswordHasher({ rounds: 10 });
|
const passwordHasher = new BcryptPasswordHasher({ rounds: 10 });
|
||||||
const accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({
|
const accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({
|
||||||
secret: config.auth.jwt.secret,
|
secret: config.auth.jwt.secret,
|
||||||
@ -111,6 +127,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
initialCompanyAdminAssigner,
|
||||||
auth: {
|
auth: {
|
||||||
refreshTokenExpiration: config.auth.jwt.refreshExpiration,
|
refreshTokenExpiration: config.auth.jwt.refreshExpiration,
|
||||||
authenticateUserDependencies: {
|
authenticateUserDependencies: {
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
import accountModelInit from "./models/sequelize-account.model";
|
import accountModelInit from "./models/sequelize-account.model";
|
||||||
import companyMembershipModelInit from "./models/sequelize-company-membership.model";
|
import companyMembershipModelInit from "./models/sequelize-company-membership.model";
|
||||||
|
import membershipRoleModelInit from "./models/sequelize-company-membership-role.model";
|
||||||
import refreshTokenModelInit from "./models/sequelize-refresh-token.model";
|
import refreshTokenModelInit from "./models/sequelize-refresh-token.model";
|
||||||
|
import roleModelInit from "./models/sequelize-role.model";
|
||||||
|
|
||||||
export * from "./mappers";
|
export * from "./mappers";
|
||||||
export * from "./models";
|
export * from "./models";
|
||||||
export * from "./repositories";
|
export * from "./repositories";
|
||||||
|
|
||||||
export const models = [accountModelInit, refreshTokenModelInit, companyMembershipModelInit];
|
export const models = [
|
||||||
|
accountModelInit,
|
||||||
|
refreshTokenModelInit,
|
||||||
|
companyMembershipModelInit,
|
||||||
|
roleModelInit,
|
||||||
|
membershipRoleModelInit,
|
||||||
|
];
|
||||||
|
|||||||
@ -1,2 +1,4 @@
|
|||||||
export * from "./sequelize-account-domain.mapper";
|
export * from "./sequelize-account-domain.mapper";
|
||||||
|
export * from "./sequelize-company-membership-domain.mapper";
|
||||||
export * from "./sequelize-refresh-token-domain.mapper";
|
export * from "./sequelize-refresh-token-domain.mapper";
|
||||||
|
export * from "./sequelize-role-domain.mapper";
|
||||||
|
|||||||
@ -0,0 +1,95 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Sequelize } from "sequelize";
|
||||||
|
|
||||||
|
import { CompanyMembership, CompanyMembershipStatus } from "../../../../domain";
|
||||||
|
import { SequelizeCompanyMembershipDomainMapper } from "../mappers/sequelize-company-membership-domain.mapper";
|
||||||
|
import initMembership, {
|
||||||
|
CompanyMembershipModel,
|
||||||
|
} from "../models/sequelize-company-membership.model";
|
||||||
|
import initLinks, {
|
||||||
|
CompanyMembershipRoleModel,
|
||||||
|
} from "../models/sequelize-company-membership-role.model";
|
||||||
|
import initRole from "../models/sequelize-role.model";
|
||||||
|
|
||||||
|
const database = new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false });
|
||||||
|
initMembership(database);
|
||||||
|
initRole(database);
|
||||||
|
initLinks(database);
|
||||||
|
const membershipId = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
|
||||||
|
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
||||||
|
const companyId = UniqueID.create("33333333-3333-4333-8333-333333333333").data;
|
||||||
|
const a = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const b = "55555555-5555-4555-8555-555555555555";
|
||||||
|
const c = "66666666-6666-4666-8666-666666666666";
|
||||||
|
function membership(ids: string[] = [a], active = true) {
|
||||||
|
return CompanyMembership.create(
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
roleIds: ids.map((id) => UniqueID.create(id).data),
|
||||||
|
status: active
|
||||||
|
? CompanyMembershipStatus.createActive()
|
||||||
|
: CompanyMembershipStatus.createDisabled(),
|
||||||
|
createdAt: UtcDate.createFromISO("2026-01-01").data,
|
||||||
|
updatedAt: UtcDate.createFromISO("2026-02-02").data,
|
||||||
|
},
|
||||||
|
membershipId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mapper = new SequelizeCompanyMembershipDomainMapper();
|
||||||
|
function row(ids: string[] = [a], active = true) {
|
||||||
|
return CompanyMembershipModel.build(
|
||||||
|
mapper.mapToPersistence(membership(ids, active).data).data.membership,
|
||||||
|
{ raw: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("join tiene PK compuesta única, FKs y política de borrado", () => {
|
||||||
|
assert.equal(CompanyMembershipRoleModel.getTableName(), "identity_company_membership_roles");
|
||||||
|
const attrs = CompanyMembershipRoleModel.getAttributes();
|
||||||
|
assert.deepEqual(Object.keys(attrs).sort(), ["membership_id", "role_id"]);
|
||||||
|
for (const attr of Object.values(attrs)) {
|
||||||
|
assert.equal(attr.primaryKey, true);
|
||||||
|
assert.equal(attr.allowNull, false);
|
||||||
|
}
|
||||||
|
assert.deepEqual(attrs.membership_id.references, {
|
||||||
|
model: "identity_company_memberships",
|
||||||
|
key: "id",
|
||||||
|
});
|
||||||
|
assert.deepEqual(attrs.role_id.references, { model: "identity_roles", key: "id" });
|
||||||
|
assert.equal(attrs.membership_id.onDelete, "CASCADE");
|
||||||
|
assert.equal(attrs.role_id.onDelete, "RESTRICT");
|
||||||
|
assert.equal(CompanyMembershipRoleModel.options.timestamps, false);
|
||||||
|
});
|
||||||
|
for (const ids of [[], [a], [a, b, c]]) {
|
||||||
|
test(`mapper preserva ${ids.length} roles y los campos principales`, () => {
|
||||||
|
const source = membership(ids, ids.length > 0).data;
|
||||||
|
const persisted = mapper.mapToPersistence(source).data;
|
||||||
|
const restored = mapper.mapToDomain(row(ids, ids.length > 0), persisted.roleIds);
|
||||||
|
assert.equal(restored.isSuccess, true);
|
||||||
|
assert.deepEqual(mapper.mapToPersistence(restored.data).data, persisted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("dominio y mapper rechazan roles duplicados y activa vacía", () => {
|
||||||
|
assert.equal(membership([a, a]).isFailure, true);
|
||||||
|
assert.equal(membership([]).isFailure, true);
|
||||||
|
assert.equal(mapper.mapToDomain(row(), [a, a]).isFailure, true);
|
||||||
|
assert.equal(mapper.mapToDomain(row(), []).isFailure, true);
|
||||||
|
});
|
||||||
|
test("replaceRoles conserva conjunto sin duplicados y actualiza fecha", () => {
|
||||||
|
const source = membership([a, b]).data;
|
||||||
|
const updatedAt = UtcDate.createFromISO("2026-03-03").data;
|
||||||
|
assert.equal(
|
||||||
|
source.replaceRoles({ roleIds: [b, c].map((id) => UniqueID.create(id).data), updatedAt })
|
||||||
|
.isSuccess,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
source.roleIds.map((id) => id.toPrimitive()),
|
||||||
|
[b, c]
|
||||||
|
);
|
||||||
|
assert.ok(source.updatedAt.equals(updatedAt));
|
||||||
|
});
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { CompanyMembership, CompanyMembershipStatus } from "../../../../domain";
|
||||||
|
import type { CompanyMembershipCreationAttributes, CompanyMembershipModel } from "../models";
|
||||||
|
|
||||||
|
function date(value: Date | string): Result<UtcDate, Error> {
|
||||||
|
return UtcDate.createFromISO(
|
||||||
|
value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SequelizeCompanyMembershipDomainMapper {
|
||||||
|
public mapToDomain(
|
||||||
|
source: CompanyMembershipModel,
|
||||||
|
roleIds: readonly string[]
|
||||||
|
): Result<CompanyMembership, Error> {
|
||||||
|
try {
|
||||||
|
const id = UniqueID.create(source.id);
|
||||||
|
if (id.isFailure) return Result.fail(id.error);
|
||||||
|
const accountId = UniqueID.create(source.account_id);
|
||||||
|
if (accountId.isFailure) return Result.fail(accountId.error);
|
||||||
|
const companyId = UniqueID.create(source.company_id);
|
||||||
|
if (companyId.isFailure) return Result.fail(companyId.error);
|
||||||
|
const status = CompanyMembershipStatus.create(source.status);
|
||||||
|
if (status.isFailure) return Result.fail(status.error);
|
||||||
|
const createdAt = date(source.created_at);
|
||||||
|
if (createdAt.isFailure) return Result.fail(createdAt.error);
|
||||||
|
const updatedAt = date(source.updated_at);
|
||||||
|
if (updatedAt.isFailure) return Result.fail(updatedAt.error);
|
||||||
|
const roles: UniqueID[] = [];
|
||||||
|
for (const value of roleIds) {
|
||||||
|
const role = UniqueID.create(value);
|
||||||
|
if (role.isFailure) return Result.fail(role.error);
|
||||||
|
roles.push(role.data);
|
||||||
|
}
|
||||||
|
const membership = CompanyMembership.rehydrate(
|
||||||
|
{
|
||||||
|
accountId: accountId.data,
|
||||||
|
companyId: companyId.data,
|
||||||
|
status: status.data,
|
||||||
|
roleIds: roles,
|
||||||
|
createdAt: createdAt.data,
|
||||||
|
updatedAt: updatedAt.data,
|
||||||
|
},
|
||||||
|
id.data
|
||||||
|
);
|
||||||
|
// canAccessCompany valida roles antes de evaluar el estado de acceso.
|
||||||
|
const access = membership.canAccessCompany();
|
||||||
|
if (
|
||||||
|
access.isFailure &&
|
||||||
|
(status.data.isActive() || new Set(roleIds).size !== roleIds.length)
|
||||||
|
) {
|
||||||
|
return Result.fail(access.error);
|
||||||
|
}
|
||||||
|
return Result.ok(membership);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public mapToPersistence(source: CompanyMembership): Result<
|
||||||
|
{
|
||||||
|
membership: CompanyMembershipCreationAttributes;
|
||||||
|
roleIds: string[];
|
||||||
|
},
|
||||||
|
Error
|
||||||
|
> {
|
||||||
|
return Result.ok({
|
||||||
|
membership: {
|
||||||
|
id: source.id.toPrimitive(),
|
||||||
|
account_id: source.accountId.toPrimitive(),
|
||||||
|
company_id: source.companyId.toPrimitive(),
|
||||||
|
status: source.status.toPrimitive(),
|
||||||
|
created_at: source.createdAt.toISOString(),
|
||||||
|
updated_at: source.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
roleIds: source.roleIds.map((id) => id.toPrimitive()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { DataTypes, Sequelize } from "sequelize";
|
||||||
|
|
||||||
|
import { Role, RoleCode } from "../../../../domain";
|
||||||
|
import initRole, { RoleModel } from "../models/sequelize-role.model";
|
||||||
|
|
||||||
|
import { SequelizeRoleDomainMapper } from "./sequelize-role-domain.mapper";
|
||||||
|
|
||||||
|
initRole(new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }));
|
||||||
|
const mapper = new SequelizeRoleDomainMapper();
|
||||||
|
const id = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
|
||||||
|
const companyId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
||||||
|
function role(isSystem = false) {
|
||||||
|
return Role.create(
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
code: RoleCode.create("billing-editor").data,
|
||||||
|
name: Name.create("Billing").data,
|
||||||
|
permissionCodes: ["invoices:read", "invoices:write"],
|
||||||
|
isSystem,
|
||||||
|
createdAt: UtcDate.createFromISO("2026-01-01").data,
|
||||||
|
updatedAt: UtcDate.createFromISO("2026-02-02").data,
|
||||||
|
},
|
||||||
|
id
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("modelo declara columnas, JSON y código único por compañía", () => {
|
||||||
|
assert.equal(RoleModel.getTableName(), "identity_roles");
|
||||||
|
const attributes = RoleModel.getAttributes();
|
||||||
|
assert.deepEqual(Object.keys(attributes).sort(), [
|
||||||
|
"code",
|
||||||
|
"company_id",
|
||||||
|
"created_at",
|
||||||
|
"id",
|
||||||
|
"is_system",
|
||||||
|
"name",
|
||||||
|
"permission_codes",
|
||||||
|
"updated_at",
|
||||||
|
]);
|
||||||
|
assert.ok(attributes.permission_codes.type instanceof DataTypes.JSON);
|
||||||
|
for (const value of Object.values(attributes)) assert.equal(value.allowNull, false);
|
||||||
|
assert.equal(RoleModel.options.timestamps, false);
|
||||||
|
const unique = RoleModel.options.indexes?.find((index) => index.unique);
|
||||||
|
assert.deepEqual(unique?.fields, ["company_id", "code"]);
|
||||||
|
});
|
||||||
|
for (const system of [false, true]) {
|
||||||
|
test(`mapper preserva todos los campos con isSystem=${system}`, () => {
|
||||||
|
const original = role(system);
|
||||||
|
const persisted = mapper.mapToPersistence(original);
|
||||||
|
assert.equal(persisted.isSuccess, true);
|
||||||
|
assert.deepEqual(persisted.data, {
|
||||||
|
id: id.toPrimitive(),
|
||||||
|
company_id: companyId.toPrimitive(),
|
||||||
|
code: "billing-editor",
|
||||||
|
name: "Billing",
|
||||||
|
is_system: system,
|
||||||
|
permission_codes: ["invoices:read", "invoices:write"],
|
||||||
|
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
updated_at: new Date("2026-02-02T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
const restored = mapper.mapToDomain(RoleModel.build(persisted.data, { raw: true }));
|
||||||
|
assert.equal(restored.isSuccess, true);
|
||||||
|
assert.deepEqual(mapper.mapToPersistence(restored.data).data, persisted.data);
|
||||||
|
assert.ok(restored.data.createdAt.equals(original.createdAt));
|
||||||
|
assert.ok(restored.data.updatedAt.equals(original.updatedAt));
|
||||||
|
assert.equal(restored.data.isSystem, system);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("mapper admite JSON textual del driver y copia los permisos", () => {
|
||||||
|
const values = mapper.mapToPersistence(role()).data;
|
||||||
|
const row = RoleModel.build(
|
||||||
|
{ ...values, permission_codes: JSON.stringify(["invoices:read", "invoices:write"]) },
|
||||||
|
{ raw: true }
|
||||||
|
);
|
||||||
|
const restored = mapper.mapToDomain(row);
|
||||||
|
assert.equal(restored.isSuccess, true);
|
||||||
|
const permissions = restored.data.permissionCodes;
|
||||||
|
permissions.push("other:read");
|
||||||
|
assert.deepEqual(restored.data.permissionCodes, ["invoices:read", "invoices:write"]);
|
||||||
|
});
|
||||||
|
test("Role.create rechaza permisos vacíos sin cambiar sus invariantes", () => {
|
||||||
|
const source = role();
|
||||||
|
assert.equal(
|
||||||
|
Role.create(
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
code: source.code,
|
||||||
|
name: source.name,
|
||||||
|
permissionCodes: [],
|
||||||
|
isSystem: false,
|
||||||
|
createdAt: source.createdAt,
|
||||||
|
updatedAt: source.updatedAt,
|
||||||
|
},
|
||||||
|
id
|
||||||
|
).isFailure,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("mapper rechaza JSON de forma inválida", () => {
|
||||||
|
for (const permissions of ["{", {}, [1], null]) {
|
||||||
|
const row = RoleModel.build(
|
||||||
|
{ ...mapper.mapToPersistence(role()).data, permission_codes: permissions },
|
||||||
|
{ raw: true }
|
||||||
|
);
|
||||||
|
assert.equal(mapper.mapToDomain(row).isFailure, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { Role, RoleCode } from "../../../../domain";
|
||||||
|
import type { RoleCreationAttributes, RoleModel } from "../models";
|
||||||
|
|
||||||
|
function toUtcDate(value: Date | string): Result<UtcDate, Error> {
|
||||||
|
return UtcDate.createFromISO(
|
||||||
|
value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SequelizeRoleDomainMapper {
|
||||||
|
public mapToDomain(source: RoleModel): Result<Role, Error> {
|
||||||
|
try {
|
||||||
|
const id = UniqueID.create(source.id);
|
||||||
|
if (id.isFailure) return Result.fail(id.error);
|
||||||
|
const companyId = UniqueID.create(source.company_id);
|
||||||
|
if (companyId.isFailure) return Result.fail(companyId.error);
|
||||||
|
const code = RoleCode.create(source.code);
|
||||||
|
if (code.isFailure) return Result.fail(code.error);
|
||||||
|
const name = Name.create(source.name);
|
||||||
|
if (name.isFailure) return Result.fail(name.error);
|
||||||
|
const createdAt = toUtcDate(source.created_at);
|
||||||
|
if (createdAt.isFailure) return Result.fail(createdAt.error);
|
||||||
|
const updatedAt = toUtcDate(source.updated_at);
|
||||||
|
if (updatedAt.isFailure) return Result.fail(updatedAt.error);
|
||||||
|
// MariaDB puede entregar JSON como texto. Solo se comprueba su forma de persistencia.
|
||||||
|
const permissions: unknown =
|
||||||
|
typeof source.permission_codes === "string"
|
||||||
|
? JSON.parse(source.permission_codes)
|
||||||
|
: source.permission_codes;
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
Array.isArray(permissions) &&
|
||||||
|
permissions.every((value): value is string => typeof value === "string")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Result.fail(new Error("Invalid role permission codes persistence."));
|
||||||
|
}
|
||||||
|
return Result.ok(
|
||||||
|
Role.rehydrate(
|
||||||
|
{
|
||||||
|
companyId: companyId.data,
|
||||||
|
code: code.data,
|
||||||
|
name: name.data,
|
||||||
|
isSystem: source.is_system,
|
||||||
|
permissionCodes: [...permissions],
|
||||||
|
createdAt: createdAt.data,
|
||||||
|
updatedAt: updatedAt.data,
|
||||||
|
},
|
||||||
|
id.data
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public mapToPersistence(source: Role): Result<RoleCreationAttributes, Error> {
|
||||||
|
return Result.ok({
|
||||||
|
id: source.id.toPrimitive(),
|
||||||
|
company_id: source.companyId.toPrimitive(),
|
||||||
|
code: source.code.toPrimitive(),
|
||||||
|
name: source.name.toPrimitive(),
|
||||||
|
is_system: source.isSystem,
|
||||||
|
permission_codes: source.permissionCodes,
|
||||||
|
created_at: source.createdAt.toDate(),
|
||||||
|
updated_at: source.updatedAt.toDate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
export * from "./sequelize-account.model";
|
export * from "./sequelize-account.model";
|
||||||
export * from "./sequelize-company-membership.model";
|
export * from "./sequelize-company-membership.model";
|
||||||
|
export * from "./sequelize-company-membership-role.model";
|
||||||
export * from "./sequelize-refresh-token.model";
|
export * from "./sequelize-refresh-token.model";
|
||||||
|
export * from "./sequelize-role.model";
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
|
||||||
|
import { DataTypes, Model } from "sequelize";
|
||||||
|
|
||||||
|
export class CompanyMembershipRoleModel extends Model<
|
||||||
|
InferAttributes<CompanyMembershipRoleModel>,
|
||||||
|
InferCreationAttributes<CompanyMembershipRoleModel>
|
||||||
|
> {
|
||||||
|
declare membership_id: string;
|
||||||
|
declare role_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (database: Sequelize) => {
|
||||||
|
CompanyMembershipRoleModel.init(
|
||||||
|
{
|
||||||
|
membership_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
allowNull: false,
|
||||||
|
references: { model: "identity_company_memberships", key: "id" },
|
||||||
|
onDelete: "CASCADE",
|
||||||
|
onUpdate: "CASCADE",
|
||||||
|
},
|
||||||
|
role_id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
allowNull: false,
|
||||||
|
references: { model: "identity_roles", key: "id" },
|
||||||
|
onDelete: "RESTRICT",
|
||||||
|
onUpdate: "CASCADE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize: database,
|
||||||
|
modelName: "CompanyMembershipRoleModel",
|
||||||
|
tableName: "identity_company_membership_roles",
|
||||||
|
charset: "utf8mb4",
|
||||||
|
collate: "utf8mb4_unicode_ci",
|
||||||
|
underscored: true,
|
||||||
|
timestamps: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return CompanyMembershipRoleModel;
|
||||||
|
};
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
|
||||||
|
import { DataTypes, Model } from "sequelize";
|
||||||
|
|
||||||
|
export type RoleCreationAttributes = InferCreationAttributes<RoleModel>;
|
||||||
|
export class RoleModel extends Model<
|
||||||
|
InferAttributes<RoleModel>,
|
||||||
|
InferCreationAttributes<RoleModel>
|
||||||
|
> {
|
||||||
|
declare id: string;
|
||||||
|
declare company_id: string;
|
||||||
|
declare code: string;
|
||||||
|
declare name: string;
|
||||||
|
declare is_system: boolean;
|
||||||
|
declare permission_codes: unknown;
|
||||||
|
declare created_at: Date | string;
|
||||||
|
declare updated_at: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (database: Sequelize) => {
|
||||||
|
RoleModel.init(
|
||||||
|
{
|
||||||
|
id: { type: DataTypes.UUID, primaryKey: true, allowNull: false },
|
||||||
|
company_id: { type: DataTypes.UUID, allowNull: false },
|
||||||
|
code: { type: DataTypes.STRING(100), allowNull: false },
|
||||||
|
name: { type: DataTypes.STRING, allowNull: false },
|
||||||
|
is_system: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||||
|
permission_codes: { type: DataTypes.JSON, allowNull: false },
|
||||||
|
created_at: { type: DataTypes.DATE, allowNull: false },
|
||||||
|
updated_at: { type: DataTypes.DATE, allowNull: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize: database,
|
||||||
|
modelName: "RoleModel",
|
||||||
|
tableName: "identity_roles",
|
||||||
|
charset: "utf8mb4",
|
||||||
|
collate: "utf8mb4_unicode_ci",
|
||||||
|
underscored: true,
|
||||||
|
timestamps: false,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
name: "uq_identity_roles_company_code",
|
||||||
|
fields: ["company_id", "code"],
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return RoleModel;
|
||||||
|
};
|
||||||
@ -1,3 +1,5 @@
|
|||||||
export * from "./sequelize-account.repository";
|
export * from "./sequelize-account.repository";
|
||||||
|
export * from "./sequelize-company-membership.repository";
|
||||||
export * from "./sequelize-company-membership-access.repository";
|
export * from "./sequelize-company-membership-access.repository";
|
||||||
export * from "./sequelize-refresh-token.repository";
|
export * from "./sequelize-refresh-token.repository";
|
||||||
|
export * from "./sequelize-role.repository";
|
||||||
|
|||||||
@ -0,0 +1,272 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Sequelize } from "sequelize";
|
||||||
|
|
||||||
|
import { CompanyMembership, CompanyMembershipStatus } from "../../../../domain";
|
||||||
|
import { SequelizeCompanyMembershipDomainMapper } from "../mappers/sequelize-company-membership-domain.mapper";
|
||||||
|
import initMembership, {
|
||||||
|
CompanyMembershipModel,
|
||||||
|
} from "../models/sequelize-company-membership.model";
|
||||||
|
import initLinks, {
|
||||||
|
CompanyMembershipRoleModel,
|
||||||
|
} from "../models/sequelize-company-membership-role.model";
|
||||||
|
import initRole, { RoleModel } from "../models/sequelize-role.model";
|
||||||
|
|
||||||
|
const database = new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false });
|
||||||
|
initMembership(database);
|
||||||
|
initRole(database);
|
||||||
|
initLinks(database);
|
||||||
|
|
||||||
|
import type { TestContext } from "node:test";
|
||||||
|
|
||||||
|
import { Op } from "sequelize";
|
||||||
|
|
||||||
|
import { SequelizeCompanyMembershipRepository } from "./sequelize-company-membership.repository";
|
||||||
|
|
||||||
|
const membershipId = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
|
||||||
|
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
||||||
|
const companyId = UniqueID.create("33333333-3333-4333-8333-333333333333").data;
|
||||||
|
const a = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const b = "55555555-5555-4555-8555-555555555555";
|
||||||
|
const c = "66666666-6666-4666-8666-666666666666";
|
||||||
|
function membership(ids: string[] = [a], active = true) {
|
||||||
|
return CompanyMembership.create(
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
roleIds: ids.map((id) => UniqueID.create(id).data),
|
||||||
|
status: active
|
||||||
|
? CompanyMembershipStatus.createActive()
|
||||||
|
: CompanyMembershipStatus.createDisabled(),
|
||||||
|
createdAt: UtcDate.createFromISO("2026-01-01").data,
|
||||||
|
updatedAt: UtcDate.createFromISO("2026-02-02").data,
|
||||||
|
},
|
||||||
|
membershipId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mapper = new SequelizeCompanyMembershipDomainMapper();
|
||||||
|
function row(ids: string[] = [a], active = true) {
|
||||||
|
return CompanyMembershipModel.build(
|
||||||
|
mapper.mapToPersistence(membership(ids, active).data).data.membership,
|
||||||
|
{ raw: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = new SequelizeCompanyMembershipRepository();
|
||||||
|
const transaction = {};
|
||||||
|
function setup(t: TestContext, old: string[] | null = null) {
|
||||||
|
const roleFind = t.mock.method(RoleModel, "findAll", async () =>
|
||||||
|
[a, b, c].map((id) =>
|
||||||
|
RoleModel.build(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
company_id: companyId.toPrimitive(),
|
||||||
|
code: "role-code",
|
||||||
|
name: "Role",
|
||||||
|
is_system: false,
|
||||||
|
permission_codes: ["invoices:read"],
|
||||||
|
created_at: "2026-01-01",
|
||||||
|
updated_at: "2026-02-02",
|
||||||
|
},
|
||||||
|
{ raw: true }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const find = t.mock.method(CompanyMembershipModel, "findOne", async () =>
|
||||||
|
old === null ? null : row(old)
|
||||||
|
);
|
||||||
|
const all = t.mock.method(CompanyMembershipModel, "findAll", async () =>
|
||||||
|
old === null ? [] : [row(old)]
|
||||||
|
);
|
||||||
|
const links = t.mock.method(CompanyMembershipRoleModel, "findAll", async () =>
|
||||||
|
(old ?? []).map((roleId) =>
|
||||||
|
CompanyMembershipRoleModel.build({
|
||||||
|
membership_id: membershipId.toPrimitive(),
|
||||||
|
role_id: roleId,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const create = t.mock.method(CompanyMembershipModel, "create", async () => row());
|
||||||
|
const update = t.mock.method(CompanyMembershipModel, "update", async () => [1]);
|
||||||
|
const add = t.mock.method(CompanyMembershipRoleModel, "bulkCreate", async () => []);
|
||||||
|
const remove = t.mock.method(CompanyMembershipRoleModel, "destroy", async () => 1);
|
||||||
|
return { roleFind, find, all, links, create, update, add, remove };
|
||||||
|
}
|
||||||
|
test("find carga roleIds y propaga transaction", async (t) => {
|
||||||
|
const mocks = setup(t, [a, b]);
|
||||||
|
const result = await repository.findByAccountIdAndCompanyId({
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.data.unwrap().roleIds.map((id) => id.toPrimitive()),
|
||||||
|
[a, b]
|
||||||
|
);
|
||||||
|
assert.equal(mocks.find.mock.calls[0].arguments[0]?.transaction, transaction);
|
||||||
|
assert.equal(mocks.links.mock.calls[0].arguments[0]?.transaction, transaction);
|
||||||
|
});
|
||||||
|
test("find ausente devuelve none", async (t) => {
|
||||||
|
setup(t);
|
||||||
|
assert.equal(
|
||||||
|
(await repository.findByAccountIdAndCompanyId({ accountId, companyId })).data.isNone(),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("listados cargan roles por account y company", async (t) => {
|
||||||
|
setup(t, [a]);
|
||||||
|
assert.equal(
|
||||||
|
(await repository.findByAccountId({ accountId, transaction })).data.getAll()[0].roleIds.length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await repository.findByCompanyId({ companyId, transaction })).data.getAll()[0].roleIds.length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("save crea membership y roles con transacción en todas las operaciones", async (t) => {
|
||||||
|
const mocks = setup(t);
|
||||||
|
const result = await repository.save(membership([a, b, c]).data, transaction);
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(mocks.create.mock.callCount(), 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
mocks.add.mock.calls[0].arguments[0],
|
||||||
|
[a, b, c].map((roleId) => ({ membership_id: membershipId.toPrimitive(), role_id: roleId }))
|
||||||
|
);
|
||||||
|
for (const options of [
|
||||||
|
mocks.roleFind.mock.calls[0].arguments[0],
|
||||||
|
mocks.find.mock.calls[0].arguments[0],
|
||||||
|
mocks.create.mock.calls[0].arguments[1],
|
||||||
|
mocks.add.mock.calls[0].arguments[1],
|
||||||
|
])
|
||||||
|
assert.equal(options?.transaction, transaction);
|
||||||
|
});
|
||||||
|
test("save sincroniza A B a B C sin recrear B", async (t) => {
|
||||||
|
const mocks = setup(t, [a, b]);
|
||||||
|
mocks.roleFind.mock.mockImplementation(async () =>
|
||||||
|
[b, c].map((id) =>
|
||||||
|
RoleModel.build(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
company_id: companyId.toPrimitive(),
|
||||||
|
code: "role-code",
|
||||||
|
name: "Role",
|
||||||
|
is_system: false,
|
||||||
|
permission_codes: ["invoices:read"],
|
||||||
|
created_at: "2026-01-01",
|
||||||
|
updated_at: "2026-02-02",
|
||||||
|
},
|
||||||
|
{ raw: true }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.equal((await repository.save(membership([b, c]).data, transaction)).isSuccess, true);
|
||||||
|
assert.deepEqual(mocks.add.mock.calls[0].arguments[0], [
|
||||||
|
{ membership_id: membershipId.toPrimitive(), role_id: c },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(mocks.remove.mock.calls[0].arguments[0]?.where, {
|
||||||
|
membership_id: membershipId.toPrimitive(),
|
||||||
|
role_id: { [Op.in]: [a] },
|
||||||
|
});
|
||||||
|
assert.equal(mocks.create.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
test("update conserva asignaciones sin duplicarlas", async (t) => {
|
||||||
|
const mocks = setup(t, [a, b, c]);
|
||||||
|
assert.equal((await repository.update(membership([a, b, c]).data, transaction)).isSuccess, true);
|
||||||
|
assert.equal(mocks.add.mock.callCount(), 0);
|
||||||
|
assert.equal(mocks.remove.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
for (const reason of ["inexistente", "otra company"]) {
|
||||||
|
test(`rol ${reason} falla antes de escribir`, async (t) => {
|
||||||
|
const mocks = setup(t);
|
||||||
|
mocks.roleFind.mock.mockImplementation(async () => []);
|
||||||
|
const result = await repository.save(membership([a]).data, transaction);
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.deepEqual(mocks.roleFind.mock.calls[0].arguments[0]?.where, {
|
||||||
|
id: { [Op.in]: [a] },
|
||||||
|
company_id: companyId.toPrimitive(),
|
||||||
|
});
|
||||||
|
assert.equal(mocks.create.mock.callCount(), 0);
|
||||||
|
assert.equal(mocks.add.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("error leyendo join se propaga", async (t) => {
|
||||||
|
const mocks = setup(t, [a]);
|
||||||
|
const error = new Error("join read failed");
|
||||||
|
mocks.links.mock.mockImplementation(async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const result = await repository.findByAccountIdAndCompanyId({
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(result.error, error);
|
||||||
|
});
|
||||||
|
test("error escribiendo join retorna fallo para rollback externo", async (t) => {
|
||||||
|
const mocks = setup(t);
|
||||||
|
const error = new Error("join write failed");
|
||||||
|
mocks.add.mock.mockImplementation(async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const result = await repository.save(membership([a, b, c]).data, transaction);
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(result.error, error);
|
||||||
|
});
|
||||||
|
test("sin transaction falla sin I/O", async (t) => {
|
||||||
|
const mocks = setup(t);
|
||||||
|
assert.equal((await repository.save(membership().data)).isFailure, true);
|
||||||
|
assert.equal((await repository.update(membership().data)).isFailure, true);
|
||||||
|
assert.equal(mocks.roleFind.mock.callCount(), 0);
|
||||||
|
assert.equal(mocks.create.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
test("update no crea membership ausente", async (t) => {
|
||||||
|
const mocks = setup(t);
|
||||||
|
assert.equal((await repository.update(membership([a, b, c]).data, transaction)).isFailure, true);
|
||||||
|
assert.equal(mocks.create.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("membership deshabilitada puede retirar todos sus roles", async (t) => {
|
||||||
|
const mocks = setup(t, [a]);
|
||||||
|
const result = await repository.update(membership([], false).data, transaction);
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(mocks.roleFind.mock.callCount(), 0);
|
||||||
|
assert.equal(mocks.add.mock.callCount(), 0);
|
||||||
|
assert.deepEqual(mocks.remove.mock.calls[0].arguments[0]?.where, {
|
||||||
|
membership_id: membershipId.toPrimitive(),
|
||||||
|
role_id: { [Op.in]: [a] },
|
||||||
|
});
|
||||||
|
for (const options of [
|
||||||
|
mocks.find.mock.calls[0].arguments[0],
|
||||||
|
mocks.links.mock.calls[0].arguments[0],
|
||||||
|
mocks.update.mock.calls[0].arguments[1],
|
||||||
|
mocks.remove.mock.calls[0].arguments[0],
|
||||||
|
])
|
||||||
|
assert.equal(options?.transaction, transaction);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lectura actual bloquea membership y asignaciones en la misma transacción", async (t) => {
|
||||||
|
const find = t.mock.method(CompanyMembershipModel, "findOne", async () => row());
|
||||||
|
const links = t.mock.method(CompanyMembershipRoleModel, "findAll", async () => [
|
||||||
|
CompanyMembershipRoleModel.build({ membership_id: membershipId.toPrimitive(), role_id: a }),
|
||||||
|
]);
|
||||||
|
const result = await repository.findByAccountIdAndCompanyId({
|
||||||
|
accountId,
|
||||||
|
companyId,
|
||||||
|
transaction,
|
||||||
|
currentRead: true,
|
||||||
|
});
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
for (const call of [find.mock.calls[0], links.mock.calls[0]]) {
|
||||||
|
assert.equal(call.arguments[0]?.lock, true);
|
||||||
|
assert.equal(call.arguments[0]?.transaction, transaction);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
result.data.unwrap().roleIds.map((id) => id.toPrimitive()),
|
||||||
|
[a]
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
import { translateSequelizeError } from "@erp/core/api";
|
||||||
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
import type { Transaction, WhereOptions } from "sequelize";
|
||||||
|
import { Op } from "sequelize";
|
||||||
|
|
||||||
|
import type { ICompanyMembershipRepository } from "../../../../application/company-memberships/contracts/company-membership-repository.interface";
|
||||||
|
import type { CompanyMembership } from "../../../../domain";
|
||||||
|
import { SequelizeCompanyMembershipDomainMapper } from "../mappers";
|
||||||
|
import { CompanyMembershipModel, CompanyMembershipRoleModel, RoleModel } from "../models";
|
||||||
|
|
||||||
|
export class SequelizeCompanyMembershipRepository implements ICompanyMembershipRepository {
|
||||||
|
public constructor(private readonly mapper = new SequelizeCompanyMembershipDomainMapper()) {}
|
||||||
|
|
||||||
|
/** Requiere transacción externa; el caller debe revertirla si recibe fallo. */
|
||||||
|
public save(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>> {
|
||||||
|
return this.persist(membership, transaction, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requiere transacción externa; no crea una membership ausente. */
|
||||||
|
public update(
|
||||||
|
membership: CompanyMembership,
|
||||||
|
transaction?: unknown
|
||||||
|
): Promise<Result<void, Error>> {
|
||||||
|
return this.persist(membership, transaction, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persist(
|
||||||
|
source: CompanyMembership,
|
||||||
|
transaction: unknown,
|
||||||
|
updateOnly: boolean
|
||||||
|
): Promise<Result<void, Error>> {
|
||||||
|
if (!transaction)
|
||||||
|
return Result.fail(new Error("Company membership writes require an external transaction."));
|
||||||
|
try {
|
||||||
|
const mapped = this.mapper.mapToPersistence(source);
|
||||||
|
if (mapped.isFailure) return Result.fail(mapped.error);
|
||||||
|
const { membership, roleIds } = mapped.data;
|
||||||
|
const options = { transaction: transaction as Transaction };
|
||||||
|
const roles =
|
||||||
|
roleIds.length === 0
|
||||||
|
? []
|
||||||
|
: await RoleModel.findAll({
|
||||||
|
attributes: ["id"],
|
||||||
|
where: { id: { [Op.in]: roleIds }, company_id: membership.company_id },
|
||||||
|
...options,
|
||||||
|
lock: true,
|
||||||
|
});
|
||||||
|
if (new Set(roles.map((role) => role.id)).size !== roleIds.length) {
|
||||||
|
return Result.fail(
|
||||||
|
new Error("Membership roles must exist and belong to the same company.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Serializa cambios sobre la misma membership dentro de la transacción del caller.
|
||||||
|
const current = await CompanyMembershipModel.findOne({
|
||||||
|
where: { id: membership.id },
|
||||||
|
...options,
|
||||||
|
lock: true,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
(current.company_id !== membership.company_id ||
|
||||||
|
current.account_id !== membership.account_id)
|
||||||
|
) {
|
||||||
|
return Result.fail(new Error("Membership identity cannot change."));
|
||||||
|
}
|
||||||
|
if (!current && updateOnly)
|
||||||
|
return Result.fail(new Error("Company membership not found for update."));
|
||||||
|
const links = current
|
||||||
|
? await CompanyMembershipRoleModel.findAll({
|
||||||
|
where: { membership_id: membership.id },
|
||||||
|
...options,
|
||||||
|
lock: true,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
if (current) {
|
||||||
|
await CompanyMembershipModel.update(membership, {
|
||||||
|
where: { id: membership.id, company_id: membership.company_id },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await CompanyMembershipModel.create(membership, options);
|
||||||
|
}
|
||||||
|
const old = new Set(links.map((link) => link.role_id));
|
||||||
|
const wanted = new Set(roleIds);
|
||||||
|
const removed = [...old].filter((id) => !wanted.has(id));
|
||||||
|
const added = roleIds.filter((id) => !old.has(id));
|
||||||
|
if (removed.length > 0)
|
||||||
|
await CompanyMembershipRoleModel.destroy({
|
||||||
|
where: { membership_id: membership.id, role_id: { [Op.in]: removed } },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (added.length > 0)
|
||||||
|
await CompanyMembershipRoleModel.bulkCreate(
|
||||||
|
added.map((roleId) => ({ membership_id: membership.id, role_id: roleId })),
|
||||||
|
options
|
||||||
|
);
|
||||||
|
return Result.ok();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByAccountIdAndCompanyId(
|
||||||
|
params: Parameters<ICompanyMembershipRepository["findByAccountIdAndCompanyId"]>[0]
|
||||||
|
): Promise<Result<Maybe<CompanyMembership>, Error>> {
|
||||||
|
try {
|
||||||
|
const row = await CompanyMembershipModel.findOne({
|
||||||
|
where: {
|
||||||
|
account_id: params.accountId.toPrimitive(),
|
||||||
|
company_id: params.companyId.toPrimitive(),
|
||||||
|
},
|
||||||
|
transaction: params.transaction as Transaction | undefined,
|
||||||
|
...(params.currentRead && params.transaction ? { lock: true } : {}),
|
||||||
|
});
|
||||||
|
if (!row) return Result.ok(Maybe.none());
|
||||||
|
return (await this.hydrate(row, params.transaction, params.currentRead)).map(Maybe.some);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public findByCompanyId(
|
||||||
|
params: Parameters<ICompanyMembershipRepository["findByCompanyId"]>[0]
|
||||||
|
): Promise<Result<Collection<CompanyMembership>, Error>> {
|
||||||
|
return this.findMany({ company_id: params.companyId.toPrimitive() }, params.transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public findByAccountId(
|
||||||
|
params: Parameters<ICompanyMembershipRepository["findByAccountId"]>[0]
|
||||||
|
): Promise<Result<Collection<CompanyMembership>, Error>> {
|
||||||
|
return this.findMany({ account_id: params.accountId.toPrimitive() }, params.transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMany(
|
||||||
|
where: WhereOptions,
|
||||||
|
transaction?: unknown
|
||||||
|
): Promise<Result<Collection<CompanyMembership>, Error>> {
|
||||||
|
try {
|
||||||
|
const rows = await CompanyMembershipModel.findAll({
|
||||||
|
where,
|
||||||
|
transaction: transaction as Transaction | undefined,
|
||||||
|
});
|
||||||
|
const memberships: CompanyMembership[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const result = await this.hydrate(row, transaction);
|
||||||
|
if (result.isFailure) return Result.fail(result.error);
|
||||||
|
memberships.push(result.data);
|
||||||
|
}
|
||||||
|
return Result.ok(new Collection(memberships));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hydrate(
|
||||||
|
row: CompanyMembershipModel,
|
||||||
|
transaction?: unknown,
|
||||||
|
currentRead = false
|
||||||
|
): Promise<Result<CompanyMembership, Error>> {
|
||||||
|
const links = await CompanyMembershipRoleModel.findAll({
|
||||||
|
where: { membership_id: row.id },
|
||||||
|
...(currentRead && transaction ? { lock: true } : {}),
|
||||||
|
transaction: transaction as Transaction | undefined,
|
||||||
|
});
|
||||||
|
return this.mapper.mapToDomain(
|
||||||
|
row,
|
||||||
|
links.map((link) => link.role_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { DuplicateEntityError } from "@erp/core/api";
|
||||||
|
import { Name, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||||
|
import { Op, Sequelize, UniqueConstraintError } from "sequelize";
|
||||||
|
|
||||||
|
import { Role, RoleCode } from "../../../../domain";
|
||||||
|
import { SequelizeRoleDomainMapper } from "../mappers/sequelize-role-domain.mapper";
|
||||||
|
import initRole, { RoleModel } from "../models/sequelize-role.model";
|
||||||
|
|
||||||
|
import { SequelizeRoleRepository } from "./sequelize-role.repository";
|
||||||
|
|
||||||
|
initRole(new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }));
|
||||||
|
const mapper = new SequelizeRoleDomainMapper();
|
||||||
|
const repository = new SequelizeRoleRepository(mapper);
|
||||||
|
const id = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
|
||||||
|
const companyId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
||||||
|
function role(isSystem = false) {
|
||||||
|
return Role.create(
|
||||||
|
{
|
||||||
|
companyId,
|
||||||
|
code: RoleCode.create("billing-editor").data,
|
||||||
|
name: Name.create("Billing").data,
|
||||||
|
permissionCodes: ["invoices:read", "invoices:write"],
|
||||||
|
isSystem,
|
||||||
|
createdAt: UtcDate.createFromISO("2026-01-01").data,
|
||||||
|
updatedAt: UtcDate.createFromISO("2026-02-02").data,
|
||||||
|
},
|
||||||
|
id
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = () => RoleModel.build(mapper.mapToPersistence(role()).data, { raw: true });
|
||||||
|
test("find existente filtra compañía e id y propaga transacción", async (t) => {
|
||||||
|
const transaction = {};
|
||||||
|
const find = t.mock.method(RoleModel, "findOne", async () => row());
|
||||||
|
const result = await repository.findByIdInCompany({ roleId: id, companyId, transaction });
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.ok(result.data.unwrap().id.equals(id));
|
||||||
|
assert.deepEqual(find.mock.calls[0].arguments[0], {
|
||||||
|
where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("find ausente devuelve none", async (t) => {
|
||||||
|
t.mock.method(RoleModel, "findOne", async () => null);
|
||||||
|
const result = await repository.findByIdInCompany({ roleId: id, companyId });
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(result.data.isNone(), true);
|
||||||
|
});
|
||||||
|
for (const exists of [false, true]) {
|
||||||
|
test(`save ${exists ? "update" : "create"} preserva payload y transaction`, async (t) => {
|
||||||
|
const transaction = {};
|
||||||
|
t.mock.method(RoleModel, "findOne", async () => (exists ? row() : null));
|
||||||
|
const write = t.mock.method(RoleModel, exists ? "update" : "create", async () =>
|
||||||
|
exists ? [1] : row()
|
||||||
|
);
|
||||||
|
const result = await repository.save(role(), transaction);
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.deepEqual(write.mock.calls[0].arguments[0], mapper.mapToPersistence(role()).data);
|
||||||
|
assert.equal(write.mock.calls[0].arguments[1]?.transaction, transaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("update y delete limitan compañía y propagan transaction", async (t) => {
|
||||||
|
const transaction = {};
|
||||||
|
const update = t.mock.method(RoleModel, "update", async () => [1]);
|
||||||
|
const destroy = t.mock.method(RoleModel, "destroy", async () => 1);
|
||||||
|
assert.equal((await repository.update(role(), transaction)).isSuccess, true);
|
||||||
|
assert.equal((await repository.delete(role(), transaction)).isSuccess, true);
|
||||||
|
const options = {
|
||||||
|
where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() },
|
||||||
|
transaction,
|
||||||
|
};
|
||||||
|
assert.deepEqual(update.mock.calls[0].arguments[1], options);
|
||||||
|
assert.deepEqual(destroy.mock.calls[0].arguments[0], options);
|
||||||
|
});
|
||||||
|
test("consultas múltiples filtran compañía e ids y no consultan ids vacíos", async (t) => {
|
||||||
|
const transaction = {};
|
||||||
|
const find = t.mock.method(RoleModel, "findAll", async () => [row()]);
|
||||||
|
const many = await repository.findByIdsInCompany({ roleIds: [id], companyId, transaction });
|
||||||
|
assert.equal(many.data.getAll().length, 1);
|
||||||
|
assert.deepEqual(find.mock.calls[0].arguments[0], {
|
||||||
|
where: { id: { [Op.in]: [id.toPrimitive()] }, company_id: companyId.toPrimitive() },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await repository.findByCompanyId({ companyId, transaction })).data.getAll().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert.deepEqual(find.mock.calls[1].arguments[0], {
|
||||||
|
where: { company_id: companyId.toPrimitive() },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await repository.findByIdsInCompany({ roleIds: [], companyId })).data.getAll().length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(find.mock.callCount(), 2);
|
||||||
|
});
|
||||||
|
test("read error se propaga", async (t) => {
|
||||||
|
const error = new Error("read failed");
|
||||||
|
t.mock.method(RoleModel, "findOne", async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const result = await repository.findByIdInCompany({ roleId: id, companyId });
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(result.error, error);
|
||||||
|
});
|
||||||
|
test("write error se propaga", async (t) => {
|
||||||
|
const error = new Error("write failed");
|
||||||
|
t.mock.method(RoleModel, "findOne", async () => null);
|
||||||
|
t.mock.method(RoleModel, "create", async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const result = await repository.save(role());
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(result.error, error);
|
||||||
|
});
|
||||||
|
test("duplicate code se traduce a DuplicateEntityError", async (t) => {
|
||||||
|
const error = new UniqueConstraintError({ message: "duplicate code" });
|
||||||
|
t.mock.method(RoleModel, "findOne", async () => null);
|
||||||
|
t.mock.method(RoleModel, "create", async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const result = await repository.save(role());
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.ok(result.error instanceof DuplicateEntityError);
|
||||||
|
assert.equal(result.error.cause, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const currentRead of [false, true]) {
|
||||||
|
test(`find por código acota compañía y propaga lectura actual=${currentRead}`, async (t) => {
|
||||||
|
const transaction = {};
|
||||||
|
const find = t.mock.method(RoleModel, "findOne", async () => row());
|
||||||
|
const result = await repository.findByCodeInCompany({
|
||||||
|
companyId,
|
||||||
|
code: role().code,
|
||||||
|
transaction,
|
||||||
|
currentRead,
|
||||||
|
});
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(result.data.unwrap().id.equals(id), true);
|
||||||
|
assert.deepEqual(find.mock.calls[0].arguments[0], {
|
||||||
|
where: { company_id: companyId.toPrimitive(), code: "billing-editor" },
|
||||||
|
transaction,
|
||||||
|
...(currentRead ? { lock: true } : {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("find por código ausente devuelve none", async (t) => {
|
||||||
|
t.mock.method(RoleModel, "findOne", async () => null);
|
||||||
|
const result = await repository.findByCodeInCompany({ companyId, code: role().code });
|
||||||
|
assert.equal(result.data.isNone(), true);
|
||||||
|
});
|
||||||
|
test("find por código propaga error", async (t) => {
|
||||||
|
const error = new Error("lookup failed");
|
||||||
|
t.mock.method(RoleModel, "findOne", async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const result = await repository.findByCodeInCompany({ companyId, code: role().code });
|
||||||
|
assert.equal(result.error, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lectura actual de roles propaga bloqueo y transacción", async (t) => {
|
||||||
|
const transaction = {};
|
||||||
|
const find = t.mock.method(RoleModel, "findAll", async () => [row()]);
|
||||||
|
const result = await repository.findByIdsInCompany({
|
||||||
|
roleIds: [id],
|
||||||
|
companyId,
|
||||||
|
transaction,
|
||||||
|
currentRead: true,
|
||||||
|
});
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(find.mock.calls[0].arguments[0]?.lock, true);
|
||||||
|
assert.equal(find.mock.calls[0].arguments[0]?.transaction, transaction);
|
||||||
|
});
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
import { translateSequelizeError } from "@erp/core/api";
|
||||||
|
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
import type { Transaction, WhereOptions } from "sequelize";
|
||||||
|
import { Op } from "sequelize";
|
||||||
|
|
||||||
|
import type { IRoleRepository } from "../../../../application/roles/contracts/role-repository.interface";
|
||||||
|
import type { Role } from "../../../../domain";
|
||||||
|
import { SequelizeRoleDomainMapper } from "../mappers";
|
||||||
|
import { RoleModel } from "../models";
|
||||||
|
|
||||||
|
export class SequelizeRoleRepository implements IRoleRepository {
|
||||||
|
public constructor(private readonly mapper = new SequelizeRoleDomainMapper()) {}
|
||||||
|
|
||||||
|
public async findByCodeInCompany(
|
||||||
|
params: Parameters<IRoleRepository["findByCodeInCompany"]>[0]
|
||||||
|
): Promise<Result<Maybe<Role>, Error>> {
|
||||||
|
try {
|
||||||
|
const row = await RoleModel.findOne({
|
||||||
|
where: { company_id: params.companyId.toPrimitive(), code: params.code.toPrimitive() },
|
||||||
|
transaction: params.transaction as Transaction | undefined,
|
||||||
|
// Una lectura bloqueante ve el ganador incluso bajo REPEATABLE READ.
|
||||||
|
...(params.currentRead && params.transaction ? { lock: true } : {}),
|
||||||
|
});
|
||||||
|
if (!row) return Result.ok(Maybe.none());
|
||||||
|
return this.mapper.mapToDomain(row).map(Maybe.some);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async save(role: Role, transaction?: unknown): Promise<Result<void, Error>> {
|
||||||
|
try {
|
||||||
|
const mapped = this.mapper.mapToPersistence(role);
|
||||||
|
if (mapped.isFailure) return Result.fail(mapped.error);
|
||||||
|
const where = { id: role.id.toPrimitive(), company_id: role.companyId.toPrimitive() };
|
||||||
|
const options = { transaction: transaction as Transaction | undefined };
|
||||||
|
const existing = await RoleModel.findOne({ where, ...options });
|
||||||
|
if (existing) {
|
||||||
|
await RoleModel.update(mapped.data, { where, ...options });
|
||||||
|
} else {
|
||||||
|
await RoleModel.create(mapped.data, options);
|
||||||
|
}
|
||||||
|
return Result.ok();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(role: Role, transaction?: unknown): Promise<Result<void, Error>> {
|
||||||
|
try {
|
||||||
|
const mapped = this.mapper.mapToPersistence(role);
|
||||||
|
if (mapped.isFailure) return Result.fail(mapped.error);
|
||||||
|
await RoleModel.update(mapped.data, {
|
||||||
|
where: { id: role.id.toPrimitive(), company_id: role.companyId.toPrimitive() },
|
||||||
|
transaction: transaction as Transaction | undefined,
|
||||||
|
});
|
||||||
|
return Result.ok();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(role: Role, transaction?: unknown): Promise<Result<void, Error>> {
|
||||||
|
try {
|
||||||
|
await RoleModel.destroy({
|
||||||
|
where: { id: role.id.toPrimitive(), company_id: role.companyId.toPrimitive() },
|
||||||
|
transaction: transaction as Transaction | undefined,
|
||||||
|
});
|
||||||
|
return Result.ok();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findByIdInCompany(
|
||||||
|
params: Parameters<IRoleRepository["findByIdInCompany"]>[0]
|
||||||
|
): Promise<Result<Maybe<Role>, Error>> {
|
||||||
|
try {
|
||||||
|
const row = await RoleModel.findOne({
|
||||||
|
where: { id: params.roleId.toPrimitive(), company_id: params.companyId.toPrimitive() },
|
||||||
|
transaction: params.transaction as Transaction | undefined,
|
||||||
|
});
|
||||||
|
if (!row) return Result.ok(Maybe.none());
|
||||||
|
return this.mapper.mapToDomain(row).map(Maybe.some);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public findByIdsInCompany(
|
||||||
|
params: Parameters<IRoleRepository["findByIdsInCompany"]>[0]
|
||||||
|
): Promise<Result<Collection<Role>, Error>> {
|
||||||
|
if (params.roleIds.length === 0) return Promise.resolve(Result.ok(new Collection<Role>([])));
|
||||||
|
return this.findMany(
|
||||||
|
{
|
||||||
|
id: { [Op.in]: params.roleIds.map((id) => id.toPrimitive()) },
|
||||||
|
company_id: params.companyId.toPrimitive(),
|
||||||
|
},
|
||||||
|
params.transaction,
|
||||||
|
params.currentRead
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public findByCompanyId(
|
||||||
|
params: Parameters<IRoleRepository["findByCompanyId"]>[0]
|
||||||
|
): Promise<Result<Collection<Role>, Error>> {
|
||||||
|
return this.findMany({ company_id: params.companyId.toPrimitive() }, params.transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMany(
|
||||||
|
where: WhereOptions,
|
||||||
|
transaction?: unknown,
|
||||||
|
currentRead = false
|
||||||
|
): Promise<Result<Collection<Role>, Error>> {
|
||||||
|
try {
|
||||||
|
const rows = await RoleModel.findAll({
|
||||||
|
...(currentRead && transaction ? { lock: true } : {}),
|
||||||
|
where,
|
||||||
|
transaction: transaction as Transaction | undefined,
|
||||||
|
});
|
||||||
|
const roles: Role[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const mapped = this.mapper.mapToDomain(row);
|
||||||
|
if (mapped.isFailure) return Result.fail(mapped.error);
|
||||||
|
roles.push(mapped.data);
|
||||||
|
}
|
||||||
|
return Result.ok(new Collection(roles));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,7 +4,9 @@ import test from "node:test";
|
|||||||
import { UniqueID } from "@repo/rdx-ddd";
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Collection, Result } from "@repo/rdx-utils";
|
import { Collection, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { createIdentityCompanyAccess } from "./module";
|
import { InitialCompanyAdminAssigner } from "./application";
|
||||||
|
import type { IIdentityNeutralPublicServices } from "./infrastructure/di/identity-public-services";
|
||||||
|
import { createIdentityCompanyAccess, identityModule } from "./module";
|
||||||
|
|
||||||
test("identity neutral composition propagates accessible company ids from memberships", async () => {
|
test("identity neutral composition propagates accessible company ids from memberships", async () => {
|
||||||
const accountId = UniqueID.generateNewID();
|
const accountId = UniqueID.generateNewID();
|
||||||
@ -24,3 +26,31 @@ test("identity neutral composition propagates accessible company ids from member
|
|||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
assert.deepEqual(result.data.getAll(), companyIds.getAll());
|
assert.deepEqual(result.data.getAll(), companyIds.getAll());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Identity publica assigner sin asignar ni abrir transacciones al componer", async (t) => {
|
||||||
|
const assign = t.mock.method(InitialCompanyAdminAssigner.prototype, "assign", async () =>
|
||||||
|
Result.ok()
|
||||||
|
);
|
||||||
|
let transactions = 0;
|
||||||
|
const setup = await identityModule.setup?.({
|
||||||
|
database: {
|
||||||
|
transaction: async () => {
|
||||||
|
transactions++;
|
||||||
|
throw new Error("Unexpected transaction");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transactionContext: {},
|
||||||
|
config: {
|
||||||
|
auth: {
|
||||||
|
jwt: { secret: "composition-test", accessExpiration: "15m", refreshExpiration: "7d" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
operationContext: {},
|
||||||
|
} as never);
|
||||||
|
const general = setup?.services?.general as IIdentityNeutralPublicServices;
|
||||||
|
assert.equal(typeof general.companyAccess.canAccessCompany, "function");
|
||||||
|
assert.ok(general.initialCompanyAdminAssigner instanceof InitialCompanyAdminAssigner);
|
||||||
|
assert.deepEqual(Object.keys(general).sort(), ["companyAccess", "initialCompanyAdminAssigner"]);
|
||||||
|
assert.equal(assign.mock.callCount(), 0);
|
||||||
|
assert.equal(transactions, 0);
|
||||||
|
});
|
||||||
|
|||||||
@ -6,13 +6,14 @@ import { models } from "./infrastructure/persistence/sequelize";
|
|||||||
export type { IIdentityCompanyAccessChecker } from "./application";
|
export type { IIdentityCompanyAccessChecker } from "./application";
|
||||||
export { createIdentityCompanyAccess } from "./application";
|
export { createIdentityCompanyAccess } from "./application";
|
||||||
|
|
||||||
/** Manifest neutral de Identity: únicamente companyAccess. */
|
/** Manifest neutral de Identity: acceso a compañías y asignación inicial de administrador. */
|
||||||
export const identityModule: IModuleServer = {
|
export const identityModule: IModuleServer = {
|
||||||
name: "identity",
|
name: "identity",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
async setup(params) {
|
async setup(params) {
|
||||||
const internal = buildIdentityDependencies(params);
|
const internal = buildIdentityDependencies(params);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
models,
|
models,
|
||||||
services: { general: buildIdentityNeutralPublicServices(internal) },
|
services: { general: buildIdentityNeutralPublicServices(internal) },
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-[2rem] border border-border/60 bg-gradient-to-br from-background via-background to-muted/50 p-2 shadow-2xl">
|
<div className="relative overflow-hidden rounded-4xl border border-border/60 bg-linear-to-br from-background via-background to-muted/50 p-2 shadow-2xl">
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute inset-x-8 top-0 h-40 rounded-full bg-primary/8 blur-3xl"
|
className="pointer-events-none absolute inset-x-8 top-0 h-40 rounded-full bg-primary/8 blur-3xl"
|
||||||
|
|||||||
@ -45,7 +45,8 @@
|
|||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"sequelize": "^6.37.8",
|
"sequelize": "^6.37.8",
|
||||||
"use-debounce": "^10.1.1",
|
"use-debounce": "^10.1.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6",
|
||||||
|
"@erp/companies": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|||||||
4
modules/onboarding/src/api/application/index.ts
Normal file
4
modules/onboarding/src/api/application/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./mappers";
|
||||||
|
export * from "./repositories";
|
||||||
|
export * from "./services";
|
||||||
|
export * from "./use-cases";
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
City,
|
||||||
|
CountryCode,
|
||||||
|
EmailAddress,
|
||||||
|
Name,
|
||||||
|
PhoneNumber,
|
||||||
|
PostalAddress,
|
||||||
|
PostalCode,
|
||||||
|
Province,
|
||||||
|
Street,
|
||||||
|
TextValue,
|
||||||
|
URLAddress,
|
||||||
|
UniqueID,
|
||||||
|
} from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { CompleteInitialOnboardingRequestDTO } from "../../../common";
|
||||||
|
import type { CompleteInitialOnboardingParams } from "../use-cases/complete-initial-onboarding.use-case";
|
||||||
|
|
||||||
|
export interface ICompleteInitialOnboardingInputMapper {
|
||||||
|
map(
|
||||||
|
dto: CompleteInitialOnboardingRequestDTO,
|
||||||
|
params: { accountId: string }
|
||||||
|
): Result<CompleteInitialOnboardingParams, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompleteInitialOnboardingInputMapper implements ICompleteInitialOnboardingInputMapper {
|
||||||
|
public map(
|
||||||
|
dto: CompleteInitialOnboardingRequestDTO,
|
||||||
|
params: { accountId: string }
|
||||||
|
): Result<CompleteInitialOnboardingParams, Error> {
|
||||||
|
const accountId = UniqueID.create(params.accountId);
|
||||||
|
const legalName = Name.create(dto.legal_name);
|
||||||
|
const tin = TextValue.create(dto.tin);
|
||||||
|
const street = Street.create(dto.address.street);
|
||||||
|
const city = City.create(dto.address.city);
|
||||||
|
const postalCode = PostalCode.create(dto.address.postal_code);
|
||||||
|
const province = Province.create(dto.address.province);
|
||||||
|
const countryCode = CountryCode.create(dto.address.country_code);
|
||||||
|
const required = [accountId, legalName, tin, street, city, postalCode, province, countryCode];
|
||||||
|
const failed = required.find((value) => value.isFailure);
|
||||||
|
if (failed) return Result.fail(failed.error);
|
||||||
|
|
||||||
|
const street2 = dto.address.street_2 ? Street.create(dto.address.street_2) : undefined;
|
||||||
|
if (street2?.isFailure) return Result.fail(street2.error);
|
||||||
|
const address = PostalAddress.create({
|
||||||
|
street: Maybe.some(street.data),
|
||||||
|
street2: street2 ? Maybe.some(street2.data) : Maybe.none(),
|
||||||
|
city: Maybe.some(city.data),
|
||||||
|
postalCode: Maybe.some(postalCode.data),
|
||||||
|
province: Maybe.some(province.data),
|
||||||
|
countryCode: Maybe.some(countryCode.data),
|
||||||
|
});
|
||||||
|
if (address.isFailure) return Result.fail(address.error);
|
||||||
|
|
||||||
|
const tradeName = dto.trade_name ? Name.create(dto.trade_name) : undefined;
|
||||||
|
const email = dto.email ? EmailAddress.create(dto.email) : undefined;
|
||||||
|
const phone = dto.phone ? PhoneNumber.create(dto.phone) : undefined;
|
||||||
|
const website = dto.website ? URLAddress.create(dto.website) : undefined;
|
||||||
|
if (tradeName?.isFailure) return Result.fail(tradeName.error);
|
||||||
|
if (email?.isFailure) return Result.fail(email.error);
|
||||||
|
if (phone?.isFailure) return Result.fail(phone.error);
|
||||||
|
if (website?.isFailure) return Result.fail(website.error);
|
||||||
|
|
||||||
|
return Result.ok({
|
||||||
|
accountId: accountId.data,
|
||||||
|
company: {
|
||||||
|
legalName: legalName.data,
|
||||||
|
tradeName: tradeName?.data,
|
||||||
|
tin: tin.data,
|
||||||
|
email: email?.data,
|
||||||
|
phone: phone?.data,
|
||||||
|
website: website?.data,
|
||||||
|
address: address.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
modules/onboarding/src/api/application/mappers/index.ts
Normal file
1
modules/onboarding/src/api/application/mappers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./complete-initial-onboarding-input.mapper";
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
import { InitialOnboarding } from "../../domain";
|
||||||
|
import { GetInitialOnboardingStatusUseCase } from "./get-initial-onboarding-status.use-case";
|
||||||
|
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
||||||
|
const companyId = UniqueID.create("33333333-3333-4333-8333-333333333333").data;
|
||||||
|
const pending = InitialOnboarding.createPending(accountId).data;
|
||||||
|
const completed = InitialOnboarding.rehydrate(
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
status: "completed",
|
||||||
|
companyId: Maybe.some(companyId),
|
||||||
|
createdAt: pending.createdAt,
|
||||||
|
updatedAt: pending.updatedAt,
|
||||||
|
completedAt: Maybe.some(pending.updatedAt),
|
||||||
|
},
|
||||||
|
pending.id
|
||||||
|
).data;
|
||||||
|
for (const [name, value, expected] of [
|
||||||
|
["none", Maybe.none(), { status: "not_provisioned", companyId: null }],
|
||||||
|
["pending", Maybe.some(pending), { status: "pending", companyId: null }],
|
||||||
|
["completed", Maybe.some(completed), { status: "completed", companyId: companyId.toPrimitive() }],
|
||||||
|
] as const)
|
||||||
|
test(name, async () => {
|
||||||
|
const repo = { findByAccountId: async () => Result.ok(value) } as never;
|
||||||
|
const result = await new GetInitialOnboardingStatusUseCase(repo).execute({ accountId });
|
||||||
|
assert.deepEqual(result.data, expected);
|
||||||
|
});
|
||||||
|
test("repository error fails without writing", async () => {
|
||||||
|
const error = new Error("read");
|
||||||
|
const repo = { findByAccountId: async () => Result.fail(error) } as never;
|
||||||
|
const result = await new GetInitialOnboardingStatusUseCase(repo).execute({ accountId });
|
||||||
|
assert.equal(result.error, error);
|
||||||
|
});
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { IInitialOnboardingRepository } from "./initial-onboarding-repository.interface";
|
||||||
|
|
||||||
|
export type InitialOnboardingStatusOutput = "not_provisioned" | "pending" | "completed";
|
||||||
|
export interface InitialOnboardingStatusModel {
|
||||||
|
status: InitialOnboardingStatusOutput;
|
||||||
|
companyId: string | null;
|
||||||
|
}
|
||||||
|
export interface GetInitialOnboardingStatusParams {
|
||||||
|
accountId: UniqueID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetInitialOnboardingStatusUseCase {
|
||||||
|
public constructor(private readonly repository: IInitialOnboardingRepository) {}
|
||||||
|
public async execute(
|
||||||
|
params: GetInitialOnboardingStatusParams
|
||||||
|
): Promise<Result<InitialOnboardingStatusModel, Error>> {
|
||||||
|
const found = await this.repository.findByAccountId(params.accountId);
|
||||||
|
if (found.isFailure) return Result.fail(found.error);
|
||||||
|
if (found.data.isNone()) return Result.ok({ status: "not_provisioned", companyId: null });
|
||||||
|
const onboarding = found.data.unwrap();
|
||||||
|
return Result.ok({
|
||||||
|
status: onboarding.status,
|
||||||
|
companyId: onboarding.companyId.isSome() ? onboarding.companyId.unwrap().toPrimitive() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./get-initial-onboarding-status.use-case";
|
||||||
|
export * from "./initial-onboarding-repository.interface";
|
||||||
@ -4,6 +4,12 @@ import type { Maybe, Result } from "@repo/rdx-utils";
|
|||||||
import type { InitialOnboarding } from "../../domain";
|
import type { InitialOnboarding } from "../../domain";
|
||||||
|
|
||||||
export interface IInitialOnboardingRepository {
|
export interface IInitialOnboardingRepository {
|
||||||
|
/** Bloquea el registro hasta finalizar la transacción externa obligatoria. */
|
||||||
|
findByAccountIdForUpdate(
|
||||||
|
accountId: UniqueID,
|
||||||
|
transaction: unknown
|
||||||
|
): Promise<Result<Maybe<InitialOnboarding>, Error>>;
|
||||||
|
|
||||||
/** Devuelve ausencia explícita si la cuenta no tiene onboarding; no es un error. */
|
/** Devuelve ausencia explícita si la cuenta no tiene onboarding; no es un error. */
|
||||||
findByAccountId(
|
findByAccountId(
|
||||||
accountId: UniqueID,
|
accountId: UniqueID,
|
||||||
|
|||||||
2
modules/onboarding/src/api/application/services/index.ts
Normal file
2
modules/onboarding/src/api/application/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./initial-onboarding-provisioner";
|
||||||
|
export * from "./onboarding-public-services.interface";
|
||||||
@ -15,6 +15,9 @@ const duplicate = () =>
|
|||||||
new DuplicateEntityError("InitialOnboarding", "accountId", accountId.toPrimitive());
|
new DuplicateEntityError("InitialOnboarding", "accountId", accountId.toPrimitive());
|
||||||
|
|
||||||
class FakeRepository implements IInitialOnboardingRepository {
|
class FakeRepository implements IInitialOnboardingRepository {
|
||||||
|
async findByAccountIdForUpdate(): Promise<Result<Maybe<InitialOnboarding>, Error>> {
|
||||||
|
return Result.fail(new Error("Not used"));
|
||||||
|
}
|
||||||
public stored: InitialOnboarding | undefined;
|
public stored: InitialOnboarding | undefined;
|
||||||
public reads = 0;
|
public reads = 0;
|
||||||
public writes = 0;
|
public writes = 0;
|
||||||
@ -161,6 +164,7 @@ test("dos provision simultáneos con barrera devuelven un único ganador", async
|
|||||||
let writes = 0;
|
let writes = 0;
|
||||||
let stored: InitialOnboarding | undefined;
|
let stored: InitialOnboarding | undefined;
|
||||||
const repository: IInitialOnboardingRepository = {
|
const repository: IInitialOnboardingRepository = {
|
||||||
|
findByAccountIdForUpdate: async () => Result.fail(new Error("Not used")),
|
||||||
async findByAccountId(id) {
|
async findByAccountId(id) {
|
||||||
assert.ok(id.equals(accountId));
|
assert.ok(id.equals(accountId));
|
||||||
reads++;
|
reads++;
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export class InitialOnboardingProvisioner {
|
|||||||
|
|
||||||
// UNIQUE es la garantía entre procesos. Solo recuperamos el ganador confirmado por cuenta.
|
// UNIQUE es la garantía entre procesos. Solo recuperamos el ganador confirmado por cuenta.
|
||||||
const winner = await this.repository.findByAccountId(accountId);
|
const winner = await this.repository.findByAccountId(accountId);
|
||||||
|
|
||||||
if (winner.isFailure) return Result.fail(winner.error);
|
if (winner.isFailure) return Result.fail(winner.error);
|
||||||
if (winner.data.isSome()) return Result.ok(winner.data.unwrap());
|
if (winner.data.isSome()) return Result.ok(winner.data.unwrap());
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export type IOnboardingPublicServices = Record<never, never>;
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
|
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { InitialOnboarding } from "../../domain";
|
||||||
|
import { CompleteInitialOnboardingInputMapper } from "../mappers/complete-initial-onboarding-input.mapper";
|
||||||
|
|
||||||
|
import { CompleteInitialOnboardingUseCase } from "./complete-initial-onboarding.use-case";
|
||||||
|
|
||||||
|
const accountId = UniqueID.generateNewID();
|
||||||
|
const companyId = UniqueID.generateNewID();
|
||||||
|
const input = {
|
||||||
|
accountId: accountId.toPrimitive(),
|
||||||
|
dto: {
|
||||||
|
legal_name: "Company",
|
||||||
|
tin: "B12345678",
|
||||||
|
address: {
|
||||||
|
street: "Street",
|
||||||
|
city: "City",
|
||||||
|
postal_code: "00000",
|
||||||
|
province: "Province",
|
||||||
|
country_code: "ES",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function setup(failure?: string, absent = false) {
|
||||||
|
let stored = absent ? undefined : InitialOnboarding.createPending(accountId).data;
|
||||||
|
const events: string[] = [];
|
||||||
|
const transaction = {};
|
||||||
|
const error = new Error(failure);
|
||||||
|
const manager: ITransactionManager = {
|
||||||
|
start: async () => {
|
||||||
|
events.push("begin");
|
||||||
|
if (failure === "begin") throw error;
|
||||||
|
},
|
||||||
|
getTransaction: () => transaction,
|
||||||
|
commit: async () => {
|
||||||
|
events.push("commit");
|
||||||
|
if (failure === "commit") throw error;
|
||||||
|
},
|
||||||
|
rollback: async () => {
|
||||||
|
events.push("rollback");
|
||||||
|
},
|
||||||
|
complete: async <T>(work: (tx: unknown) => Promise<T>): Promise<T> => {
|
||||||
|
await manager.start();
|
||||||
|
try {
|
||||||
|
const value = await work(transaction);
|
||||||
|
if (value instanceof Result && value.isFailure) throw value.error;
|
||||||
|
await manager.commit();
|
||||||
|
return value;
|
||||||
|
} catch (caught) {
|
||||||
|
await manager.rollback();
|
||||||
|
throw caught;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const useCase = new CompleteInitialOnboardingUseCase({
|
||||||
|
transactionManager: manager,
|
||||||
|
repository: {
|
||||||
|
findByAccountId: async () => {
|
||||||
|
assert.fail("Debe usar lock");
|
||||||
|
},
|
||||||
|
findByAccountIdForUpdate: async (id, tx) => {
|
||||||
|
events.push("lock");
|
||||||
|
assert.equal(tx, transaction);
|
||||||
|
assert.equal(id.toPrimitive(), accountId.toPrimitive());
|
||||||
|
if (failure === "read") return Result.fail(error);
|
||||||
|
return Result.ok(stored ? Maybe.some(stored) : Maybe.none<InitialOnboarding>());
|
||||||
|
},
|
||||||
|
save: async (value, tx) => {
|
||||||
|
events.push("save");
|
||||||
|
assert.equal(tx, transaction);
|
||||||
|
assert.equal(value.status, "completed");
|
||||||
|
assert.equal(value.companyId.unwrap().toPrimitive(), companyId.toPrimitive());
|
||||||
|
assert.equal(value.completedAt.unwrap().toPrimitive(), value.updatedAt.toPrimitive());
|
||||||
|
if (failure === "save") return Result.fail(error);
|
||||||
|
stored = value;
|
||||||
|
return Result.ok();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
companyCreator: {
|
||||||
|
create: async (p) => {
|
||||||
|
events.push("company");
|
||||||
|
assert.equal(p.transaction, transaction);
|
||||||
|
if (failure === "throw") throw error;
|
||||||
|
if (failure === "company") return Result.fail(error);
|
||||||
|
return Result.ok({ companyId: companyId.toPrimitive(), slug: "internal" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adminAssigner: {
|
||||||
|
assign: async (p) => {
|
||||||
|
events.push("admin");
|
||||||
|
assert.equal(p.transaction, transaction);
|
||||||
|
assert.equal(p.accountId.toPrimitive(), accountId.toPrimitive());
|
||||||
|
assert.equal(p.companyId.toPrimitive(), companyId.toPrimitive());
|
||||||
|
return failure === "admin" ? Result.fail(error) : Result.ok();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dtoMapper: new CompleteInitialOnboardingInputMapper(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
useCase,
|
||||||
|
events,
|
||||||
|
error,
|
||||||
|
get stored() {
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
test("pending ordena lock, Company, admin, completed/save y commit", async () => {
|
||||||
|
const s = setup();
|
||||||
|
const result = await s.useCase.execute(input);
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.deepEqual(result.data, { companyId: companyId.toPrimitive() });
|
||||||
|
assert.deepEqual(s.events, ["begin", "lock", "company", "admin", "save", "commit"]);
|
||||||
|
});
|
||||||
|
test("segunda ejecución tras commit devuelve misma Company sin participantes", async () => {
|
||||||
|
const s = setup();
|
||||||
|
await s.useCase.execute(input);
|
||||||
|
s.events.length = 0;
|
||||||
|
const result = await s.useCase.execute({
|
||||||
|
...input,
|
||||||
|
dto: { ...input.dto, legal_name: "Changed" },
|
||||||
|
});
|
||||||
|
assert.equal(result.data.companyId, companyId.toPrimitive());
|
||||||
|
assert.deepEqual(s.events, ["begin", "lock", "commit"]);
|
||||||
|
});
|
||||||
|
test("completed existente ignora payload nuevo", async () => {
|
||||||
|
const s = setup();
|
||||||
|
const now = UtcDateTime.now();
|
||||||
|
s.stored?.complete({ companyId, completedAt: now, updatedAt: now });
|
||||||
|
assert.equal((await s.useCase.execute(input)).data.companyId, companyId.toPrimitive());
|
||||||
|
assert.deepEqual(s.events, ["begin", "lock", "commit"]);
|
||||||
|
});
|
||||||
|
test("ausente falla sin provisionar", async () => {
|
||||||
|
const s = setup(undefined, true);
|
||||||
|
assert.equal((await s.useCase.execute(input)).isFailure, true);
|
||||||
|
assert.deepEqual(s.events, ["begin", "lock", "rollback"]);
|
||||||
|
});
|
||||||
|
for (const failure of ["read", "company", "admin", "save", "throw", "commit", "begin"]) {
|
||||||
|
test(`fallo ${failure} retorna Result.fail y gestiona transacción`, async () => {
|
||||||
|
const s = setup(failure);
|
||||||
|
assert.equal((await s.useCase.execute(input)).error, s.error);
|
||||||
|
if (failure === "begin") assert.deepEqual(s.events, ["begin"]);
|
||||||
|
else assert.equal(s.events.at(-1), "rollback");
|
||||||
|
if (failure !== "commit") assert.equal(s.events.includes("commit"), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("fallo del dominio no persiste ni confirma", async (t) => {
|
||||||
|
const s = setup();
|
||||||
|
assert.ok(s.stored);
|
||||||
|
t.mock.method(s.stored, "complete", () => Result.fail(s.error));
|
||||||
|
assert.equal((await s.useCase.execute(input)).isFailure, true);
|
||||||
|
assert.deepEqual(s.events, ["begin", "lock", "company", "admin", "rollback"]);
|
||||||
|
});
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
import type { CreateInitialCompanyParams, IInitialCompanyCreator } from "@erp/companies/api";
|
||||||
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
|
import type { IInitialCompanyAdminAssigner } from "@erp/identity/api";
|
||||||
|
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import type { CompleteInitialOnboardingRequestDTO } from "../../../common";
|
||||||
|
import type { InitialOnboarding } from "../../domain";
|
||||||
|
import type { ICompleteInitialOnboardingInputMapper } from "../mappers/complete-initial-onboarding-input.mapper";
|
||||||
|
import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface";
|
||||||
|
|
||||||
|
export interface CompleteInitialOnboardingParams {
|
||||||
|
/** La futura capa HTTP debe obtenerlo del contexto autenticado. */
|
||||||
|
accountId: UniqueID;
|
||||||
|
company: Omit<CreateInitialCompanyParams, "transaction">;
|
||||||
|
}
|
||||||
|
export interface CompleteInitialOnboardingResult {
|
||||||
|
companyId: string;
|
||||||
|
}
|
||||||
|
export type CompleteInitialOnboardingUseCaseInput = {
|
||||||
|
dto: CompleteInitialOnboardingRequestDTO;
|
||||||
|
accountId: string;
|
||||||
|
};
|
||||||
|
/** Posee la transacción global; no provisiona elegibilidad ni modifica la sesión. */
|
||||||
|
export class CompleteInitialOnboardingUseCase {
|
||||||
|
public constructor(
|
||||||
|
private readonly deps: {
|
||||||
|
transactionManager: ITransactionManager;
|
||||||
|
repository: IInitialOnboardingRepository;
|
||||||
|
companyCreator: IInitialCompanyCreator;
|
||||||
|
adminAssigner: IInitialCompanyAdminAssigner;
|
||||||
|
dtoMapper: ICompleteInitialOnboardingInputMapper;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
public async execute(
|
||||||
|
input: CompleteInitialOnboardingUseCaseInput
|
||||||
|
): Promise<Result<CompleteInitialOnboardingResult, Error>> {
|
||||||
|
const mappedResult = this.deps.dtoMapper.map(input.dto, { accountId: input.accountId });
|
||||||
|
if (mappedResult.isFailure) return Result.fail(mappedResult.error);
|
||||||
|
const params = mappedResult.data;
|
||||||
|
try {
|
||||||
|
return await this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||||
|
const found = await this.deps.repository.findByAccountIdForUpdate(
|
||||||
|
params.accountId,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
if (found.isFailure) return Result.fail(found.error);
|
||||||
|
if (found.data.isNone())
|
||||||
|
return Result.fail(new Error("Initial onboarding has not been provisioned."));
|
||||||
|
const onboarding = found.data.unwrap();
|
||||||
|
if (!onboarding.accountId.equals(params.accountId))
|
||||||
|
return Result.fail(new Error("Initial onboarding account mismatch."));
|
||||||
|
if (onboarding.status === "completed") {
|
||||||
|
if (onboarding.companyId.isNone() || onboarding.completedAt.isNone())
|
||||||
|
return Result.fail(new Error("Invalid completed onboarding."));
|
||||||
|
return Result.ok({ companyId: onboarding.companyId.unwrap().toPrimitive() });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
onboarding.status !== "pending" ||
|
||||||
|
onboarding.companyId.isSome() ||
|
||||||
|
onboarding.completedAt.isSome()
|
||||||
|
) {
|
||||||
|
return Result.fail(new Error("Invalid pending onboarding."));
|
||||||
|
}
|
||||||
|
return this.completePending(params, onboarding, transaction);
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error("Initial onboarding transaction failed.", { cause: error })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async completePending(
|
||||||
|
params: CompleteInitialOnboardingParams,
|
||||||
|
onboarding: InitialOnboarding,
|
||||||
|
transaction: unknown
|
||||||
|
): Promise<Result<CompleteInitialOnboardingResult, Error>> {
|
||||||
|
const company = await this.deps.companyCreator.create({ ...params.company, transaction });
|
||||||
|
if (company.isFailure) return Result.fail(company.error);
|
||||||
|
const companyId = UniqueID.create(company.data.companyId);
|
||||||
|
if (companyId.isFailure) return Result.fail(companyId.error);
|
||||||
|
const admin = await this.deps.adminAssigner.assign({
|
||||||
|
accountId: params.accountId,
|
||||||
|
companyId: companyId.data,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
if (admin.isFailure) return Result.fail(admin.error);
|
||||||
|
const now = UtcDateTime.now();
|
||||||
|
const completed = onboarding.complete({
|
||||||
|
companyId: companyId.data,
|
||||||
|
completedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
if (completed.isFailure) return Result.fail(completed.error);
|
||||||
|
const saved = await this.deps.repository.save(onboarding, transaction);
|
||||||
|
if (saved.isFailure) return Result.fail(saved.error);
|
||||||
|
return Result.ok({ companyId: companyId.data.toPrimitive() });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from './complete-initial-onboarding.use-case';
|
||||||
25
modules/onboarding/src/api/index.ts
Normal file
25
modules/onboarding/src/api/index.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { IModuleServer } from "@erp/core/api";
|
||||||
|
|
||||||
|
import type { IOnboardingPublicServices } from "./application";
|
||||||
|
import { buildOnboardingDependencies } from "./infrastructure/di";
|
||||||
|
import { onboardingRouter } from "./infrastructure/express/onboarding.routes";
|
||||||
|
import { models } from "./infrastructure/persistence/sequelize";
|
||||||
|
|
||||||
|
export type { IOnboardingPublicServices } from "./application";
|
||||||
|
export * from "./application";
|
||||||
|
export * from "./infrastructure/persistence/sequelize";
|
||||||
|
|
||||||
|
export const onboardingAPIModule: IModuleServer = {
|
||||||
|
name: "onboarding",
|
||||||
|
version: "1.0.0",
|
||||||
|
dependencies: ["companies", "identity"],
|
||||||
|
setup(params) {
|
||||||
|
const internal = buildOnboardingDependencies(params);
|
||||||
|
const general: IOnboardingPublicServices = {};
|
||||||
|
return { models, services: { general }, internal };
|
||||||
|
},
|
||||||
|
start(params) {
|
||||||
|
onboardingRouter(params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default onboardingAPIModule;
|
||||||
1
modules/onboarding/src/api/infrastructure/di/index.ts
Normal file
1
modules/onboarding/src/api/infrastructure/di/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./onboarding.di";
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import type { IInitialOnboardingRepository } from "../../application/repositories/initial-onboarding-repository.interface";
|
||||||
|
import { InitialOnboardingProvisioner } from "../../application/services/initial-onboarding-provisioner";
|
||||||
|
|
||||||
|
export const buildOnboardingApplicationServices = (deps: {
|
||||||
|
repository: IInitialOnboardingRepository;
|
||||||
|
}) => ({
|
||||||
|
provisioner: new InitialOnboardingProvisioner(deps.repository),
|
||||||
|
});
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
import type { IInitialOnboardingRepository } from "../../application";
|
||||||
|
import { SequelizeInitialOnboardingRepository } from "../persistence/sequelize";
|
||||||
|
|
||||||
|
export type OnboardingPersistenceDependencies = { repository: IInitialOnboardingRepository };
|
||||||
|
|
||||||
|
export const buildOnboardingPersistence = (): OnboardingPersistenceDependencies => ({
|
||||||
|
repository: new SequelizeInitialOnboardingRepository(),
|
||||||
|
});
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import type { IInitialCompanyCreator } from "@erp/companies/api";
|
||||||
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
|
import type { IInitialCompanyAdminAssigner } from "@erp/identity/api";
|
||||||
|
|
||||||
|
import { CompleteInitialOnboardingInputMapper } from "../../application/mappers/complete-initial-onboarding-input.mapper";
|
||||||
|
import { GetInitialOnboardingStatusUseCase } from "../../application/repositories/get-initial-onboarding-status.use-case";
|
||||||
|
import type { IInitialOnboardingRepository } from "../../application/repositories/initial-onboarding-repository.interface";
|
||||||
|
import { CompleteInitialOnboardingUseCase } from "../../application/use-cases/complete-initial-onboarding.use-case";
|
||||||
|
|
||||||
|
export const buildOnboardingUseCases = (deps: {
|
||||||
|
repository: IInitialOnboardingRepository;
|
||||||
|
transactionManager: ITransactionManager;
|
||||||
|
initialCompanyCreator: IInitialCompanyCreator;
|
||||||
|
initialCompanyAdminAssigner: IInitialCompanyAdminAssigner;
|
||||||
|
}) => {
|
||||||
|
const completeInitialOnboardingInputMapper = new CompleteInitialOnboardingInputMapper();
|
||||||
|
return {
|
||||||
|
getInitialOnboardingStatus: new GetInitialOnboardingStatusUseCase(deps.repository),
|
||||||
|
completeInitialOnboarding: new CompleteInitialOnboardingUseCase({
|
||||||
|
transactionManager: deps.transactionManager,
|
||||||
|
repository: deps.repository,
|
||||||
|
companyCreator: deps.initialCompanyCreator,
|
||||||
|
adminAssigner: deps.initialCompanyAdminAssigner,
|
||||||
|
dtoMapper: completeInitialOnboardingInputMapper,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import type { IInitialCompanyAdminAssigner } from "@erp/identity/api";
|
||||||
|
import type { IInitialCompanyCreator } from "@erp/companies/api";
|
||||||
|
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
|
||||||
|
import { buildOnboardingApplicationServices } from "./onboarding-application-services.di";
|
||||||
|
import { buildOnboardingPersistence } from "./onboarding-persistence.di";
|
||||||
|
import { buildOnboardingUseCases } from "./onboarding-use-cases.di";
|
||||||
|
import type { Sequelize } from "sequelize";
|
||||||
|
|
||||||
|
export type OnboardingInternalDeps = {
|
||||||
|
applicationServices: ReturnType<typeof buildOnboardingApplicationServices>;
|
||||||
|
useCases: ReturnType<typeof buildOnboardingUseCases>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildOnboardingDependencies = (params: ModuleParams): OnboardingInternalDeps => {
|
||||||
|
const persistence = buildOnboardingPersistence();
|
||||||
|
const companies = params.getService<{ initialCompanyCreator: IInitialCompanyCreator }>("companies:general");
|
||||||
|
const identity = params.getService<{ initialCompanyAdminAssigner: IInitialCompanyAdminAssigner }>("identity:general");
|
||||||
|
const useCases = buildOnboardingUseCases({
|
||||||
|
...persistence,
|
||||||
|
transactionManager: buildTransactionManager(params.database as Sequelize, params.transactionContext),
|
||||||
|
initialCompanyCreator: companies.initialCompanyCreator,
|
||||||
|
initialCompanyAdminAssigner: identity.initialCompanyAdminAssigner,
|
||||||
|
});
|
||||||
|
return { applicationServices: buildOnboardingApplicationServices(persistence), useCases };
|
||||||
|
};
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { ExpressController } from "@erp/core/api";
|
||||||
|
|
||||||
|
import type { CompleteInitialOnboardingRequestDTO } from "../../../common";
|
||||||
|
import type { CompleteInitialOnboardingUseCase } from "../../application";
|
||||||
|
|
||||||
|
export class CompleteInitialOnboardingController extends ExpressController {
|
||||||
|
constructor(private readonly useCase: CompleteInitialOnboardingUseCase) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async executeImpl() {
|
||||||
|
const user = this.getUser();
|
||||||
|
if (!user?.userId) return this.unauthorizedError("Unauthorized");
|
||||||
|
const dto = this.req.body as CompleteInitialOnboardingRequestDTO;
|
||||||
|
const result = await this.useCase.execute({ dto, accountId: user.userId });
|
||||||
|
return result.match(
|
||||||
|
(value) => this.ok({ company_id: value.companyId }),
|
||||||
|
(error) => this.handleError(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { ExpressController } from "@erp/core/api";
|
||||||
|
|
||||||
|
import type { GetInitialOnboardingStatusUseCase } from "../../application";
|
||||||
|
|
||||||
|
export class GetInitialOnboardingStatusController extends ExpressController {
|
||||||
|
constructor(private readonly useCase: GetInitialOnboardingStatusUseCase) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async executeImpl() {
|
||||||
|
const user = this.getUser();
|
||||||
|
if (!user?.userId) {
|
||||||
|
return this.unauthorizedError("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.useCase.execute({ accountId: user.userId });
|
||||||
|
return result.match(
|
||||||
|
(value) => this.ok({ status: value.status, company_id: value.companyId }),
|
||||||
|
(error) => this.handleError(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./onboarding.routes";
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { type StartParams, requireAuthenticatedGuard, validateRequest } from "@erp/core/api";
|
||||||
|
import { requireIdentityTenant } from "@erp/identity/api";
|
||||||
|
import { type NextFunction, type Request, type Response, Router } from "express";
|
||||||
|
|
||||||
|
import { CompleteInitialOnboardingRequestSchema } from "../../../common";
|
||||||
|
import type { OnboardingInternalDeps } from "../di";
|
||||||
|
|
||||||
|
import { CompleteInitialOnboardingController } from "./complete-initial-onboarding.controller";
|
||||||
|
import { GetInitialOnboardingStatusController } from "./get-initial-onboarding-status.controller";
|
||||||
|
|
||||||
|
export const onboardingRouter = (params: StartParams) => {
|
||||||
|
const { app, config, getInternal } = params;
|
||||||
|
const deps = getInternal<OnboardingInternalDeps>("onboarding");
|
||||||
|
const router: Router = Router({ mergeParams: true });
|
||||||
|
|
||||||
|
// ----------------------------------------------
|
||||||
|
|
||||||
|
router.use(...requireIdentityTenant(params));
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/initial",
|
||||||
|
requireAuthenticatedGuard(),
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const controller = new GetInitialOnboardingStatusController(
|
||||||
|
deps.useCases.getInitialOnboardingStatus
|
||||||
|
);
|
||||||
|
return controller.execute(req, res, next);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/initial/complete",
|
||||||
|
requireAuthenticatedGuard(),
|
||||||
|
validateRequest(CompleteInitialOnboardingRequestSchema, "body"),
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const controller = new CompleteInitialOnboardingController(
|
||||||
|
deps.useCases.completeInitialOnboarding
|
||||||
|
);
|
||||||
|
return controller.execute(req, res, next);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(`${config.server.apiBasePath}/onboarding`, router);
|
||||||
|
};
|
||||||
2
modules/onboarding/src/api/infrastructure/index.ts
Normal file
2
modules/onboarding/src/api/infrastructure/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./di";
|
||||||
|
export * from "./persistence/sequelize";
|
||||||
@ -167,3 +167,47 @@ test("fallo de mapper en escritura evita consultar o escribir", async (t) => {
|
|||||||
assert.equal(find.mock.callCount(), 0);
|
assert.equal(find.mock.callCount(), 0);
|
||||||
assert.equal(create.mock.callCount(), 0);
|
assert.equal(create.mock.callCount(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("FOR UPDATE exige transaction sin consultar", async (t) => {
|
||||||
|
const find = t.mock.method(InitialOnboardingModel, "findOne", async () => null);
|
||||||
|
const result = await new SequelizeInitialOnboardingRepository().findByAccountIdForUpdate(
|
||||||
|
accountId,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(find.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
test("FOR UPDATE acota account y usa lock de escritura con mapper", async (t) => {
|
||||||
|
const transaction = {};
|
||||||
|
const find = t.mock.method(InitialOnboardingModel, "findOne", async () => row());
|
||||||
|
const result = await new SequelizeInitialOnboardingRepository().findByAccountIdForUpdate(
|
||||||
|
accountId,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
assert.equal(result.data.unwrap().accountId.toPrimitive(), accountId.toPrimitive());
|
||||||
|
assert.deepEqual(find.mock.calls[0].arguments[0], {
|
||||||
|
where: { account_id: accountId.toPrimitive() },
|
||||||
|
transaction,
|
||||||
|
lock: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("FOR UPDATE ausencia", async (t) => {
|
||||||
|
t.mock.method(InitialOnboardingModel, "findOne", async () => null);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await new SequelizeInitialOnboardingRepository().findByAccountIdForUpdate(accountId, {})
|
||||||
|
).data.isNone(),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("FOR UPDATE error traducido", async (t) => {
|
||||||
|
const error = new Error("read failure");
|
||||||
|
t.mock.method(InitialOnboardingModel, "findOne", async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await new SequelizeInitialOnboardingRepository().findByAccountIdForUpdate(accountId, {}))
|
||||||
|
.error,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { UniqueID } from "@repo/rdx-ddd";
|
|||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
import type { Transaction } from "sequelize";
|
import type { Transaction } from "sequelize";
|
||||||
|
|
||||||
import type { IInitialOnboardingRepository } from "../../../../application/repositories/initial-onboarding-repository.interface";
|
import type { IInitialOnboardingRepository } from "../../../../application";
|
||||||
import type { InitialOnboarding } from "../../../../domain";
|
import type { InitialOnboarding } from "../../../../domain";
|
||||||
import { SequelizeInitialOnboardingDomainMapper } from "../mappers";
|
import { SequelizeInitialOnboardingDomainMapper } from "../mappers";
|
||||||
import { InitialOnboardingModel } from "../models";
|
import { InitialOnboardingModel } from "../models";
|
||||||
@ -13,6 +13,24 @@ export class SequelizeInitialOnboardingRepository implements IInitialOnboardingR
|
|||||||
private readonly domainMapper = new SequelizeInitialOnboardingDomainMapper()
|
private readonly domainMapper = new SequelizeInitialOnboardingDomainMapper()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async findByAccountIdForUpdate(
|
||||||
|
accountId: UniqueID,
|
||||||
|
transaction: unknown
|
||||||
|
): Promise<Result<Maybe<InitialOnboarding>, Error>> {
|
||||||
|
if (!transaction) return Result.fail(new Error("Onboarding lock requires a transaction."));
|
||||||
|
try {
|
||||||
|
const row = await InitialOnboardingModel.findOne({
|
||||||
|
where: { account_id: accountId.toPrimitive() },
|
||||||
|
transaction: transaction as Transaction,
|
||||||
|
lock: true,
|
||||||
|
});
|
||||||
|
if (!row) return Result.ok(Maybe.none<InitialOnboarding>());
|
||||||
|
return this.domainMapper.mapToDomain(row).map(Maybe.some);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async findByAccountId(
|
public async findByAccountId(
|
||||||
accountId: UniqueID,
|
accountId: UniqueID,
|
||||||
transaction?: unknown
|
transaction?: unknown
|
||||||
|
|||||||
34
modules/onboarding/src/api/module.composition.test.ts
Normal file
34
modules/onboarding/src/api/module.composition.test.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import initialOnboardingModelInit from "./infrastructure/persistence/sequelize/models/sequelize-initial-onboarding.model";
|
||||||
|
import { onboardingModule } from "./module";
|
||||||
|
|
||||||
|
test("onboarding composition builds public capabilities and registers its model", async () => {
|
||||||
|
let provisionCalls = 0;
|
||||||
|
const initialCompanyCreator = {
|
||||||
|
create: async () => {
|
||||||
|
throw new Error("not invoked");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const initialCompanyAdminAssigner = {
|
||||||
|
assign: async () => {
|
||||||
|
throw new Error("not invoked");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await onboardingModule.setup?.({
|
||||||
|
database: {} as never,
|
||||||
|
transactionContext: {} as never,
|
||||||
|
getService: <T>(name: string) => {
|
||||||
|
if (name === "companies:general") return { initialCompanyCreator } as T;
|
||||||
|
if (name === "identity:general") return { initialCompanyAdminAssigner } as T;
|
||||||
|
throw new Error(`Unexpected service ${name}`);
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(result?.services?.general);
|
||||||
|
assert.deepEqual(result.services.general, {});
|
||||||
|
assert.ok(result.internal);
|
||||||
|
assert.ok(result.models?.includes(initialOnboardingModelInit));
|
||||||
|
assert.equal(provisionCalls, 0);
|
||||||
|
});
|
||||||
18
modules/onboarding/src/api/module.ts
Normal file
18
modules/onboarding/src/api/module.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { IModuleServer } from "@erp/core/api";
|
||||||
|
|
||||||
|
import type { IOnboardingPublicServices } from "./application/services/onboarding-public-services.interface";
|
||||||
|
import { buildOnboardingDependencies } from "./infrastructure/di";
|
||||||
|
import { models } from "./infrastructure/persistence/sequelize";
|
||||||
|
|
||||||
|
export type { IOnboardingPublicServices } from "./application/services/onboarding-public-services.interface";
|
||||||
|
|
||||||
|
export const onboardingModule: IModuleServer = {
|
||||||
|
name: "onboarding",
|
||||||
|
version: "1.0.0",
|
||||||
|
dependencies: ["companies", "identity"],
|
||||||
|
setup(params) {
|
||||||
|
const internal = buildOnboardingDependencies(params);
|
||||||
|
const general: IOnboardingPublicServices = {};
|
||||||
|
return { models, services: { general }, internal };
|
||||||
|
},
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user