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",
|
||||
"private": true,
|
||||
"workspaces": ["apps/*", "modules/*", "packages/*"],
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"modules/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev",
|
||||
@ -18,6 +22,7 @@
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"inquirer": "^12.5.2",
|
||||
"plop": "^4.0.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"turbo": "^2.5.1",
|
||||
"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