This commit is contained in:
David Arranz 2025-10-30 14:47:33 +01:00
parent 3b235c8cca
commit b1f27e6f98
32 changed files with 0 additions and 612 deletions

View File

@ -1,2 +0,0 @@
node_modules
dist

View File

@ -1,12 +0,0 @@
{
"name": "rdx-verifactu",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@ -1,20 +0,0 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"allowJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"noEmit": false,
"skipLibCheck": true,
"strict": true,
"incremental": false,
"declaration": true,
"exactOptionalPropertyTypes": true,
"target": "ES2022"
}
}

View File

@ -1,9 +0,0 @@
{
"name": "@erp/{{kebabCase module}}",
"version": "0.1.0",
"main": "src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint . --ext .ts"
}
}

View File

@ -1,12 +0,0 @@
import { Presenter } from "@erp/core/api";
import { {{pascalCase name}} } from "../../../domain";
export class {{pascalCase name}}FullPresenter extends Presenter<{{pascalCase name}}, any> {
toOutput(entity: {{pascalCase name}}): any {
return {
id: entity.id.toPrimitive(),
name: entity.name,
metadata: { entity: "{{kebabCase name}}" }
};
}
}

View File

@ -1,22 +0,0 @@
import { Presenter } from "@erp/core/api";
import { Criteria } from "@repo/rdx-criteria/server";
import { Collection } from "@repo/rdx-utils";
import { {{pascalCase name}} } from "../../../domain";
export class List{{pascalCase name}}sPresenter extends Presenter {
private _mapEntity(entity: {{pascalCase name}}) {
return { id: entity.id.toPrimitive(), name: entity.name };
}
toOutput(params: { entities: Collection<{{pascalCase name}}>; criteria: Criteria }): any {
const { entities, criteria } = params;
return {
page: criteria.pageNumber,
per_page: criteria.pageSize,
total_pages: Math.ceil(entities.total() / criteria.pageSize),
total_items: entities.total(),
items: entities.map((e) => this._mapEntity(e)),
metadata: { entity: "{{kebabCase name}}s", criteria: criteria.toJSON() }
};
}
}

View File

@ -1,31 +0,0 @@
import { CompositeSpecification, UniqueID } from "@repo/rdx-ddd";
import { I
{
pascalCase;
name;
}
Repository;
} from "../../domain/repositories"
export class {
{
pascalCase;
name;
}
}NotExistsSpecification extends CompositeSpecification<UniqueID>
{
constructor(
private readonly repo: I{{pascalCase name}}Repository,
private readonly transaction?: unknown
)
super();
async;
isSatisfiedBy(entityId: UniqueID)
: Promise<boolean>
{
const existsOrError = await this.repo.existsById(entityId, this.transaction);
if (existsOrError.isFailure) throw existsOrError.error;
return existsOrError.data === false;
}
}

View File

@ -1,32 +0,0 @@
import { CompositeSpecification } from "@repo/rdx-ddd";
import { I
{
pascalCase;
name;
}
Repository;
} from "../../domain/repositories"
export class {
{
pascalCase;
name;
}
}UniqueNameSpecification extends CompositeSpecification<string>
{
constructor(
private readonly repo: I{{pascalCase name}}Repository,
private readonly transaction?: unknown
)
super();
async;
isSatisfiedBy(name: string)
: Promise<boolean>
{
const criteria = { filters: { name } };
const resultOrError = await this.repo.findByCriteria(criteria as any, this.transaction);
if (resultOrError.isFailure) throw resultOrError.error;
return resultOrError.data.total() === 0;
}
}

View File

