Compare commits
No commits in common. "0cbb6fc175bee63f1e21916e85103451cb0fb647" and "2f23d75b6fed9e16325f44025a10f2d4c90c9a5e" have entirely different histories.
0cbb6fc175
...
2f23d75b6f
@ -1,8 +1,8 @@
|
|||||||
import type { ISequelizeTransactionContext } from "@erp/core/api";
|
import type { ISequelizeTransactionContext } from "@erp/core/api";
|
||||||
import type { AuditEntry, IAuditStore } from "@repo/rdx-audit";
|
import type { AuditEntry, AuditRecordOptions, IAuditStore } from "@repo/rdx-audit";
|
||||||
import { type AuditRecordOptions, AuditStoreError } from "@repo/rdx-audit";
|
import { AuditStoreError } from "@repo/rdx-audit";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import type { ModelStatic, Transaction } from "sequelize";
|
import type { ModelStatic } from "sequelize";
|
||||||
|
|
||||||
import type { AuditChangeModel } from "./audit-change.model";
|
import type { AuditChangeModel } from "./audit-change.model";
|
||||||
import type { AuditEventModel } from "./audit-event.model";
|
import type { AuditEventModel } from "./audit-event.model";
|
||||||
@ -19,12 +19,8 @@ export class SequelizeAuditStore implements IAuditStore {
|
|||||||
private readonly transactionContext: ISequelizeTransactionContext
|
private readonly transactionContext: ISequelizeTransactionContext
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async append(
|
async append(entry: AuditEntry, options?: AuditRecordOptions): Promise<Result<void, AuditStoreError>> {
|
||||||
entry: AuditEntry,
|
const transaction = options?.transaction ?? this.transactionContext.current();
|
||||||
options?: AuditRecordOptions
|
|
||||||
): Promise<Result<void, AuditStoreError>> {
|
|
||||||
const transaction = (options?.transaction as Transaction) ?? this.transactionContext.current();
|
|
||||||
|
|
||||||
if (entry.kind === "mutation" && !transaction) {
|
if (entry.kind === "mutation" && !transaction) {
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
new AuditStoreError("An active transaction is required for audit mutations.")
|
new AuditStoreError("An active transaction is required for audit mutations.")
|
||||||
|
|||||||
@ -1,3 +1,2 @@
|
|||||||
export * from "./company-public-finder";
|
export * from "./company-public-finder";
|
||||||
export * from "./company-public-services";
|
export * from "./company-public-services";
|
||||||
export * from "./initial-company-creator";
|
|
||||||
|
|||||||
@ -1,145 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { DuplicateEntityError } from "@erp/core/api";
|
|
||||||
import type { IAudit } from "@repo/rdx-audit";
|
|
||||||
import {
|
|
||||||
City,
|
|
||||||
CountryCode,
|
|
||||||
Name,
|
|
||||||
PostalAddress,
|
|
||||||
PostalCode,
|
|
||||||
Province,
|
|
||||||
Street,
|
|
||||||
TextValue,
|
|
||||||
UniqueID,
|
|
||||||
UtcDate,
|
|
||||||
} from "@repo/rdx-ddd";
|
|
||||||
import { Collection, Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
|
|
||||||
import type { Company } from "../../../../domain";
|
|
||||||
import { CompanySlug } from "../../../../domain";
|
|
||||||
import type { ICompanyRepository } from "../../contracts";
|
|
||||||
import { CompanyCreator } from "../../services/company-creator";
|
|
||||||
|
|
||||||
import { InitialCompanyCreator } from "./initial-company-creator";
|
|
||||||
|
|
||||||
const address = () =>
|
|
||||||
PostalAddress.create({
|
|
||||||
street: Maybe.some(Street.create("Calle Mayor 1").data),
|
|
||||||
street2: Maybe.none(),
|
|
||||||
city: Maybe.some(City.create("Madrid").data),
|
|
||||||
postalCode: Maybe.some(PostalCode.create("28001").data),
|
|
||||||
province: Maybe.some(Province.create("Madrid").data),
|
|
||||||
countryCode: Maybe.some(CountryCode.create("ES").data),
|
|
||||||
}).data;
|
|
||||||
const input = () => ({
|
|
||||||
legalName: Name.create("Rodax").data,
|
|
||||||
tin: TextValue.create("B12345678").data,
|
|
||||||
address: address(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function setup(error?: Error, duplicate = false) {
|
|
||||||
const saved: Company[] = [];
|
|
||||||
const transactions: unknown[] = [];
|
|
||||||
const repository: ICompanyRepository = {
|
|
||||||
save: async (company, transaction) => {
|
|
||||||
transactions.push(transaction);
|
|
||||||
if (error) return Result.fail(error);
|
|
||||||
saved.push(company);
|
|
||||||
return Result.ok();
|
|
||||||
},
|
|
||||||
update: async () => Result.ok(),
|
|
||||||
findById: async () => Result.ok(Maybe.none()),
|
|
||||||
findBySlug: async (_slug, transaction) => {
|
|
||||||
transactions.push(transaction);
|
|
||||||
return Result.ok(duplicate ? Maybe.some(saved[0]) : Maybe.none());
|
|
||||||
},
|
|
||||||
existsActiveById: async () => Result.ok(false),
|
|
||||||
findActiveByIds: async () => Result.ok(new Collection([])),
|
|
||||||
};
|
|
||||||
const audit: IAudit = {
|
|
||||||
record: async () => Result.ok(),
|
|
||||||
recordEvent: async () => Result.ok(),
|
|
||||||
recordMutation: async () => Result.ok(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
capability: new InitialCompanyCreator(new CompanyCreator(repository, audit)),
|
|
||||||
saved,
|
|
||||||
transactions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { capability, saved } = setup();
|
|
||||||
const params = input();
|
|
||||||
const result = await capability.create(params);
|
|
||||||
assert.equal(result.isSuccess, true);
|
|
||||||
const company = saved[0];
|
|
||||||
assert.equal(UniqueID.create(result.data.companyId).isSuccess, true);
|
|
||||||
assert.equal(result.data.companyId, company.id.toPrimitive());
|
|
||||||
assert.equal(result.data.slug, `company-${company.id.toPrimitive()}`);
|
|
||||||
assert.equal(CompanySlug.create(result.data.slug).isSuccess, true);
|
|
||||||
assert.equal(company.status.isActive(), true);
|
|
||||||
assert.equal(company.createdAt, now);
|
|
||||||
assert.equal(company.updatedAt, now);
|
|
||||||
assert.equal(company.address.unwrap(), params.address);
|
|
||||||
assert.equal(company.tin.unwrap(), params.tin);
|
|
||||||
assert.deepEqual(Object.keys(result.data).sort(), ["companyId", "slug"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nombres iguales generan IDs y slugs distintos", async () => {
|
|
||||||
const { capability } = setup();
|
|
||||||
const first = await capability.create(input());
|
|
||||||
const second = await capability.create(input());
|
|
||||||
assert.equal(first.isSuccess, true);
|
|
||||||
assert.equal(second.isSuccess, true);
|
|
||||||
assert.notEqual(first.data.companyId, second.data.companyId);
|
|
||||||
assert.notEqual(first.data.slug, second.data.slug);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const field of ["street", "city", "postalCode", "province", "countryCode"] as const) {
|
|
||||||
test(`rechaza dirección sin ${field} antes de persistir`, async () => {
|
|
||||||
const { capability, saved, transactions } = setup();
|
|
||||||
const incomplete = PostalAddress.create({
|
|
||||||
...address().getProps(),
|
|
||||||
[field]: Maybe.none(),
|
|
||||||
}).data;
|
|
||||||
const result = await capability.create({ ...input(), address: incomplete });
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(saved.length, 0);
|
|
||||||
assert.equal(transactions.length, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("propaga transaction al chequeo de slug y save", async () => {
|
|
||||||
const { capability, transactions } = setup();
|
|
||||||
const transaction = {};
|
|
||||||
const result = await capability.create({ ...input(), transaction });
|
|
||||||
assert.equal(result.isSuccess, true);
|
|
||||||
assert.deepEqual(transactions, [transaction, transaction]);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const error of [
|
|
||||||
new Error("persistence failed"),
|
|
||||||
new DuplicateEntityError("Company", "slug", "duplicate"),
|
|
||||||
]) {
|
|
||||||
test(`propaga error repository ${error.name}`, async () => {
|
|
||||||
const { capability } = setup(error);
|
|
||||||
const result = await capability.create(input());
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(result.error, error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("conflicto de slug conserva el rechazo de CompanyCreator", async () => {
|
|
||||||
const { capability, saved } = setup(undefined, true);
|
|
||||||
const first = setup();
|
|
||||||
await first.capability.create(input());
|
|
||||||
saved.push(first.saved[0]);
|
|
||||||
const result = await capability.create(input());
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(saved.length, 1);
|
|
||||||
});
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import type {
|
|
||||||
EmailAddress,
|
|
||||||
Name,
|
|
||||||
PhoneNumber,
|
|
||||||
PostalAddress,
|
|
||||||
TextValue,
|
|
||||||
URLAddress,
|
|
||||||
} from "@repo/rdx-ddd";
|
|
||||||
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
|
|
||||||
import { CompanySlug, CompanyStatus } from "../../../../domain";
|
|
||||||
import type { ICompanyCreator } from "../../services/company-creator";
|
|
||||||
|
|
||||||
export interface CreateInitialCompanyParams {
|
|
||||||
legalName: Name;
|
|
||||||
tradeName?: Name;
|
|
||||||
tin: TextValue;
|
|
||||||
email?: EmailAddress;
|
|
||||||
phone?: PhoneNumber;
|
|
||||||
website?: URLAddress;
|
|
||||||
address: PostalAddress;
|
|
||||||
transaction?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InitialCompanyPublicModel {
|
|
||||||
companyId: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IInitialCompanyCreator {
|
|
||||||
create(params: CreateInitialCompanyParams): Promise<Result<InitialCompanyPublicModel, Error>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InitialCompanyCreator implements IInitialCompanyCreator {
|
|
||||||
public constructor(private readonly creator: ICompanyCreator) {}
|
|
||||||
|
|
||||||
public async create(
|
|
||||||
params: CreateInitialCompanyParams
|
|
||||||
): Promise<Result<InitialCompanyPublicModel, Error>> {
|
|
||||||
const address = params.address;
|
|
||||||
if (
|
|
||||||
!address ||
|
|
||||||
[
|
|
||||||
address.street,
|
|
||||||
address.city,
|
|
||||||
address.postalCode,
|
|
||||||
address.province,
|
|
||||||
address.countryCode,
|
|
||||||
].some((part) => part.isNone())
|
|
||||||
) {
|
|
||||||
return Result.fail(new Error("Initial company requires a complete postal address."));
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = UniqueID.generateNewID();
|
|
||||||
// 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 created = await this.creator.create({
|
|
||||||
id,
|
|
||||||
transaction: params.transaction,
|
|
||||||
props: {
|
|
||||||
legalName: params.legalName,
|
|
||||||
tradeName: Maybe.fromNullable(params.tradeName),
|
|
||||||
tin: Maybe.some(params.tin),
|
|
||||||
email: Maybe.fromNullable(params.email),
|
|
||||||
phone: Maybe.fromNullable(params.phone),
|
|
||||||
website: Maybe.fromNullable(params.website),
|
|
||||||
address: Maybe.some(address),
|
|
||||||
slug: slug.data,
|
|
||||||
status: CompanyStatus.createActive(),
|
|
||||||
defaultPaymentTermId: Maybe.none(),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (created.isFailure) return Result.fail(created.error);
|
|
||||||
return Result.ok({
|
|
||||||
companyId: created.data.id.toPrimitive(),
|
|
||||||
slug: created.data.slug.toPrimitive(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { AuditEntry, AuditRecordOptions, IAuditStore } from "@repo/rdx-audit";
|
import type { AuditEntry, IAuditStore } from "@repo/rdx-audit";
|
||||||
import { AuditStoreError } from "@repo/rdx-audit";
|
import { AuditStoreError } from "@repo/rdx-audit";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import type { ModelStatic, Transaction } from "sequelize";
|
import type { ModelStatic } from "sequelize";
|
||||||
|
|
||||||
import type { ISequelizeTransactionContext } from "../../persistence/sequelize";
|
import type { ISequelizeTransactionContext } from "../../persistence/sequelize";
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ export class SequelizeAuditStore implements IAuditStore {
|
|||||||
entry: AuditEntry,
|
entry: AuditEntry,
|
||||||
options?: AuditRecordOptions
|
options?: AuditRecordOptions
|
||||||
): Promise<Result<void, AuditStoreError>> {
|
): Promise<Result<void, AuditStoreError>> {
|
||||||
const transaction = (options?.transaction as Transaction) ?? this.transactionContext.current();
|
const transaction = options?.transaction ?? this.transactionContext.current();
|
||||||
if (entry.kind === "mutation" && !transaction)
|
if (entry.kind === "mutation" && !transaction)
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
new AuditStoreError("An active transaction is required for audit mutations.")
|
new AuditStoreError("An active transaction is required for audit mutations.")
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import type { ICompanyCertificateContext, ISigningContextResolver } from "../../../application";
|
// core/api/infrastructure/documents/certificates/
|
||||||
import type { ModuleParams } from "../../../modules";
|
// env-company-signing-context-store.ts
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ICompanyCertificateContext,
|
||||||
|
ISigningContextResolver,
|
||||||
|
} from "@erp/core/api/application";
|
||||||
|
import type { ModuleParams } from "@erp/core/api/modules";
|
||||||
|
|
||||||
import type { ICompanySigningContextRecord } from "./company-signing-context-record.interface";
|
import type { ICompanySigningContextRecord } from "./company-signing-context-record.interface";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { ICompanyCertificateContext, IDocumentSigningService } from "../../../application";
|
import type { IDocumentSigningService } from "@erp/core/api/application";
|
||||||
|
import type { ICompanyCertificateContext } from "@erp/core/api/application/documents/application-models/company-certificate-context.interface";
|
||||||
|
|
||||||
import type { RestDocumentSigningConfig } from "./rest-document-signing-config";
|
import type { RestDocumentSigningConfig } from "./rest-document-signing-config";
|
||||||
import { SimpleCircuitBreaker } from "./simple-circuit-breaker";
|
import { SimpleCircuitBreaker } from "./simple-circuit-breaker";
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { DomainMapperWithBulk, IQueryMapperWithBulk } from "../../../../application";
|
import type { DomainMapperWithBulk, IQueryMapperWithBulk } from "@erp/core/api/application";
|
||||||
|
|
||||||
export interface ISequelizeDomainMapper<TModel, TModelAttributes, TEntity>
|
export interface ISequelizeDomainMapper<TModel, TModelAttributes, TEntity>
|
||||||
extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {}
|
extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export * from "./module-runtime";
|
|
||||||
export * from "./module-server.interface";
|
export * from "./module-server.interface";
|
||||||
|
export * from "./module-runtime";
|
||||||
export * from "./modules-runtime";
|
export * from "./modules-runtime";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import type { ILogger } from "@repo/rdx-logger";
|
import type { ILogger } from "@repo/rdx-logger";
|
||||||
import type { Model, ModelStatic, Sequelize } from "sequelize";
|
import type { ModelStatic, Sequelize } from "sequelize";
|
||||||
|
|
||||||
import type { IModuleServer } from "./module-server.interface";
|
import type { IModuleServer } from "./module-server.interface";
|
||||||
import type { ModulesRuntime } from "./modules-runtime";
|
|
||||||
import type { ModuleParams, ModuleSetupResult, StartParams, WarmupParams } from "./types";
|
import type { ModuleParams, ModuleSetupResult, StartParams, WarmupParams } from "./types";
|
||||||
|
import type { ModulesRuntime } from "./modules-runtime";
|
||||||
|
|
||||||
type ModelInitializer = (database: Sequelize) => ModelStatic<Model>;
|
type ModelInitializer = (database: Sequelize) => ModelStatic;
|
||||||
type ModelEntry = { initializer: ModelInitializer; moduleName: string };
|
type ModelEntry = { initializer: ModelInitializer; moduleName: string };
|
||||||
|
|
||||||
const modules = new Map<string, IModuleServer>();
|
const modules = new Map<string, IModuleServer>();
|
||||||
@ -29,28 +29,13 @@ export function registerModels(initializers: ModelInitializer[], moduleName: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Inicializa únicamente infraestructura backend: setup, modelos y warmups. */
|
/** Inicializa únicamente infraestructura backend: setup, modelos y warmups. */
|
||||||
export async function initModules(
|
export async function initModules(params: ModuleParams & { logger: ILogger; database: Sequelize; warmup: { timeoutMs: number; strict: boolean }; databaseSyncMode: string }): Promise<ModulesRuntime> {
|
||||||
params: ModuleParams & {
|
|
||||||
logger: ILogger;
|
|
||||||
database: Sequelize;
|
|
||||||
warmup: { timeoutMs: number; strict: boolean };
|
|
||||||
databaseSyncMode: string;
|
|
||||||
}
|
|
||||||
): Promise<ModulesRuntime> {
|
|
||||||
for (const name of modules.keys()) await setupModule(name, params, []);
|
for (const name of modules.keys()) await setupModule(name, params, []);
|
||||||
await initializeModels(params.database, params.databaseSyncMode, params.logger);
|
await initializeModels(params.database, params.databaseSyncMode, params.logger);
|
||||||
await warmupModules(params);
|
await warmupModules(params);
|
||||||
validateDependencies();
|
validateDependencies();
|
||||||
|
const customerInvoices = internals.get("customer-invoices") as { proformas: { recurringProformas: ModulesRuntime["customerInvoices"]["recurringProformas"] } } | undefined;
|
||||||
const customerInvoices = internals.get("customer-invoices") as
|
if (!customerInvoices) throw new Error('Module "customer-invoices" did not expose recurring runtime capabilities');
|
||||||
| {
|
|
||||||
proformas: { recurringProformas: ModulesRuntime["customerInvoices"]["recurringProformas"] };
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!customerInvoices)
|
|
||||||
throw new Error('Module "customer-invoices" did not expose recurring runtime capabilities');
|
|
||||||
|
|
||||||
return { customerInvoices: customerInvoices.proformas.recurringProformas };
|
return { customerInvoices: customerInvoices.proformas.recurringProformas };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,228 +44,71 @@ export async function startModules(params: ModuleParams & { logger: ILogger }):
|
|||||||
for (const name of setupOrder) {
|
for (const name of setupOrder) {
|
||||||
const module = modules.get(name);
|
const module = modules.get(name);
|
||||||
if (!module?.start) continue;
|
if (!module?.start) continue;
|
||||||
await module.start({
|
await module.start({ ...params, getService: makeGetService(name, module), getInternal: makeGetInternal(name), listServices } as StartParams);
|
||||||
...params,
|
|
||||||
getService: makeGetService(name, module),
|
|
||||||
getInternal: makeGetInternal(name),
|
|
||||||
listServices,
|
|
||||||
} as StartParams);
|
|
||||||
}
|
}
|
||||||
validateDependencies();
|
validateDependencies();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupModule(
|
async function setupModule(name: string, params: ModuleParams & { logger: ILogger }, stack: string[]): Promise<void> {
|
||||||
name: string,
|
if (setupDone.has(name)) return;
|
||||||
params: ModuleParams & { logger: ILogger },
|
if (visiting.has(name)) throw new Error(`Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
|
||||||
stack: string[]
|
|
||||||
): Promise<void> {
|
|
||||||
if (setupDone.has(name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visiting.has(name)) {
|
|
||||||
throw new Error(`Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const module = modules.get(name);
|
const module = modules.get(name);
|
||||||
|
if (!module) throw new Error(`Module "${name}" not found`);
|
||||||
if (!module) {
|
|
||||||
throw new Error(`Module "${name}" not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
visiting.add(name);
|
visiting.add(name);
|
||||||
for (const dependency of module.dependencies ?? []) {
|
for (const dependency of module.dependencies ?? []) await setupModule(dependency, params, [...stack, name]);
|
||||||
await setupModule(dependency, params, [...stack, name]);
|
const result = (await module.setup?.({ ...params, getService: makeGetService(name, module), listServices })) as ModuleSetupResult | undefined;
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await module.setup?.({
|
|
||||||
...params,
|
|
||||||
getService: makeGetService(name, module),
|
|
||||||
listServices,
|
|
||||||
})) as ModuleSetupResult | undefined;
|
|
||||||
|
|
||||||
internals.set(name, result?.internal ?? {});
|
internals.set(name, result?.internal ?? {});
|
||||||
|
if (result?.models) registerModels(result.models as ModelInitializer[], name);
|
||||||
if (result?.models) {
|
for (const [key, service] of Object.entries(result?.services ?? {})) registerService(`${name}:${key}`, service);
|
||||||
registerModels(result.models as ModelInitializer[], name);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, service] of Object.entries(result?.services ?? {})) {
|
|
||||||
registerService(`${name}:${key}`, service);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupDone.add(name);
|
setupDone.add(name);
|
||||||
|
|
||||||
setupOrder.push(name);
|
setupOrder.push(name);
|
||||||
|
|
||||||
visiting.delete(name);
|
visiting.delete(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initializeModels(
|
async function initializeModels(database: Sequelize, syncMode: string, logger: ILogger): Promise<void> {
|
||||||
database: Sequelize,
|
if (modelsInitialized) return;
|
||||||
syncMode: string,
|
const registered = new Map<string, { model: ModelStatic; moduleName: string }>();
|
||||||
logger: ILogger
|
|
||||||
): Promise<void> {
|
|
||||||
if (modelsInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const registered = new Map<string, { model: ModelStatic<Model>; moduleName: string }>();
|
|
||||||
|
|
||||||
for (const entry of models) {
|
for (const entry of models) {
|
||||||
const model = entry.initializer(database);
|
const model = entry.initializer(database);
|
||||||
if (!model?.name) {
|
if (!model?.name) throw new Error(`Invalid model initializer from "${entry.moduleName}"`);
|
||||||
throw new Error(`Invalid model initializer from "${entry.moduleName}"`);
|
if (registered.has(model.name)) throw new Error(`Model name collision: "${model.name}"`);
|
||||||
}
|
|
||||||
if (registered.has(model.name)) {
|
|
||||||
throw new Error(`Model name collision: "${model.name}"`);
|
|
||||||
}
|
|
||||||
registered.set(model.name, { model, moduleName: entry.moduleName });
|
registered.set(model.name, { model, moduleName: entry.moduleName });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { model } of registered.values()) {
|
for (const { model } of registered.values()) {
|
||||||
const associate = model as ModelStatic<Model> & { associate?: (sequelize: Sequelize) => void };
|
const associate = model as ModelStatic & { associate?: (sequelize: Sequelize) => void };
|
||||||
associate.associate?.(database);
|
associate.associate?.(database);
|
||||||
}
|
}
|
||||||
|
if (syncMode === "alter") await database.sync({ force: false, alter: true });
|
||||||
if (syncMode === "alter") {
|
if (syncMode === "force") await database.sync({ force: true, alter: false });
|
||||||
await database.sync({ force: false, alter: true });
|
logger.info("Backend module models initialized", { label: "moduleRuntime", modelCount: registered.size });
|
||||||
}
|
|
||||||
|
|
||||||
if (syncMode === "force") {
|
|
||||||
await database.sync({ force: true, alter: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Backend module models initialized", {
|
|
||||||
label: "moduleRuntime",
|
|
||||||
modelCount: registered.size,
|
|
||||||
});
|
|
||||||
modelsInitialized = true;
|
modelsInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function warmupModules(
|
async function warmupModules(params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }): Promise<void> {
|
||||||
params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }
|
|
||||||
): Promise<void> {
|
|
||||||
for (const name of setupOrder) {
|
for (const name of setupOrder) {
|
||||||
const module = modules.get(name);
|
const module = modules.get(name);
|
||||||
if (!module?.warmup) continue;
|
if (!module?.warmup) continue;
|
||||||
try {
|
try {
|
||||||
await withTimeout(
|
await withTimeout(Promise.resolve(module.warmup({ ...params, getService: makeGetService(name, module), getInternal: makeGetInternal(name), listServices } as WarmupParams)), params.warmup.timeoutMs, `${name}.warmup`);
|
||||||
Promise.resolve(
|
|
||||||
module.warmup({
|
|
||||||
...params,
|
|
||||||
getService: makeGetService(name, module),
|
|
||||||
getInternal: makeGetInternal(name),
|
|
||||||
listServices,
|
|
||||||
} as WarmupParams)
|
|
||||||
),
|
|
||||||
params.warmup.timeoutMs,
|
|
||||||
`${name}.warmup`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (params.warmup.strict) throw error;
|
if (params.warmup.strict) throw error;
|
||||||
params.logger.warn("Module warmup failed but continuing", {
|
params.logger.warn("Module warmup failed but continuing", { label: "moduleRuntime", module: name, error });
|
||||||
label: "moduleRuntime",
|
|
||||||
module: name,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerService(name: string, service: unknown): void {
|
function registerService(name: string, service: unknown): void { if (services[name]) throw new Error(`Service "${name}" already registered`); services[name] = service; }
|
||||||
if (services[name]) throw new Error(`Service "${name}" already registered`);
|
function makeGetService(moduleName: string, module: IModuleServer) { return <T>(name: string): T => { const [dependency, ...key] = name.split(":"); if (dependency !== "self") { if (!(module.dependencies ?? []).includes(dependency)) throw new Error(`Module "${moduleName}" tried to access service "${name}" without declaring dependency on "${dependency}"`); trackDependency(moduleName, dependency); return getService<T>(name); } return getService<T>(`${moduleName}:${key.join(":")}`); }; }
|
||||||
services[name] = service;
|
function makeGetInternal(moduleName: string) { return <T>(requestedModule: string, key?: string): T => { if (requestedModule !== moduleName) throw new Error(`Module "${moduleName}" attempted to access private internal state`); const internal = internals.get(moduleName) ?? {}; return (key ? internal[key] : internal) as T; }; }
|
||||||
}
|
function getService<T>(name: string): T { const service = services[name]; if (!service) throw new Error(`Service "${name}" not found`); return service as T; }
|
||||||
|
function trackDependency(moduleName: string, dependency: string): void { const used = usedDependencies.get(moduleName) ?? new Set<string>(); used.add(dependency); usedDependencies.set(moduleName, used); }
|
||||||
function makeGetService(moduleName: string, module: IModuleServer) {
|
function validateDependencies(): void { for (const [name, module] of modules) { const declared = new Set(module.dependencies ?? []); const used = usedDependencies.get(name) ?? new Set<string>(); const undeclared = [...used].filter((dependency) => !declared.has(dependency)); if (undeclared.length) throw new Error(`Module "${name}" used undeclared dependencies: ${undeclared.join(", ")}`); } }
|
||||||
return <T>(name: string): T => {
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> { let timeout: NodeJS.Timeout | undefined; const timeoutPromise = new Promise<T>((_, reject) => { timeout = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms (${label})`)), timeoutMs); }); return Promise.race([promise, timeoutPromise]).finally(() => { if (timeout) clearTimeout(timeout); }) as Promise<T>; }
|
||||||
const [dependency, ...key] = name.split(":");
|
export function listServices(): string[] { return Object.keys(services); }
|
||||||
if (dependency !== "self") {
|
export function getServiceScoped<T>(requesterModule: string, allowedDependencies: readonly string[], name: string): T {
|
||||||
if (!(module.dependencies ?? []).includes(dependency))
|
|
||||||
throw new Error(
|
|
||||||
`Module "${moduleName}" tried to access service "${name}" without declaring dependency on "${dependency}"`
|
|
||||||
);
|
|
||||||
trackDependency(moduleName, dependency);
|
|
||||||
return getService<T>(name);
|
|
||||||
}
|
|
||||||
return getService<T>(`${moduleName}:${key.join(":")}`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeGetInternal(moduleName: string) {
|
|
||||||
return <T>(requestedModule: string, key?: string): T => {
|
|
||||||
if (requestedModule !== moduleName)
|
|
||||||
throw new Error(`Module "${moduleName}" attempted to access private internal state`);
|
|
||||||
const internal = internals.get(moduleName) ?? {};
|
|
||||||
return (key ? internal[key] : internal) as T;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getService<T>(name: string): T {
|
|
||||||
const service = services[name];
|
|
||||||
if (!service) throw new Error(`Service "${name}" not found`);
|
|
||||||
return service as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function trackDependency(moduleName: string, dependency: string): void {
|
|
||||||
const used = usedDependencies.get(moduleName) ?? new Set<string>();
|
|
||||||
used.add(dependency);
|
|
||||||
usedDependencies.set(moduleName, used);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateDependencies(): void {
|
|
||||||
for (const [name, module] of modules) {
|
|
||||||
const declared = new Set(module.dependencies ?? []);
|
|
||||||
const used = usedDependencies.get(name) ?? new Set<string>();
|
|
||||||
const undeclared = [...used].filter((dependency) => !declared.has(dependency));
|
|
||||||
if (undeclared.length)
|
|
||||||
throw new Error(`Module "${name}" used undeclared dependencies: ${undeclared.join(", ")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
||||||
let timeout: NodeJS.Timeout | undefined;
|
|
||||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
|
||||||
timeout = setTimeout(
|
|
||||||
() => reject(new Error(`Timeout after ${timeoutMs}ms (${label})`)),
|
|
||||||
timeoutMs
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
}) as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listServices(): string[] {
|
|
||||||
return Object.keys(services);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServiceScoped<T>(
|
|
||||||
requesterModule: string,
|
|
||||||
allowedDependencies: readonly string[],
|
|
||||||
name: string
|
|
||||||
): T {
|
|
||||||
const [moduleName, ...key] = name.split(":");
|
const [moduleName, ...key] = name.split(":");
|
||||||
if (moduleName === "self") return getService<T>(`${requesterModule}:${key.join(":")}`);
|
if (moduleName === "self") return getService<T>(`${requesterModule}:${key.join(":")}`);
|
||||||
if (!allowedDependencies.includes(moduleName))
|
if (!allowedDependencies.includes(moduleName)) throw new Error(`Module "${requesterModule}" tried to access service "${name}" without declaring dependency on "${moduleName}"`);
|
||||||
throw new Error(
|
|
||||||
`Module "${requesterModule}" tried to access service "${name}" without declaring dependency on "${moduleName}"`
|
|
||||||
);
|
|
||||||
return getService<T>(name);
|
return getService<T>(name);
|
||||||
}
|
}
|
||||||
|
export function resetModuleRuntimeForTests(): void { modules.clear(); models.length = 0; setupOrder.length = 0; setupDone.clear(); visiting.clear(); internals.clear(); usedDependencies.clear(); for (const key of Object.keys(services)) delete services[key]; modelsInitialized = false; }
|
||||||
export function resetModuleRuntimeForTests(): void {
|
|
||||||
modules.clear();
|
|
||||||
models.length = 0;
|
|
||||||
setupOrder.length = 0;
|
|
||||||
setupDone.clear();
|
|
||||||
visiting.clear();
|
|
||||||
internals.clear();
|
|
||||||
usedDependencies.clear();
|
|
||||||
|
|
||||||
for (const key of Object.keys(services)) {
|
|
||||||
delete services[key];
|
|
||||||
}
|
|
||||||
modelsInitialized = false;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,17 +8,10 @@ export interface RecurringProformaOccurrenceProcessorCapability {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RecurringProformaDiscoveryCapability {
|
export interface RecurringProformaDiscoveryCapability {
|
||||||
findDue(
|
findDue(dueThrough: Date, limit: number): Promise<{
|
||||||
dueThrough: Date,
|
|
||||||
limit: number
|
|
||||||
): Promise<{
|
|
||||||
isFailure: boolean;
|
isFailure: boolean;
|
||||||
error: Error;
|
error: Error;
|
||||||
data: readonly {
|
data: readonly { recurringProformaId: UniqueID; companyId: UniqueID; nextOccurrenceDate: Date }[];
|
||||||
recurringProformaId: UniqueID;
|
|
||||||
companyId: UniqueID;
|
|
||||||
nextOccurrenceDate: Date;
|
|
||||||
}[];
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import Dinero, { type Currency } from "dinero.js";
|
||||||
|
|
||||||
import type { MoneyDTO } from "../dto";
|
import type { MoneyDTO } from "../dto";
|
||||||
|
|
||||||
//type DineroPlain = { amount: number; precision: number; currency: string };
|
type DineroPlain = { amount: number; precision: number; currency: string };
|
||||||
|
|
||||||
const isEmptyMoneyDTO = (dto?: MoneyDTO | null) =>
|
const isEmptyMoneyDTO = (dto?: MoneyDTO | null) =>
|
||||||
!dto || dto.value?.trim?.() === "" || dto.scale?.trim?.() === "";
|
!dto || dto.value?.trim?.() === "" || dto.scale?.trim?.() === "";
|
||||||
@ -147,40 +149,40 @@ export const MoneyDTOHelper = {
|
|||||||
/**
|
/**
|
||||||
* Convierte un DTO a una instancia Dinero.js.
|
* Convierte un DTO a una instancia Dinero.js.
|
||||||
*/
|
*/
|
||||||
/*function dineroFromDTO(dto: MoneyDTO, fallbackCurrency = "EUR"): Dinero.Dinero {
|
function dineroFromDTO(dto: MoneyDTO, fallbackCurrency = "EUR"): Dinero.Dinero {
|
||||||
const n = normalizeDTO(dto, fallbackCurrency);
|
const n = normalizeDTO(dto, fallbackCurrency);
|
||||||
return Dinero({
|
return Dinero({
|
||||||
amount: Number.parseInt(n.value, 10),
|
amount: Number.parseInt(n.value, 10),
|
||||||
precision: Number.parseInt(n.scale, 10),
|
precision: Number.parseInt(n.scale, 10),
|
||||||
currency: n.currency_code as Currency,
|
currency: n.currency_code as Currency,
|
||||||
});
|
});
|
||||||
}*/
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convierte una instancia Dinero a un DTO.
|
* Convierte una instancia Dinero a un DTO.
|
||||||
*/
|
*/
|
||||||
/*function dtoFromDinero(d: Dinero.Dinero): MoneyDTO {
|
function dtoFromDinero(d: Dinero.Dinero): MoneyDTO {
|
||||||
const { amount, precision, currency } = d.toObject() as DineroPlain;
|
const { amount, precision, currency } = d.toObject() as DineroPlain;
|
||||||
return {
|
return {
|
||||||
value: amount.toString(),
|
value: amount.toString(),
|
||||||
scale: precision.toString(),
|
scale: precision.toString(),
|
||||||
currency_code: currency,
|
currency_code: currency,
|
||||||
};
|
};
|
||||||
}*/
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suma una lista de MoneyDTO.
|
* Suma una lista de MoneyDTO.
|
||||||
*/
|
*/
|
||||||
/*function sumDTO(list: MoneyDTO[], fallbackCurrency = "EUR"): MoneyDTO {
|
function sumDTO(list: MoneyDTO[], fallbackCurrency = "EUR"): MoneyDTO {
|
||||||
if (list.length === 0) return { value: "0", scale: "2", currency_code: fallbackCurrency };
|
if (list.length === 0) return { value: "0", scale: "2", currency_code: fallbackCurrency };
|
||||||
const sum = list.map((x) => dineroFromDTO(x, fallbackCurrency)).reduce((a, b) => a.add(b));
|
const sum = list.map((x) => dineroFromDTO(x, fallbackCurrency)).reduce((a, b) => a.add(b));
|
||||||
return dtoFromDinero(sum);
|
return dtoFromDinero(sum);
|
||||||
}*/
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multiplica un MoneyDTO por un número.
|
* Multiplica un MoneyDTO por un número.
|
||||||
*/
|
*/
|
||||||
/*function multiplyDTO(
|
function multiplyDTO(
|
||||||
dto: MoneyDTO,
|
dto: MoneyDTO,
|
||||||
multiplier: number,
|
multiplier: number,
|
||||||
rounding: Dinero.RoundingMode = "HALF_EVEN",
|
rounding: Dinero.RoundingMode = "HALF_EVEN",
|
||||||
@ -188,12 +190,12 @@ export const MoneyDTOHelper = {
|
|||||||
): MoneyDTO {
|
): MoneyDTO {
|
||||||
const d = dineroFromDTO(dto, fallbackCurrency).multiply(multiplier, rounding);
|
const d = dineroFromDTO(dto, fallbackCurrency).multiply(multiplier, rounding);
|
||||||
return dtoFromDinero(d);
|
return dtoFromDinero(d);
|
||||||
}*/
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcula un porcentaje de un MoneyDTO.
|
* Calcula un porcentaje de un MoneyDTO.
|
||||||
*/
|
*/
|
||||||
/*function percentageDTO(
|
function percentageDTO(
|
||||||
dto: MoneyDTO,
|
dto: MoneyDTO,
|
||||||
percent: number,
|
percent: number,
|
||||||
rounding: Dinero.RoundingMode = "HALF_EVEN",
|
rounding: Dinero.RoundingMode = "HALF_EVEN",
|
||||||
@ -201,4 +203,4 @@ export const MoneyDTOHelper = {
|
|||||||
): MoneyDTO {
|
): MoneyDTO {
|
||||||
const d = dineroFromDTO(dto, fallbackCurrency).percentage(percent, rounding);
|
const d = dineroFromDTO(dto, fallbackCurrency).percentage(percent, rounding);
|
||||||
return dtoFromDinero(d);
|
return dtoFromDinero(d);
|
||||||
}*/
|
}
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
|
||||||
import type { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
|
|
||||||
import type { InitialOnboarding } from "../../domain";
|
|
||||||
|
|
||||||
export interface IInitialOnboardingRepository {
|
|
||||||
/** Devuelve ausencia explícita si la cuenta no tiene onboarding; no es un error. */
|
|
||||||
findByAccountId(
|
|
||||||
accountId: UniqueID,
|
|
||||||
transaction?: unknown
|
|
||||||
): Promise<Result<Maybe<InitialOnboarding>, Error>>;
|
|
||||||
|
|
||||||
/** Persiste el estado actual del agregado, tanto pending como completed. */
|
|
||||||
save(initialOnboarding: InitialOnboarding, transaction?: unknown): Promise<Result<void, Error>>;
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { DuplicateEntityError } from "@erp/core/api";
|
|
||||||
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
|
|
||||||
import { InitialOnboarding } from "../../domain";
|
|
||||||
import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface";
|
|
||||||
|
|
||||||
import { InitialOnboardingProvisioner } from "./initial-onboarding-provisioner";
|
|
||||||
|
|
||||||
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
|
||||||
const duplicate = () =>
|
|
||||||
new DuplicateEntityError("InitialOnboarding", "accountId", accountId.toPrimitive());
|
|
||||||
|
|
||||||
class FakeRepository implements IInitialOnboardingRepository {
|
|
||||||
public stored: InitialOnboarding | undefined;
|
|
||||||
public reads = 0;
|
|
||||||
public writes = 0;
|
|
||||||
public readError: Error | undefined;
|
|
||||||
public writeError: Error | undefined;
|
|
||||||
public winner: InitialOnboarding | undefined;
|
|
||||||
public rereadError: Error | undefined;
|
|
||||||
public async findByAccountId(id: UniqueID): Promise<Result<Maybe<InitialOnboarding>, Error>> {
|
|
||||||
assert.ok(id.equals(accountId));
|
|
||||||
this.reads++;
|
|
||||||
if (this.readError) return Result.fail(this.readError);
|
|
||||||
if (this.reads > 1 && this.rereadError) return Result.fail(this.rereadError);
|
|
||||||
return Result.ok(Maybe.fromNullable(this.stored));
|
|
||||||
}
|
|
||||||
public async save(value: InitialOnboarding): Promise<Result<void, Error>> {
|
|
||||||
this.writes++;
|
|
||||||
if (this.winner) this.stored = this.winner;
|
|
||||||
if (this.writeError) return Result.fail(this.writeError);
|
|
||||||
this.stored = value;
|
|
||||||
return Result.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function existing(completed = false) {
|
|
||||||
const value = InitialOnboarding.createPending(accountId).data;
|
|
||||||
if (completed) {
|
|
||||||
const timestamp = UtcDateTime.createFromISO("2026-01-02T10:30:00.123Z").data;
|
|
||||||
assert.equal(
|
|
||||||
value.complete({
|
|
||||||
companyId: UniqueID.generateNewID(),
|
|
||||||
completedAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
}).isSuccess,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("inexistente crea y guarda pending mediante el agregado", async () => {
|
|
||||||
const repository = new FakeRepository();
|
|
||||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
|
||||||
assert.equal(result.isSuccess, true);
|
|
||||||
assert.equal(result.data, repository.stored);
|
|
||||||
assert.equal(result.data.status, "pending");
|
|
||||||
assert.ok(result.data.accountId.equals(accountId));
|
|
||||||
assert.ok(result.data.createdAt.equals(result.data.updatedAt));
|
|
||||||
assert.ok(result.data.companyId.isNone());
|
|
||||||
assert.ok(result.data.completedAt.isNone());
|
|
||||||
assert.equal(repository.writes, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const completed of [false, true]) {
|
|
||||||
test(`existente ${completed ? "completed" : "pending"} conserva identidad y estado sin save`, async () => {
|
|
||||||
const repository = new FakeRepository();
|
|
||||||
const value = existing(completed);
|
|
||||||
repository.stored = value;
|
|
||||||
const before = [value.id, value.createdAt, value.updatedAt, value.companyId, value.completedAt];
|
|
||||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
|
||||||
assert.equal(result.data, value);
|
|
||||||
assert.equal(result.data.status, completed ? "completed" : "pending");
|
|
||||||
assert.deepEqual(
|
|
||||||
[value.id, value.createdAt, value.updatedAt, value.companyId, value.completedAt],
|
|
||||||
before
|
|
||||||
);
|
|
||||||
assert.equal(repository.writes, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("error de find inicial se propaga sin save", async () => {
|
|
||||||
const repository = new FakeRepository();
|
|
||||||
repository.readError = new Error("read failed");
|
|
||||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(result.error, repository.readError);
|
|
||||||
assert.equal(repository.writes, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("error de save no duplicate se propaga sin releer", async () => {
|
|
||||||
const repository = new FakeRepository();
|
|
||||||
repository.writeError = new Error("write failed");
|
|
||||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(result.error, repository.writeError);
|
|
||||||
assert.equal(repository.reads, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const completed of [false, true]) {
|
|
||||||
test(`duplicate devuelve ganador ${completed ? "completed" : "pending"} tras una relectura`, async () => {
|
|
||||||
const repository = new FakeRepository();
|
|
||||||
repository.writeError = duplicate();
|
|
||||||
repository.winner = existing(completed);
|
|
||||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
|
||||||
assert.equal(result.isSuccess, true);
|
|
||||||
assert.equal(result.data, repository.winner);
|
|
||||||
assert.equal(repository.reads, 2);
|
|
||||||
assert.equal(repository.writes, 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("duplicate y relectura fallida propaga el error de lectura", async () => {
|
|
||||||
const repository = new FakeRepository();
|
|
||||||
repository.writeError = duplicate();
|
|
||||||
repository.rereadError = new Error("reread failed");
|
|
||||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(result.error, repository.rereadError);
|
|
||||||
assert.equal(repository.reads, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("duplicate y relectura none retorna error explícito con causa", async () => {
|
|
||||||
const repository = new FakeRepository();
|
|
||||||
repository.writeError = duplicate();
|
|
||||||
const result = await new InitialOnboardingProvisioner(repository).provision(accountId);
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(
|
|
||||||
result.error.message,
|
|
||||||
"Initial onboarding duplicate without a persisted onboarding for the account."
|
|
||||||
);
|
|
||||||
assert.equal(result.error.cause, repository.writeError);
|
|
||||||
assert.equal(repository.reads, 2);
|
|
||||||
assert.equal(repository.writes, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("provision repetido devuelve el mismo agregado y timestamps", async () => {
|
|
||||||
const repository = new FakeRepository();
|
|
||||||
const provisioner = new InitialOnboardingProvisioner(repository);
|
|
||||||
const first = await provisioner.provision(accountId);
|
|
||||||
const second = await provisioner.provision(accountId);
|
|
||||||
const third = await provisioner.provision(accountId);
|
|
||||||
assert.equal(first.isSuccess, true);
|
|
||||||
assert.equal(second.data, first.data);
|
|
||||||
assert.equal(third.data, first.data);
|
|
||||||
assert.equal(repository.writes, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dos provision simultáneos con barrera devuelven un único ganador", async () => {
|
|
||||||
let release: () => void = () => {
|
|
||||||
throw new Error("Barrier not initialized");
|
|
||||||
};
|
|
||||||
const barrier = new Promise<void>((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
let reads = 0;
|
|
||||||
let writes = 0;
|
|
||||||
let stored: InitialOnboarding | undefined;
|
|
||||||
const repository: IInitialOnboardingRepository = {
|
|
||||||
async findByAccountId(id) {
|
|
||||||
assert.ok(id.equals(accountId));
|
|
||||||
reads++;
|
|
||||||
if (reads <= 2) {
|
|
||||||
if (reads === 2) release();
|
|
||||||
await barrier;
|
|
||||||
return Result.ok(Maybe.none());
|
|
||||||
}
|
|
||||||
return Result.ok(Maybe.fromNullable(stored));
|
|
||||||
},
|
|
||||||
async save(value) {
|
|
||||||
writes++;
|
|
||||||
if (stored) return Result.fail(duplicate());
|
|
||||||
stored = value;
|
|
||||||
return Result.ok();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const provisioner = new InitialOnboardingProvisioner(repository);
|
|
||||||
const [first, second] = await Promise.all([
|
|
||||||
provisioner.provision(accountId),
|
|
||||||
provisioner.provision(accountId),
|
|
||||||
]);
|
|
||||||
assert.equal(first.isSuccess, true);
|
|
||||||
assert.equal(second.isSuccess, true);
|
|
||||||
assert.equal(first.data, stored);
|
|
||||||
assert.equal(second.data, stored);
|
|
||||||
assert.ok(first.data.id.equals(second.data.id));
|
|
||||||
assert.equal(writes, 2);
|
|
||||||
assert.equal(reads, 3);
|
|
||||||
});
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { isDuplicateEntityError } from "@erp/core/api";
|
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
|
||||||
import { Result } from "@repo/rdx-utils";
|
|
||||||
|
|
||||||
import { InitialOnboarding } from "../../domain";
|
|
||||||
import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface";
|
|
||||||
|
|
||||||
export class InitialOnboardingProvisioner {
|
|
||||||
public constructor(private readonly repository: IInitialOnboardingRepository) {}
|
|
||||||
|
|
||||||
/** Asegura que existe onboarding; conserva los estados pending y completed existentes. */
|
|
||||||
public async provision(accountId: UniqueID): Promise<Result<InitialOnboarding, Error>> {
|
|
||||||
const current = await this.repository.findByAccountId(accountId);
|
|
||||||
if (current.isFailure) return Result.fail(current.error);
|
|
||||||
if (current.data.isSome()) return Result.ok(current.data.unwrap());
|
|
||||||
|
|
||||||
const created = InitialOnboarding.createPending(accountId);
|
|
||||||
if (created.isFailure) return Result.fail(created.error);
|
|
||||||
const saved = await this.repository.save(created.data);
|
|
||||||
if (saved.isSuccess) return Result.ok(created.data);
|
|
||||||
if (!isDuplicateEntityError(saved.error)) return Result.fail(saved.error);
|
|
||||||
|
|
||||||
// UNIQUE es la garantía entre procesos. Solo recuperamos el ganador confirmado por cuenta.
|
|
||||||
const winner = await this.repository.findByAccountId(accountId);
|
|
||||||
if (winner.isFailure) return Result.fail(winner.error);
|
|
||||||
if (winner.data.isSome()) return Result.ok(winner.data.unwrap());
|
|
||||||
return Result.fail(
|
|
||||||
new Error("Initial onboarding duplicate without a persisted onboarding for the account.", {
|
|
||||||
cause: saved.error,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -30,8 +30,6 @@ test("creates pending", () => {
|
|||||||
check(r.data.companyId.isNone());
|
check(r.data.companyId.isNone());
|
||||||
check(r.data.completedAt.isNone());
|
check(r.data.completedAt.isNone());
|
||||||
check(r.data.accountId.equals(accountId));
|
check(r.data.accountId.equals(accountId));
|
||||||
check(r.data.createdAt instanceof UtcDateTime);
|
|
||||||
check(r.data.updatedAt.equals(r.data.createdAt));
|
|
||||||
});
|
});
|
||||||
test("rehydrates valid states", () => {
|
test("rehydrates valid states", () => {
|
||||||
check(InitialOnboarding.rehydrate(props("pending"), UniqueID.generateNewID()).isSuccess);
|
check(InitialOnboarding.rehydrate(props("pending"), UniqueID.generateNewID()).isSuccess);
|
||||||
@ -66,11 +64,7 @@ test("rejects invalid state combinations", () => {
|
|||||||
test("completes once", () => {
|
test("completes once", () => {
|
||||||
const o = InitialOnboarding.createPending(UniqueID.generateNewID()).data;
|
const o = InitialOnboarding.createPending(UniqueID.generateNewID()).data;
|
||||||
const id = UniqueID.generateNewID();
|
const id = UniqueID.generateNewID();
|
||||||
const createdAt = o.createdAt;
|
check(o.complete({ companyId: id, completedAt: date(), updatedAt: date() }).isSuccess);
|
||||||
const updatedAt = UtcDateTime.createFromISO("2026-01-02T11:00:00.456Z").data;
|
|
||||||
check(o.complete({ companyId: id, completedAt: date(), updatedAt }).isSuccess);
|
|
||||||
check(o.createdAt.equals(createdAt));
|
|
||||||
check(o.updatedAt.equals(updatedAt));
|
|
||||||
check(o.status === "completed");
|
check(o.status === "completed");
|
||||||
check(o.companyId.unwrap().equals(id));
|
check(o.companyId.unwrap().equals(id));
|
||||||
check(o.completedAt.isSome());
|
check(o.completedAt.isSome());
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { AggregateRoot, UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
import { AggregateRoot, UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
export type InitialOnboardingStatus = "pending" | "completed";
|
export type InitialOnboardingStatus = "pending" | "completed";
|
||||||
@ -6,8 +6,6 @@ export interface InitialOnboardingProps {
|
|||||||
accountId: UniqueID;
|
accountId: UniqueID;
|
||||||
status: InitialOnboardingStatus;
|
status: InitialOnboardingStatus;
|
||||||
companyId: Maybe<UniqueID>;
|
companyId: Maybe<UniqueID>;
|
||||||
createdAt: UtcDateTime;
|
|
||||||
updatedAt: UtcDateTime;
|
|
||||||
completedAt: Maybe<UtcDateTime>;
|
completedAt: Maybe<UtcDateTime>;
|
||||||
}
|
}
|
||||||
export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
||||||
@ -22,14 +20,11 @@ export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static createPending(accountId: UniqueID): Result<InitialOnboarding, Error> {
|
static createPending(accountId: UniqueID): Result<InitialOnboarding, Error> {
|
||||||
const now = UtcDateTime.now();
|
|
||||||
return InitialOnboarding.rehydrate(
|
return InitialOnboarding.rehydrate(
|
||||||
{
|
{
|
||||||
accountId,
|
accountId,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
companyId: Maybe.none(),
|
companyId: Maybe.none(),
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
completedAt: Maybe.none(),
|
completedAt: Maybe.none(),
|
||||||
},
|
},
|
||||||
UniqueID.generateNewID()
|
UniqueID.generateNewID()
|
||||||
@ -66,14 +61,6 @@ export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
|||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
get createdAt(): UtcDateTime {
|
|
||||||
return this.props.createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
get updatedAt(): UtcDateTime {
|
|
||||||
return this.props.updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
get accountId() {
|
get accountId() {
|
||||||
return this.props.accountId;
|
return this.props.accountId;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,5 @@ import initialOnboardingModelInit from "./models/sequelize-initial-onboarding.mo
|
|||||||
|
|
||||||
export * from "./mappers";
|
export * from "./mappers";
|
||||||
export * from "./models";
|
export * from "./models";
|
||||||
export * from "./repositories";
|
|
||||||
|
|
||||||
export const models = [initialOnboardingModelInit];
|
export const models = [initialOnboardingModelInit];
|
||||||
|
|||||||
@ -20,8 +20,6 @@ const mapper = new SequelizeInitialOnboardingDomainMapper();
|
|||||||
const id = "11111111-1111-4111-8111-111111111111";
|
const id = "11111111-1111-4111-8111-111111111111";
|
||||||
const accountId = "22222222-2222-4222-8222-222222222222";
|
const accountId = "22222222-2222-4222-8222-222222222222";
|
||||||
const companyId = "33333333-3333-4333-8333-333333333333";
|
const companyId = "33333333-3333-4333-8333-333333333333";
|
||||||
const createdAt = "2026-01-01T10:00:00.123Z";
|
|
||||||
const updatedAt = "2026-01-02T11:00:00.456Z";
|
|
||||||
const completedAt = "2026-01-02T10:30:00.789Z";
|
const completedAt = "2026-01-02T10:30:00.789Z";
|
||||||
|
|
||||||
function aggregate(status: InitialOnboardingStatus) {
|
function aggregate(status: InitialOnboardingStatus) {
|
||||||
@ -32,8 +30,6 @@ function aggregate(status: InitialOnboardingStatus) {
|
|||||||
companyId:
|
companyId:
|
||||||
status === "completed" ? Maybe.some(UniqueID.create(companyId).data) : Maybe.none(),
|
status === "completed" ? Maybe.some(UniqueID.create(companyId).data) : Maybe.none(),
|
||||||
|
|
||||||
createdAt: UtcDateTime.createFromISO(createdAt).data,
|
|
||||||
updatedAt: UtcDateTime.createFromISO(updatedAt).data,
|
|
||||||
completedAt:
|
completedAt:
|
||||||
status === "completed"
|
status === "completed"
|
||||||
? Maybe.some(UtcDateTime.createFromISO(completedAt).data)
|
? Maybe.some(UtcDateTime.createFromISO(completedAt).data)
|
||||||
@ -52,8 +48,6 @@ function row(patch: Partial<InferAttributes<InitialOnboardingModel>> = {}) {
|
|||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
company_id: null,
|
company_id: null,
|
||||||
created_at: new Date(createdAt),
|
|
||||||
updated_at: new Date(updatedAt),
|
|
||||||
completed_at: null,
|
completed_at: null,
|
||||||
...patch,
|
...patch,
|
||||||
},
|
},
|
||||||
@ -63,18 +57,13 @@ function row(patch: Partial<InferAttributes<InitialOnboardingModel>> = {}) {
|
|||||||
|
|
||||||
for (const status of ["pending", "completed"] as const) {
|
for (const status of ["pending", "completed"] as const) {
|
||||||
test(`toPersistence ${status}`, () => {
|
test(`toPersistence ${status}`, () => {
|
||||||
const source = aggregate(status);
|
const result = mapper.mapToPersistence(aggregate(status));
|
||||||
const result = mapper.mapToPersistence(source);
|
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
assert.deepEqual(result.data.created_at, source.createdAt.toDate());
|
|
||||||
assert.deepEqual(result.data.updated_at, source.updatedAt.toDate());
|
|
||||||
assert.deepEqual(result.data, {
|
assert.deepEqual(result.data, {
|
||||||
id,
|
id,
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
status,
|
status,
|
||||||
company_id: status === "completed" ? companyId : null,
|
company_id: status === "completed" ? companyId : null,
|
||||||
created_at: new Date(createdAt),
|
|
||||||
updated_at: new Date(updatedAt),
|
|
||||||
completed_at: status === "completed" ? completedAt : null,
|
completed_at: status === "completed" ? completedAt : null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -84,8 +73,6 @@ for (const status of ["pending", "completed"] as const) {
|
|||||||
row({
|
row({
|
||||||
status,
|
status,
|
||||||
company_id: status === "completed" ? companyId : null,
|
company_id: status === "completed" ? companyId : null,
|
||||||
created_at: new Date(createdAt),
|
|
||||||
updated_at: new Date(updatedAt),
|
|
||||||
completed_at: status === "completed" ? completedAt : null,
|
completed_at: status === "completed" ? completedAt : null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -96,8 +83,6 @@ for (const status of ["pending", "completed"] as const) {
|
|||||||
assert.equal(value.id.toPrimitive(), id);
|
assert.equal(value.id.toPrimitive(), id);
|
||||||
assert.equal(value.accountId.toPrimitive(), accountId);
|
assert.equal(value.accountId.toPrimitive(), accountId);
|
||||||
assert.equal(value.status, status);
|
assert.equal(value.status, status);
|
||||||
assert.equal(value.createdAt.toISOString(), createdAt);
|
|
||||||
assert.equal(value.updatedAt.toISOString(), updatedAt);
|
|
||||||
assert.equal(value.companyId.isSome(), status === "completed");
|
assert.equal(value.companyId.isSome(), status === "completed");
|
||||||
assert.equal(value.completedAt.isSome(), status === "completed");
|
assert.equal(value.completedAt.isSome(), status === "completed");
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
@ -128,14 +113,10 @@ test("toDomain accepts Sequelize Date timestamps and preserves UTC milliseconds"
|
|||||||
row({
|
row({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
company_id: companyId,
|
company_id: companyId,
|
||||||
created_at: new Date(createdAt),
|
|
||||||
updated_at: new Date(updatedAt),
|
|
||||||
completed_at: new Date(completedAt),
|
completed_at: new Date(completedAt),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert.equal(result.isSuccess, true);
|
assert.equal(result.isSuccess, true);
|
||||||
assert.equal(result.data.createdAt.toISOString(), createdAt);
|
|
||||||
assert.equal(result.data.updatedAt.toISOString(), updatedAt);
|
|
||||||
assert.equal(result.data.completedAt.unwrap().toISOString(), completedAt);
|
assert.equal(result.data.completedAt.unwrap().toISOString(), completedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -152,8 +133,8 @@ test("toDomain propagates invalid identifiers and timestamps as failures", () =>
|
|||||||
{ id: "invalid" },
|
{ id: "invalid" },
|
||||||
{ account_id: "invalid" },
|
{ account_id: "invalid" },
|
||||||
{ status: "completed", company_id: "invalid", completed_at: completedAt },
|
{ status: "completed", company_id: "invalid", completed_at: completedAt },
|
||||||
{ created_at: new Date(Number.NaN) },
|
{ created_at: "invalid" },
|
||||||
{ updated_at: new Date(Number.NaN) },
|
{ updated_at: "invalid" },
|
||||||
{ status: "completed", company_id: companyId, completed_at: "invalid" },
|
{ status: "completed", company_id: companyId, completed_at: "invalid" },
|
||||||
]) {
|
]) {
|
||||||
assert.equal(mapper.mapToDomain(row(patch)).isFailure, true);
|
assert.equal(mapper.mapToDomain(row(patch)).isFailure, true);
|
||||||
|
|||||||
@ -32,10 +32,6 @@ export class SequelizeInitialOnboardingDomainMapper {
|
|||||||
if (accountId.isFailure) return Result.fail(accountId.error);
|
if (accountId.isFailure) return Result.fail(accountId.error);
|
||||||
const companyId = maybeFromNullableResult(source.company_id, (value) => UniqueID.create(value));
|
const companyId = maybeFromNullableResult(source.company_id, (value) => UniqueID.create(value));
|
||||||
if (companyId.isFailure) return Result.fail(companyId.error);
|
if (companyId.isFailure) return Result.fail(companyId.error);
|
||||||
const createdAt = toUtcDateTime(source.created_at);
|
|
||||||
if (createdAt.isFailure) return Result.fail(createdAt.error);
|
|
||||||
const updatedAt = toUtcDateTime(source.updated_at);
|
|
||||||
if (updatedAt.isFailure) return Result.fail(updatedAt.error);
|
|
||||||
const completedAt = maybeFromNullableResult(source.completed_at, (value) =>
|
const completedAt = maybeFromNullableResult(source.completed_at, (value) =>
|
||||||
toUtcDateTime(value)
|
toUtcDateTime(value)
|
||||||
);
|
);
|
||||||
@ -46,8 +42,6 @@ export class SequelizeInitialOnboardingDomainMapper {
|
|||||||
accountId: accountId.data,
|
accountId: accountId.data,
|
||||||
status: source.status,
|
status: source.status,
|
||||||
companyId: companyId.data,
|
companyId: companyId.data,
|
||||||
createdAt: createdAt.data,
|
|
||||||
updatedAt: updatedAt.data,
|
|
||||||
completedAt: completedAt.data,
|
completedAt: completedAt.data,
|
||||||
},
|
},
|
||||||
id.data
|
id.data
|
||||||
@ -62,8 +56,6 @@ export class SequelizeInitialOnboardingDomainMapper {
|
|||||||
account_id: source.accountId.toPrimitive(),
|
account_id: source.accountId.toPrimitive(),
|
||||||
status: source.status,
|
status: source.status,
|
||||||
company_id: maybeToNullable(source.companyId, (value) => value.toPrimitive()),
|
company_id: maybeToNullable(source.companyId, (value) => value.toPrimitive()),
|
||||||
created_at: source.createdAt.toDate(),
|
|
||||||
updated_at: source.updatedAt.toDate(),
|
|
||||||
completed_at: maybeToNullable(source.completedAt, (value) => value.toISOString()),
|
completed_at: maybeToNullable(source.completedAt, (value) => value.toISOString()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,9 +12,7 @@ initialOnboardingModelInit(database);
|
|||||||
|
|
||||||
test("model declares table, nullability, UUIDs, timestamps and unique account without cross-module FKs", () => {
|
test("model declares table, nullability, UUIDs, timestamps and unique account without cross-module FKs", () => {
|
||||||
assert.equal(InitialOnboardingModel.getTableName(), "initial_onboardings");
|
assert.equal(InitialOnboardingModel.getTableName(), "initial_onboardings");
|
||||||
assert.equal(InitialOnboardingModel.options.timestamps, true);
|
assert.equal(InitialOnboardingModel.options.timestamps, false);
|
||||||
assert.equal(InitialOnboardingModel.options.createdAt, "created_at");
|
|
||||||
assert.equal(InitialOnboardingModel.options.updatedAt, "updated_at");
|
|
||||||
const attributes = InitialOnboardingModel.getAttributes();
|
const attributes = InitialOnboardingModel.getAttributes();
|
||||||
assert.deepEqual(Object.keys(attributes).sort(), [
|
assert.deepEqual(Object.keys(attributes).sort(), [
|
||||||
"account_id",
|
"account_id",
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
import type {
|
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
|
||||||
CreationOptional,
|
|
||||||
InferAttributes,
|
|
||||||
InferCreationAttributes,
|
|
||||||
Sequelize,
|
|
||||||
} from "sequelize";
|
|
||||||
import { DataTypes, Model } from "sequelize";
|
import { DataTypes, Model } from "sequelize";
|
||||||
|
|
||||||
export type InitialOnboardingCreationAttributes = InferCreationAttributes<InitialOnboardingModel>;
|
export type InitialOnboardingCreationAttributes = InferCreationAttributes<InitialOnboardingModel>;
|
||||||
@ -16,8 +11,6 @@ export class InitialOnboardingModel extends Model<
|
|||||||
declare account_id: string;
|
declare account_id: string;
|
||||||
declare status: string;
|
declare status: string;
|
||||||
declare company_id: string | null;
|
declare company_id: string | null;
|
||||||
declare created_at: CreationOptional<Date>;
|
|
||||||
declare updated_at: CreationOptional<Date>;
|
|
||||||
declare completed_at: Date | string | null;
|
declare completed_at: Date | string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,8 +26,6 @@ export default (database: Sequelize) => {
|
|||||||
validate: { isIn: [["pending", "completed"]] },
|
validate: { isIn: [["pending", "completed"]] },
|
||||||
},
|
},
|
||||||
company_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null },
|
company_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null },
|
||||||
created_at: { type: DataTypes.DATE, allowNull: false },
|
|
||||||
updated_at: { type: DataTypes.DATE, allowNull: false },
|
|
||||||
completed_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null },
|
completed_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -45,8 +36,6 @@ export default (database: Sequelize) => {
|
|||||||
collate: "utf8mb4_unicode_ci",
|
collate: "utf8mb4_unicode_ci",
|
||||||
underscored: true,
|
underscored: true,
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
createdAt: "created_at",
|
|
||||||
updatedAt: "updated_at",
|
|
||||||
indexes: [
|
indexes: [
|
||||||
{
|
{
|
||||||
name: "uq_initial_onboardings_account_id",
|
name: "uq_initial_onboardings_account_id",
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export * from "./sequelize-initial-onboarding.repository";
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { DuplicateEntityError } from "@erp/core/api";
|
|
||||||
import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
import { Sequelize, UniqueConstraintError } from "sequelize";
|
|
||||||
|
|
||||||
import { InitialOnboarding } from "../../../../domain";
|
|
||||||
import { SequelizeInitialOnboardingDomainMapper } from "../mappers";
|
|
||||||
import initialOnboardingModelInit, {
|
|
||||||
InitialOnboardingModel,
|
|
||||||
} from "../models/sequelize-initial-onboarding.model";
|
|
||||||
|
|
||||||
import { SequelizeInitialOnboardingRepository } from "./sequelize-initial-onboarding.repository";
|
|
||||||
|
|
||||||
initialOnboardingModelInit(
|
|
||||||
new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false })
|
|
||||||
);
|
|
||||||
const mapper = new SequelizeInitialOnboardingDomainMapper();
|
|
||||||
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
|
|
||||||
function aggregate(completed = false) {
|
|
||||||
return InitialOnboarding.rehydrate(
|
|
||||||
{
|
|
||||||
accountId,
|
|
||||||
status: completed ? "completed" : "pending",
|
|
||||||
companyId: completed
|
|
||||||
? Maybe.some(UniqueID.create("33333333-3333-4333-8333-333333333333").data)
|
|
||||||
: Maybe.none(),
|
|
||||||
createdAt: UtcDateTime.createFromISO("2026-01-01T10:00:00.123Z").data,
|
|
||||||
updatedAt: UtcDateTime.createFromISO("2026-01-02T11:00:00.456Z").data,
|
|
||||||
completedAt: completed
|
|
||||||
? Maybe.some(UtcDateTime.createFromISO("2026-01-02T10:30:00.789Z").data)
|
|
||||||
: Maybe.none(),
|
|
||||||
},
|
|
||||||
UniqueID.create("11111111-1111-4111-8111-111111111111").data
|
|
||||||
).data;
|
|
||||||
}
|
|
||||||
function row(completed = false) {
|
|
||||||
return InitialOnboardingModel.build(mapper.mapToPersistence(aggregate(completed)).data, {
|
|
||||||
raw: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("find existente usa filtro account_id, mapper y transaction", async (t) => {
|
|
||||||
const transaction = {};
|
|
||||||
const model = row();
|
|
||||||
const find = t.mock.method(InitialOnboardingModel, "findOne", async () => model);
|
|
||||||
const map = t.mock.method(mapper, "mapToDomain");
|
|
||||||
const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(
|
|
||||||
accountId,
|
|
||||||
transaction
|
|
||||||
);
|
|
||||||
assert.equal(result.isSuccess, true);
|
|
||||||
assert.equal(result.data.isSome(), true);
|
|
||||||
assert.equal(result.data.unwrap().id.toPrimitive(), model.id);
|
|
||||||
assert.deepEqual(find.mock.calls[0].arguments[0], {
|
|
||||||
where: { account_id: accountId.toPrimitive() },
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
assert.equal(map.mock.calls[0].arguments[0], model);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("find ausente devuelve Maybe.none sin llamar al mapper", async (t) => {
|
|
||||||
t.mock.method(InitialOnboardingModel, "findOne", async () => null);
|
|
||||||
const map = t.mock.method(mapper, "mapToDomain");
|
|
||||||
const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(accountId);
|
|
||||||
assert.equal(result.isSuccess, true);
|
|
||||||
assert.equal(result.data.isNone(), true);
|
|
||||||
assert.equal(map.mock.callCount(), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const completed of [false, true]) {
|
|
||||||
test(`save ${completed ? "completed" : "pending"} crea usando mapper y conserva timestamps y transaction`, async (t) => {
|
|
||||||
const source = aggregate(completed);
|
|
||||||
const expected = mapper.mapToPersistence(source).data;
|
|
||||||
const transaction = {};
|
|
||||||
const find = t.mock.method(InitialOnboardingModel, "findByPk", async () => null);
|
|
||||||
const create = t.mock.method(InitialOnboardingModel, "create", async () => row(completed));
|
|
||||||
const map = t.mock.method(mapper, "mapToPersistence");
|
|
||||||
const result = await new SequelizeInitialOnboardingRepository(mapper).save(source, transaction);
|
|
||||||
assert.equal(result.isSuccess, true);
|
|
||||||
assert.equal(map.mock.calls[0].arguments[0], source);
|
|
||||||
assert.deepEqual(find.mock.calls[0].arguments, [source.id.toPrimitive(), { transaction }]);
|
|
||||||
assert.deepEqual(create.mock.calls[0].arguments, [expected, { transaction, silent: true }]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("save repetido actualiza por id sin volver a insertar", async (t) => {
|
|
||||||
const source = aggregate(true);
|
|
||||||
const transaction = {};
|
|
||||||
let stored: InitialOnboardingModel | null = null;
|
|
||||||
t.mock.method(InitialOnboardingModel, "findByPk", async () => stored);
|
|
||||||
const create = t.mock.method(InitialOnboardingModel, "create", async () => {
|
|
||||||
stored = row(true);
|
|
||||||
return stored;
|
|
||||||
});
|
|
||||||
const update = t.mock.method(InitialOnboardingModel, "update", async () => [1]);
|
|
||||||
const repository = new SequelizeInitialOnboardingRepository();
|
|
||||||
assert.equal((await repository.save(source, transaction)).isSuccess, true);
|
|
||||||
assert.equal((await repository.save(source, transaction)).isSuccess, true);
|
|
||||||
assert.equal(create.mock.callCount(), 1);
|
|
||||||
assert.equal(update.mock.callCount(), 1);
|
|
||||||
const { id, ...values } = mapper.mapToPersistence(source).data;
|
|
||||||
assert.deepEqual(update.mock.calls[0].arguments, [
|
|
||||||
values,
|
|
||||||
{ transaction, silent: true, where: { id } },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("read error retorna Result.fail", async (t) => {
|
|
||||||
const error = new Error("read failed");
|
|
||||||
t.mock.method(InitialOnboardingModel, "findOne", async () => {
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
const result = await new SequelizeInitialOnboardingRepository().findByAccountId(accountId);
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(result.error, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const existing of [false, true]) {
|
|
||||||
test(`write error en ${existing ? "update" : "create"} retorna Result.fail`, async (t) => {
|
|
||||||
const error = new Error("write failed");
|
|
||||||
t.mock.method(InitialOnboardingModel, "findByPk", async () => (existing ? row() : null));
|
|
||||||
t.mock.method(InitialOnboardingModel, existing ? "update" : "create", async () => {
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
const result = await new SequelizeInitialOnboardingRepository().save(aggregate());
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(result.error, error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("unique account_id retorna fallo y no reintenta ni relee", async (t) => {
|
|
||||||
const error = new UniqueConstraintError({ message: "duplicate account_id" });
|
|
||||||
const find = t.mock.method(InitialOnboardingModel, "findByPk", async () => null);
|
|
||||||
const reread = t.mock.method(InitialOnboardingModel, "findOne", async () => row());
|
|
||||||
const create = t.mock.method(InitialOnboardingModel, "create", async () => {
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
const result = await new SequelizeInitialOnboardingRepository().save(aggregate());
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.ok(result.error instanceof DuplicateEntityError);
|
|
||||||
assert.equal(result.error.cause, error);
|
|
||||||
assert.equal(find.mock.callCount(), 1);
|
|
||||||
assert.equal(create.mock.callCount(), 1);
|
|
||||||
assert.equal(reread.mock.callCount(), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fallo de mapper en lectura se propaga", async (t) => {
|
|
||||||
const error = new Error("mapping failed");
|
|
||||||
t.mock.method(InitialOnboardingModel, "findOne", async () => row());
|
|
||||||
t.mock.method(mapper, "mapToDomain", () => Result.fail(error));
|
|
||||||
const result = await new SequelizeInitialOnboardingRepository(mapper).findByAccountId(accountId);
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(result.error, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fallo de mapper en escritura evita consultar o escribir", async (t) => {
|
|
||||||
const error = new Error("mapping failed");
|
|
||||||
t.mock.method(mapper, "mapToPersistence", () => Result.fail(error));
|
|
||||||
const find = t.mock.method(InitialOnboardingModel, "findByPk");
|
|
||||||
const create = t.mock.method(InitialOnboardingModel, "create");
|
|
||||||
const result = await new SequelizeInitialOnboardingRepository(mapper).save(aggregate());
|
|
||||||
assert.equal(result.isFailure, true);
|
|
||||||
assert.equal(result.error, error);
|
|
||||||
assert.equal(find.mock.callCount(), 0);
|
|
||||||
assert.equal(create.mock.callCount(), 0);
|
|
||||||
});
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { translateSequelizeError } from "@erp/core/api";
|
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
|
||||||
import { Maybe, Result } from "@repo/rdx-utils";
|
|
||||||
import type { Transaction } from "sequelize";
|
|
||||||
|
|
||||||
import type { IInitialOnboardingRepository } from "../../../../application/repositories/initial-onboarding-repository.interface";
|
|
||||||
import type { InitialOnboarding } from "../../../../domain";
|
|
||||||
import { SequelizeInitialOnboardingDomainMapper } from "../mappers";
|
|
||||||
import { InitialOnboardingModel } from "../models";
|
|
||||||
|
|
||||||
export class SequelizeInitialOnboardingRepository implements IInitialOnboardingRepository {
|
|
||||||
public constructor(
|
|
||||||
private readonly domainMapper = new SequelizeInitialOnboardingDomainMapper()
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async findByAccountId(
|
|
||||||
accountId: UniqueID,
|
|
||||||
transaction?: unknown
|
|
||||||
): Promise<Result<Maybe<InitialOnboarding>, Error>> {
|
|
||||||
try {
|
|
||||||
const row = await InitialOnboardingModel.findOne({
|
|
||||||
where: { account_id: accountId.toPrimitive() },
|
|
||||||
transaction: transaction as Transaction | undefined,
|
|
||||||
});
|
|
||||||
if (!row) return Result.ok(Maybe.none<InitialOnboarding>());
|
|
||||||
return this.domainMapper.mapToDomain(row).map((value) => Maybe.some(value));
|
|
||||||
} catch (error: unknown) {
|
|
||||||
return Result.fail(translateSequelizeError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async save(
|
|
||||||
initialOnboarding: InitialOnboarding,
|
|
||||||
transaction?: unknown
|
|
||||||
): Promise<Result<void, Error>> {
|
|
||||||
try {
|
|
||||||
const persistence = this.domainMapper.mapToPersistence(initialOnboarding);
|
|
||||||
if (persistence.isFailure) return Result.fail(persistence.error);
|
|
||||||
|
|
||||||
const sqlTransaction = transaction as Transaction | undefined;
|
|
||||||
const { id, ...values } = persistence.data;
|
|
||||||
// Comprobar por id evita que un upsert MySQL absorba una colisión de account_id.
|
|
||||||
const existing = await InitialOnboardingModel.findByPk(id, {
|
|
||||||
transaction: sqlTransaction,
|
|
||||||
});
|
|
||||||
// El agregado proporciona los timestamps; Sequelize no debe reemplazar updated_at.
|
|
||||||
const options = { transaction: sqlTransaction, silent: true };
|
|
||||||
if (existing) {
|
|
||||||
await InitialOnboardingModel.update(values, { ...options, where: { id } });
|
|
||||||
} else {
|
|
||||||
await InitialOnboardingModel.create(persistence.data, options);
|
|
||||||
}
|
|
||||||
return Result.ok();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
return Result.fail(translateSequelizeError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export { Audit, type AuditDependencies } from "./audit";
|
export { Audit, type AuditDependencies } from "./audit";
|
||||||
|
export type { AuditArray, AuditObject, AuditScalar, AuditState, AuditValue } from "./audit-state.types";
|
||||||
export type {
|
export type {
|
||||||
AuditActor,
|
AuditActor,
|
||||||
AuditChange,
|
AuditChange,
|
||||||
@ -10,7 +11,6 @@ export type {
|
|||||||
AuditMutationEntry,
|
AuditMutationEntry,
|
||||||
AuditMutationTarget,
|
AuditMutationTarget,
|
||||||
AuditOperation,
|
AuditOperation,
|
||||||
AuditRecordOptions,
|
|
||||||
AuditScope,
|
AuditScope,
|
||||||
AuditSource,
|
AuditSource,
|
||||||
IAudit,
|
IAudit,
|
||||||
@ -22,13 +22,6 @@ export type {
|
|||||||
RecordAuditMutationInput,
|
RecordAuditMutationInput,
|
||||||
RecordAuditMutationShortcutInput,
|
RecordAuditMutationShortcutInput,
|
||||||
} from "./audit.types";
|
} from "./audit.types";
|
||||||
export type {
|
|
||||||
AuditArray,
|
|
||||||
AuditObject,
|
|
||||||
AuditScalar,
|
|
||||||
AuditState,
|
|
||||||
AuditValue,
|
|
||||||
} from "./audit-state.types";
|
|
||||||
export {
|
export {
|
||||||
AuditContextUnavailableError,
|
AuditContextUnavailableError,
|
||||||
AuditError,
|
AuditError,
|
||||||
|
|||||||
@ -75,7 +75,7 @@ export class CriteriaFromUrlConverter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Object.values(tempFilters).filter(
|
return Object.values(tempFilters).filter(
|
||||||
(filter): filter is FiltersPrimitives =>
|
(filter) =>
|
||||||
filter.field !== undefined &&
|
filter.field !== undefined &&
|
||||||
filter.operator !== undefined &&
|
filter.operator !== undefined &&
|
||||||
(filter.value !== undefined ||
|
(filter.value !== undefined ||
|
||||||
|
|||||||
@ -1,4 +1 @@
|
|||||||
// biome-ignore lint/correctness/noUnusedVariables: <se necesita para definir la interfaz, aunque no se use directamente>
|
export type IAggregateRootRepository = {};
|
||||||
export type IAggregateRootRepository<T> = {
|
|
||||||
// Define the methods for the aggregate root repository
|
|
||||||
};
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user