@gzl10/nexus-sdk 0.1.11 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -3
- package/dist/index.d.ts +450 -20
- package/dist/index.js +451 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,17 +98,131 @@ export const projectPlugin: PluginManifest = {
|
|
|
98
98
|
}
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
## EntityDefinition (v0.2.0+)
|
|
102
|
+
|
|
103
|
+
Define entities as single source of truth for DB, validation, UI, and CASL:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import type { EntityDefinition } from '@gzl10/nexus-sdk'
|
|
107
|
+
|
|
108
|
+
export const postEntity: EntityDefinition = {
|
|
109
|
+
table: 'cms_posts',
|
|
110
|
+
label: 'Posts',
|
|
111
|
+
labelField: 'title',
|
|
112
|
+
timestamps: true,
|
|
113
|
+
audit: true,
|
|
114
|
+
|
|
115
|
+
fields: {
|
|
116
|
+
id: {
|
|
117
|
+
name: 'id',
|
|
118
|
+
label: 'ID',
|
|
119
|
+
input: 'hidden',
|
|
120
|
+
db: { type: 'uuid', nullable: false, defaultFn: 'uuid' }
|
|
121
|
+
},
|
|
122
|
+
title: {
|
|
123
|
+
name: 'title',
|
|
124
|
+
label: 'Title',
|
|
125
|
+
input: 'text',
|
|
126
|
+
db: { type: 'string', size: 200, nullable: false },
|
|
127
|
+
validation: { required: true, min: 3, max: 200 },
|
|
128
|
+
meta: { searchable: true, sortable: true }
|
|
129
|
+
},
|
|
130
|
+
content: {
|
|
131
|
+
name: 'content',
|
|
132
|
+
label: 'Content',
|
|
133
|
+
input: 'markdown',
|
|
134
|
+
db: { type: 'text', nullable: true }
|
|
135
|
+
},
|
|
136
|
+
status: {
|
|
137
|
+
name: 'status',
|
|
138
|
+
label: 'Status',
|
|
139
|
+
input: 'select',
|
|
140
|
+
db: { type: 'string', nullable: false, default: 'draft' },
|
|
141
|
+
validation: { enum: ['draft', 'published', 'archived'] },
|
|
142
|
+
options: {
|
|
143
|
+
static: [
|
|
144
|
+
{ value: 'draft', label: 'Draft' },
|
|
145
|
+
{ value: 'published', label: 'Published' },
|
|
146
|
+
{ value: 'archived', label: 'Archived' }
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
casl: {
|
|
153
|
+
ownership: { field: 'created_by' },
|
|
154
|
+
permissions: {
|
|
155
|
+
ADMIN: { actions: ['manage'] },
|
|
156
|
+
EDITOR: { actions: ['create', 'read', 'update'] },
|
|
157
|
+
VIEWER: { actions: ['read'] }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Code Generators
|
|
164
|
+
|
|
165
|
+
Generate migrations, Zod schemas, TypeScript models, and CASL permissions:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import {
|
|
169
|
+
generateMigration,
|
|
170
|
+
generateZodSchema,
|
|
171
|
+
generateModel,
|
|
172
|
+
generateCaslPermissions
|
|
173
|
+
} from '@gzl10/nexus-sdk'
|
|
174
|
+
|
|
175
|
+
// Generate Knex migration code
|
|
176
|
+
const migrationCode = generateMigration(postEntity)
|
|
177
|
+
|
|
178
|
+
// Generate Zod validation schemas
|
|
179
|
+
const zodCode = generateZodSchema(postEntity)
|
|
180
|
+
// Creates: createPostSchema, updatePostSchema, postParamsSchema, postQuerySchema
|
|
181
|
+
|
|
182
|
+
// Generate TypeScript interface
|
|
183
|
+
const modelCode = generateModel(postEntity)
|
|
184
|
+
// Creates: export interface Post { ... }
|
|
185
|
+
|
|
186
|
+
// Generate CASL permissions for seeding
|
|
187
|
+
const permissions = generateCaslPermissions(postEntity)
|
|
188
|
+
// Returns: Array<{ role, action, subject, conditions, fields, inverted }>
|
|
189
|
+
```
|
|
190
|
+
|
|
101
191
|
## Main Types
|
|
102
192
|
|
|
103
193
|
### Manifests & Context
|
|
104
194
|
|
|
105
195
|
| Type | Description |
|
|
106
196
|
|------|-------------|
|
|
107
|
-
| `ModuleManifest` | Defines a module: routes, migrations, seeds,
|
|
197
|
+
| `ModuleManifest` | Defines a module: routes, migrations, seeds, entities |
|
|
108
198
|
| `PluginManifest` | Groups modules under a plugin with shared metadata |
|
|
109
199
|
| `ModuleContext` | Context injected by Nexus: `db`, `logger`, `helpers`, `services`, `abilities` |
|
|
110
|
-
|
|
111
|
-
|
|
200
|
+
|
|
201
|
+
### Entity Definition System
|
|
202
|
+
|
|
203
|
+
| Type | Description |
|
|
204
|
+
|------|-------------|
|
|
205
|
+
| `EntityDefinition` | Complete entity definition (DB, UI, validation, CASL) |
|
|
206
|
+
| `FieldDefinition` | Field configuration with DB, UI, and validation settings |
|
|
207
|
+
| `FieldDbConfig` | Database column configuration |
|
|
208
|
+
| `FieldRelation` | Foreign key configuration |
|
|
209
|
+
| `EntityCaslConfig` | CASL authorization configuration |
|
|
210
|
+
|
|
211
|
+
### Entity Types (Discriminated Union)
|
|
212
|
+
|
|
213
|
+
| Type | Description |
|
|
214
|
+
|------|-------------|
|
|
215
|
+
| `CollectionEntity` | Full CRUD (users, posts) |
|
|
216
|
+
| `ReferenceEntity` | Catalogs with admin CRUD (countries) |
|
|
217
|
+
| `SingleEntity` | Singleton, update/read only (site_config) |
|
|
218
|
+
| `ConfigEntity` | Per-module config, singleton |
|
|
219
|
+
| `ExternalEntity` | Read-only from external systems |
|
|
220
|
+
| `VirtualEntity` | Orchestration of multiple sources |
|
|
221
|
+
| `ComputedEntity` | KPIs, calculated stats |
|
|
222
|
+
| `ViewEntity` | Optimized read projections |
|
|
223
|
+
| `EventEntity` | Audit logs, append-only |
|
|
224
|
+
| `TempEntity` | TTL-based (cache, sessions) |
|
|
225
|
+
| `ActionEntity` | Commands without persistence |
|
|
112
226
|
|
|
113
227
|
### Request & Auth
|
|
114
228
|
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,85 @@ import { Request, RequestHandler, Router } from 'express';
|
|
|
4
4
|
export { CookieOptions, NextFunction, Request, RequestHandler, Response, Router } from 'express';
|
|
5
5
|
import { Logger } from 'pino';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Generadores de código desde EntityDefinition
|
|
9
|
+
*
|
|
10
|
+
* Estos generadores producen código TypeScript como strings
|
|
11
|
+
* que pueden ser escritos a archivos o usados en runtime.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Entidades que persisten en BD local
|
|
16
|
+
* Excluye: action, external, virtual, computed
|
|
17
|
+
*/
|
|
18
|
+
type PersistentEntityDefinition = CollectionEntityDefinition | SingleEntityDefinition | ReferenceEntityDefinition | EventEntityDefinition | ConfigEntityDefinition | TempEntityDefinition | ViewEntityDefinition;
|
|
19
|
+
/**
|
|
20
|
+
* Genera código de migración Knex desde EntityDefinition
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const code = generateMigration(postEntity)
|
|
24
|
+
* // Genera:
|
|
25
|
+
* // import type { ModuleContext, Knex } from '@gzl10/nexus-sdk'
|
|
26
|
+
* //
|
|
27
|
+
* // export async function migrate(ctx: ModuleContext): Promise<void> {
|
|
28
|
+
* // const { db, logger, helpers } = ctx
|
|
29
|
+
* // ...
|
|
30
|
+
* // }
|
|
31
|
+
*/
|
|
32
|
+
declare function generateMigration(entity: PersistentEntityDefinition): string;
|
|
33
|
+
/**
|
|
34
|
+
* Genera código de schemas Zod desde EntityDefinition
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* const code = generateZodSchema(postEntity)
|
|
38
|
+
* // Genera createPostSchema, updatePostSchema, postParamsSchema, postQuerySchema
|
|
39
|
+
*/
|
|
40
|
+
declare function generateZodSchema(entity: PersistentEntityDefinition): string;
|
|
41
|
+
/**
|
|
42
|
+
* Genera interface TypeScript desde EntityDefinition
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const code = generateModel(postEntity)
|
|
46
|
+
* // Genera:
|
|
47
|
+
* // export interface Post {
|
|
48
|
+
* // id: string
|
|
49
|
+
* // title: string
|
|
50
|
+
* // ...
|
|
51
|
+
* // }
|
|
52
|
+
*/
|
|
53
|
+
declare function generateModel(entity: PersistentEntityDefinition): string;
|
|
54
|
+
/**
|
|
55
|
+
* Estructura de permiso generado para insertar en BD
|
|
56
|
+
*/
|
|
57
|
+
interface GeneratedPermission {
|
|
58
|
+
role: string;
|
|
59
|
+
action: string;
|
|
60
|
+
subject: string;
|
|
61
|
+
conditions: string | null;
|
|
62
|
+
fields: string | null;
|
|
63
|
+
inverted: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Genera permisos CASL desde EntityDefinition
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* const permissions = generateCaslPermissions(postEntity)
|
|
70
|
+
* // Genera array de permisos para insertar en rol_role_permissions
|
|
71
|
+
*/
|
|
72
|
+
declare function generateCaslPermissions(entity: PersistentEntityDefinition): GeneratedPermission[];
|
|
73
|
+
/**
|
|
74
|
+
* Genera código de seed para permisos CASL
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* const code = generateCaslSeed([postEntity, pageEntity])
|
|
78
|
+
* // Genera código para insertar permisos en rol_role_permissions
|
|
79
|
+
*/
|
|
80
|
+
declare function generateCaslSeed(entities: EntityDefinition[]): string;
|
|
81
|
+
/**
|
|
82
|
+
* Obtiene el subject CASL de una entidad
|
|
83
|
+
*/
|
|
84
|
+
declare function getEntitySubject(entity: PersistentEntityDefinition): string;
|
|
85
|
+
|
|
7
86
|
/**
|
|
8
87
|
* @gzl10/nexus-sdk
|
|
9
88
|
*
|
|
@@ -11,6 +90,23 @@ import { Logger } from 'pino';
|
|
|
11
90
|
* Use this package to define plugin/module manifests without
|
|
12
91
|
* depending on the full @gzl10/nexus-backend package.
|
|
13
92
|
*/
|
|
93
|
+
/**
|
|
94
|
+
* Unión discriminada de todos los tipos de entidad
|
|
95
|
+
*
|
|
96
|
+
* | Type | Persistencia | CRUD | Uso principal |
|
|
97
|
+
* |------------|--------------|-----------------|----------------------------------|
|
|
98
|
+
* | collection | Sí (BD) | Completo | Datos de negocio (users, posts) |
|
|
99
|
+
* | single | Sí (BD) | Update/Read | Config global (site_config) |
|
|
100
|
+
* | external | No | Read | Datos externos (stripe_customers)|
|
|
101
|
+
* | virtual | No | Read | Orquestación (unified_customers) |
|
|
102
|
+
* | computed | No/opcional | Read | KPIs, estadísticas |
|
|
103
|
+
* | view | Sí/virtual | Read | Lectura optimizada (projections) |
|
|
104
|
+
* | reference | Sí | Read (admin) | Catálogos (countries, currencies)|
|
|
105
|
+
* | config | Sí | Update/Read | Config por módulo |
|
|
106
|
+
* | event | Sí | Append | Auditoría (audit_logs) |
|
|
107
|
+
* | temp | No (TTL) | Read/Write | Cache, sesiones (otp_codes) |
|
|
108
|
+
* | action | No | Execute | Operaciones, workflows |
|
|
109
|
+
*/
|
|
14
110
|
|
|
15
111
|
type KnexCreateTableBuilder = Knex.CreateTableBuilder;
|
|
16
112
|
type KnexAlterTableBuilder = Knex.AlterTableBuilder;
|
|
@@ -93,18 +189,347 @@ interface FormField {
|
|
|
93
189
|
*/
|
|
94
190
|
type ListType = 'table' | 'list' | 'grid' | 'masonry';
|
|
95
191
|
/**
|
|
96
|
-
*
|
|
192
|
+
* Tipos de columna en base de datos
|
|
193
|
+
*/
|
|
194
|
+
type DbType = 'string' | 'text' | 'integer' | 'decimal' | 'boolean' | 'date' | 'datetime' | 'json' | 'uuid';
|
|
195
|
+
/**
|
|
196
|
+
* Tipos de input en UI
|
|
97
197
|
*/
|
|
98
|
-
|
|
198
|
+
type InputType = 'text' | 'email' | 'password' | 'url' | 'tel' | 'number' | 'decimal' | 'textarea' | 'markdown' | 'select' | 'multiselect' | 'checkbox' | 'switch' | 'date' | 'datetime' | 'file' | 'image' | 'hidden';
|
|
199
|
+
/**
|
|
200
|
+
* Configuración de base de datos para un campo
|
|
201
|
+
*/
|
|
202
|
+
interface FieldDbConfig {
|
|
203
|
+
type: DbType;
|
|
204
|
+
size?: number;
|
|
205
|
+
precision?: [number, number];
|
|
206
|
+
nullable: boolean;
|
|
207
|
+
unique?: boolean;
|
|
208
|
+
default?: unknown;
|
|
209
|
+
defaultFn?: 'now' | 'uuid';
|
|
210
|
+
index?: boolean;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Configuración de relación (Foreign Key)
|
|
214
|
+
*/
|
|
215
|
+
interface FieldRelation {
|
|
216
|
+
table: string;
|
|
217
|
+
column?: string;
|
|
218
|
+
onDelete?: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION';
|
|
219
|
+
onUpdate?: 'CASCADE' | 'RESTRICT' | 'SET NULL' | 'NO ACTION';
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Configuración de validación para un campo
|
|
223
|
+
*/
|
|
224
|
+
interface FieldValidationConfig {
|
|
225
|
+
required?: boolean;
|
|
226
|
+
min?: number;
|
|
227
|
+
max?: number;
|
|
228
|
+
pattern?: string;
|
|
229
|
+
format?: 'email' | 'url' | 'uuid' | 'slug';
|
|
230
|
+
enum?: string[];
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Opciones para campos select/relaciones
|
|
234
|
+
*/
|
|
235
|
+
interface FieldOptions {
|
|
236
|
+
endpoint?: string;
|
|
237
|
+
valueField?: string;
|
|
238
|
+
labelField?: string;
|
|
239
|
+
static?: Array<{
|
|
240
|
+
value: string;
|
|
241
|
+
label: string;
|
|
242
|
+
}>;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Restricciones de acceso CASL para un campo
|
|
246
|
+
*/
|
|
247
|
+
interface FieldCaslAccess {
|
|
248
|
+
/** Roles que pueden leer este campo (vacío = todos, null = nadie excepto ADMIN) */
|
|
249
|
+
readRoles?: string[];
|
|
250
|
+
/** Roles que pueden escribir este campo (vacío = todos, null = nadie excepto ADMIN) */
|
|
251
|
+
writeRoles?: string[];
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Metadata del campo
|
|
255
|
+
*/
|
|
256
|
+
interface FieldMeta {
|
|
257
|
+
sortable?: boolean;
|
|
258
|
+
searchable?: boolean;
|
|
259
|
+
exportable?: boolean;
|
|
260
|
+
auditField?: 'created_by' | 'updated_by';
|
|
261
|
+
/** Restricciones de acceso CASL para este campo */
|
|
262
|
+
casl?: FieldCaslAccess;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Definición completa de un campo
|
|
266
|
+
*
|
|
267
|
+
* Contiene toda la información necesaria para:
|
|
268
|
+
* - UI: formularios, listas
|
|
269
|
+
* - BD: migraciones Knex
|
|
270
|
+
* - Validación: schemas Zod
|
|
271
|
+
*/
|
|
272
|
+
interface FieldDefinition {
|
|
99
273
|
name: string;
|
|
100
274
|
label: string;
|
|
101
|
-
|
|
102
|
-
|
|
275
|
+
input: InputType;
|
|
276
|
+
placeholder?: string;
|
|
277
|
+
hint?: string;
|
|
278
|
+
hidden?: boolean;
|
|
279
|
+
disabled?: boolean;
|
|
280
|
+
db: FieldDbConfig;
|
|
281
|
+
relation?: FieldRelation;
|
|
282
|
+
validation?: FieldValidationConfig;
|
|
283
|
+
options?: FieldOptions;
|
|
284
|
+
meta?: FieldMeta;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Índice compuesto de tabla
|
|
288
|
+
*/
|
|
289
|
+
interface EntityIndex {
|
|
290
|
+
columns: string[];
|
|
291
|
+
unique?: boolean;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Acciones CASL estándar
|
|
295
|
+
*/
|
|
296
|
+
type CaslAction = 'manage' | 'create' | 'read' | 'update' | 'delete';
|
|
297
|
+
/**
|
|
298
|
+
* Condición de ownership para permisos
|
|
299
|
+
* El campo de la entidad que referencia al usuario
|
|
300
|
+
*/
|
|
301
|
+
interface OwnershipCondition {
|
|
302
|
+
/** Campo que contiene el ID del propietario (ej: 'author_id', 'created_by') */
|
|
303
|
+
field: string;
|
|
304
|
+
/** Variable del usuario a comparar (default: 'id') */
|
|
305
|
+
userField?: string;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Permiso CASL para un rol específico
|
|
309
|
+
*/
|
|
310
|
+
interface RolePermission {
|
|
311
|
+
/** Acciones permitidas */
|
|
312
|
+
actions: CaslAction[];
|
|
313
|
+
/** Condiciones (ownership, status, etc.) */
|
|
314
|
+
conditions?: Record<string, unknown>;
|
|
315
|
+
/** Campos permitidos (null = todos) */
|
|
316
|
+
fields?: string[] | null;
|
|
317
|
+
/** Invertir permiso (cannot en lugar de can) */
|
|
318
|
+
inverted?: boolean;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Configuración de autorización CASL para una entidad
|
|
322
|
+
*/
|
|
323
|
+
interface EntityCaslConfig {
|
|
324
|
+
/**
|
|
325
|
+
* Subject CASL (default: inferido de table → 'cms_posts' → 'CmsPost')
|
|
326
|
+
*/
|
|
327
|
+
subject?: string;
|
|
328
|
+
/**
|
|
329
|
+
* Campo de ownership para conditions automáticas
|
|
330
|
+
* Si se define, se genera condition { [field]: '${user.id}' }
|
|
331
|
+
*/
|
|
332
|
+
ownership?: OwnershipCondition;
|
|
333
|
+
/**
|
|
334
|
+
* Permisos por rol
|
|
335
|
+
* Las keys son nombres de roles (ADMIN, EDITOR, VIEWER, etc.)
|
|
336
|
+
*/
|
|
337
|
+
permissions?: Record<string, RolePermission>;
|
|
338
|
+
/**
|
|
339
|
+
* Campos sensibles que requieren permiso especial
|
|
340
|
+
* Se excluyen de 'read' para roles sin acceso explícito
|
|
341
|
+
*/
|
|
342
|
+
sensitiveFields?: string[];
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Propiedades base compartidas por todas las EntityDefinition
|
|
346
|
+
*/
|
|
347
|
+
interface BaseEntityDefinition {
|
|
348
|
+
/** Nombre de tabla en BD (con prefijo: 'cms_posts') */
|
|
349
|
+
table: string;
|
|
350
|
+
/** Nombre para mostrar en UI */
|
|
351
|
+
label: string;
|
|
352
|
+
/** Definición de campos */
|
|
353
|
+
fields: Record<string, FieldDefinition>;
|
|
354
|
+
/** Autorización CASL */
|
|
355
|
+
casl?: EntityCaslConfig;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Entidad de colección - CRUD completo (users, posts, orders)
|
|
359
|
+
* Default cuando no se especifica type
|
|
360
|
+
*/
|
|
361
|
+
interface CollectionEntityDefinition extends BaseEntityDefinition {
|
|
362
|
+
type?: 'collection';
|
|
363
|
+
/** Campo para mostrar en selects/referencias */
|
|
103
364
|
labelField: string;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
365
|
+
/** Añadir created_at, updated_at */
|
|
366
|
+
timestamps?: boolean;
|
|
367
|
+
/** Añadir created_by, updated_by */
|
|
368
|
+
audit?: boolean;
|
|
369
|
+
/** Índices compuestos */
|
|
370
|
+
indexes?: EntityIndex[];
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Entidad singleton - Solo Update/Read, sin lista (site_config)
|
|
374
|
+
*/
|
|
375
|
+
interface SingleEntityDefinition extends BaseEntityDefinition {
|
|
376
|
+
type: 'single';
|
|
377
|
+
/** Añadir updated_at */
|
|
378
|
+
timestamps?: boolean;
|
|
379
|
+
/** Añadir updated_by */
|
|
380
|
+
audit?: boolean;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Entidad de referencia - Catálogos con CRUD admin (countries, currencies)
|
|
384
|
+
*/
|
|
385
|
+
interface ReferenceEntityDefinition extends BaseEntityDefinition {
|
|
386
|
+
type: 'reference';
|
|
387
|
+
/** Campo para mostrar en selects/referencias */
|
|
388
|
+
labelField: string;
|
|
389
|
+
/** Añadir created_at, updated_at */
|
|
390
|
+
timestamps?: boolean;
|
|
391
|
+
/** Índices compuestos */
|
|
392
|
+
indexes?: EntityIndex[];
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Entidad de eventos - Logs de auditoría, append-only
|
|
396
|
+
*/
|
|
397
|
+
interface EventEntityDefinition extends BaseEntityDefinition {
|
|
398
|
+
type: 'event';
|
|
399
|
+
/** Campo para mostrar en listas */
|
|
400
|
+
labelField: string;
|
|
401
|
+
/** Siempre true para eventos */
|
|
402
|
+
timestamps: true;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Entidad de acción - Comandos/operaciones sin persistencia
|
|
406
|
+
*/
|
|
407
|
+
interface ActionEntityDefinition {
|
|
408
|
+
type: 'action';
|
|
409
|
+
/** Nombre para mostrar en UI */
|
|
410
|
+
label: string;
|
|
411
|
+
/** Solo campos de formulario, sin BD */
|
|
412
|
+
fields: Record<string, FieldDefinition>;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Entidad externa - Datos de APIs externas (stripe_customers, github_repos)
|
|
416
|
+
* Read-only, sin persistencia local
|
|
417
|
+
*/
|
|
418
|
+
interface ExternalEntityDefinition {
|
|
419
|
+
type: 'external';
|
|
420
|
+
/** Nombre para mostrar en UI */
|
|
421
|
+
label: string;
|
|
422
|
+
/** Campo para mostrar en selects/referencias */
|
|
423
|
+
labelField: string;
|
|
424
|
+
/** Definición de campos (estructura esperada del API externo) */
|
|
425
|
+
fields: Record<string, FieldDefinition>;
|
|
426
|
+
/** Configuración del origen externo */
|
|
427
|
+
source: {
|
|
428
|
+
/** Tipo de origen */
|
|
429
|
+
provider: string;
|
|
430
|
+
/** Endpoint o recurso */
|
|
431
|
+
endpoint?: string;
|
|
432
|
+
};
|
|
433
|
+
/** Autorización CASL */
|
|
434
|
+
casl?: EntityCaslConfig;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Entidad virtual - Orquestación de múltiples fuentes (unified_customers)
|
|
438
|
+
* Read-only, combina datos de varias entidades
|
|
439
|
+
*/
|
|
440
|
+
interface VirtualEntityDefinition {
|
|
441
|
+
type: 'virtual';
|
|
442
|
+
/** Nombre para mostrar en UI */
|
|
443
|
+
label: string;
|
|
444
|
+
/** Campo para mostrar en selects/referencias */
|
|
445
|
+
labelField: string;
|
|
446
|
+
/** Definición de campos (esquema unificado) */
|
|
447
|
+
fields: Record<string, FieldDefinition>;
|
|
448
|
+
/** Fuentes de datos que se combinan */
|
|
449
|
+
sources: string[];
|
|
450
|
+
/** Autorización CASL */
|
|
451
|
+
casl?: EntityCaslConfig;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Entidad computed - KPIs, estadísticas, métricas calculadas
|
|
455
|
+
* Read-only, puede cachear opcionalmente
|
|
456
|
+
*/
|
|
457
|
+
interface ComputedEntityDefinition {
|
|
458
|
+
type: 'computed';
|
|
459
|
+
/** Nombre para mostrar en UI */
|
|
460
|
+
label: string;
|
|
461
|
+
/** Campo para mostrar en selects/referencias */
|
|
462
|
+
labelField?: string;
|
|
463
|
+
/** Definición de campos (estructura del resultado) */
|
|
464
|
+
fields: Record<string, FieldDefinition>;
|
|
465
|
+
/** Tiempo de cache en segundos (0 = sin cache) */
|
|
466
|
+
cacheTtl?: number;
|
|
467
|
+
/** Autorización CASL */
|
|
468
|
+
casl?: EntityCaslConfig;
|
|
107
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* Entidad view - Vista optimizada para lectura (projections, denormalizaciones)
|
|
472
|
+
* Read-only, puede ser vista de BD o virtual
|
|
473
|
+
*/
|
|
474
|
+
interface ViewEntityDefinition {
|
|
475
|
+
type: 'view';
|
|
476
|
+
/** Tabla o vista en BD (puede ser VIEW SQL) */
|
|
477
|
+
table: string;
|
|
478
|
+
/** Nombre para mostrar en UI */
|
|
479
|
+
label: string;
|
|
480
|
+
/** Campo para mostrar en selects/referencias */
|
|
481
|
+
labelField: string;
|
|
482
|
+
/** Definición de campos */
|
|
483
|
+
fields: Record<string, FieldDefinition>;
|
|
484
|
+
/** Entidad fuente de la que deriva */
|
|
485
|
+
sourceEntity?: string;
|
|
486
|
+
/** Autorización CASL */
|
|
487
|
+
casl?: EntityCaslConfig;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Entidad config - Configuración por módulo/tenant
|
|
491
|
+
* Similar a single pero con scope
|
|
492
|
+
*/
|
|
493
|
+
interface ConfigEntityDefinition extends BaseEntityDefinition {
|
|
494
|
+
type: 'config';
|
|
495
|
+
/** Scope de la configuración */
|
|
496
|
+
scope?: 'global' | 'module' | 'tenant' | 'user';
|
|
497
|
+
/** Añadir updated_at */
|
|
498
|
+
timestamps?: boolean;
|
|
499
|
+
/** Añadir updated_by */
|
|
500
|
+
audit?: boolean;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Entidad temporal - Cache, sesiones, OTP codes
|
|
504
|
+
* Con TTL automático, sin auditoría
|
|
505
|
+
*/
|
|
506
|
+
interface TempEntityDefinition extends BaseEntityDefinition {
|
|
507
|
+
type: 'temp';
|
|
508
|
+
/** Tiempo de vida en segundos */
|
|
509
|
+
ttl: number;
|
|
510
|
+
/** Campo para mostrar en listas (opcional) */
|
|
511
|
+
labelField?: string;
|
|
512
|
+
/** Índices para búsqueda rápida */
|
|
513
|
+
indexes?: EntityIndex[];
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Union de todas las definiciones de entidad
|
|
517
|
+
*
|
|
518
|
+
* | Type | Persistencia | CRUD | Uso principal |
|
|
519
|
+
* |------------|--------------|-----------------|----------------------------------|
|
|
520
|
+
* | collection | Sí (BD) | Completo | Datos de negocio (users, posts) |
|
|
521
|
+
* | single | Sí (BD) | Update/Read | Config global (site_config) |
|
|
522
|
+
* | external | No | Read | Datos externos (stripe_customers)|
|
|
523
|
+
* | virtual | No | Read | Orquestación (unified_customers) |
|
|
524
|
+
* | computed | No/opcional | Read | KPIs, estadísticas |
|
|
525
|
+
* | view | Sí/virtual | Read | Lectura optimizada (projections) |
|
|
526
|
+
* | reference | Sí | Read (admin) | Catálogos (countries, currencies)|
|
|
527
|
+
* | config | Sí | Update/Read | Config por módulo |
|
|
528
|
+
* | event | Sí | Append | Auditoría (audit_logs) |
|
|
529
|
+
* | temp | No (TTL) | Read/Write | Cache, sesiones (otp_codes) |
|
|
530
|
+
* | action | No | Execute | Operaciones, workflows |
|
|
531
|
+
*/
|
|
532
|
+
type EntityDefinition = CollectionEntityDefinition | SingleEntityDefinition | ExternalEntityDefinition | VirtualEntityDefinition | ComputedEntityDefinition | ViewEntityDefinition | ReferenceEntityDefinition | ConfigEntityDefinition | EventEntityDefinition | TempEntityDefinition | ActionEntityDefinition;
|
|
108
533
|
/**
|
|
109
534
|
* Requisitos para activar un módulo
|
|
110
535
|
*/
|
|
@@ -130,19 +555,21 @@ interface ForbiddenErrorInstance {
|
|
|
130
555
|
* Constructor de ForbiddenError con método from()
|
|
131
556
|
*/
|
|
132
557
|
interface ForbiddenErrorConstructor {
|
|
133
|
-
from: (ability:
|
|
558
|
+
from: (ability: any) => ForbiddenErrorInstance;
|
|
134
559
|
}
|
|
135
560
|
/**
|
|
136
561
|
* Abilities CASL disponibles en el contexto
|
|
137
562
|
* Permite usar CASL en plugins sin importar @casl/ability directamente
|
|
138
563
|
*/
|
|
139
564
|
interface ModuleAbilities {
|
|
565
|
+
/** Crea una ability CASL para un usuario (acepta argumentos adicionales como permissions) */
|
|
566
|
+
defineAbilityFor: (user: any, ...args: any[]) => any;
|
|
567
|
+
/** Empaqueta reglas CASL para enviar al cliente (recibe ability, retorna reglas) */
|
|
568
|
+
packRules: (ability: any) => unknown[];
|
|
140
569
|
/** Wrapper para verificar permisos contra instancias */
|
|
141
|
-
subject: (type: string, object:
|
|
570
|
+
subject: (type: string, object: Record<string, any>) => unknown;
|
|
142
571
|
/** Error de CASL para throwUnlessCan */
|
|
143
572
|
ForbiddenError: ForbiddenErrorConstructor;
|
|
144
|
-
/** Otros métodos que el backend pueda añadir */
|
|
145
|
-
[key: string]: unknown;
|
|
146
573
|
}
|
|
147
574
|
/**
|
|
148
575
|
* AuthRequest pre-tipado para uso en plugins
|
|
@@ -206,7 +633,8 @@ interface ModuleContext {
|
|
|
206
633
|
createRouter: () => Router;
|
|
207
634
|
middleware: ModuleMiddlewares;
|
|
208
635
|
registerMiddleware: (name: string, handler: RequestHandler) => void;
|
|
209
|
-
|
|
636
|
+
/** Configuración resuelta de la aplicación (permite propiedades tipadas del backend) */
|
|
637
|
+
config: any;
|
|
210
638
|
errors: {
|
|
211
639
|
AppError: new (message: string, statusCode?: number) => Error;
|
|
212
640
|
NotFoundError: new (message?: string) => Error;
|
|
@@ -215,10 +643,8 @@ interface ModuleContext {
|
|
|
215
643
|
ConflictError: new (message?: string) => Error;
|
|
216
644
|
};
|
|
217
645
|
abilities: ModuleAbilities;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
|
|
221
|
-
};
|
|
646
|
+
/** Sistema de eventos (EventEmitter2 compatible, permite implementaciones tipadas) */
|
|
647
|
+
events: any;
|
|
222
648
|
mail: {
|
|
223
649
|
send: (options: {
|
|
224
650
|
to: string | string[];
|
|
@@ -227,7 +653,7 @@ interface ModuleContext {
|
|
|
227
653
|
text?: string;
|
|
228
654
|
template?: string;
|
|
229
655
|
data?: Record<string, unknown>;
|
|
230
|
-
}) => Promise<
|
|
656
|
+
}) => Promise<unknown>;
|
|
231
657
|
};
|
|
232
658
|
/** Servicios de módulos core (users, etc.) */
|
|
233
659
|
services: CoreServices & Record<string, unknown>;
|
|
@@ -264,8 +690,12 @@ interface ModuleManifest {
|
|
|
264
690
|
routePrefix?: string;
|
|
265
691
|
/** Subjects CASL adicionales (sin entity). Los subjects de entities se infieren automáticamente */
|
|
266
692
|
additionalSubjects?: string[];
|
|
267
|
-
/**
|
|
268
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Definiciones de entidades (nuevo sistema unificado).
|
|
695
|
+
* Single source of truth para BD, validación, UI y CASL.
|
|
696
|
+
* Sus subjects se registran automáticamente.
|
|
697
|
+
*/
|
|
698
|
+
definitions?: EntityDefinition[];
|
|
269
699
|
}
|
|
270
700
|
/**
|
|
271
701
|
* Categorías disponibles para plugins
|
|
@@ -293,4 +723,4 @@ interface PluginManifest {
|
|
|
293
723
|
modules: ModuleManifest[];
|
|
294
724
|
}
|
|
295
725
|
|
|
296
|
-
export type
|
|
726
|
+
export { type AbilityLike, type ActionEntityDefinition, type AuthRequest, type BaseUser, type CaslAction, type CollectionEntityDefinition, type ComputedEntityDefinition, type ConfigEntityDefinition, type CoreServices, type DbType, type EntityCaslConfig, type EntityDefinition, type EntityIndex, type EventEntityDefinition, type ExternalEntityDefinition, type FieldCaslAccess, type FieldDbConfig, type FieldDefinition, type FieldMeta, type FieldOptions, type FieldRelation, type FieldValidation, type FieldValidationConfig, type ForbiddenErrorConstructor, type ForbiddenErrorInstance, type FormField, type FormFieldType, type GeneratedPermission, type InputType, type KnexAlterTableBuilder, type KnexCreateTableBuilder, type KnexTransaction, type ListType, type MigrationHelpers, type ModuleAbilities, type ModuleContext, type ModuleManifest, type ModuleMiddlewares, type ModuleRequirements, type OwnershipCondition, type PaginatedResult, type PaginationParams, type PluginAuthRequest, type PluginCategory, type PluginManifest, type ReferenceEntityDefinition, type RolePermission, type SingleEntityDefinition, type TempEntityDefinition, type UsersResolver, type ValidateSchemas, type ValidationSchema, type ViewEntityDefinition, type VirtualEntityDefinition, generateCaslPermissions, generateCaslSeed, generateMigration, generateModel, generateZodSchema, getEntitySubject };
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// src/generators.ts
|
|
2
|
+
function isPersistentEntity(entity) {
|
|
3
|
+
const nonPersistent = ["action", "external", "virtual", "computed"];
|
|
4
|
+
return !nonPersistent.includes(entity.type ?? "collection");
|
|
5
|
+
}
|
|
6
|
+
function generateMigration(entity) {
|
|
7
|
+
const { table, fields } = entity;
|
|
8
|
+
const timestamps = "timestamps" in entity ? entity.timestamps : false;
|
|
9
|
+
const audit = "audit" in entity ? entity.audit : false;
|
|
10
|
+
const indexes = "indexes" in entity ? entity.indexes : void 0;
|
|
11
|
+
const lines = [
|
|
12
|
+
`import type { ModuleContext, Knex } from '@gzl10/nexus-sdk'`,
|
|
13
|
+
``,
|
|
14
|
+
`export async function migrate(ctx: ModuleContext): Promise<void> {`,
|
|
15
|
+
` const { db, logger, helpers } = ctx`
|
|
16
|
+
];
|
|
17
|
+
if (timestamps) {
|
|
18
|
+
lines.push(` const { addTimestamps } = helpers`);
|
|
19
|
+
}
|
|
20
|
+
if (audit) {
|
|
21
|
+
lines.push(` const { addAuditFieldsIfMissing } = helpers`);
|
|
22
|
+
}
|
|
23
|
+
lines.push(``);
|
|
24
|
+
lines.push(` if (!(await db.schema.hasTable('${table}'))) {`);
|
|
25
|
+
lines.push(` await db.schema.createTable('${table}', (table: Knex.CreateTableBuilder) => {`);
|
|
26
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
27
|
+
const columnCode = generateColumnCode(name, field);
|
|
28
|
+
lines.push(` ${columnCode}`);
|
|
29
|
+
}
|
|
30
|
+
if (timestamps) {
|
|
31
|
+
lines.push(` addTimestamps(table, db)`);
|
|
32
|
+
}
|
|
33
|
+
if (indexes?.length) {
|
|
34
|
+
lines.push(``);
|
|
35
|
+
for (const idx of indexes) {
|
|
36
|
+
if (idx.unique) {
|
|
37
|
+
lines.push(` table.unique([${idx.columns.map((c) => `'${c}'`).join(", ")}])`);
|
|
38
|
+
} else {
|
|
39
|
+
lines.push(` table.index([${idx.columns.map((c) => `'${c}'`).join(", ")}])`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
lines.push(` })`);
|
|
44
|
+
lines.push(` logger.info('Created table: ${table}')`);
|
|
45
|
+
lines.push(` }`);
|
|
46
|
+
if (audit) {
|
|
47
|
+
lines.push(``);
|
|
48
|
+
lines.push(` await addAuditFieldsIfMissing(db, '${table}')`);
|
|
49
|
+
}
|
|
50
|
+
lines.push(`}`);
|
|
51
|
+
lines.push(``);
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
function generateColumnCode(name, field) {
|
|
55
|
+
const { db, relation } = field;
|
|
56
|
+
let code = "";
|
|
57
|
+
switch (db.type) {
|
|
58
|
+
case "string":
|
|
59
|
+
code = db.size ? `table.string('${name}', ${db.size})` : `table.string('${name}')`;
|
|
60
|
+
break;
|
|
61
|
+
case "text":
|
|
62
|
+
code = `table.text('${name}')`;
|
|
63
|
+
break;
|
|
64
|
+
case "integer":
|
|
65
|
+
code = `table.integer('${name}')`;
|
|
66
|
+
break;
|
|
67
|
+
case "decimal":
|
|
68
|
+
if (db.precision) {
|
|
69
|
+
code = `table.decimal('${name}', ${db.precision[0]}, ${db.precision[1]})`;
|
|
70
|
+
} else {
|
|
71
|
+
code = `table.decimal('${name}')`;
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
case "boolean":
|
|
75
|
+
code = `table.boolean('${name}')`;
|
|
76
|
+
break;
|
|
77
|
+
case "date":
|
|
78
|
+
code = `table.date('${name}')`;
|
|
79
|
+
break;
|
|
80
|
+
case "datetime":
|
|
81
|
+
code = `table.timestamp('${name}')`;
|
|
82
|
+
break;
|
|
83
|
+
case "json":
|
|
84
|
+
code = `table.json('${name}')`;
|
|
85
|
+
break;
|
|
86
|
+
case "uuid":
|
|
87
|
+
code = `table.uuid('${name}')`;
|
|
88
|
+
break;
|
|
89
|
+
default:
|
|
90
|
+
code = `table.string('${name}')`;
|
|
91
|
+
}
|
|
92
|
+
if (name === "id") {
|
|
93
|
+
code += `.primary()`;
|
|
94
|
+
}
|
|
95
|
+
if (db.nullable) {
|
|
96
|
+
code += `.nullable()`;
|
|
97
|
+
} else {
|
|
98
|
+
code += `.notNullable()`;
|
|
99
|
+
}
|
|
100
|
+
if (db.unique) {
|
|
101
|
+
code += `.unique()`;
|
|
102
|
+
}
|
|
103
|
+
if (db.default !== void 0) {
|
|
104
|
+
if (typeof db.default === "string") {
|
|
105
|
+
code += `.defaultTo('${db.default}')`;
|
|
106
|
+
} else {
|
|
107
|
+
code += `.defaultTo(${JSON.stringify(db.default)})`;
|
|
108
|
+
}
|
|
109
|
+
} else if (db.defaultFn === "now") {
|
|
110
|
+
code += `.defaultTo(db.fn.now())`;
|
|
111
|
+
} else if (db.defaultFn === "uuid") {
|
|
112
|
+
code += `.defaultTo(db.raw('gen_random_uuid()'))`;
|
|
113
|
+
}
|
|
114
|
+
if (relation) {
|
|
115
|
+
const col = relation.column ?? "id";
|
|
116
|
+
code += `.references('${col}').inTable('${relation.table}')`;
|
|
117
|
+
if (relation.onDelete) {
|
|
118
|
+
code += `.onDelete('${relation.onDelete}')`;
|
|
119
|
+
}
|
|
120
|
+
if (relation.onUpdate) {
|
|
121
|
+
code += `.onUpdate('${relation.onUpdate}')`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (db.index && !db.unique && name !== "id") {
|
|
125
|
+
}
|
|
126
|
+
return code;
|
|
127
|
+
}
|
|
128
|
+
function generateZodSchema(entity) {
|
|
129
|
+
const { table, fields } = entity;
|
|
130
|
+
const entityName = tableToEntityName(table);
|
|
131
|
+
const lines = [
|
|
132
|
+
`import { z } from 'zod'`,
|
|
133
|
+
``
|
|
134
|
+
];
|
|
135
|
+
lines.push(`// === CREATE ===`);
|
|
136
|
+
lines.push(`export const create${entityName}Schema = z.object({`);
|
|
137
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
138
|
+
if (name === "id" || field.meta?.auditField) continue;
|
|
139
|
+
const zodCode = generateZodFieldCode(field, "create");
|
|
140
|
+
if (zodCode) {
|
|
141
|
+
lines.push(` ${name}: ${zodCode},`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
lines.push(`})`);
|
|
145
|
+
lines.push(``);
|
|
146
|
+
lines.push(`// === UPDATE ===`);
|
|
147
|
+
lines.push(`export const update${entityName}Schema = z.object({`);
|
|
148
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
149
|
+
if (name === "id" || field.meta?.auditField) continue;
|
|
150
|
+
const zodCode = generateZodFieldCode(field, "update");
|
|
151
|
+
if (zodCode) {
|
|
152
|
+
lines.push(` ${name}: ${zodCode},`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
lines.push(`})`);
|
|
156
|
+
lines.push(``);
|
|
157
|
+
lines.push(`// === PARAMS ===`);
|
|
158
|
+
lines.push(`export const ${entityName.toLowerCase()}ParamsSchema = z.object({`);
|
|
159
|
+
lines.push(` id: z.string(),`);
|
|
160
|
+
lines.push(`})`);
|
|
161
|
+
lines.push(``);
|
|
162
|
+
lines.push(`// === QUERY ===`);
|
|
163
|
+
lines.push(`export const ${entityName.toLowerCase()}QuerySchema = z.object({`);
|
|
164
|
+
lines.push(` page: z.coerce.number().int().min(1).default(1),`);
|
|
165
|
+
lines.push(` limit: z.coerce.number().int().min(1).max(100).default(20),`);
|
|
166
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
167
|
+
if (field.meta?.searchable) {
|
|
168
|
+
const zodType = dbTypeToZodType(field.db.type);
|
|
169
|
+
lines.push(` ${name}: ${zodType}.optional(),`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
lines.push(`})`);
|
|
173
|
+
lines.push(``);
|
|
174
|
+
lines.push(`// === INFERRED TYPES ===`);
|
|
175
|
+
lines.push(`export type Create${entityName}Input = z.infer<typeof create${entityName}Schema>`);
|
|
176
|
+
lines.push(`export type Update${entityName}Input = z.infer<typeof update${entityName}Schema>`);
|
|
177
|
+
lines.push(`export type ${entityName}Params = z.infer<typeof ${entityName.toLowerCase()}ParamsSchema>`);
|
|
178
|
+
lines.push(`export type ${entityName}Query = z.infer<typeof ${entityName.toLowerCase()}QuerySchema>`);
|
|
179
|
+
lines.push(``);
|
|
180
|
+
return lines.join("\n");
|
|
181
|
+
}
|
|
182
|
+
function generateZodFieldCode(field, mode) {
|
|
183
|
+
const { db, validation } = field;
|
|
184
|
+
let code = dbTypeToZodType(db.type);
|
|
185
|
+
if (validation) {
|
|
186
|
+
if (validation.format === "email") {
|
|
187
|
+
code += `.email()`;
|
|
188
|
+
} else if (validation.format === "url") {
|
|
189
|
+
code += `.url()`;
|
|
190
|
+
} else if (validation.format === "uuid") {
|
|
191
|
+
code += `.uuid()`;
|
|
192
|
+
}
|
|
193
|
+
if (validation.min !== void 0) {
|
|
194
|
+
if (db.type === "string" || db.type === "text") {
|
|
195
|
+
code += `.min(${validation.min})`;
|
|
196
|
+
} else if (db.type === "integer" || db.type === "decimal") {
|
|
197
|
+
code += `.min(${validation.min})`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (validation.max !== void 0) {
|
|
201
|
+
if (db.type === "string" || db.type === "text") {
|
|
202
|
+
code += `.max(${validation.max})`;
|
|
203
|
+
} else if (db.type === "integer" || db.type === "decimal") {
|
|
204
|
+
code += `.max(${validation.max})`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (validation.pattern) {
|
|
208
|
+
code += `.regex(/${validation.pattern}/)`;
|
|
209
|
+
}
|
|
210
|
+
if (validation.enum?.length) {
|
|
211
|
+
code = `z.enum([${validation.enum.map((v) => `'${v}'`).join(", ")}])`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (db.nullable) {
|
|
215
|
+
code += `.nullable()`;
|
|
216
|
+
}
|
|
217
|
+
if (mode === "update") {
|
|
218
|
+
code += `.optional()`;
|
|
219
|
+
} else if (mode === "create" && !validation?.required && db.default !== void 0) {
|
|
220
|
+
code += `.optional()`;
|
|
221
|
+
}
|
|
222
|
+
return code;
|
|
223
|
+
}
|
|
224
|
+
function dbTypeToZodType(dbType) {
|
|
225
|
+
switch (dbType) {
|
|
226
|
+
case "string":
|
|
227
|
+
case "text":
|
|
228
|
+
case "uuid":
|
|
229
|
+
return "z.string()";
|
|
230
|
+
case "integer":
|
|
231
|
+
return "z.number().int()";
|
|
232
|
+
case "decimal":
|
|
233
|
+
return "z.number()";
|
|
234
|
+
case "boolean":
|
|
235
|
+
return "z.boolean()";
|
|
236
|
+
case "date":
|
|
237
|
+
case "datetime":
|
|
238
|
+
return "z.string().datetime()";
|
|
239
|
+
case "json":
|
|
240
|
+
return "z.record(z.unknown())";
|
|
241
|
+
default:
|
|
242
|
+
return "z.string()";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function generateModel(entity) {
|
|
246
|
+
const { table, fields } = entity;
|
|
247
|
+
const timestamps = "timestamps" in entity ? entity.timestamps : false;
|
|
248
|
+
const audit = "audit" in entity ? entity.audit : false;
|
|
249
|
+
const entityName = tableToEntityName(table);
|
|
250
|
+
const lines = [
|
|
251
|
+
`/**`,
|
|
252
|
+
` * ${entity.label}`,
|
|
253
|
+
` * Generated from EntityDefinition`,
|
|
254
|
+
` */`,
|
|
255
|
+
`export interface ${entityName} {`
|
|
256
|
+
];
|
|
257
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
258
|
+
const tsType = dbTypeToTsType(field.db);
|
|
259
|
+
const optional = field.db.nullable ? "?" : "";
|
|
260
|
+
lines.push(` ${name}${optional}: ${tsType}`);
|
|
261
|
+
}
|
|
262
|
+
if (timestamps) {
|
|
263
|
+
lines.push(` created_at: Date`);
|
|
264
|
+
lines.push(` updated_at: Date`);
|
|
265
|
+
}
|
|
266
|
+
if (audit) {
|
|
267
|
+
lines.push(` created_by: string | null`);
|
|
268
|
+
lines.push(` updated_by: string | null`);
|
|
269
|
+
}
|
|
270
|
+
lines.push(`}`);
|
|
271
|
+
lines.push(``);
|
|
272
|
+
return lines.join("\n");
|
|
273
|
+
}
|
|
274
|
+
function dbTypeToTsType(db) {
|
|
275
|
+
switch (db.type) {
|
|
276
|
+
case "string":
|
|
277
|
+
case "text":
|
|
278
|
+
case "uuid":
|
|
279
|
+
return "string";
|
|
280
|
+
case "integer":
|
|
281
|
+
case "decimal":
|
|
282
|
+
return "number";
|
|
283
|
+
case "boolean":
|
|
284
|
+
return "boolean";
|
|
285
|
+
case "date":
|
|
286
|
+
case "datetime":
|
|
287
|
+
return "Date";
|
|
288
|
+
case "json":
|
|
289
|
+
return "Record<string, unknown>";
|
|
290
|
+
default:
|
|
291
|
+
return "unknown";
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function tableToEntityName(table) {
|
|
295
|
+
const withoutPrefix = table.replace(/^[a-z]{2,4}_/, "");
|
|
296
|
+
const singular = withoutPrefix.endsWith("ies") ? withoutPrefix.slice(0, -3) + "y" : withoutPrefix.endsWith("s") ? withoutPrefix.slice(0, -1) : withoutPrefix;
|
|
297
|
+
return singular.charAt(0).toUpperCase() + singular.slice(1);
|
|
298
|
+
}
|
|
299
|
+
function tableToSubject(table) {
|
|
300
|
+
const match = table.match(/^([a-z]{2,4})_(.+)$/);
|
|
301
|
+
if (!match) return tableToEntityName(table);
|
|
302
|
+
const [, prefix, rest] = match;
|
|
303
|
+
const prefixPascal = prefix.charAt(0).toUpperCase() + prefix.slice(1);
|
|
304
|
+
const singular = rest.endsWith("ies") ? rest.slice(0, -3) + "y" : rest.endsWith("s") ? rest.slice(0, -1) : rest;
|
|
305
|
+
const restPascal = singular.charAt(0).toUpperCase() + singular.slice(1);
|
|
306
|
+
return prefixPascal + restPascal;
|
|
307
|
+
}
|
|
308
|
+
function getFieldsForRole(entity, role, action) {
|
|
309
|
+
const { fields, casl } = entity;
|
|
310
|
+
const isReadAction = action === "read";
|
|
311
|
+
const isWriteAction = action === "create" || action === "update";
|
|
312
|
+
if (action === "manage") return null;
|
|
313
|
+
const allowedFields = [];
|
|
314
|
+
const sensitiveFields = casl?.sensitiveFields ?? [];
|
|
315
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
316
|
+
const fieldCasl = field.meta?.casl;
|
|
317
|
+
if (sensitiveFields.includes(fieldName) && role !== "ADMIN") {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (isReadAction) {
|
|
321
|
+
if (fieldCasl?.readRoles !== void 0) {
|
|
322
|
+
if (fieldCasl.readRoles.length === 0 || fieldCasl.readRoles.includes(role)) {
|
|
323
|
+
allowedFields.push(fieldName);
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
allowedFields.push(fieldName);
|
|
327
|
+
}
|
|
328
|
+
} else if (isWriteAction) {
|
|
329
|
+
if (fieldCasl?.writeRoles !== void 0) {
|
|
330
|
+
if (fieldCasl.writeRoles.length === 0 || fieldCasl.writeRoles.includes(role)) {
|
|
331
|
+
allowedFields.push(fieldName);
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
allowedFields.push(fieldName);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const allFieldNames = Object.keys(fields).filter((f) => !sensitiveFields.includes(f) || role === "ADMIN");
|
|
341
|
+
if (allowedFields.length === allFieldNames.length) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
return allowedFields.length > 0 ? allowedFields : null;
|
|
345
|
+
}
|
|
346
|
+
function generateCaslPermissions(entity) {
|
|
347
|
+
const { table, fields, casl } = entity;
|
|
348
|
+
if (!casl) return [];
|
|
349
|
+
const subject = casl.subject ?? tableToSubject(table);
|
|
350
|
+
const permissions = [];
|
|
351
|
+
for (const [role, config] of Object.entries(casl.permissions ?? {})) {
|
|
352
|
+
for (const action of config.actions) {
|
|
353
|
+
let conditions = config.conditions ?? null;
|
|
354
|
+
if (casl.ownership && (action === "update" || action === "delete") && !conditions) {
|
|
355
|
+
const userField = casl.ownership.userField ?? "id";
|
|
356
|
+
conditions = { [casl.ownership.field]: `\${user.${userField}}` };
|
|
357
|
+
}
|
|
358
|
+
let permFields = config.fields ?? null;
|
|
359
|
+
if (!permFields) {
|
|
360
|
+
permFields = getFieldsForRole(entity, role, action);
|
|
361
|
+
}
|
|
362
|
+
permissions.push({
|
|
363
|
+
role,
|
|
364
|
+
action,
|
|
365
|
+
subject,
|
|
366
|
+
conditions: conditions ? JSON.stringify(conditions) : null,
|
|
367
|
+
fields: permFields ? JSON.stringify(permFields) : null,
|
|
368
|
+
inverted: config.inverted ?? false
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return permissions;
|
|
373
|
+
}
|
|
374
|
+
function generateCaslSeed(entities) {
|
|
375
|
+
const allPermissions = [];
|
|
376
|
+
for (const entity of entities) {
|
|
377
|
+
if (isPersistentEntity(entity)) {
|
|
378
|
+
allPermissions.push(...generateCaslPermissions(entity));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (allPermissions.length === 0) {
|
|
382
|
+
return "// No CASL permissions defined in entities\n";
|
|
383
|
+
}
|
|
384
|
+
const lines = [
|
|
385
|
+
`import type { ModuleContext } from '@gzl10/nexus-sdk'`,
|
|
386
|
+
``,
|
|
387
|
+
`/**`,
|
|
388
|
+
` * Seed de permisos CASL generado desde EntityDefinition`,
|
|
389
|
+
` */`,
|
|
390
|
+
`export async function seedPermissions(ctx: ModuleContext): Promise<void> {`,
|
|
391
|
+
` const { db, generateId, logger } = ctx`,
|
|
392
|
+
``,
|
|
393
|
+
` // Obtener IDs de roles`,
|
|
394
|
+
` const roles = await db('rol_roles').select('id', 'name')`,
|
|
395
|
+
` const roleMap = Object.fromEntries(roles.map((r: { id: string; name: string }) => [r.name, r.id]))`,
|
|
396
|
+
``,
|
|
397
|
+
` const permissions = [`
|
|
398
|
+
];
|
|
399
|
+
for (const perm of allPermissions) {
|
|
400
|
+
lines.push(` {`);
|
|
401
|
+
lines.push(` role: '${perm.role}',`);
|
|
402
|
+
lines.push(` action: '${perm.action}',`);
|
|
403
|
+
lines.push(` subject: '${perm.subject}',`);
|
|
404
|
+
lines.push(` conditions: ${perm.conditions ? `'${perm.conditions}'` : "null"},`);
|
|
405
|
+
lines.push(` fields: ${perm.fields ? `'${perm.fields}'` : "null"},`);
|
|
406
|
+
lines.push(` inverted: ${perm.inverted}`);
|
|
407
|
+
lines.push(` },`);
|
|
408
|
+
}
|
|
409
|
+
lines.push(` ]`);
|
|
410
|
+
lines.push(``);
|
|
411
|
+
lines.push(` for (const perm of permissions) {`);
|
|
412
|
+
lines.push(` const roleId = roleMap[perm.role]`);
|
|
413
|
+
lines.push(` if (!roleId) {`);
|
|
414
|
+
lines.push(` logger.warn({ role: perm.role }, 'Role not found, skipping permission')`);
|
|
415
|
+
lines.push(` continue`);
|
|
416
|
+
lines.push(` }`);
|
|
417
|
+
lines.push(``);
|
|
418
|
+
lines.push(` // Check if permission exists`);
|
|
419
|
+
lines.push(` const existing = await db('rol_role_permissions')`);
|
|
420
|
+
lines.push(` .where({ role_id: roleId, action: perm.action, subject: perm.subject })`);
|
|
421
|
+
lines.push(` .first()`);
|
|
422
|
+
lines.push(``);
|
|
423
|
+
lines.push(` if (!existing) {`);
|
|
424
|
+
lines.push(` await db('rol_role_permissions').insert({`);
|
|
425
|
+
lines.push(` id: generateId(),`);
|
|
426
|
+
lines.push(` role_id: roleId,`);
|
|
427
|
+
lines.push(` action: perm.action,`);
|
|
428
|
+
lines.push(` subject: perm.subject,`);
|
|
429
|
+
lines.push(` conditions: perm.conditions,`);
|
|
430
|
+
lines.push(` fields: perm.fields,`);
|
|
431
|
+
lines.push(` inverted: perm.inverted`);
|
|
432
|
+
lines.push(` })`);
|
|
433
|
+
lines.push(` }`);
|
|
434
|
+
lines.push(` }`);
|
|
435
|
+
lines.push(``);
|
|
436
|
+
lines.push(` logger.info('Seeded CASL permissions from EntityDefinitions')`);
|
|
437
|
+
lines.push(`}`);
|
|
438
|
+
lines.push(``);
|
|
439
|
+
return lines.join("\n");
|
|
440
|
+
}
|
|
441
|
+
function getEntitySubject(entity) {
|
|
442
|
+
return entity.casl?.subject ?? tableToSubject(entity.table);
|
|
443
|
+
}
|
|
444
|
+
export {
|
|
445
|
+
generateCaslPermissions,
|
|
446
|
+
generateCaslSeed,
|
|
447
|
+
generateMigration,
|
|
448
|
+
generateModel,
|
|
449
|
+
generateZodSchema,
|
|
450
|
+
getEntitySubject
|
|
451
|
+
};
|