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 { AuditEntry, IAuditStore } from "@repo/rdx-audit";
|
||||
import { type AuditRecordOptions, AuditStoreError } from "@repo/rdx-audit";
|
||||
import type { AuditEntry, AuditRecordOptions, IAuditStore } from "@repo/rdx-audit";
|
||||
import { AuditStoreError } from "@repo/rdx-audit";
|
||||
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 { AuditEventModel } from "./audit-event.model";
|
||||
@ -19,12 +19,8 @@ export class SequelizeAuditStore implements IAuditStore {
|
||||
private readonly transactionContext: ISequelizeTransactionContext
|
||||
) {}
|
||||
|
||||
async append(
|
||||
entry: AuditEntry,
|
||||
options?: AuditRecordOptions
|
||||
): Promise<Result<void, AuditStoreError>> {
|
||||
const transaction = (options?.transaction as Transaction) ?? this.transactionContext.current();
|
||||
|
||||
async append(entry: AuditEntry, options?: AuditRecordOptions): Promise<Result<void, AuditStoreError>> {
|
||||
const transaction = options?.transaction ?? this.transactionContext.current();
|
||||
if (entry.kind === "mutation" && !transaction) {
|
||||
return Result.fail(
|
||||
new AuditStoreError("An active transaction is required for audit mutations.")
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export * from "./company-public-finder";
|
||||
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 { Result } from "@repo/rdx-utils";
|
||||
import type { ModelStatic, Transaction } from "sequelize";
|
||||
import type { ModelStatic } from "sequelize";
|
||||
|
||||
import type { ISequelizeTransactionContext } from "../../persistence/sequelize";
|
||||
|
||||
@ -18,7 +18,7 @@ export class SequelizeAuditStore implements IAuditStore {
|
||||
entry: AuditEntry,
|
||||
options?: AuditRecordOptions
|
||||
): 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)
|
||||
return Result.fail(
|
||||
new AuditStoreError("An active transaction is required for audit mutations.")
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import type { ICompanyCertificateContext, ISigningContextResolver } from "../../../application";
|
||||
import type { ModuleParams } from "../../../modules";
|
||||
// core/api/infrastructure/documents/certificates/
|
||||
// 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";
|
||||
|
||||
|
||||
@ -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 { 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>
|
||||
extends DomainMapperWithBulk<TModel | TModelAttributes, TEntity> {}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export * from "./module-runtime";
|
||||
export * from "./module-server.interface";
|
||||
export * from "./module-runtime";
|
||||
export * from "./modules-runtime";
|
||||
export * from "./types";
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
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 { ModulesRuntime } from "./modules-runtime";
|
||||
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 };
|
||||
|
||||
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. */
|
||||
export async function initModules(
|
||||
params: ModuleParams & {
|
||||
logger: ILogger;
|
||||
database: Sequelize;
|
||||
warmup: { timeoutMs: number; strict: boolean };
|
||||
databaseSyncMode: string;
|
||||
}
|
||||
): Promise<ModulesRuntime> {
|
||||
export async function initModules(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, []);
|
||||
await initializeModels(params.database, params.databaseSyncMode, params.logger);
|
||||
await warmupModules(params);
|
||||
validateDependencies();
|
||||
|
||||
const customerInvoices = internals.get("customer-invoices") as
|
||||
| {
|
||||
proformas: { recurringProformas: ModulesRuntime["customerInvoices"]["recurringProformas"] };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!customerInvoices)
|
||||
throw new Error('Module "customer-invoices" did not expose recurring runtime capabilities');
|
||||
|
||||
const customerInvoices = internals.get("customer-invoices") as { 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 };
|
||||
}
|
||||
|
||||
@ -59,228 +44,71 @@ export async function startModules(params: ModuleParams & { logger: ILogger }):
|
||||
for (const name of setupOrder) {
|
||||
const module = modules.get(name);
|
||||
if (!module?.start) continue;
|
||||
await module.start({
|
||||
...params,
|
||||
getService: makeGetService(name, module),
|
||||
getInternal: makeGetInternal(name),
|
||||
listServices,
|
||||
} as StartParams);
|
||||
await module.start({ ...params, getService: makeGetService(name, module), getInternal: makeGetInternal(name), listServices } as StartParams);
|
||||
}
|
||||
validateDependencies();
|
||||
}
|
||||
|
||||
async function setupModule(
|
||||
name: string,
|
||||
params: ModuleParams & { logger: ILogger },
|
||||
stack: string[]
|
||||
): Promise<void> {
|
||||
if (setupDone.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (visiting.has(name)) {
|
||||
throw new Error(`Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
|
||||
}
|
||||
|
||||
async function setupModule(name: string, params: ModuleParams & { logger: ILogger }, 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);
|
||||
|
||||
if (!module) {
|
||||
throw new Error(`Module "${name}" not found`);
|
||||
}
|
||||
|
||||
if (!module) throw new Error(`Module "${name}" not found`);
|
||||
visiting.add(name);
|
||||
for (const dependency of module.dependencies ?? []) {
|
||||
await setupModule(dependency, params, [...stack, name]);
|
||||
}
|
||||
|
||||
const result = (await module.setup?.({
|
||||
...params,
|
||||
getService: makeGetService(name, module),
|
||||
listServices,
|
||||
})) as ModuleSetupResult | undefined;
|
||||
|
||||
for (const dependency of module.dependencies ?? []) await setupModule(dependency, params, [...stack, name]);
|
||||
const result = (await module.setup?.({ ...params, getService: makeGetService(name, module), listServices })) as ModuleSetupResult | undefined;
|
||||
internals.set(name, result?.internal ?? {});
|
||||
|
||||
if (result?.models) {
|
||||
registerModels(result.models as ModelInitializer[], name);
|
||||
}
|
||||
|
||||
for (const [key, service] of Object.entries(result?.services ?? {})) {
|
||||
registerService(`${name}:${key}`, service);
|
||||
}
|
||||
|
||||
if (result?.models) registerModels(result.models as ModelInitializer[], name);
|
||||
for (const [key, service] of Object.entries(result?.services ?? {})) registerService(`${name}:${key}`, service);
|
||||
setupDone.add(name);
|
||||
|
||||
setupOrder.push(name);
|
||||
|
||||
visiting.delete(name);
|
||||
}
|
||||
|
||||
async function initializeModels(
|
||||
database: Sequelize,
|
||||
syncMode: string,
|
||||
logger: ILogger
|
||||
): Promise<void> {
|
||||
if (modelsInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registered = new Map<string, { model: ModelStatic<Model>; moduleName: string }>();
|
||||
|
||||
async function initializeModels(database: Sequelize, syncMode: string, logger: ILogger): Promise<void> {
|
||||
if (modelsInitialized) return;
|
||||
const registered = new Map<string, { model: ModelStatic; moduleName: string }>();
|
||||
for (const entry of models) {
|
||||
const model = entry.initializer(database);
|
||||
if (!model?.name) {
|
||||
throw new Error(`Invalid model initializer from "${entry.moduleName}"`);
|
||||
}
|
||||
if (registered.has(model.name)) {
|
||||
throw new Error(`Model name collision: "${model.name}"`);
|
||||
}
|
||||
if (!model?.name) throw new Error(`Invalid model initializer from "${entry.moduleName}"`);
|
||||
if (registered.has(model.name)) throw new Error(`Model name collision: "${model.name}"`);
|
||||
registered.set(model.name, { model, moduleName: entry.moduleName });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (syncMode === "alter") {
|
||||
await database.sync({ force: false, alter: true });
|
||||
}
|
||||
|
||||
if (syncMode === "force") {
|
||||
await database.sync({ force: true, alter: false });
|
||||
}
|
||||
|
||||
logger.info("Backend module models initialized", {
|
||||
label: "moduleRuntime",
|
||||
modelCount: registered.size,
|
||||
});
|
||||
if (syncMode === "alter") await database.sync({ force: false, alter: true });
|
||||
if (syncMode === "force") await database.sync({ force: true, alter: false });
|
||||
logger.info("Backend module models initialized", { label: "moduleRuntime", modelCount: registered.size });
|
||||
modelsInitialized = true;
|
||||
}
|
||||
|
||||
async function warmupModules(
|
||||
params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }
|
||||
): Promise<void> {
|
||||
async function warmupModules(params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }): Promise<void> {
|
||||
for (const name of setupOrder) {
|
||||
const module = modules.get(name);
|
||||
if (!module?.warmup) continue;
|
||||
try {
|
||||
await withTimeout(
|
||||
Promise.resolve(
|
||||
module.warmup({
|
||||
...params,
|
||||
getService: makeGetService(name, module),
|
||||
getInternal: makeGetInternal(name),
|
||||
listServices,
|
||||
} as WarmupParams)
|
||||
),
|
||||
params.warmup.timeoutMs,
|
||||
`${name}.warmup`
|
||||
);
|
||||
await withTimeout(Promise.resolve(module.warmup({ ...params, getService: makeGetService(name, module), getInternal: makeGetInternal(name), listServices } as WarmupParams)), params.warmup.timeoutMs, `${name}.warmup`);
|
||||
} catch (error) {
|
||||
if (params.warmup.strict) throw error;
|
||||
params.logger.warn("Module warmup failed but continuing", {
|
||||
label: "moduleRuntime",
|
||||
module: name,
|
||||
error,
|
||||
});
|
||||
params.logger.warn("Module warmup failed but continuing", { label: "moduleRuntime", module: name, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerService(name: string, service: unknown): void {
|
||||
if (services[name]) throw new Error(`Service "${name}" already registered`);
|
||||
services[name] = service;
|
||||
}
|
||||
|
||||
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(":")}`);
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
function registerService(name: string, service: unknown): void { if (services[name]) throw new Error(`Service "${name}" already registered`); services[name] = service; }
|
||||
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(":")}`); }; }
|
||||
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(":");
|
||||
if (moduleName === "self") return getService<T>(`${requesterModule}:${key.join(":")}`);
|
||||
if (!allowedDependencies.includes(moduleName))
|
||||
throw new Error(
|
||||
`Module "${requesterModule}" tried to access service "${name}" without declaring dependency on "${moduleName}"`
|
||||
);
|
||||
if (!allowedDependencies.includes(moduleName)) throw new Error(`Module "${requesterModule}" tried to access service "${name}" without declaring dependency on "${moduleName}"`);
|
||||
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 {
|
||||
findDue(
|
||||
dueThrough: Date,
|
||||
limit: number
|
||||
): Promise<{
|
||||
findDue(dueThrough: Date, limit: number): Promise<{
|
||||
isFailure: boolean;
|
||||
error: Error;
|
||||
data: readonly {
|
||||
recurringProformaId: UniqueID;
|
||||
companyId: UniqueID;
|
||||
nextOccurrenceDate: Date;
|
||||
}[];
|
||||
data: readonly { recurringProformaId: UniqueID; companyId: UniqueID; nextOccurrenceDate: Date }[];
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import Dinero, { type Currency } from "dinero.js";
|
||||
|
||||
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) =>
|
||||
!dto || dto.value?.trim?.() === "" || dto.scale?.trim?.() === "";
|
||||
@ -147,40 +149,40 @@ export const MoneyDTOHelper = {
|
||||
/**
|
||||
* 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);
|
||||
return Dinero({
|
||||
amount: Number.parseInt(n.value, 10),
|
||||
precision: Number.parseInt(n.scale, 10),
|
||||
currency: n.currency_code as Currency,
|
||||
});
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return {
|
||||
value: amount.toString(),
|
||||
scale: precision.toString(),
|
||||
currency_code: currency,
|
||||
};
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
const sum = list.map((x) => dineroFromDTO(x, fallbackCurrency)).reduce((a, b) => a.add(b));
|
||||
return dtoFromDinero(sum);
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplica un MoneyDTO por un número.
|
||||
*/
|
||||
/*function multiplyDTO(
|
||||
function multiplyDTO(
|
||||
dto: MoneyDTO,
|
||||
multiplier: number,
|
||||
rounding: Dinero.RoundingMode = "HALF_EVEN",
|
||||
@ -188,12 +190,12 @@ export const MoneyDTOHelper = {
|
||||
): MoneyDTO {
|
||||
const d = dineroFromDTO(dto, fallbackCurrency).multiply(multiplier, rounding);
|
||||
return dtoFromDinero(d);
|
||||
}*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula un porcentaje de un MoneyDTO.
|
||||
*/
|
||||
/*function percentageDTO(
|
||||
function percentageDTO(
|
||||
dto: MoneyDTO,
|
||||
percent: number,
|
||||
rounding: Dinero.RoundingMode = "HALF_EVEN",
|
||||
@ -201,4 +203,4 @@ export const MoneyDTOHelper = {
|
||||
): MoneyDTO {
|
||||
const d = dineroFromDTO(dto, fallbackCurrency).percentage(percent, rounding);
|
||||
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.completedAt.isNone());
|
||||
check(r.data.accountId.equals(accountId));
|
||||
check(r.data.createdAt instanceof UtcDateTime);
|
||||
check(r.data.updatedAt.equals(r.data.createdAt));
|
||||
});
|
||||
test("rehydrates valid states", () => {
|
||||
check(InitialOnboarding.rehydrate(props("pending"), UniqueID.generateNewID()).isSuccess);
|
||||
@ -66,11 +64,7 @@ test("rejects invalid state combinations", () => {
|
||||
test("completes once", () => {
|
||||
const o = InitialOnboarding.createPending(UniqueID.generateNewID()).data;
|
||||
const id = UniqueID.generateNewID();
|
||||
const createdAt = o.createdAt;
|
||||
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.complete({ companyId: id, completedAt: date(), updatedAt: date() }).isSuccess);
|
||||
check(o.status === "completed");
|
||||
check(o.companyId.unwrap().equals(id));
|
||||
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";
|
||||
|
||||
export type InitialOnboardingStatus = "pending" | "completed";
|
||||
@ -6,8 +6,6 @@ export interface InitialOnboardingProps {
|
||||
accountId: UniqueID;
|
||||
status: InitialOnboardingStatus;
|
||||
companyId: Maybe<UniqueID>;
|
||||
createdAt: UtcDateTime;
|
||||
updatedAt: UtcDateTime;
|
||||
completedAt: Maybe<UtcDateTime>;
|
||||
}
|
||||
export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
||||
@ -22,14 +20,11 @@ export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
||||
}
|
||||
|
||||
static createPending(accountId: UniqueID): Result<InitialOnboarding, Error> {
|
||||
const now = UtcDateTime.now();
|
||||
return InitialOnboarding.rehydrate(
|
||||
{
|
||||
accountId,
|
||||
status: "pending",
|
||||
companyId: Maybe.none(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
completedAt: Maybe.none(),
|
||||
},
|
||||
UniqueID.generateNewID()
|
||||
@ -66,14 +61,6 @@ export class InitialOnboarding extends AggregateRoot<InitialOnboardingProps> {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
get createdAt(): UtcDateTime {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): UtcDateTime {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
get accountId() {
|
||||
return this.props.accountId;
|
||||
}
|
||||
|
||||
@ -2,6 +2,5 @@ import initialOnboardingModelInit from "./models/sequelize-initial-onboarding.mo
|
||||
|
||||
export * from "./mappers";
|
||||
export * from "./models";
|
||||
export * from "./repositories";
|
||||
|
||||
export const models = [initialOnboardingModelInit];
|
||||
|
||||
@ -20,8 +20,6 @@ const mapper = new SequelizeInitialOnboardingDomainMapper();
|
||||
const id = "11111111-1111-4111-8111-111111111111";
|
||||
const accountId = "22222222-2222-4222-8222-222222222222";
|
||||
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";
|
||||
|
||||
function aggregate(status: InitialOnboardingStatus) {
|
||||
@ -32,8 +30,6 @@ function aggregate(status: InitialOnboardingStatus) {
|
||||
companyId:
|
||||
status === "completed" ? Maybe.some(UniqueID.create(companyId).data) : Maybe.none(),
|
||||
|
||||
createdAt: UtcDateTime.createFromISO(createdAt).data,
|
||||
updatedAt: UtcDateTime.createFromISO(updatedAt).data,
|
||||
completedAt:
|
||||
status === "completed"
|
||||
? Maybe.some(UtcDateTime.createFromISO(completedAt).data)
|
||||
@ -52,8 +48,6 @@ function row(patch: Partial<InferAttributes<InitialOnboardingModel>> = {}) {
|
||||
account_id: accountId,
|
||||
status: "pending",
|
||||
company_id: null,
|
||||
created_at: new Date(createdAt),
|
||||
updated_at: new Date(updatedAt),
|
||||
completed_at: null,
|
||||
...patch,
|
||||
},
|
||||
@ -63,18 +57,13 @@ function row(patch: Partial<InferAttributes<InitialOnboardingModel>> = {}) {
|
||||
|
||||
for (const status of ["pending", "completed"] as const) {
|
||||
test(`toPersistence ${status}`, () => {
|
||||
const source = aggregate(status);
|
||||
const result = mapper.mapToPersistence(source);
|
||||
const result = mapper.mapToPersistence(aggregate(status));
|
||||
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, {
|
||||
id,
|
||||
account_id: accountId,
|
||||
status,
|
||||
company_id: status === "completed" ? companyId : null,
|
||||
created_at: new Date(createdAt),
|
||||
updated_at: new Date(updatedAt),
|
||||
completed_at: status === "completed" ? completedAt : null,
|
||||
});
|
||||
});
|
||||
@ -84,8 +73,6 @@ for (const status of ["pending", "completed"] as const) {
|
||||
row({
|
||||
status,
|
||||
company_id: status === "completed" ? companyId : null,
|
||||
created_at: new Date(createdAt),
|
||||
updated_at: new Date(updatedAt),
|
||||
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.accountId.toPrimitive(), accountId);
|
||||
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.completedAt.isSome(), status === "completed");
|
||||
if (status === "completed") {
|
||||
@ -128,14 +113,10 @@ test("toDomain accepts Sequelize Date timestamps and preserves UTC milliseconds"
|
||||
row({
|
||||
status: "completed",
|
||||
company_id: companyId,
|
||||
created_at: new Date(createdAt),
|
||||
updated_at: new Date(updatedAt),
|
||||
completed_at: new Date(completedAt),
|
||||
})
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
@ -152,8 +133,8 @@ test("toDomain propagates invalid identifiers and timestamps as failures", () =>
|
||||
{ id: "invalid" },
|
||||
{ account_id: "invalid" },
|
||||
{ status: "completed", company_id: "invalid", completed_at: completedAt },
|
||||
{ created_at: new Date(Number.NaN) },
|
||||
{ updated_at: new Date(Number.NaN) },
|
||||
{ created_at: "invalid" },
|
||||
{ updated_at: "invalid" },
|
||||
{ status: "completed", company_id: companyId, completed_at: "invalid" },
|
||||
]) {
|
||||
assert.equal(mapper.mapToDomain(row(patch)).isFailure, true);
|
||||
|
||||
@ -32,10 +32,6 @@ export class SequelizeInitialOnboardingDomainMapper {
|
||||
if (accountId.isFailure) return Result.fail(accountId.error);
|
||||
const companyId = maybeFromNullableResult(source.company_id, (value) => UniqueID.create(value));
|
||||
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) =>
|
||||
toUtcDateTime(value)
|
||||
);
|
||||
@ -46,8 +42,6 @@ export class SequelizeInitialOnboardingDomainMapper {
|
||||
accountId: accountId.data,
|
||||
status: source.status,
|
||||
companyId: companyId.data,
|
||||
createdAt: createdAt.data,
|
||||
updatedAt: updatedAt.data,
|
||||
completedAt: completedAt.data,
|
||||
},
|
||||
id.data
|
||||
@ -62,8 +56,6 @@ export class SequelizeInitialOnboardingDomainMapper {
|
||||
account_id: source.accountId.toPrimitive(),
|
||||
status: source.status,
|
||||
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()),
|
||||
});
|
||||
}
|
||||
|
||||
@ -12,9 +12,7 @@ initialOnboardingModelInit(database);
|
||||
|
||||
test("model declares table, nullability, UUIDs, timestamps and unique account without cross-module FKs", () => {
|
||||
assert.equal(InitialOnboardingModel.getTableName(), "initial_onboardings");
|
||||
assert.equal(InitialOnboardingModel.options.timestamps, true);
|
||||
assert.equal(InitialOnboardingModel.options.createdAt, "created_at");
|
||||
assert.equal(InitialOnboardingModel.options.updatedAt, "updated_at");
|
||||
assert.equal(InitialOnboardingModel.options.timestamps, false);
|
||||
const attributes = InitialOnboardingModel.getAttributes();
|
||||
assert.deepEqual(Object.keys(attributes).sort(), [
|
||||
"account_id",
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
import type {
|
||||
CreationOptional,
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
Sequelize,
|
||||
} from "sequelize";
|
||||
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
|
||||
import { DataTypes, Model } from "sequelize";
|
||||
|
||||
export type InitialOnboardingCreationAttributes = InferCreationAttributes<InitialOnboardingModel>;
|
||||
@ -16,8 +11,6 @@ export class InitialOnboardingModel extends Model<
|
||||
declare account_id: string;
|
||||
declare status: string;
|
||||
declare company_id: string | null;
|
||||
declare created_at: CreationOptional<Date>;
|
||||
declare updated_at: CreationOptional<Date>;
|
||||
declare completed_at: Date | string | null;
|
||||
}
|
||||
|
||||
@ -33,8 +26,6 @@ export default (database: Sequelize) => {
|
||||
validate: { isIn: [["pending", "completed"]] },
|
||||
},
|
||||
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 },
|
||||
},
|
||||
{
|
||||
@ -45,8 +36,6 @@ export default (database: Sequelize) => {
|
||||
collate: "utf8mb4_unicode_ci",
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
indexes: [
|
||||
{
|
||||
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 type { AuditArray, AuditObject, AuditScalar, AuditState, AuditValue } from "./audit-state.types";
|
||||
export type {
|
||||
AuditActor,
|
||||
AuditChange,
|
||||
@ -10,7 +11,6 @@ export type {
|
||||
AuditMutationEntry,
|
||||
AuditMutationTarget,
|
||||
AuditOperation,
|
||||
AuditRecordOptions,
|
||||
AuditScope,
|
||||
AuditSource,
|
||||
IAudit,
|
||||
@ -22,13 +22,6 @@ export type {
|
||||
RecordAuditMutationInput,
|
||||
RecordAuditMutationShortcutInput,
|
||||
} from "./audit.types";
|
||||
export type {
|
||||
AuditArray,
|
||||
AuditObject,
|
||||
AuditScalar,
|
||||
AuditState,
|
||||
AuditValue,
|
||||
} from "./audit-state.types";
|
||||
export {
|
||||
AuditContextUnavailableError,
|
||||
AuditError,
|
||||
|
||||
@ -75,7 +75,7 @@ export class CriteriaFromUrlConverter {
|
||||
});
|
||||
|
||||
return Object.values(tempFilters).filter(
|
||||
(filter): filter is FiltersPrimitives =>
|
||||
(filter) =>
|
||||
filter.field !== undefined &&
|
||||
filter.operator !== 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<T> = {
|
||||
// Define the methods for the aggregate root repository
|
||||
};
|
||||
export type IAggregateRootRepository = {};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user