Plantilla para módulos
This commit is contained in:
parent
d837e4d362
commit
c5ed10b8fd
43
docs/TEMPLATE.md
Normal file
43
docs/TEMPLATE.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
### Prompt para tu README / CLI
|
||||||
|
|
||||||
|
**Generar un nuevo módulo ERP desde la plantilla**
|
||||||
|
|
||||||
|
Nuestro ERP permite crear módulos de forma **estandarizada** usando una **plantilla base** (Hexagonal/DDD, TS, Express, Sequelize) y un **generador Plop**.
|
||||||
|
|
||||||
|
**Opción A — con Plop (recomendada)**
|
||||||
|
|
||||||
|
1. Instala dependencias de desarrollo si no las tienes:
|
||||||
|
|
||||||
|
* `pnpm add -D plop`
|
||||||
|
2. Asegúrate de tener en el repo:
|
||||||
|
|
||||||
|
* `plopfile.js` con el generador `module`
|
||||||
|
* Carpeta `templates/new-module/` con la plantilla (placeholders Handlebars: `{{kebabCase name}}`, `{{pascalCase name}}`, etc.)
|
||||||
|
3. Ejecuta el generador:
|
||||||
|
|
||||||
|
* `pnpm plop module`
|
||||||
|
4. Introduce el nombre del módulo en **kebab-case** (p. ej., `suppliers`).
|
||||||
|
5. Se creará `packages/suppliers/` con:
|
||||||
|
|
||||||
|
* `src/api/{application,domain,infrastructure,helpers}` + `common`
|
||||||
|
* Casos de uso CRUD, presenters (FULL/LIST), specs comunes y contratos de repositorio.
|
||||||
|
6. Integra en el monorepo:
|
||||||
|
|
||||||
|
* Añade targets en `turbo.json` si aplica.
|
||||||
|
* Ejecuta `pnpm -w build` para validar compilación.
|
||||||
|
|
||||||
|
**Opción B — manual (desde ZIP de plantilla)**
|
||||||
|
|
||||||
|
1. Descomprime `new-module-template.zip`.
|
||||||
|
2. Copia `new-module/` a `packages/<mi-modulo>/`.
|
||||||
|
3. Renombra los archivos con `entity`/`{{name}}` por el nombre real o usa búsqueda global.
|
||||||
|
4. Ajusta `package.json` (`name`, `scripts`), y registra el módulo en `turbo.json`.
|
||||||
|
5. `pnpm -w install && pnpm -w build`.
|
||||||
|
|
||||||
|
**Convenciones clave**
|
||||||
|
|
||||||
|
* Nombres de archivos/directorios en **kebab-case**; código en **Inglés**; comentarios **TSDoc en castellano**.
|
||||||
|
* Mantén SOLID, SoC, alta cohesión/bajo acoplamiento, y hexagonal (puertos/adaptadores).
|
||||||
|
* Observabilidad (logs estructurados), idempotencia en endpoints, y principio de menor privilegio.
|
||||||
|
|
||||||
|
|
||||||
@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "uecko-erp-2025",
|
"name": "uecko-erp-2025",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["apps/*", "modules/*", "packages/*"],
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"modules/*",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
@ -18,6 +22,7 @@
|
|||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"inquirer": "^12.5.2",
|
"inquirer": "^12.5.2",
|
||||||
|
"plop": "^4.0.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"turbo": "^2.5.1",
|
"turbo": "^2.5.1",
|
||||||
"typescript": "5.8.3"
|
"typescript": "5.8.3"
|
||||||
|
|||||||
26
plopfile.js
Normal file
26
plopfile.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export default function (plop) {
|
||||||
|
plop.setGenerator("module", {
|
||||||
|
description: "Crea un nuevo módulo ERP con plantilla base",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
type: "input",
|
||||||
|
name: "name",
|
||||||
|
message: "Nombre del módulo (kebab-case):",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "input",
|
||||||
|
name: "plural",
|
||||||
|
message: "Nombre plural para las rutas (kebab-case):",
|
||||||
|
default: (answers) => `${plop.getHelper("kebabCase")(answers.name)}s`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: "addMany",
|
||||||
|
destination: "modules/{{kebabCase name}}",
|
||||||
|
base: "templates/new-module",
|
||||||
|
templateFiles: "templates/new-module/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
480
pnpm-lock.yaml
480
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,65 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import inquirer from "inquirer";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
function capitalize(str: string) {
|
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
let rawName = process.argv[2];
|
|
||||||
|
|
||||||
if (!rawName) {
|
|
||||||
const answers = await inquirer.prompt([
|
|
||||||
{
|
|
||||||
type: "input",
|
|
||||||
name: "packageName",
|
|
||||||
message: "Nombre del nuevo package:",
|
|
||||||
validate: (input) => (input ? true : "El nombre no puede estar vacío"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
rawName = answers.packageName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = rawName.toLowerCase();
|
|
||||||
const capitalized = capitalize(name);
|
|
||||||
const packagePath = path.resolve(__dirname, "../packages", name);
|
|
||||||
const clientPath = path.join(packagePath, "client");
|
|
||||||
const serverPath = path.join(packagePath, "server");
|
|
||||||
const templatePath = path.resolve(__dirname, "./templates");
|
|
||||||
|
|
||||||
// Crear carpetas
|
|
||||||
fs.mkdirSync(clientPath, { recursive: true });
|
|
||||||
fs.mkdirSync(serverPath, { recursive: true });
|
|
||||||
|
|
||||||
// Función de reemplazo de contenido
|
|
||||||
const renderTemplate = (content: string) =>
|
|
||||||
content
|
|
||||||
.replace(/__PACKAGE_NAME__/g, name)
|
|
||||||
.replace(/__PACKAGE_NAME_CAPITALIZED__/g, capitalized);
|
|
||||||
|
|
||||||
// Copiar plantillas desde carpeta 'templates/client'
|
|
||||||
const copyFromTemplate = (srcDir: string, destDir: string) => {
|
|
||||||
const files = fs.readdirSync(srcDir);
|
|
||||||
for (const file of files) {
|
|
||||||
const filePath = path.join(srcDir, file);
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8");
|
|
||||||
|
|
||||||
const outputName = file.replace(/__PACKAGE_NAME__/g, capitalized);
|
|
||||||
const outputPath = path.join(destDir, outputName);
|
|
||||||
|
|
||||||
fs.writeFileSync(outputPath, renderTemplate(content));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
copyFromTemplate(path.join(templatePath, "client"), clientPath);
|
|
||||||
copyFromTemplate(path.join(templatePath, "server"), serverPath);
|
|
||||||
|
|
||||||
// package.json
|
|
||||||
const pkgJsonTemplate = fs.readFileSync(path.join(templatePath, "package.json"), "utf-8");
|
|
||||||
fs.writeFileSync(path.join(packagePath, "package.json"), renderTemplate(pkgJsonTemplate));
|
|
||||||
|
|
||||||
console.log(`✅ Package '${name}' creado correctamente en packages/${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export default function __PACKAGE_NAME_CAPITALIZED__Page() {
|
|
||||||
return <div>__PACKAGE_NAME_CAPITALIZED__ Package Page</div>;
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { IPackageClient } from "@packages/package";
|
|
||||||
import __PACKAGE_NAME_CAPITALIZED__Page from "./__PACKAGE_NAME__Page";
|
|
||||||
|
|
||||||
export const __PACKAGE_NAME_CAPITALIZED__Package: IPackageClient = {
|
|
||||||
metadata: {
|
|
||||||
name: "__PACKAGE_NAME__",
|
|
||||||
route: "/__PACKAGE_NAME__",
|
|
||||||
version: "1.0.0",
|
|
||||||
description: "__PACKAGE_NAME_CAPITALIZED__ package",
|
|
||||||
},
|
|
||||||
component: __PACKAGE_NAME_CAPITALIZED__Page,
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@repo/typescript-config/react",
|
|
||||||
"include": ["."]
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@packages/__PACKAGE_NAME__",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "server/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc -b",
|
|
||||||
"dev": "turbo run dev --filter=@packages/__PACKAGE_NAME__"
|
|
||||||
},
|
|
||||||
"dependencies": {},
|
|
||||||
"devDependencies": {},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/express": "*",
|
|
||||||
"@types/node": "*",
|
|
||||||
"@types/react": "*",
|
|
||||||
"express": "*",
|
|
||||||
"react": "*",
|
|
||||||
"react-router-dom": "*",
|
|
||||||
"sequelize": "*",
|
|
||||||
"typescript": "*",
|
|
||||||
"zod": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
export function __PACKAGE_NAME__Controller(req, res) {
|
|
||||||
res.send("__PACKAGE_NAME_CAPITALIZED__ package response");
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { IPackageServer } from "@packages/package";
|
|
||||||
import { __PACKAGE_NAME__Controller } from "./controller";
|
|
||||||
|
|
||||||
export const __PACKAGE_NAME_CAPITALIZED__Package: IPackageServer = {
|
|
||||||
metadata: {
|
|
||||||
name: "__PACKAGE_NAME__",
|
|
||||||
version: "1.0.0",
|
|
||||||
dependencies: [],
|
|
||||||
},
|
|
||||||
init(app) {
|
|
||||||
app.get("/__PACKAGE_NAME__", __PACKAGE_NAME__Controller);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@repo/typescript-config/node",
|
|
||||||
"include": ["."],
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
9
templates/new-module/package.json
Normal file
9
templates/new-module/package.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@erp/{{kebabCase name}}",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"lint": "eslint . --ext .ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
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}}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
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() }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
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?: any
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
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?: any
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
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: any): Promise<Result<{{pascalCase name}}, Error>>;
|
||||||
|
existsById(id: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
|
||||||
|
getById(id: UniqueID, transaction?: any): Promise<Result<{{pascalCase name}}, Error>>;
|
||||||
|
findByCriteria(criteria: Criteria, transaction?: any): Promise<Result<Collection<{{pascalCase name}}>, Error>>;
|
||||||
|
deleteById(id: UniqueID, transaction: any): Promise<Result<void, Error>>;
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
// 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";
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
// Barrel de express
|
||||||
|
export * from "./controllers";
|
||||||
|
export * from "./{{kebabCase plural}}.routes";
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
5
templates/new-module/src/api/infrastructure/index.ts.hbs
Normal file
5
templates/new-module/src/api/infrastructure/index.ts.hbs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// Barrel de infraestructura
|
||||||
|
export * from "./express";
|
||||||
|
export * from "./mappers";
|
||||||
|
export * from "./repositories";
|
||||||
|
export * from "./sequelize";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./{{kebabCase name}}.mapper";
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
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}};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./domain";
|
||||||
|
export * from "./queries";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./{{kebabCase name}}.list.mapper";
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
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)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./{{kebabCase name}}.repository";
|
||||||
|
export * from "./dependencies";
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
// Barrel de sequelize y registro de modelos
|
||||||
|
export * from "./models";
|
||||||
|
import { init{{pascalCase name}}Model } from "./models";
|
||||||
|
|
||||||
|
export const models = [init{{pascalCase name}}Model];
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export { {{pascalCase name}}Model as {{pascalCase name}}Model } from "./{{kebabCase name}}.model";
|
||||||
|
export { default as init{{pascalCase name}}Model } from "./{{kebabCase name}}.model";
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
9
templates/new-module/tsconfig.json
Normal file
9
templates/new-module/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user