@ -1,28 +0,0 @@
import { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { Result } from "@repo/rdx-utils";
import { I{{pascalCase name}}Repository } from "../../domain/repositories";
import { {{pascalCase name}} } from "../../domain/aggregates";
import { {{pascalCase name}}FullPresenter } from "../presenters";
type Create{{pascalCase name}}Input = { name: string };
export class Create{{pascalCase name}}UseCase {
constructor(
private readonly repo: I{{pascalCase name}}Repository,
private readonly txManager: ITransactionManager,
private readonly presenters: IPresenterRegistry
) {}
public execute(params: Create{{pascalCase name}}Input) {
return this.txManager.complete(async (tx) => {
const entityOrError = {{pascalCase name}}.create({ name: params.name });
if (entityOrError.isFailure) return Result.fail(entityOrError.error);
const savedOrError = await this.repo.save(entityOrError.data, tx);
if (savedOrError.isFailure) return Result.fail(savedOrError.error);
const presenter = this.presenters.getPresenter({ resource: "{{kebabCase name}}", projection: "FULL" }) as {{pascalCase name}}FullPresenter;
return Result.ok(presenter.toOutput(savedOrError.data));
});
}
}

View File

@ -1,17 +0,0 @@
import { AggregateRoot, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
export interface I{{pascalCase name}}Props {
name: string;
}
export class {{pascalCase name}} extends AggregateRoot<I{{pascalCase name}}Props> {
static create(props: I{{pascalCase name}}Props, id?: UniqueID): Result<{{pascalCase name}}, Error> {
const entity = new {{pascalCase name}}(props, id);
return Result.ok(entity);
}
get name(): string {
return this.props.name;
}
}

View File

@ -1,33 +0,0 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Result, Collection } from "@repo/rdx-utils";
import { Criteria } from "@repo/rdx-criteria/server";
import {
{
pascalCase;
name;
}
} from "../aggregates"
export interface I{{pascalCase name}
}Repository
{
save(entity: {{pascalCase name}}, transaction: unknown)
: Promise<Result<
pascalCase;
name;
, Error>>
existsById(id: UniqueID, transaction?: unknown)
: Promise<Result<boolean, Error>>
getById(id: UniqueID, transaction?: unknown)
: Promise<Result<
pascalCase;
name;
, Error>>
findByCriteria(criteria: Criteria, transaction?: unknown)
: Promise<Result<Collection<
pascalCase;
name;
>, Error>>
deleteById(id: UniqueID, transaction: unknown)
: Promise<Result<void, Error>>
}

View File

@ -1,32 +0,0 @@
import { ExpressController, authGuard, tenantGuard, forbidQueryFieldGuard } from "@erp/core/api";
import { Create{{pascalCase name}}UseCase } from "../../../application";
/**
* Controlador de creación
* @remarks
* - Aplica guards de autenticación, tenant y prohíbe `companyId` en query.
*/
export class Create{{pascalCase name}}Controller extends ExpressController {
public constructor(private readonly useCase: Create{{pascalCase name}}UseCase) {
super();
this.useGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) return this.forbiddenError("Tenant ID not found");
// TODO: descomentar cuando exista DTO
// const validation = validateRequest(Create{{pascalCase name}}Dto, this.req.body);
// if (validation.isErr()) return this.badRequestError(validation.error.message);
// const dto = validation.value;
const dto = this.req.body;
const result = await this.useCase.execute({ companyId, ...dto });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -1,25 +0,0 @@
import { ExpressController, authGuard, tenantGuard, forbidQueryFieldGuard } from "@erp/core/api";
import { Delete{{pascalCase name}}UseCase } from "../../../application";
/**
* Controlador de borrado lógico/físico según políticas
*/
export class Delete{{pascalCase name}}Controller extends ExpressController {
public constructor(private readonly useCase: Delete{{pascalCase name}}UseCase) {
super();
this.useGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) return this.forbiddenError("Tenant ID not found");
const { {{snakeCase name}}_id } = this.req.params as { {{snakeCase name}}_id: string };
const result = await this.useCase.execute({ {{snakeCase name}}_id, companyId });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -1,25 +0,0 @@
import { ExpressController, authGuard, tenantGuard, forbidQueryFieldGuard } from "@erp/core/api";
import { Get{{pascalCase name}}UseCase } from "../../../application";
/**
* Controlador de lectura por id en el contexto del tenant
*/
export class Get{{pascalCase name}}Controller extends ExpressController {
public constructor(private readonly useCase: Get{{pascalCase name}}UseCase) {
super();
this.useGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) return this.forbiddenError("Tenant ID not found");
const { {{snakeCase name}}_id } = this.req.params as { {{snakeCase name}}_id: string };
const result = await this.useCase.execute({ {{snakeCase name}}_id, companyId });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -1,6 +0,0 @@
// Barrel de controllers
export * from "./create-{{kebabCase name}}.controller";
export * from "./delete-{{kebabCase name}}.controller";
export * from "./get-{{kebabCase name}}.controller";
export * from "./list-{{kebabCase name}}.controller";
export * from "./update-{{kebabCase name}}.controller";

View File

@ -1,28 +0,0 @@
import { ExpressController, authGuard, tenantGuard, forbidQueryFieldGuard } from "@erp/core/api";
import { List{{pascalCase name}}UseCase } from "../../../application";
/**
* Controlador de listado paginado por tenant
*/
export class List{{pascalCase name}}Controller extends ExpressController {
public constructor(private readonly useCase: List{{pascalCase name}}UseCase) {
super();
this.useGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) return this.forbiddenError("Tenant ID not found");
// TODO: mapear query → criteria/pagination
const criteria = this.req.query ?? {};
const pagination = undefined;
const result = await this.useCase.execute({ companyId, criteria, pagination });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -1,32 +0,0 @@
import { ExpressController, authGuard, tenantGuard, forbidQueryFieldGuard } from "@erp/core/api";
import { Update{{pascalCase name}}UseCase } from "../../../application";
/**
* Controlador de actualización parcial/total
*/
export class Update{{pascalCase name}}Controller extends ExpressController {
public constructor(private readonly useCase: Update{{pascalCase name}}UseCase) {
super();
this.useGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) return this.forbiddenError("Tenant ID not found");
const { {{snakeCase name}}_id } = this.req.params as { {{snakeCase name}}_id: string };
// TODO: descomentar cuando exista DTO
// const validation = validateRequest(Update{{pascalCase name}}Dto, this.req.body);
// if (validation.isErr()) return this.badRequestError(validation.error.message);
// const dto = validation.value;
const dto = this.req.body;
const result = await this.useCase.execute({ {{snakeCase name}}_id, companyId, ...dto });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -1,3 +0,0 @@
// Barrel de express
export * from "./controllers";
export * from "./{{kebabCase plural}}.routes";

View File

@ -1,33 +0,0 @@
import { Router } from "express";
import type { ModuleParams } from "@erp/core/api";
import { build{{pascalCase name}}Dependencies } from "../repositories";
import {
Create{{pascalCase name}}Controller,
Delete{{pascalCase name}}Controller,
Get{{pascalCase name}}Controller,
List{{pascalCase name}}Controller,
Update{{pascalCase name}}Controller,
} from "./controllers";
/**
* Monta las rutas del módulo en `params.app` bajo `params.baseRoutePath/{{kebabCase plural}}`
* @remarks
* - Las validaciones DTO están comentadas hasta que existan en `common/dto`.
*/
export const mount{{pascalCase plural}}Routes = (params: ModuleParams) => {
const router = Router();
const deps = build{{pascalCase name}}Dependencies(params);
// TODO: descomentar cuando exista validateRequest y DTOs
// const validateCreate = validateRequest(Create{{pascalCase name}}Dto);
// const validateUpdate = validateRequest(Update{{pascalCase name}}Dto);
router.get("/", (req, res, next) => deps.build.list().bind(req, res, next));
router.get("/:{{snakeCase name}}_id", (req, res, next) => deps.build.get().bind(req, res, next));
router.post("/", /* validateCreate, */ (req, res, next) => deps.build.create().bind(req, res, next));
router.patch("/:{{snakeCase name}}_id", /* validateUpdate, */ (req, res, next) => deps.build.update().bind(req, res, next));
router.put("/:{{snakeCase name}}_id", /* validateUpdate, */ (req, res, next) => deps.build.update().bind(req, res, next));
router.delete("/:{{snakeCase name}}_id", (req, res, next) => deps.build.delete().bind(req, res, next));
params.app.use(`${params.baseRoutePath}/{{kebabCase plural}}`, router);
};

View File

@ -1,5 +0,0 @@
// Barrel de infraestructura
export * from "./express";
export * from "./mappers";
export * from "./repositories";
export * from "./sequelize";

View File

@ -1 +0,0 @@
export * from "./{{kebabCase name}}.mapper";

View File

@ -1,27 +0,0 @@
import type { {{pascalCase name}} } from "../../../domain";
import type { {{pascalCase name}}Model } from "../../sequelize/models/{{kebabCase name}}.model";
/**
* Mapper de dominio ↔ ORM/DTO
* @remarks
* - SSOT: punto único de traducción entre capas.
*/
export const {{camelCase name}}DomainMapper = {
toPersistence(entity: {{pascalCase name}}) {
// TODO: mapear campos reales
return {
id: entity.id,
company_id: entity.companyId,
status: (entity as any).status ?? "active",
};
},
toDomain(model: {{pascalCase name}}Model): {{pascalCase name}} {
// TODO: mapear campos reales
return {
id: model.id,
companyId: model.company_id,
status: (model as any).status,
} as unknown as {{pascalCase name}};
},
};

View File

@ -1,2 +0,0 @@
export * from "./domain";
export * from "./queries";

View File

@ -1 +0,0 @@
export * from "./{{kebabCase name}}.list.mapper";

View File

@ -1,13 +0,0 @@
/**
* Mapper para listados (read-models)
*/
export const {{camelCase name}}ListMapper = {
toListItem(p: any) {
// TODO: adaptar a la proyección real
return {
id: p.id,
name: p.name ?? "",
status: p.status ?? "active",
};
},
};

View File

@ -1,43 +0,0 @@
import { InMemoryMapperRegistry, InMemoryPresenterRegistry, SequelizeTransactionManager } from "@erp/core/api";
import type { ModuleParams } from "@erp/core/api";
import { {{pascalCase name}}Repository } from "./{{kebabCase name}}.repository";
import { Create{{pascalCase name}}Controller, Delete{{pascalCase name}}Controller, Get{{pascalCase name}}Controller, List{{pascalCase name}}Controller, Update{{pascalCase name}}Controller } from "../express/controllers";
// Los use-cases/servicios se resuelven desde application/domain (puede no compilar hasta que existan)
import {
Create{{pascalCase name}}UseCase,
Delete{{pascalCase name}}UseCase,
Get{{pascalCase name}}UseCase,
List{{pascalCase name}}UseCase,
Update{{pascalCase name}}UseCase,
} from "../../application";
import { {{pascalCase name}}Service } from "../../domain";
/**
* Wiring/DI de infraestructura del módulo
*/
export const build{{pascalCase name}}Dependencies = (params: ModuleParams) => {
const mapperRegistry = new InMemoryMapperRegistry();
const presenterRegistry = new InMemoryPresenterRegistry();
const transactionManager = new SequelizeTransactionManager(params.database);
const repository = new {{pascalCase name}}Repository(params.database);
const service = new {{pascalCase name}}Service(repository);
// TODO: enlazar presenters reales cuando existan
// presenterRegistry.register("{{camelCase name}}.created", ...);
return {
mapperRegistry,
presenterRegistry,
transactionManager,
repository,
service,
build: {
list: () => new List{{pascalCase name}}Controller(new List{{pascalCase name}}UseCase(service)),
get: () => new Get{{pascalCase name}}Controller(new Get{{pascalCase name}}UseCase(service)),
create: () => new Create{{pascalCase name}}Controller(new Create{{pascalCase name}}UseCase(service)),
update: () => new Update{{pascalCase name}}Controller(new Update{{pascalCase name}}UseCase(service)),
delete: () => new Delete{{pascalCase name}}Controller(new Delete{{pascalCase name}}UseCase(service)),
},
};
};

View File

@ -1,2 +0,0 @@
export * from "./{{kebabCase name}}.repository";
export * from "./dependencies";

View File

@ -1,36 +0,0 @@
import type { Sequelize } from "sequelize";
import { Result } from "@erp/core/api";
import type { Criteria, Pagination } from "@erp/core/api";
import type { I{{pascalCase name}}Repository, {{pascalCase name}} } from "../../domain";
import type { {{pascalCase name}}Model } from "../sequelize/models/{{kebabCase name}}.model";
/**
* Repositorio concreto (Sequelize)
* @remarks
* - Implementa métodos por tenant (companyId) desde el inicio.
*/
export class {{pascalCase name}}Repository implements I{{pascalCase name}}Repository {
public constructor(private readonly database: Sequelize) {}
async create(entity: {{pascalCase name}}) {
// TODO: usar mapper y modelo
return Result.fail<{{pascalCase name}}>(new Error("Not implemented"));
}
async update(entity: {{pascalCase name}}) {
return Result.fail<{{pascalCase name}}>(new Error("Not implemented"));
}
async deleteByIdInCompany(id: string, companyId: string) {
return Result.fail<void>(new Error("Not implemented"));
}
async findByIdInCompany(id: string, companyId: string) {
return Result.fail<{{pascalCase name}}>(new Error("Not implemented"));
}
async findByCriteriaInCompany(criteria: Criteria, companyId: string, pagination?: Pagination) {
// TODO: aplicar where/order/limit/offset usando CriteriaToSequelizeConverter si existe en core
return Result.fail<{ items: {{pascalCase name}}[]; total: number }>(new Error("Not implemented"));
}
}

View File

@ -1,5 +0,0 @@
// Barrel de sequelize y registro de modelos
export * from "./models";
import { init{{pascalCase name}}Model } from "./models";
export const models = [init{{pascalCase name}}Model];

View File

@ -1,2 +0,0 @@
export { {{pascalCase name}}Model as {{pascalCase name}}Model } from "./{{kebabCase name}}.model";
export { default as init{{pascalCase name}}Model } from "./{{kebabCase name}}.model";

View File

@ -1,64 +0,0 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Sequelize,
} from "sequelize";
export type {{pascalCase name}}CreationAttributes = InferCreationAttributes<{{pascalCase name}}Model, {}> & {};
/**
* Modelo Sequelize (infraestructura, no dominio)
*/
export class {{pascalCase name}}Model extends Model<
InferAttributes<{{pascalCase name}}Model>,
InferCreationAttributes<{{pascalCase name}}Model>
> {
declare id: string;
declare company_id: string;
declare status: CreationOptional<string>;
declare created_at: CreationOptional<Date>;
declare updated_at: CreationOptional<Date>;
declare deleted_at: CreationOptional<Date | null>;
static associate(database: Sequelize) {
// TODO: definir relaciones si aplica
}
static hooks(database: Sequelize) {
// TODO: definir hooks si aplica
}
}
export default (database: Sequelize) => {
{{pascalCase name}}Model.init(
{
id: { type: DataTypes.UUID, primaryKey: true },
company_id: { type: DataTypes.UUID, allowNull: false },
status: { type: DataTypes.STRING, allowNull: false, defaultValue: "active" },
created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
deleted_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null },
},
{
sequelize: database,
tableName: "{{snakeCase plural}}",
underscored: true,
paranoid: true,
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [
{ name: "company_idx", fields: ["company_id"], unique: false },
{ name: "idx_company_idx", fields: ["id", "company_id"], unique: true },
],
whereMergeStrategy: "and",
defaultScope: {},
scopes: {},
}
);
return {{pascalCase name}}Model;
};

View File

@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"strict": true
},
"include": ["src"]
}