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.
|
||||
|
||||
**## 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 es la fuente única de verdad para los contratos HTTP.
|
||||
@ -478,29 +490,15 @@ No se permite:
|
||||
|
||||
**### Flujo de entrada**
|
||||
|
||||
Respetar:
|
||||
Para el flujo normal, usar:
|
||||
|
||||
\`\`\`text
|
||||
|
||||
RequestDTO
|
||||
|
||||
-> RequestMapper
|
||||
|
||||
-> Command
|
||||
|
||||
-> PropsMapper
|
||||
|
||||
-> Aggregate o Entity
|
||||
RequestDTO -> UseCase({ dto, context }) -> InputMapper -> Value Objects / Props
|
||||
|
||||
\`\`\`
|
||||
|
||||
No mapear directamente:
|
||||
|
||||
\`\`\`text
|
||||
|
||||
DTO -> Aggregate
|
||||
|
||||
\`\`\`
|
||||
`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.
|
||||
|
||||
**### Casos de uso**
|
||||
|
||||
@ -866,4 +864,4 @@ En caso de conflicto, aplicar este orden:
|
||||
|
||||
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/identity": "workspace:*",
|
||||
"@erp/factuges": "workspace:*",
|
||||
"@erp/onboarding": "workspace:*",
|
||||
"@erp/suppliers": "workspace:*",
|
||||
"@repo/rdx-logger": "workspace:*",
|
||||
"@repo/rdx-audit": "workspace:*",
|
||||
|
||||
@ -6,6 +6,7 @@ import customersAPIModule from "@erp/customers/api";
|
||||
import documentSeriesAPIModule from "@erp/document-series/api";
|
||||
import factuGESAPIModule from "@erp/factuges/api";
|
||||
import identityAPIModule from "@erp/identity/api";
|
||||
import onboardingAPIModule from "@erp/onboarding/api";
|
||||
import suppliersAPIModule from "@erp/suppliers/api";
|
||||
|
||||
// Aquí hay que registrar los módulos que
|
||||
@ -22,4 +23,5 @@ export const registerModules = () => {
|
||||
registerModule(customerInvoicesAPIModule);
|
||||
registerModule(factuGESAPIModule);
|
||||
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 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 { 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 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();
|
||||
|
||||
test("maps Company address to public primitives", () => {
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import type { ICompanyPublicFinder } from "./company-public-finder";
|
||||
|
||||
import type { IInitialCompanyCreator } from "./initial-company-creator";
|
||||
|
||||
export interface ICompanyPublicServices {
|
||||
finder: ICompanyPublicFinder;
|
||||
initialCompanyCreator: IInitialCompanyCreator;
|
||||
}
|
||||
|
||||
export const buildCompanyPublicServices = (
|
||||
finder: ICompanyPublicFinder
|
||||
finder: ICompanyPublicFinder,
|
||||
initialCompanyCreator: IInitialCompanyCreator
|
||||
): ICompanyPublicServices => ({
|
||||
finder,
|
||||
initialCompanyCreator,
|
||||
});
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
Street,
|
||||
TextValue,
|
||||
UniqueID,
|
||||
UtcDate,
|
||||
UtcDateTime,
|
||||
} from "@repo/rdx-ddd";
|
||||
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) => {
|
||||
const now = UtcDate.createFromISO("2026-09-07").data;
|
||||
t.mock.method(UtcDate, "today", () => now);
|
||||
const now = UtcDateTime.createFromISO("2026-09-07T00:00:00.000Z").data;
|
||||
t.mock.method(UtcDateTime, "now", () => now);
|
||||
const { capability, saved } = setup();
|
||||
const params = input();
|
||||
const result = await capability.create(params);
|
||||
|
||||
@ -6,7 +6,7 @@ import type {
|
||||
TextValue,
|
||||
URLAddress,
|
||||
} 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 { 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.
|
||||
const slug = CompanySlug.create(`company-${id.toPrimitive()}`);
|
||||
if (slug.isFailure) return Result.fail(slug.error);
|
||||
const now = UtcDate.today();
|
||||
const now = UtcDateTime.now();
|
||||
const created = await this.creator.create({
|
||||
id,
|
||||
transaction: params.transaction,
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
TextValue,
|
||||
URLAddress,
|
||||
UniqueID,
|
||||
UtcDate,
|
||||
UtcDateTime,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
@ -22,7 +22,7 @@ import { CompanyStatusChanger } from "./company-status-changer";
|
||||
import { CompanyUpdater } from "./company-updater";
|
||||
|
||||
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 slug = CompanySlug.create("rodax-labs");
|
||||
const tin = TextValue.create("B12345678");
|
||||
@ -167,7 +167,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
|
||||
legalName: updatedName.data,
|
||||
email: Maybe.some(updatedEmail.data),
|
||||
defaultPaymentTermId: Maybe.some(defaultPaymentTermId),
|
||||
updatedAt: UtcDate.today(),
|
||||
updatedAt: UtcDateTime.now(),
|
||||
},
|
||||
});
|
||||
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({
|
||||
id: company.id,
|
||||
patchProps: { updatedAt: UtcDate.today() },
|
||||
patchProps: { updatedAt: UtcDateTime.now() },
|
||||
});
|
||||
assert.equal(noopResult.isSuccess, true);
|
||||
assert.equal(counters.updates, 1);
|
||||
@ -217,7 +217,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
|
||||
id: failedAuditCompany.id,
|
||||
patchProps: {
|
||||
legalName: updatedName.data,
|
||||
updatedAt: UtcDate.today(),
|
||||
updatedAt: UtcDateTime.now(),
|
||||
},
|
||||
});
|
||||
assert.equal(auditFailureResult.isFailure, true);
|
||||
@ -236,7 +236,7 @@ test("CompanyUpdater persiste y audita cambios, pero omite no-ops", async () =>
|
||||
id: disabledCompany.id,
|
||||
patchProps: {
|
||||
legalName: updatedName.data,
|
||||
updatedAt: UtcDate.today(),
|
||||
updatedAt: UtcDateTime.now(),
|
||||
},
|
||||
});
|
||||
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({
|
||||
company,
|
||||
action: "enable",
|
||||
updatedAt: UtcDate.today(),
|
||||
updatedAt: UtcDateTime.now(),
|
||||
});
|
||||
assert.equal(enableResult.isSuccess, true);
|
||||
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({
|
||||
company,
|
||||
action: "enable",
|
||||
updatedAt: UtcDate.today(),
|
||||
updatedAt: UtcDateTime.now(),
|
||||
});
|
||||
assert.equal(noopResult.isSuccess, true);
|
||||
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({
|
||||
company,
|
||||
action: "disable",
|
||||
updatedAt: UtcDate.today(),
|
||||
updatedAt: UtcDateTime.now(),
|
||||
});
|
||||
assert.equal(disableResult.isSuccess, true);
|
||||
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({
|
||||
company,
|
||||
action: "disable",
|
||||
updatedAt: UtcDate.today(),
|
||||
updatedAt: UtcDateTime.now(),
|
||||
});
|
||||
assert.equal(disabledNoopResult.isSuccess, true);
|
||||
assert.equal(counters.updates, 2);
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
|
||||
import { CreateCompanyInputMapper } from "../mappers/create-company-input.mapper";
|
||||
import { CompanyPublicModelMapper } from "../public";
|
||||
|
||||
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 useCase = new CreateCompanyUseCase({
|
||||
inputMapper: new CreateCompanyInputMapper(),
|
||||
creator: {
|
||||
create: async () => Result.ok(company),
|
||||
},
|
||||
@ -116,6 +118,7 @@ test("create serializes the company public address", async () => {
|
||||
true
|
||||
);
|
||||
const useCase = new CreateCompanyUseCase({
|
||||
inputMapper: new CreateCompanyInputMapper(),
|
||||
creator: { create: async () => Result.ok(company) },
|
||||
responseMapper: new CompanyPublicModelMapper(),
|
||||
transactionManager: buildTransactionManager(),
|
||||
@ -147,6 +150,7 @@ test("create maps a complete request address to Company props", async () => {
|
||||
const company = buildCompany();
|
||||
let capturedAddress: typeof company.address | undefined;
|
||||
const useCase = new CreateCompanyUseCase({
|
||||
inputMapper: new CreateCompanyInputMapper(),
|
||||
creator: {
|
||||
create: async ({ props }) => {
|
||||
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().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 { 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 { Result } from "@repo/rdx-utils";
|
||||
|
||||
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 type { ICreateCompanyInputMapper } from "../mappers/create-company-input.mapper";
|
||||
import type { CompanyPublicModelMapper } from "../public";
|
||||
import type { ICompanyCreator } from "../services";
|
||||
|
||||
export class CreateCompanyUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
creator: ICompanyCreator;
|
||||
inputMapper: ICreateCompanyInputMapper;
|
||||
responseMapper: CompanyPublicModelMapper;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public execute(params: {
|
||||
dto: CreateCompanyRequestDTO;
|
||||
}): Promise<Result<CompanyResponseDTO, Error>> {
|
||||
const mappedResult = mapCreateCompanyProps(params.dto);
|
||||
if (mappedResult.isFailure) {
|
||||
return Promise.resolve(Result.fail(mappedResult.error));
|
||||
}
|
||||
|
||||
const { id, props } = mappedResult.data;
|
||||
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
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) => {
|
||||
try {
|
||||
const createResult = await this.deps.creator.create({ id, props, transaction });
|
||||
if (createResult.isFailure) {
|
||||
return Result.fail(createResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(createResult.data))
|
||||
);
|
||||
const result = await this.deps.creator.create({
|
||||
id: input.data.id,
|
||||
props: input.data.props,
|
||||
transaction,
|
||||
});
|
||||
return result.isFailure
|
||||
? Result.fail(result.error)
|
||||
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
|
||||
} catch (error: unknown) {
|
||||
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 { IIdentityPublicServices } from "@erp/identity/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
@ -10,14 +10,16 @@ import type { CompanyPublicModelMapper } from "../public";
|
||||
export class FindAvailableCompaniesForAccountUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
identityServices: IIdentityPublicServices;
|
||||
identityServices: Pick<IIdentityPublicServices, "companyAccess">;
|
||||
repository: ICompanyRepository;
|
||||
responseMapper: CompanyPublicModelMapper;
|
||||
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);
|
||||
if (accountIdResult.isFailure) {
|
||||
return Promise.resolve(Result.fail(accountIdResult.error));
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { Company, type CompanyPatchProps, CompanySlug, CompanyStatus } from "../../../domain";
|
||||
import { UpdateCompanyInputMapper } from "../mappers/update-company-input.mapper";
|
||||
import { CompanyPublicModelMapper } from "../public";
|
||||
|
||||
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;
|
||||
|
||||
const useCase = new UpdateCompanyUseCase({
|
||||
inputMapper: new UpdateCompanyInputMapper(),
|
||||
updater: {
|
||||
update: async ({ 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(receivedPatch?.defaultPaymentTermId?.isNone(), true);
|
||||
assert.equal((receivedPatch as CompanyPatchProps | null)?.defaultPaymentTermId?.isNone(), true);
|
||||
assert.equal(receivedId?.isNone(), true);
|
||||
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 captured: Array<unknown> = [];
|
||||
const useCase = new UpdateCompanyUseCase({
|
||||
inputMapper: new UpdateCompanyInputMapper(),
|
||||
updater: {
|
||||
update: async ({ patchProps }) => {
|
||||
captured.push(patchProps.address);
|
||||
@ -150,3 +153,26 @@ test("update company maps the address tri-state", async () => {
|
||||
"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 {
|
||||
EmailAddress,
|
||||
Name,
|
||||
PhoneNumber,
|
||||
TextValue,
|
||||
URLAddress,
|
||||
UniqueID,
|
||||
UtcDateTime,
|
||||
maybeFromNullableResult,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
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 type { IUpdateCompanyInputMapper } from "../mappers/update-company-input.mapper";
|
||||
import type { CompanyPublicModelMapper } from "../public";
|
||||
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
|
||||
|
||||
export class UpdateCompanyUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
updater: ICompanyUpdater;
|
||||
inputMapper: IUpdateCompanyInputMapper;
|
||||
paymentTermResolver: ICompanyPaymentTermResolver;
|
||||
responseMapper: CompanyPublicModelMapper;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public execute(params: {
|
||||
company_id: string;
|
||||
dto: UpdateCompanyRequestDTO;
|
||||
}): Promise<Result<CompanyResponseDTO, Error>> {
|
||||
const companyIdResult = UniqueID.create(params.company_id);
|
||||
if (companyIdResult.isFailure) {
|
||||
return Promise.resolve(Result.fail(companyIdResult.error));
|
||||
}
|
||||
|
||||
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) => {
|
||||
const companyId = UniqueID.create(params.company_id);
|
||||
if (companyId.isFailure) return Promise.resolve(Result.fail(companyId.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) => {
|
||||
try {
|
||||
if (defaultPaymentTermIdResult.data !== undefined) {
|
||||
const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({
|
||||
companyId: companyIdResult.data,
|
||||
id: defaultPaymentTermIdResult.data,
|
||||
if (input.data.defaultPaymentTermId !== undefined) {
|
||||
const result = await this.deps.paymentTermResolver.ensureAssignable({
|
||||
companyId: companyId.data,
|
||||
id: input.data.defaultPaymentTermId,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (ensureResult.isFailure) {
|
||||
return Result.fail(ensureResult.error);
|
||||
}
|
||||
if (result.isFailure) return Result.fail(result.error);
|
||||
}
|
||||
|
||||
const updateResult = await this.deps.updater.update({
|
||||
id: companyIdResult.data,
|
||||
patchProps: patchPropsResult.data,
|
||||
const result = await this.deps.updater.update({
|
||||
id: companyId.data,
|
||||
patchProps: input.data.patchProps,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (updateResult.isFailure) {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data))
|
||||
);
|
||||
return result.isFailure
|
||||
? Result.fail(result.error)
|
||||
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
|
||||
} catch (error: unknown) {
|
||||
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,
|
||||
URLAddress,
|
||||
UniqueID,
|
||||
UtcDate,
|
||||
UtcDateTime,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { Company, CompanySlug, CompanyStatus } from "../../../domain";
|
||||
import { UpdateCurrentCompanyInputMapper } from "../mappers/update-current-company-input.mapper";
|
||||
import { CompanyPublicModelMapper } from "../public";
|
||||
|
||||
import { UpdateCurrentCompanyUseCase } from "./update-current-company.use-case";
|
||||
@ -27,7 +28,7 @@ const buildTransactionManager = (): ITransactionManager => ({
|
||||
});
|
||||
|
||||
const buildCompany = () => {
|
||||
const now = UtcDate.createFromISO("2026-08-16");
|
||||
const now = UtcDateTime.createFromISO("2026-08-16T00:00:00.000Z");
|
||||
const id = UniqueID.generateNewID();
|
||||
const legalName = Name.create("Rodax Labs");
|
||||
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;
|
||||
|
||||
const useCase = new UpdateCurrentCompanyUseCase({
|
||||
inputMapper: new UpdateCurrentCompanyInputMapper(),
|
||||
updater: {
|
||||
update: async ({ patchProps }) => {
|
||||
receivedDefaultPaymentTermId = patchProps.defaultPaymentTermId;
|
||||
@ -115,6 +117,7 @@ test("update current company no permite cambiar el slug", async () => {
|
||||
let receivedPatch: Record<string, unknown> | null = null;
|
||||
|
||||
const useCase = new UpdateCurrentCompanyUseCase({
|
||||
inputMapper: new UpdateCurrentCompanyInputMapper(),
|
||||
updater: {
|
||||
update: async ({ patchProps }) => {
|
||||
receivedPatch = patchProps;
|
||||
@ -145,6 +148,7 @@ test("update current company preserves omitted address and maps null or a replac
|
||||
const company = buildCompany();
|
||||
const captured: Array<unknown> = [];
|
||||
const useCase = new UpdateCurrentCompanyUseCase({
|
||||
inputMapper: new UpdateCurrentCompanyInputMapper(),
|
||||
updater: {
|
||||
update: async ({ patchProps }) => {
|
||||
captured.push(patchProps.address);
|
||||
@ -186,3 +190,26 @@ test("update current company preserves omitted address and maps null or a replac
|
||||
"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 {
|
||||
EmailAddress,
|
||||
Name,
|
||||
PhoneNumber,
|
||||
TextValue,
|
||||
URLAddress,
|
||||
UniqueID,
|
||||
UtcDate,
|
||||
maybeFromNullableResult,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
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 type { IUpdateCurrentCompanyInputMapper } from "../mappers/update-current-company-input.mapper";
|
||||
import type { CompanyPublicModelMapper } from "../public";
|
||||
import type { ICompanyPaymentTermResolver, ICompanyUpdater } from "../services";
|
||||
|
||||
export class UpdateCurrentCompanyUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
updater: ICompanyUpdater;
|
||||
inputMapper: IUpdateCurrentCompanyInputMapper;
|
||||
paymentTermResolver: ICompanyPaymentTermResolver;
|
||||
responseMapper: CompanyPublicModelMapper;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
|
||||
public execute(params: {
|
||||
companyId: string;
|
||||
dto: UpdateCurrentCompanyRequestDTO;
|
||||
}): Promise<Result<CompanyResponseDTO, Error>> {
|
||||
const companyIdResult = UniqueID.create(params.companyId);
|
||||
if (companyIdResult.isFailure) {
|
||||
return Promise.resolve(Result.fail(companyIdResult.error));
|
||||
}
|
||||
|
||||
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) => {
|
||||
const companyId = UniqueID.create(params.companyId);
|
||||
if (companyId.isFailure) return Promise.resolve(Result.fail(companyId.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) => {
|
||||
try {
|
||||
if (defaultPaymentTermIdResult.data !== undefined) {
|
||||
const ensureResult = await this.deps.paymentTermResolver.ensureAssignable({
|
||||
companyId: companyIdResult.data,
|
||||
id: defaultPaymentTermIdResult.data,
|
||||
if (input.data.defaultPaymentTermId !== undefined) {
|
||||
const result = await this.deps.paymentTermResolver.ensureAssignable({
|
||||
companyId: companyId.data,
|
||||
id: input.data.defaultPaymentTermId,
|
||||
transaction,
|
||||
});
|
||||
if (ensureResult.isFailure) {
|
||||
return Result.fail(ensureResult.error);
|
||||
}
|
||||
if (result.isFailure) return Result.fail(result.error);
|
||||
}
|
||||
|
||||
const updateResult = await this.deps.updater.update({
|
||||
id: companyIdResult.data,
|
||||
patchProps: patchPropsResult.data,
|
||||
const result = await this.deps.updater.update({
|
||||
id: companyId.data,
|
||||
patchProps: input.data.patchProps,
|
||||
transaction,
|
||||
});
|
||||
if (updateResult.isFailure) {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(updateResult.data))
|
||||
);
|
||||
return result.isFailure
|
||||
? Result.fail(result.error)
|
||||
: Result.ok(toCompanyResponseDTO(this.deps.responseMapper.toPublicModel(result.data)));
|
||||
} catch (error: unknown) {
|
||||
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,
|
||||
Street,
|
||||
UniqueID,
|
||||
UtcDate,
|
||||
UtcDateTime,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
@ -41,8 +41,8 @@ const props = (value = Maybe.none<PostalAddress>()) => ({
|
||||
address: value,
|
||||
defaultPaymentTermId: Maybe.none(),
|
||||
status: CompanyStatus.createActive(),
|
||||
createdAt: unwrap(UtcDate.createFromISO("2026-01-01")),
|
||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")),
|
||||
createdAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
|
||||
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
|
||||
});
|
||||
const company = (value = Maybe.none<PostalAddress>()) =>
|
||||
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 target = company(Maybe.some(initial));
|
||||
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
|
||||
);
|
||||
const equivalent = address("Street A");
|
||||
@ -73,26 +74,28 @@ test("Company address update preserves, sets, replaces, clears and compares prim
|
||||
assert.equal(
|
||||
target.update({
|
||||
address: Maybe.some(equivalent),
|
||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")),
|
||||
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
|
||||
}).data,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
target.update({
|
||||
address: Maybe.some(address("Street B")),
|
||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")),
|
||||
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
|
||||
}).data,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
target.update({ address: Maybe.none(), updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")) })
|
||||
.data,
|
||||
target.update({
|
||||
address: Maybe.none(),
|
||||
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
|
||||
}).data,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
target.update({
|
||||
address: Maybe.some(address()),
|
||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-02")),
|
||||
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-09T10:38:25.654Z")),
|
||||
}).data,
|
||||
true
|
||||
);
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
type TextValue,
|
||||
type URLAddress,
|
||||
type UniqueID,
|
||||
type UtcDate,
|
||||
type UtcDateTime,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
@ -26,8 +26,8 @@ export interface ICompanyCreateProps {
|
||||
address: Maybe<PostalAddress>;
|
||||
defaultPaymentTermId: Maybe<UniqueID>;
|
||||
status: CompanyStatus;
|
||||
createdAt: UtcDate;
|
||||
updatedAt: UtcDate;
|
||||
createdAt: UtcDateTime;
|
||||
updatedAt: UtcDateTime;
|
||||
}
|
||||
|
||||
export type CompanyPatchProps = Partial<{
|
||||
@ -41,7 +41,7 @@ export type CompanyPatchProps = Partial<{
|
||||
address: Maybe<PostalAddress>;
|
||||
defaultPaymentTermId: Maybe<UniqueID>;
|
||||
}> & {
|
||||
updatedAt: UtcDate;
|
||||
updatedAt: UtcDateTime;
|
||||
};
|
||||
|
||||
export type CompanyInternalProps = ICompanyCreateProps;
|
||||
@ -124,11 +124,11 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
||||
return this.props.status;
|
||||
}
|
||||
|
||||
public get createdAt(): UtcDate {
|
||||
public get createdAt(): UtcDateTime {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
public get updatedAt(): UtcDate {
|
||||
public get updatedAt(): UtcDateTime {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
@ -204,7 +204,7 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public disable(updatedAt: UtcDate): Result<boolean, Error> {
|
||||
public disable(updatedAt: UtcDateTime): Result<boolean, Error> {
|
||||
if (this.status.isDisabled()) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
@ -214,7 +214,7 @@ export class Company extends AggregateRoot<CompanyInternalProps> {
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
public enable(updatedAt: UtcDate): Result<boolean, Error> {
|
||||
public enable(updatedAt: UtcDateTime): Result<boolean, Error> {
|
||||
if (this.status.isActive()) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api";
|
||||
import type { IIdentityCompanyAccessChecker } from "@erp/identity/module";
|
||||
import type { CatalogsPublicServicesType } from "@erp/catalogs/api";
|
||||
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
|
||||
import type { IIdentityPublicServices } from "@erp/identity/api";
|
||||
import type { Sequelize } from "sequelize";
|
||||
|
||||
import {
|
||||
@ -17,43 +18,49 @@ import {
|
||||
GetCompanyByIdUseCase,
|
||||
GetCurrentCompanyUseCase,
|
||||
type ICompanyPublicServices,
|
||||
InitialCompanyCreator,
|
||||
UpdateCompanyUseCase,
|
||||
UpdateCurrentCompanyUseCase,
|
||||
buildCompanyPublicServices,
|
||||
} 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 { resolveCompanyCatalogsDeps } from "./company-catalog-deps.di";
|
||||
|
||||
export type CompaniesInternalDeps = {
|
||||
repository: SequelizeCompanyRepository;
|
||||
companyAccess: IIdentityPublicServices["companyAccess"];
|
||||
publicFinder: CompanyPublicFinder;
|
||||
useCases: {
|
||||
createCompany: () => CreateCompanyUseCase;
|
||||
getCompanyById: () => GetCompanyByIdUseCase;
|
||||
getCurrentCompany: () => GetCurrentCompanyUseCase;
|
||||
findAvailableCompaniesForAccount: () => FindAvailableCompaniesForAccountUseCase;
|
||||
updateCompany: () => UpdateCompanyUseCase;
|
||||
updateCurrentCompany: () => UpdateCurrentCompanyUseCase;
|
||||
enableCompany: () => EnableCompanyUseCase;
|
||||
disableCompany: () => DisableCompanyUseCase;
|
||||
createCompany: CreateCompanyUseCase;
|
||||
getCompanyById: GetCompanyByIdUseCase;
|
||||
getCurrentCompany: GetCurrentCompanyUseCase;
|
||||
findAvailableCompaniesForAccount: FindAvailableCompaniesForAccountUseCase;
|
||||
updateCompany: UpdateCompanyUseCase;
|
||||
updateCurrentCompany: UpdateCurrentCompanyUseCase;
|
||||
enableCompany: EnableCompanyUseCase;
|
||||
disableCompany: DisableCompanyUseCase;
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInternalDeps => {
|
||||
const { audit, database, getService, transactionContext } = params;
|
||||
export type CompaniesDependencies = {
|
||||
internal: CompaniesInternalDeps;
|
||||
publicServices: ICompanyPublicServices;
|
||||
};
|
||||
|
||||
export const buildCompaniesDependencies = (params: ModuleParams): CompaniesDependencies => {
|
||||
const { audit, database, transactionContext } = params;
|
||||
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
|
||||
const responseMapper = new CompanyPublicModelMapper();
|
||||
const companyAccess = getService<{ companyAccess: IIdentityCompanyAccessChecker }>(
|
||||
"identity:general"
|
||||
).companyAccess;
|
||||
const catalogsDeps = resolveCompanyCatalogsDeps(params);
|
||||
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
|
||||
const paymentTermServices =
|
||||
params.getService<CatalogsPublicServicesType["paymentTerms"]>("catalogs:paymentTerms");
|
||||
|
||||
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
|
||||
const finder = new CompanyFinder(repository);
|
||||
const creator = new CompanyCreator(repository, audit);
|
||||
const paymentTermResolver = new CompanyPaymentTermResolver({
|
||||
paymentTermFinder: catalogsDeps.paymentTerm.finder,
|
||||
paymentTermFinder: paymentTermServices.finder,
|
||||
});
|
||||
const updater = new CompanyUpdater({ finder, repository, audit });
|
||||
const statusChanger = new CompanyStatusChanger(repository, audit);
|
||||
@ -62,70 +69,66 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
||||
mapper: responseMapper,
|
||||
});
|
||||
|
||||
const initialCompanyCreator = new InitialCompanyCreator(creator);
|
||||
const createCompanyInputMapper = new CreateCompanyInputMapper();
|
||||
const updateCompanyInputMapper = new UpdateCompanyInputMapper();
|
||||
const updateCurrentCompanyInputMapper = new UpdateCurrentCompanyInputMapper();
|
||||
|
||||
return {
|
||||
repository,
|
||||
publicFinder,
|
||||
useCases: {
|
||||
createCompany: () =>
|
||||
new CreateCompanyUseCase({
|
||||
internal: {
|
||||
companyAccess: identityServices.companyAccess,
|
||||
publicFinder,
|
||||
useCases: {
|
||||
createCompany: new CreateCompanyUseCase({
|
||||
creator,
|
||||
inputMapper: createCompanyInputMapper,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
getCompanyById: () =>
|
||||
new GetCompanyByIdUseCase({
|
||||
getCompanyById: new GetCompanyByIdUseCase({
|
||||
finder,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
getCurrentCompany: () =>
|
||||
new GetCurrentCompanyUseCase({
|
||||
getCurrentCompany: new GetCurrentCompanyUseCase({
|
||||
finder,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
findAvailableCompaniesForAccount: () =>
|
||||
new FindAvailableCompaniesForAccountUseCase({
|
||||
identityServices: { companyAccess },
|
||||
findAvailableCompaniesForAccount: new FindAvailableCompaniesForAccountUseCase({
|
||||
identityServices: { companyAccess: identityServices.companyAccess },
|
||||
repository,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
updateCompany: () =>
|
||||
new UpdateCompanyUseCase({
|
||||
updateCompany: new UpdateCompanyUseCase({
|
||||
updater,
|
||||
inputMapper: updateCompanyInputMapper,
|
||||
paymentTermResolver,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
updateCurrentCompany: () =>
|
||||
new UpdateCurrentCompanyUseCase({
|
||||
updateCurrentCompany: new UpdateCurrentCompanyUseCase({
|
||||
updater,
|
||||
inputMapper: updateCurrentCompanyInputMapper,
|
||||
paymentTermResolver,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
enableCompany: () =>
|
||||
new EnableCompanyUseCase({
|
||||
enableCompany: new EnableCompanyUseCase({
|
||||
finder,
|
||||
changer: statusChanger,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
}),
|
||||
disableCompany: () =>
|
||||
new DisableCompanyUseCase({
|
||||
disableCompany: new DisableCompanyUseCase({
|
||||
finder,
|
||||
changer: statusChanger,
|
||||
responseMapper,
|
||||
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 { IIdentityPublicServices } from "@erp/identity/api";
|
||||
import { requireCompanyContext, requireIdentityAuthenticated } from "@erp/identity/api";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { Router } from "express";
|
||||
@ -26,11 +25,10 @@ import {
|
||||
} from "./controllers";
|
||||
|
||||
export const companiesRouter = (params: StartParams) => {
|
||||
const { app, config, getInternal, getService, operationContext } = params;
|
||||
const { app, config, getInternal, operationContext } = params;
|
||||
const deps = getInternal<CompaniesInternalDeps>("companies");
|
||||
const identityServices = getService<IIdentityPublicServices>("identity:general");
|
||||
const requireCurrentCompanyContext = requireCompanyContext({
|
||||
companyAccessChecker: identityServices.companyAccess,
|
||||
companyAccessChecker: deps.companyAccess,
|
||||
companyFinder: deps.publicFinder,
|
||||
operationContext,
|
||||
});
|
||||
@ -43,14 +41,14 @@ export const companiesRouter = (params: StartParams) => {
|
||||
"/",
|
||||
validateRequest(CreateCompanyRequestSchema, "body"),
|
||||
(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);
|
||||
}
|
||||
);
|
||||
|
||||
router.get("/available", (req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new FindAvailableCompaniesController(
|
||||
deps.useCases.findAvailableCompaniesForAccount()
|
||||
deps.useCases.findAvailableCompaniesForAccount
|
||||
);
|
||||
return controller.execute(req, res, next);
|
||||
});
|
||||
@ -59,7 +57,7 @@ export const companiesRouter = (params: StartParams) => {
|
||||
"/current",
|
||||
requireCurrentCompanyContext,
|
||||
(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);
|
||||
}
|
||||
);
|
||||
@ -69,7 +67,7 @@ export const companiesRouter = (params: StartParams) => {
|
||||
requireCurrentCompanyContext,
|
||||
validateRequest(UpdateCurrentCompanyRequestSchema, "body"),
|
||||
(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);
|
||||
}
|
||||
);
|
||||
@ -79,7 +77,7 @@ export const companiesRouter = (params: StartParams) => {
|
||||
requireCurrentCompanyContext,
|
||||
validateRequest(GetCompanyByIdRequestSchema, "params"),
|
||||
(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);
|
||||
}
|
||||
);
|
||||
@ -90,7 +88,7 @@ export const companiesRouter = (params: StartParams) => {
|
||||
validateRequest(UpdateCompanyByIdParamsRequestSchema, "params"),
|
||||
validateRequest(UpdateCompanyRequestSchema, "body"),
|
||||
(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);
|
||||
}
|
||||
);
|
||||
@ -100,7 +98,7 @@ export const companiesRouter = (params: StartParams) => {
|
||||
requireCurrentCompanyContext,
|
||||
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
||||
(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);
|
||||
}
|
||||
);
|
||||
@ -110,7 +108,7 @@ export const companiesRouter = (params: StartParams) => {
|
||||
requireCurrentCompanyContext,
|
||||
validateRequest(ChangeCompanyStatusRequestSchema, "params"),
|
||||
(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);
|
||||
}
|
||||
);
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
Province,
|
||||
Street,
|
||||
UniqueID,
|
||||
UtcDate,
|
||||
UtcDateTime,
|
||||
} from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
@ -47,8 +47,8 @@ const company = (address = Maybe.none<PostalAddress>()) =>
|
||||
address,
|
||||
defaultPaymentTermId: Maybe.none(),
|
||||
status: CompanyStatus.createActive(),
|
||||
createdAt: unwrap(UtcDate.createFromISO("2026-01-01")),
|
||||
updatedAt: unwrap(UtcDate.createFromISO("2026-01-01")),
|
||||
createdAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
|
||||
updatedAt: unwrap(UtcDateTime.createFromISO("2026-09-08T09:37:24.321Z")),
|
||||
},
|
||||
UniqueID.generateNewID()
|
||||
)
|
||||
@ -65,8 +65,8 @@ const row = (patch: Record<string, string | null> = {}) =>
|
||||
website: null,
|
||||
default_payment_term_id: null,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
created_at: "2026-09-08T09:37:24.321Z",
|
||||
updated_at: "2026-09-08T09:37:24.321Z",
|
||||
address_street: null,
|
||||
address_street_2: null,
|
||||
address_city: null,
|
||||
|
||||
@ -36,18 +36,27 @@ function mapAddress(source: CompanyModel): Result<Maybe<PostalAddress>, Error> {
|
||||
source.address_province,
|
||||
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());
|
||||
if (values.some((value) => value === null))
|
||||
}
|
||||
|
||||
if (values.some((value) => value === null)) {
|
||||
return Result.fail(new Error("Company address persistence is partial."));
|
||||
}
|
||||
|
||||
const street = Street.create(source.address_street as string);
|
||||
const city = City.create(source.address_city as string);
|
||||
const postalCode = PostalCode.create(source.address_postal_code as string);
|
||||
const province = Province.create(source.address_province as string);
|
||||
const countryCode = CountryCode.create(source.address_country_code as string);
|
||||
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({
|
||||
street: Maybe.some(street.data),
|
||||
street2: street2.data,
|
||||
@ -56,6 +65,7 @@ function mapAddress(source: CompanyModel): Result<Maybe<PostalAddress>, Error> {
|
||||
province: Maybe.some(province.data),
|
||||
countryCode: Maybe.some(countryCode.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()
|
||||
),
|
||||
status: source.status.toPrimitive(),
|
||||
created_at: source.createdAt.toISOString(),
|
||||
updated_at: source.updatedAt.toISOString(),
|
||||
created_at: source.createdAt.toDate(),
|
||||
updated_at: source.updatedAt.toDate(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,8 +28,9 @@ export class CompanyModel extends Model<
|
||||
declare address_country_code: string | null;
|
||||
declare default_payment_term_id: string | null;
|
||||
declare status: string;
|
||||
declare created_at: string;
|
||||
declare updated_at: string;
|
||||
declare created_at: Date;
|
||||
declare updated_at: Date;
|
||||
declare deleted_at: Date;
|
||||
|
||||
static associate(_database: Sequelize) {}
|
||||
|
||||
@ -101,6 +102,10 @@ export default (database: Sequelize) => {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
deleted_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize: database,
|
||||
@ -109,7 +114,13 @@ export default (database: Sequelize) => {
|
||||
charset: "utf8mb4",
|
||||
collate: "utf8mb4_unicode_ci",
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
|
||||
timestamps: true,
|
||||
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
deletedAt: "deleted_at",
|
||||
|
||||
indexes: [
|
||||
{
|
||||
name: "uq_companies_slug",
|
||||
|
||||
@ -1,17 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
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";
|
||||
|
||||
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?.({
|
||||
database: {},
|
||||
transactionContext: {},
|
||||
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 },
|
||||
} 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.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 { ICompanyPublicServices } from "./application";
|
||||
import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di";
|
||||
import { buildCompaniesDependencies } from "./infrastructure/di";
|
||||
import { models } from "./infrastructure/persistence/sequelize";
|
||||
|
||||
export type { ICompanyPublicServices } from "./application";
|
||||
export * from "./infrastructure/persistence/sequelize";
|
||||
export * from "./application/companies/public/services/initial-company-creator";
|
||||
|
||||
export type CompaniesPublicServicesType = ICompanyPublicServices;
|
||||
|
||||
@ -15,8 +15,8 @@ export const companiesModule: IModuleServer = {
|
||||
version: "1.0.0",
|
||||
dependencies: ["identity", "catalogs"],
|
||||
async setup(params) {
|
||||
const internal = buildCompaniesDependencies(params);
|
||||
const general: ICompanyPublicServices = buildCompaniesPublicServices(params, internal);
|
||||
const { internal, publicServices } = buildCompaniesDependencies(params);
|
||||
const general: ICompanyPublicServices = publicServices;
|
||||
return { models, services: { general }, internal };
|
||||
},
|
||||
};
|
||||
|
||||
@ -1157,7 +1157,7 @@
|
||||
"form_fields": {
|
||||
"proformas": {
|
||||
"status": {
|
||||
"label": "Estado",
|
||||
"label": "Status",
|
||||
"placeholder": "",
|
||||
"description": ""
|
||||
},
|
||||
@ -1223,6 +1223,11 @@
|
||||
"clear_label": "No payment term",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"retention_percentage": {
|
||||
"label": "Retention",
|
||||
"placeholder": "Select a percentage",
|
||||
"description": "Retention percentage"
|
||||
},
|
||||
"item": {
|
||||
"quantity": {
|
||||
"label": "Quantity",
|
||||
@ -1405,4 +1410,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1215,6 +1215,11 @@
|
||||
"clear_label": "Sin plazo de pago",
|
||||
"inactive": "Inactivo"
|
||||
},
|
||||
"retention_percentage": {
|
||||
"label": "Retención",
|
||||
"placeholder": "Selecciona porcentaje",
|
||||
"description": "Porcentaje de retención"
|
||||
},
|
||||
"item": {
|
||||
"quantity": {
|
||||
"label": "Cantidad",
|
||||
@ -1397,4 +1402,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -140,7 +140,7 @@ export const LineEditor = <TLine,>({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] font-semibold text-muted-foreground">#</TableHead>
|
||||
<TableHead className="w-10 font-semibold text-muted-foreground">#</TableHead>
|
||||
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
@ -151,7 +151,7 @@ export const LineEditor = <TLine,>({
|
||||
</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
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
|
||||
@ -74,7 +74,7 @@ export const ProformaUpdateTaxEditor = ({
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<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={
|
||||
taxCtrl.usesSingleTax
|
||||
? t("form_fields.proformas.default_tax_percentage.description", "")
|
||||
@ -133,7 +133,7 @@ export const ProformaUpdateTaxEditor = ({
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<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))}
|
||||
disabled={disabled || !taxCtrl.hasRetentionPercentage}
|
||||
inputClassName="bg-background"
|
||||
@ -147,8 +147,8 @@ export const ProformaUpdateTaxEditor = ({
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"form_fields.proformas.default_tax_percentage.placeholder",
|
||||
"Selecciona IVA"
|
||||
"form_fields.proformas.retention_percentage.placeholder",
|
||||
"Selecciona porcentaje"
|
||||
)}
|
||||
readOnly={readOnly}
|
||||
serialize={(value) => (typeof value === "number" ? String(value) : "")}
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
import {
|
||||
DataTableColumnHeader,
|
||||
InitialsAvatar,
|
||||
RdxBadge,
|
||||
type RdxRowAction,
|
||||
RdxRowActions,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components";
|
||||
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 { useTranslation } from "../../../../i18n";
|
||||
import type { CustomerListRow } from "../../../../shared";
|
||||
import { type CustomerListRow, CustomerTypeBadge } from "../../../../shared";
|
||||
import { AddressCell, ContactCell } from "../../components";
|
||||
|
||||
type GridActionHandlers = {
|
||||
@ -98,19 +97,11 @@ export function useCustomersGridColumns(
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono">{customer.tin}</span>
|
||||
<RdxBadge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
|
||||
{customer.isCompany ? (
|
||||
<>
|
||||
<Building2Icon className="size-3" />
|
||||
{t("fields.customer_type.options.company")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserIcon className="size-3" />
|
||||
{t("fields.customer_type.options.individual")}
|
||||
</>
|
||||
)}
|
||||
</RdxBadge>
|
||||
<CustomerTypeBadge
|
||||
companyLabel={t("fields.customer_type.options.company")}
|
||||
individualLabel={t("fields.customer_type.options.individual")}
|
||||
isCompany={customer.isCompany}
|
||||
/>
|
||||
</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-type-badge";
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Company } from "../../../domain";
|
||||
|
||||
@ -3,9 +3,13 @@ import type { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||
import type { CompanyMembership } from "../../../domain";
|
||||
|
||||
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>>;
|
||||
/** Requiere la misma transacción externa para datos y asignaciones; revertirla ante Result.fail. */
|
||||
update(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
|
||||
/** currentRead carga membership y roles desde el estado actual de la transacción. */
|
||||
findByAccountIdAndCompanyId(params: {
|
||||
currentRead?: boolean;
|
||||
accountId: CompanyMembership["accountId"];
|
||||
companyId: CompanyMembership["companyId"];
|
||||
transaction?: unknown;
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./company-access-checker";
|
||||
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 "./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";
|
||||
|
||||
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>>;
|
||||
update(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;
|
||||
}): Promise<Result<Maybe<Role>, Error>>;
|
||||
findByIdsInCompany(params: {
|
||||
currentRead?: boolean;
|
||||
roleIds: UniqueID[];
|
||||
companyId: Role["companyId"];
|
||||
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 "./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";
|
||||
|
||||
export interface IIdentityPublicServices {
|
||||
auth: IIdentityAuthPublicServices;
|
||||
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";
|
||||
|
||||
export interface IIdentityNeutralPublicServices {
|
||||
companyAccess: IIdentityCompanyAccessChecker;
|
||||
initialCompanyAdminAssigner: IInitialCompanyAdminAssigner;
|
||||
}
|
||||
|
||||
export function buildIdentityNeutralPublicServices(
|
||||
deps: IdentityInternalDeps
|
||||
): 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 {
|
||||
CompanyAdminRoleEnsurer,
|
||||
EffectivePermissionsResolver,
|
||||
type IIdentityCompanyAccessChecker,
|
||||
IdentityCompanyAccessChecker,
|
||||
InitialCompanyAdminAssigner,
|
||||
} from "../../application";
|
||||
import type { IAccountRepository } from "../../application/accounts";
|
||||
import {
|
||||
@ -31,8 +34,10 @@ import {
|
||||
SequelizeAccountDomainMapper,
|
||||
SequelizeAccountRepository,
|
||||
SequelizeCompanyMembershipAccessRepository,
|
||||
SequelizeCompanyMembershipRepository,
|
||||
SequelizeRefreshTokenDomainMapper,
|
||||
SequelizeRefreshTokenRepository,
|
||||
SequelizeRoleRepository,
|
||||
} from "../persistence";
|
||||
import {
|
||||
BcryptPasswordHasher,
|
||||
@ -43,6 +48,7 @@ import {
|
||||
} from "../security";
|
||||
|
||||
export type IdentityInternalDeps = {
|
||||
initialCompanyAdminAssigner: InitialCompanyAdminAssigner;
|
||||
auth: {
|
||||
refreshTokenExpiration: string;
|
||||
authenticateUserDependencies: {
|
||||
@ -88,6 +94,16 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
|
||||
);
|
||||
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 accessTokenIssuer = new JsonWebTokenAccessTokenIssuer({
|
||||
secret: config.auth.jwt.secret,
|
||||
@ -111,6 +127,7 @@ export const buildIdentityDependencies = (params: ModuleParams): IdentityInterna
|
||||
});
|
||||
|
||||
return {
|
||||
initialCompanyAdminAssigner,
|
||||
auth: {
|
||||
refreshTokenExpiration: config.auth.jwt.refreshExpiration,
|
||||
authenticateUserDependencies: {
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import accountModelInit from "./models/sequelize-account.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 roleModelInit from "./models/sequelize-role.model";
|
||||
|
||||
export * from "./mappers";
|
||||
export * from "./models";
|
||||
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-company-membership-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-company-membership.model";
|
||||
export * from "./sequelize-company-membership-role.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-company-membership.repository";
|
||||
export * from "./sequelize-company-membership-access.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 { 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 () => {
|
||||
const accountId = UniqueID.generateNewID();
|
||||
@ -24,3 +26,31 @@ test("identity neutral composition propagates accessible company ids from member
|
||||
assert.equal(result.isSuccess, true);
|
||||
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 { 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 = {
|
||||
name: "identity",
|
||||
version: "1.0.0",
|
||||
dependencies: [],
|
||||
async setup(params) {
|
||||
const internal = buildIdentityDependencies(params);
|
||||
|
||||
return {
|
||||
models,
|
||||
services: { general: buildIdentityNeutralPublicServices(internal) },
|
||||
|
||||
@ -20,7 +20,7 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
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
|
||||
aria-hidden="true"
|
||||
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",
|
||||
"sequelize": "^6.37.8",
|
||||
"use-debounce": "^10.1.1",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.3.6",
|
||||
"@erp/companies": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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";
|
||||
|
||||
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. */
|
||||
findByAccountId(
|
||||
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());
|
||||
|
||||
class FakeRepository implements IInitialOnboardingRepository {
|
||||
async findByAccountIdForUpdate(): Promise<Result<Maybe<InitialOnboarding>, Error>> {
|
||||
return Result.fail(new Error("Not used"));
|
||||
}
|
||||
public stored: InitialOnboarding | undefined;
|
||||
public reads = 0;
|
||||
public writes = 0;
|
||||
@ -161,6 +164,7 @@ test("dos provision simultáneos con barrera devuelven un único ganador", async
|
||||
let writes = 0;
|
||||
let stored: InitialOnboarding | undefined;
|
||||
const repository: IInitialOnboardingRepository = {
|
||||
findByAccountIdForUpdate: async () => Result.fail(new Error("Not used")),
|
||||
async findByAccountId(id) {
|
||||
assert.ok(id.equals(accountId));
|
||||
reads++;
|
||||
|
||||
@ -22,6 +22,7 @@ export class InitialOnboardingProvisioner {
|
||||
|
||||
// UNIQUE es la garantía entre procesos. Solo recuperamos el ganador confirmado por cuenta.
|
||||
const winner = await this.repository.findByAccountId(accountId);
|
||||
|
||||
if (winner.isFailure) return Result.fail(winner.error);
|
||||
if (winner.data.isSome()) return Result.ok(winner.data.unwrap());
|
||||
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(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 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 { SequelizeInitialOnboardingDomainMapper } from "../mappers";
|
||||
import { InitialOnboardingModel } from "../models";
|
||||
@ -13,6 +13,24 @@ export class SequelizeInitialOnboardingRepository implements IInitialOnboardingR
|
||||
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(
|
||||
accountId: UniqueID,
|
||||
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