diff --git a/AGENTS.md b/AGENTS.md index 7c77001f..2be6a5d3 100644 --- a/AGENTS.md +++ b/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. \ No newline at end of file +Si una instrucción contradice la arquitectura o genera un riesgo relevante, señalarlo antes de introducir el cambio. diff --git a/apps/server/package.json b/apps/server/package.json index 93ed32dd..e932574c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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:*", diff --git a/apps/server/src/register-modules.ts b/apps/server/src/register-modules.ts index 00bb264e..573d7c8e 100644 --- a/apps/server/src/register-modules.ts +++ b/apps/server/src/register-modules.ts @@ -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); }; diff --git a/modules/companies/src/api/application/companies/mappers/create-company-input.mapper.test.ts b/modules/companies/src/api/application/companies/mappers/create-company-input.mapper.test.ts new file mode 100644 index 00000000..68cbb741 --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/create-company-input.mapper.test.ts @@ -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); +}); diff --git a/modules/companies/src/api/application/companies/mappers/create-company-input.mapper.ts b/modules/companies/src/api/application/companies/mappers/create-company-input.mapper.ts new file mode 100644 index 00000000..04b54ca6 --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/create-company-input.mapper.ts @@ -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; +} + +export type CreateCompanyInput = { id: UniqueID; props: ICompanyCreateProps }; + +export class CreateCompanyInputMapper implements ICreateCompanyInputMapper { + public map(dto: CreateCompanyRequestDTO): Result { + 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, 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)); + } +} diff --git a/modules/companies/src/api/application/companies/mappers/update-company-input.mapper.test.ts b/modules/companies/src/api/application/companies/mappers/update-company-input.mapper.test.ts new file mode 100644 index 00000000..b9ac4634 --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/update-company-input.mapper.test.ts @@ -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); +}); diff --git a/modules/companies/src/api/application/companies/mappers/update-company-input.mapper.ts b/modules/companies/src/api/application/companies/mappers/update-company-input.mapper.ts new file mode 100644 index 00000000..386f104f --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/update-company-input.mapper.ts @@ -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 | undefined; +}; + +export interface IUpdateCompanyInputMapper { + map(dto: UpdateCompanyRequestDTO): Result; +} + +export class UpdateCompanyInputMapper implements IUpdateCompanyInputMapper { + public map(dto: UpdateCompanyRequestDTO): Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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)); + } +} diff --git a/modules/companies/src/api/application/companies/mappers/update-current-company-input.mapper.test.ts b/modules/companies/src/api/application/companies/mappers/update-current-company-input.mapper.test.ts new file mode 100644 index 00000000..c631e30b --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/update-current-company-input.mapper.test.ts @@ -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); +}); diff --git a/modules/companies/src/api/application/companies/mappers/update-current-company-input.mapper.ts b/modules/companies/src/api/application/companies/mappers/update-current-company-input.mapper.ts new file mode 100644 index 00000000..eca9add7 --- /dev/null +++ b/modules/companies/src/api/application/companies/mappers/update-current-company-input.mapper.ts @@ -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 | undefined; +}; +export interface IUpdateCurrentCompanyInputMapper { + map(dto: UpdateCurrentCompanyRequestDTO): Result; +} +export class UpdateCurrentCompanyInputMapper implements IUpdateCurrentCompanyInputMapper { + public map(dto: UpdateCurrentCompanyRequestDTO): Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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)); + } +} diff --git a/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.test.ts b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.test.ts index f5e4bdf5..cd797954 100644 --- a/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.test.ts +++ b/modules/companies/src/api/application/companies/public/mappers/company-public-model.mapper.test.ts @@ -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 = (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()) => 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()) => 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", () => { diff --git a/modules/companies/src/api/application/companies/public/services/company-public-services.ts b/modules/companies/src/api/application/companies/public/services/company-public-services.ts index 1e2fd70f..3705df1c 100644 --- a/modules/companies/src/api/application/companies/public/services/company-public-services.ts +++ b/modules/companies/src/api/application/companies/public/services/company-public-services.ts @@ -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, }); diff --git a/modules/companies/src/api/application/companies/public/services/initial-company-creator.test.ts b/modules/companies/src/api/application/companies/public/services/initial-company-creator.test.ts index 10a6c98a..c76e87e4 100644 --- a/modules/companies/src/api/application/companies/public/services/initial-company-creator.test.ts +++ b/modules/companies/src/api/application/companies/public/services/initial-company-creator.test.ts @@ -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); diff --git a/modules/companies/src/api/application/companies/public/services/initial-company-creator.ts b/modules/companies/src/api/application/companies/public/services/initial-company-creator.ts index f31a4f85..c4929aed 100644 --- a/modules/companies/src/api/application/companies/public/services/initial-company-creator.ts +++ b/modules/companies/src/api/application/companies/public/services/initial-company-creator.ts @@ -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, diff --git a/modules/companies/src/api/application/companies/services/company-audit.services.test.ts b/modules/companies/src/api/application/companies/services/company-audit.services.test.ts index 6572289c..b26ffedb 100644 --- a/modules/companies/src/api/application/companies/services/company-audit.services.test.ts +++ b/modules/companies/src/api/application/companies/services/company-audit.services.test.ts @@ -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); diff --git a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts index 1139fd90..1c2b6bf8 100644 --- a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts +++ b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.test.ts @@ -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); +}); diff --git a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts index 3f5eb12b..94ac992e 100644 --- a/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/create-company.use-case.ts @@ -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> { - 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 = 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, - }); -} diff --git a/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts b/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts index 6b59b568..dc1235fa 100644 --- a/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/find-available-companies-for-account.use-case.ts @@ -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; repository: ICompanyRepository; responseMapper: CompanyPublicModelMapper; transactionManager: ITransactionManager; } ) {} - public execute(params: { accountId: string }): Promise> { + public execute(params: { + accountId: string; + }): Promise> { const accountIdResult = UniqueID.create(params.accountId); if (accountIdResult.isFailure) { return Promise.resolve(Result.fail(accountIdResult.error)); diff --git a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts index 829f4739..6baa2dca 100644 --- a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts +++ b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.test.ts @@ -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 | 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 = []; 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); +}); diff --git a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts index 98d3c618..ce7f7b86 100644 --- a/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/update-company.use-case.ts @@ -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> { - 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 { - 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 | 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)); -} diff --git a/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.test.ts b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.test.ts index fba9f71c..0966e425 100644 --- a/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.test.ts +++ b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.test.ts @@ -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 | 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 | 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 = []; 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); +}); diff --git a/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts index cc271a28..eacc38e2 100644 --- a/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts +++ b/modules/companies/src/api/application/companies/use-cases/update-current-company.use-case.ts @@ -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> { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 | 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)); -} diff --git a/modules/companies/src/api/domain/companies/company.aggregate.test.ts b/modules/companies/src/api/domain/companies/company.aggregate.test.ts index 3fca5a23..29bc9847 100644 --- a/modules/companies/src/api/domain/companies/company.aggregate.test.ts +++ b/modules/companies/src/api/domain/companies/company.aggregate.test.ts @@ -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()) => ({ 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()) => 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 ); diff --git a/modules/companies/src/api/domain/companies/company.aggregate.ts b/modules/companies/src/api/domain/companies/company.aggregate.ts index f787c1c5..905c5f57 100644 --- a/modules/companies/src/api/domain/companies/company.aggregate.ts +++ b/modules/companies/src/api/domain/companies/company.aggregate.ts @@ -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; defaultPaymentTermId: Maybe; status: CompanyStatus; - createdAt: UtcDate; - updatedAt: UtcDate; + createdAt: UtcDateTime; + updatedAt: UtcDateTime; } export type CompanyPatchProps = Partial<{ @@ -41,7 +41,7 @@ export type CompanyPatchProps = Partial<{ address: Maybe; defaultPaymentTermId: Maybe; }> & { - updatedAt: UtcDate; + updatedAt: UtcDateTime; }; export type CompanyInternalProps = ICompanyCreateProps; @@ -124,11 +124,11 @@ export class Company extends AggregateRoot { 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 { return Result.ok(true); } - public disable(updatedAt: UtcDate): Result { + public disable(updatedAt: UtcDateTime): Result { if (this.status.isDisabled()) { return Result.ok(false); } @@ -214,7 +214,7 @@ export class Company extends AggregateRoot { return Result.ok(true); } - public enable(updatedAt: UtcDate): Result { + public enable(updatedAt: UtcDateTime): Result { if (this.status.isActive()) { return Result.ok(false); } diff --git a/modules/companies/src/api/infrastructure/di/companies.di.ts b/modules/companies/src/api/infrastructure/di/companies.di.ts index 1f9fc4f4..e4609995 100644 --- a/modules/companies/src/api/infrastructure/di/companies.di.ts +++ b/modules/companies/src/api/infrastructure/di/companies.di.ts @@ -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("identity:general"); + const paymentTermServices = + params.getService("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); -}; diff --git a/modules/companies/src/api/infrastructure/di/company-catalog-deps.di.ts b/modules/companies/src/api/infrastructure/di/company-catalog-deps.di.ts deleted file mode 100644 index 5973c443..00000000 --- a/modules/companies/src/api/infrastructure/di/company-catalog-deps.di.ts +++ /dev/null @@ -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("catalogs:paymentTerms"); - - if (!paymentTerm?.finder) { - throw new Error("Missing public service: catalogs:paymentTerms.finder"); - } - - return { - paymentTerm: { - finder: paymentTerm.finder, - }, - }; -} diff --git a/modules/companies/src/api/infrastructure/express/companies.routes.ts b/modules/companies/src/api/infrastructure/express/companies.routes.ts index 3e128f77..c2dc52d1 100644 --- a/modules/companies/src/api/infrastructure/express/companies.routes.ts +++ b/modules/companies/src/api/infrastructure/express/companies.routes.ts @@ -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("companies"); - const identityServices = getService("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); } ); diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.test.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.test.ts index 3893ae27..9780514b 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.test.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.test.ts @@ -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()) => 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 = {}) => 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, diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts index 3ded0a87..d2dd5204 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-domain.mapper.ts @@ -36,18 +36,27 @@ function mapAddress(source: CompanyModel): Result, 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, 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(), }); } } diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts index cbd58f66..500de1f4 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts @@ -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", diff --git a/modules/companies/src/api/module.test.ts b/modules/companies/src/api/module.test.ts index 22503c18..540cdc81 100644 --- a/modules/companies/src/api/module.test.ts +++ b/modules/companies/src/api/module.test.ts @@ -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); }); diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index cd394c7f..4f1490f8 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -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 }; }, }; diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index 07f045bb..2895a7c9 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -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 @@ } } } -} +} \ No newline at end of file diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index 8f61fc9c..a6f8f187 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -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 @@ } } } -} +} \ No newline at end of file diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/line-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/blocks/line-editor.tsx index 2274c0eb..642e63bd 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/line-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/line-editor.tsx @@ -140,7 +140,7 @@ export const LineEditor = ({ - # + # {columns.map((column) => ( ({ ))} - + Acciones diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-tax-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-tax-editor.tsx index dca51574..3845cf60 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-tax-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-tax-editor.tsx @@ -74,7 +74,7 @@ export const ProformaUpdateTaxEditor = ({ readOnly={readOnly} /> (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) : "")} diff --git a/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx b/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx index 23a8f715..4faf8437 100644 --- a/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx +++ b/modules/customers/src/web/list/ui/blocks/customers-grid/use-customer-grid-columns.tsx @@ -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(
{customer.tin} - - {customer.isCompany ? ( - <> - - {t("fields.customer_type.options.company")} - - ) : ( - <> - - {t("fields.customer_type.options.individual")} - - )} - +
diff --git a/modules/customers/src/web/shared/ui/components/customer-type-badge.tsx b/modules/customers/src/web/shared/ui/components/customer-type-badge.tsx new file mode 100644 index 00000000..5b641144 --- /dev/null +++ b/modules/customers/src/web/shared/ui/components/customer-type-badge.tsx @@ -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 ( + + {isCompany ? ( + <> + + {companyLabel} + + ) : ( + <> + + {individualLabel} + + )} + + ); +} diff --git a/modules/customers/src/web/shared/ui/components/index.ts b/modules/customers/src/web/shared/ui/components/index.ts index 612268d5..251db62a 100644 --- a/modules/customers/src/web/shared/ui/components/index.ts +++ b/modules/customers/src/web/shared/ui/components/index.ts @@ -1 +1,2 @@ export * from "./customer-status-badge"; +export * from "./customer-type-badge"; diff --git a/modules/identity/src/api/application/companies/contracts/company-repository.interface.ts b/modules/identity/src/api/application/companies/contracts/company-repository.interface.ts index 22f8be1f..3bd2d16e 100644 --- a/modules/identity/src/api/application/companies/contracts/company-repository.interface.ts +++ b/modules/identity/src/api/application/companies/contracts/company-repository.interface.ts @@ -1,3 +1,4 @@ +import type { UniqueID } from "@repo/rdx-ddd"; import type { Maybe, Result } from "@repo/rdx-utils"; import type { Company } from "../../../domain"; diff --git a/modules/identity/src/api/application/company-memberships/contracts/company-membership-repository.interface.ts b/modules/identity/src/api/application/company-memberships/contracts/company-membership-repository.interface.ts index 29efb403..eedb62ec 100644 --- a/modules/identity/src/api/application/company-memberships/contracts/company-membership-repository.interface.ts +++ b/modules/identity/src/api/application/company-memberships/contracts/company-membership-repository.interface.ts @@ -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>; + /** Requiere la misma transacción externa para datos y asignaciones; revertirla ante Result.fail. */ update(membership: CompanyMembership, transaction?: unknown): Promise>; + /** 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; diff --git a/modules/identity/src/api/application/company-memberships/services/index.ts b/modules/identity/src/api/application/company-memberships/services/index.ts index 3463f6e8..0d6687dd 100644 --- a/modules/identity/src/api/application/company-memberships/services/index.ts +++ b/modules/identity/src/api/application/company-memberships/services/index.ts @@ -1,2 +1,3 @@ export * from "./company-access-checker"; export * from "./create-identity-company-access"; +export * from "./initial-company-admin-assigner"; diff --git a/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.test.ts b/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.test.ts new file mode 100644 index 00000000..f1b7f08e --- /dev/null +++ b/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.test.ts @@ -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((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()); + } + if (failure === "find") return Result.fail(error); + return Result.ok(state ? Maybe.some(state) : Maybe.none()); + }, + 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([])), + findByCompanyId: async () => Result.ok(new Collection([])), + }; + const roles: IRoleRepository = { + findByCodeInCompany: async (p) => { + calls.push(p.transaction); + return Result.ok(admin ? Maybe.some(admin) : Maybe.none()); + }, + 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()), + findByCompanyId: async () => Result.ok(new Collection([])), + 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([])); + 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); +}); diff --git a/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.ts b/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.ts new file mode 100644 index 00000000..f157ba09 --- /dev/null +++ b/modules/identity/src/api/application/company-memberships/services/initial-company-admin-assigner.ts @@ -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>; +} +/** 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> { + 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> { + 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> { + 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(); + } +} diff --git a/modules/identity/src/api/application/permissions/index.ts b/modules/identity/src/api/application/permissions/index.ts index 14371ab4..b5e6feee 100644 --- a/modules/identity/src/api/application/permissions/index.ts +++ b/modules/identity/src/api/application/permissions/index.ts @@ -1 +1,2 @@ export * from "./contracts"; +export * from "./services/effective-permissions-resolver"; diff --git a/modules/identity/src/api/application/permissions/services/effective-permissions-resolver.test.ts b/modules/identity/src/api/application/permissions/services/effective-permissions-resolver.test.ts new file mode 100644 index 00000000..6027b535 --- /dev/null +++ b/modules/identity/src/api/application/permissions/services/effective-permissions-resolver.test.ts @@ -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[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() : Maybe.some(member)); + }, + findByAccountId: async () => Result.ok(new Collection([])), + findByCompanyId: async () => Result.ok(new Collection([])), + }; + const roles: IRoleRepository = { + findByCodeInCompany: async () => Result.ok(Maybe.none()), + save: async () => Result.ok(), + update: async () => Result.ok(), + delete: async () => Result.ok(), + findByIdInCompany: async () => Result.ok(Maybe.none()), + findByCompanyId: async () => Result.ok(new Collection([])), + 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); +}); diff --git a/modules/identity/src/api/application/permissions/services/effective-permissions-resolver.ts b/modules/identity/src/api/application/permissions/services/effective-permissions-resolver.ts new file mode 100644 index 00000000..0f805a78 --- /dev/null +++ b/modules/identity/src/api/application/permissions/services/effective-permissions-resolver.ts @@ -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, Error>>; +} + +export class EffectivePermissionsResolver implements IEffectivePermissionsResolver { + public constructor( + private readonly deps: { + memberships: ICompanyMembershipRepository; + roles: IRoleRepository; + } + ) {} + + public async resolve( + params: ResolveEffectivePermissionsParams + ): Promise, 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(); + for (const role of roles) { + for (const permission of role.permissionCodes) permissions.add(permission); + } + return Result.ok(new Collection([...permissions].sort())); + } +} diff --git a/modules/identity/src/api/application/roles/contracts/role-repository.interface.ts b/modules/identity/src/api/application/roles/contracts/role-repository.interface.ts index b3c5ceb6..9c6e7d78 100644 --- a/modules/identity/src/api/application/roles/contracts/role-repository.interface.ts +++ b/modules/identity/src/api/application/roles/contracts/role-repository.interface.ts @@ -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, Error>>; save(role: Role, transaction?: unknown): Promise>; update(role: Role, transaction?: unknown): Promise>; delete(role: Role, transaction?: unknown): Promise>; @@ -13,6 +20,7 @@ export interface IRoleRepository { transaction?: unknown; }): Promise, Error>>; findByIdsInCompany(params: { + currentRead?: boolean; roleIds: UniqueID[]; companyId: Role["companyId"]; transaction?: unknown; diff --git a/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.test.ts b/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.test.ts new file mode 100644 index 00000000..1c77b6dd --- /dev/null +++ b/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.test.ts @@ -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, Error>[] = [Result.ok(Maybe.none())], + saveResult: Result = Result.ok() +) { + const lookups: Parameters[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([])), + findByCompanyId: async () => Result.ok(new Collection([])), + }; + 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); + }); +} diff --git a/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.ts b/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.ts new file mode 100644 index 00000000..364179ba --- /dev/null +++ b/modules/identity/src/api/application/roles/services/company-admin-role-ensurer.ts @@ -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>; +} +/** 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> { + 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 { + const expected = new Set(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); + } +} diff --git a/modules/identity/src/api/application/roles/services/index.ts b/modules/identity/src/api/application/roles/services/index.ts index 0024cdad..9169cae0 100644 --- a/modules/identity/src/api/application/roles/services/index.ts +++ b/modules/identity/src/api/application/roles/services/index.ts @@ -1,2 +1,3 @@ +export * from "./company-admin-role-ensurer"; export * from "./role-permission-validator"; export * from "./unknown-permission-code.error"; diff --git a/modules/identity/src/api/application/services/identity-public-services.interface.ts b/modules/identity/src/api/application/services/identity-public-services.interface.ts index 40c37fa5..ad2c83dd 100644 --- a/modules/identity/src/api/application/services/identity-public-services.interface.ts +++ b/modules/identity/src/api/application/services/identity-public-services.interface.ts @@ -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; } diff --git a/modules/identity/src/api/infrastructure/di/identity-public-services.ts b/modules/identity/src/api/infrastructure/di/identity-public-services.ts index cff3c72c..586aa42f 100644 --- a/modules/identity/src/api/infrastructure/di/identity-public-services.ts +++ b/modules/identity/src/api/infrastructure/di/identity-public-services.ts @@ -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, + }; } diff --git a/modules/identity/src/api/infrastructure/di/identity.di.ts b/modules/identity/src/api/infrastructure/di/identity.di.ts index d493281d..5723cf46 100644 --- a/modules/identity/src/api/infrastructure/di/identity.di.ts +++ b/modules/identity/src/api/infrastructure/di/identity.di.ts @@ -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: { diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts index 51b38afd..bd766899 100644 --- a/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/index.ts @@ -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, +]; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/index.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/index.ts index 3076edec..5dfd7ba0 100644 --- a/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/index.ts +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/index.ts @@ -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"; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-membership-domain.mapper.test.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-membership-domain.mapper.test.ts new file mode 100644 index 00000000..3c7608c3 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-membership-domain.mapper.test.ts @@ -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)); +}); diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-membership-domain.mapper.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-membership-domain.mapper.ts new file mode 100644 index 00000000..8af0385c --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-company-membership-domain.mapper.ts @@ -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 { + 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 { + 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()), + }); + } +} diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-role-domain.mapper.test.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-role-domain.mapper.test.ts new file mode 100644 index 00000000..c3971080 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-role-domain.mapper.test.ts @@ -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); + } +}); diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-role-domain.mapper.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-role-domain.mapper.ts new file mode 100644 index 00000000..c003465c --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/mappers/sequelize-role-domain.mapper.ts @@ -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 { + return UtcDate.createFromISO( + value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10) + ); +} + +export class SequelizeRoleDomainMapper { + public mapToDomain(source: RoleModel): Result { + 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 { + 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(), + }); + } +} diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/models/index.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/models/index.ts index 6c5a361a..1d5599d5 100644 --- a/modules/identity/src/api/infrastructure/persistence/sequelize/models/index.ts +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/models/index.ts @@ -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"; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-company-membership-role.model.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-company-membership-role.model.ts new file mode 100644 index 00000000..7be1c84e --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-company-membership-role.model.ts @@ -0,0 +1,43 @@ +import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import { DataTypes, Model } from "sequelize"; + +export class CompanyMembershipRoleModel extends Model< + InferAttributes, + InferCreationAttributes +> { + 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; +}; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-role.model.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-role.model.ts new file mode 100644 index 00000000..15f84df9 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/models/sequelize-role.model.ts @@ -0,0 +1,49 @@ +import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import { DataTypes, Model } from "sequelize"; + +export type RoleCreationAttributes = InferCreationAttributes; +export class RoleModel extends Model< + InferAttributes, + InferCreationAttributes +> { + 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; +}; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/index.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/index.ts index c79a169f..7a0954f7 100644 --- a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/index.ts +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/index.ts @@ -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"; diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership.repository.test.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership.repository.test.ts new file mode 100644 index 00000000..50069553 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership.repository.test.ts @@ -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] + ); +}); diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership.repository.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership.repository.ts new file mode 100644 index 00000000..3bbf7016 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-company-membership.repository.ts @@ -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> { + return this.persist(membership, transaction, false); + } + + /** Requiere transacción externa; no crea una membership ausente. */ + public update( + membership: CompanyMembership, + transaction?: unknown + ): Promise> { + return this.persist(membership, transaction, true); + } + + private async persist( + source: CompanyMembership, + transaction: unknown, + updateOnly: boolean + ): Promise> { + 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[0] + ): Promise, 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[0] + ): Promise, Error>> { + return this.findMany({ company_id: params.companyId.toPrimitive() }, params.transaction); + } + + public findByAccountId( + params: Parameters[0] + ): Promise, Error>> { + return this.findMany({ account_id: params.accountId.toPrimitive() }, params.transaction); + } + + private async findMany( + where: WhereOptions, + transaction?: unknown + ): Promise, 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> { + 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) + ); + } +} diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-role.repository.test.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-role.repository.test.ts new file mode 100644 index 00000000..1a5c6339 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-role.repository.test.ts @@ -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); +}); diff --git a/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-role.repository.ts b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-role.repository.ts new file mode 100644 index 00000000..dbeaafe7 --- /dev/null +++ b/modules/identity/src/api/infrastructure/persistence/sequelize/repositories/sequelize-role.repository.ts @@ -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[0] + ): Promise, 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> { + 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> { + 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> { + 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[0] + ): Promise, 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[0] + ): Promise, Error>> { + if (params.roleIds.length === 0) return Promise.resolve(Result.ok(new Collection([]))); + 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[0] + ): Promise, Error>> { + return this.findMany({ company_id: params.companyId.toPrimitive() }, params.transaction); + } + + private async findMany( + where: WhereOptions, + transaction?: unknown, + currentRead = false + ): Promise, 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)); + } + } +} diff --git a/modules/identity/src/api/module.test.ts b/modules/identity/src/api/module.test.ts index 3102bf18..a34f6552 100644 --- a/modules/identity/src/api/module.test.ts +++ b/modules/identity/src/api/module.test.ts @@ -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); +}); diff --git a/modules/identity/src/api/module.ts b/modules/identity/src/api/module.ts index 073bbf1b..3354c8b2 100644 --- a/modules/identity/src/api/module.ts +++ b/modules/identity/src/api/module.ts @@ -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) }, diff --git a/modules/identity/src/web/login/ui/login-page.tsx b/modules/identity/src/web/login/ui/login-page.tsx index 5f4e1ce1..18d1616c 100644 --- a/modules/identity/src/web/login/ui/login-page.tsx +++ b/modules/identity/src/web/login/ui/login-page.tsx @@ -20,7 +20,7 @@ export function LoginPage() { } return ( -
+