arckode-framework 1.0.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 +546 -0
- package/adapters/__tests__/mysql.test.ts +283 -0
- package/adapters/jwt.ts +18 -0
- package/adapters/mysql.ts +98 -0
- package/adapters/postgres.ts +52 -0
- package/adapters/redis-cache.ts +64 -0
- package/adapters/sqlite.ts +73 -0
- package/adapters/vendor.d.ts +48 -0
- package/bin/arckode.js +7 -0
- package/cli/analyze.ts +506 -0
- package/cli/commands/db-migrate.ts +121 -0
- package/cli/commands/db-seed.ts +54 -0
- package/cli/commands/generate-api-client.ts +106 -0
- package/cli/commands/make-adapter.ts +132 -0
- package/cli/commands/make-auth.ts +297 -0
- package/cli/commands/make-frontend-module.ts +271 -0
- package/cli/commands/make-helper.ts +65 -0
- package/cli/commands/make-migration.ts +30 -0
- package/cli/commands/make-seed.ts +29 -0
- package/cli/generate.ts +132 -0
- package/cli/index.ts +604 -0
- package/cli/stubs/frontend-stub.ts +294 -0
- package/cli/stubs/fullstack-stub.ts +46 -0
- package/cli/stubs/module-stub.ts +469 -0
- package/kernel/__tests__/adapters.test.ts +101 -0
- package/kernel/__tests__/analyzer.test.ts +282 -0
- package/kernel/__tests__/framework.test.ts +617 -0
- package/kernel/__tests__/middlewares.test.ts +174 -0
- package/kernel/__tests__/static.test.ts +94 -0
- package/kernel/framework.ts +1851 -0
- package/kernel/middlewares.ts +179 -0
- package/kernel/static.ts +76 -0
- package/kernel/testing.ts +237 -0
- package/modules/events/index.ts +99 -0
- package/modules/mail/index.ts +51 -0
- package/modules/mail/smtp-adapter.ts +42 -0
- package/modules/queue/index.ts +78 -0
- package/modules/storage/index.ts +40 -0
- package/modules/storage/local-adapter.ts +41 -0
- package/modules/ws/__tests__/ws.test.ts +114 -0
- package/modules/ws/index.ts +136 -0
- package/package.json +99 -0
- package/skills/auth/SKILL.md +243 -0
- package/skills/cli/SKILL.md +258 -0
- package/skills/config/SKILL.md +253 -0
- package/skills/connectors/SKILL.md +259 -0
- package/skills/helpers/SKILL.md +206 -0
- package/skills/middlewares/SKILL.md +282 -0
- package/skills/orm/SKILL.md +260 -0
- package/skills/realtime/SKILL.md +307 -0
- package/skills/services/SKILL.md +206 -0
- package/skills/testing/SKILL.md +257 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// cli/commands/generate-api-client.ts — Genera API clients frontend desde modelos del backend
|
|
2
|
+
// Lee los modelos definidos en composition-root y genera el api/ client completo
|
|
3
|
+
|
|
4
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export async function generateApiClient(basePath: string, frontendPath: string) {
|
|
9
|
+
const modulesPath = join(basePath, 'src', 'modules')
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const modules = await readdirSafe(modulesPath)
|
|
13
|
+
if (!modules) {
|
|
14
|
+
console.log('No se encontraron módulos en', modulesPath)
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const module of modules) {
|
|
19
|
+
const typesPath = join(modulesPath, module, 'types.ts')
|
|
20
|
+
const content = await readFile(typesPath, 'utf-8').catch(() => null)
|
|
21
|
+
if (!content) continue
|
|
22
|
+
|
|
23
|
+
const interfaces = parseInterfaces(content)
|
|
24
|
+
const dtoName = interfaces.find(i => i.endsWith('DTO') && !i.startsWith('Create') && !i.startsWith('Update'))
|
|
25
|
+
const createDto = interfaces.find(i => i.startsWith('Create'))
|
|
26
|
+
const pascal = module.charAt(0).toUpperCase() + module.slice(1)
|
|
27
|
+
|
|
28
|
+
const apiDir = join(frontendPath, 'src', 'modules', module, 'api')
|
|
29
|
+
await mkdir(apiDir, { recursive: true })
|
|
30
|
+
|
|
31
|
+
const clientCode = generateClient(module, pascal, dtoName, createDto)
|
|
32
|
+
await writeFile(join(apiDir, `${module}.api.ts`), clientCode, 'utf-8')
|
|
33
|
+
|
|
34
|
+
const typesTarget = join(frontendPath, 'src', 'modules', module, 'types.ts')
|
|
35
|
+
await mkdir(join(frontendPath, 'src', 'modules', module), { recursive: true })
|
|
36
|
+
await writeFile(typesTarget, content, 'utf-8')
|
|
37
|
+
|
|
38
|
+
console.log(` ✓ ${module}: API client + types generados`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`\n✅ API clients generados en ${frontendPath}/src/modules/`)
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error('Error generando API clients:', err)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function generateClient(module: string, pascal: string, dtoName?: string, createDto?: string): string {
|
|
48
|
+
const dto = dtoName ?? `${pascal}DTO`
|
|
49
|
+
const create = createDto ?? `Create${pascal}DTO`
|
|
50
|
+
|
|
51
|
+
return `// modules/${module}/api/${module}.api.ts — GENERADO AUTOMÁTICAMENTE
|
|
52
|
+
import type { ${dto}, ${create} } from '../types'
|
|
53
|
+
|
|
54
|
+
const BASE_URL = import.meta.env.VITE_API_URL ?? '/api'
|
|
55
|
+
|
|
56
|
+
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
|
57
|
+
const token = localStorage.getItem('arckode_token')
|
|
58
|
+
const res = await fetch(\`\${BASE_URL}\${path}\`, {
|
|
59
|
+
...opts,
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
...(token ? { Authorization: \`Bearer \${token}\` } : {}),
|
|
63
|
+
...opts.headers,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Error')
|
|
67
|
+
return res.json()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PaginatedResponse<T> {
|
|
71
|
+
data: T[]
|
|
72
|
+
pagination: { page: number; limit: number; total: number; totalPages: number; hasNext: boolean; hasPrev: boolean }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const ${module}Api = {
|
|
76
|
+
list: (params?: Record<string, string>) => {
|
|
77
|
+
const q = params ? \`?\${new URLSearchParams(params)}\` : ''
|
|
78
|
+
return request<PaginatedResponse<${dto}>>(\`/${module}\${q}\`)
|
|
79
|
+
},
|
|
80
|
+
getById: (id: string) => request<${dto}>(\`/${module}/\${id}\`),
|
|
81
|
+
create: (data: ${create}) => request<${dto}>('/${module}', { method: 'POST', body: JSON.stringify(data) }),
|
|
82
|
+
update: (id: string, data: Partial<${create}>) => request<${dto}>(\`/${module}/\${id}\`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
83
|
+
delete: (id: string) => request<void>(\`/${module}/\${id}\`, { method: 'DELETE' }),
|
|
84
|
+
}
|
|
85
|
+
`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseInterfaces(content: string): string[] {
|
|
89
|
+
const names: string[] = []
|
|
90
|
+
const regex = /export\s+interface\s+(\w+DTO)/g
|
|
91
|
+
let match
|
|
92
|
+
while ((match = regex.exec(content)) !== null) {
|
|
93
|
+
if (match[1]) names.push(match[1])
|
|
94
|
+
}
|
|
95
|
+
return names
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readdirSafe(path: string): Promise<string[] | null> {
|
|
99
|
+
try {
|
|
100
|
+
const { readdir } = await import('node:fs/promises')
|
|
101
|
+
const items = await readdir(path, { withFileTypes: true })
|
|
102
|
+
return items.filter(i => i.isDirectory()).map(i => i.name)
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// cli/commands/make-adapter.ts — Generador de adapters para librerías externas
|
|
2
|
+
|
|
3
|
+
import { mkdir, writeFile, access } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
function toPascal(str: string): string {
|
|
7
|
+
return str
|
|
8
|
+
.replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())
|
|
9
|
+
.replace(/^(.)/, c => c.toUpperCase())
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toKebab(str: string): string {
|
|
13
|
+
return str
|
|
14
|
+
.replace(/([A-Z])/g, '-$1')
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/^-/, '')
|
|
17
|
+
.replace(/[-_\s]+/g, '-')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toCamel(str: string): string {
|
|
21
|
+
return str
|
|
22
|
+
.replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())
|
|
23
|
+
.replace(/^(.)/, c => c.toLowerCase())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function interfaceStub(interfaceName: string, adapterName: string): string {
|
|
27
|
+
const pascal = toPascal(interfaceName)
|
|
28
|
+
const kebab = toKebab(interfaceName)
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
`// adapters/${kebab}.interface.ts`,
|
|
32
|
+
`// Interfaz del dominio — definida por el framework, NO por la librería externa`,
|
|
33
|
+
`// Patrón B — Adapter de librería externa (ver ARCHITECTURE-REFERENCE.md)`,
|
|
34
|
+
``,
|
|
35
|
+
`export interface ${pascal} {`,
|
|
36
|
+
` // Definí los métodos que tu dominio necesita (no los de la librería)`,
|
|
37
|
+
` // Ejemplo:`,
|
|
38
|
+
` // send(to: string, subject: string, html: string): Promise<void>`,
|
|
39
|
+
`}`,
|
|
40
|
+
``,
|
|
41
|
+
].join('\n')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function adapterStub(adapterName: string, interfaceName: string): string {
|
|
45
|
+
const adapterPascal = toPascal(adapterName)
|
|
46
|
+
const interfacePascal = toPascal(interfaceName)
|
|
47
|
+
const interfaceKebab = toKebab(interfaceName)
|
|
48
|
+
const adapterKebab = toKebab(adapterName)
|
|
49
|
+
|
|
50
|
+
return [
|
|
51
|
+
`// adapters/${adapterKebab}.ts`,
|
|
52
|
+
`// Implementación concreta usando la librería externa`,
|
|
53
|
+
`// La interfaz es tuya. La implementación es de npm.`,
|
|
54
|
+
``,
|
|
55
|
+
`import type { ${interfacePascal} } from './${interfaceKebab}.interface'`,
|
|
56
|
+
``,
|
|
57
|
+
`export class ${adapterPascal} implements ${interfacePascal} {`,
|
|
58
|
+
` constructor(`,
|
|
59
|
+
` // Pasar config necesaria (API keys, endpoints, etc.)`,
|
|
60
|
+
` // private readonly apiKey: string,`,
|
|
61
|
+
` ) {}`,
|
|
62
|
+
``,
|
|
63
|
+
` // Implementá los métodos de ${interfacePascal}`,
|
|
64
|
+
` // Ejemplo:`,
|
|
65
|
+
` // async send(to: string, subject: string, html: string): Promise<void> {`,
|
|
66
|
+
` // await externalLib.sendEmail({ to, subject, html })`,
|
|
67
|
+
` // }`,
|
|
68
|
+
`}`,
|
|
69
|
+
``,
|
|
70
|
+
].join('\n')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function registrationHint(adapterName: string, interfaceName: string): string {
|
|
74
|
+
const adapterPascal = toPascal(adapterName)
|
|
75
|
+
const interfacePascal = toPascal(interfaceName)
|
|
76
|
+
const adapterKebab = toKebab(adapterName)
|
|
77
|
+
const camelVar = toCamel(adapterName)
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
``,
|
|
81
|
+
` Registralo en composition-root.ts:`,
|
|
82
|
+
``,
|
|
83
|
+
` import { ${adapterPascal} } from './adapters/${adapterKebab}'`,
|
|
84
|
+
``,
|
|
85
|
+
` const ${camelVar} = new ${adapterPascal}(/* config */)`,
|
|
86
|
+
` container.register('${camelVar}', () => ${camelVar})`,
|
|
87
|
+
``,
|
|
88
|
+
` // Pasarlo al módulo que lo usa por closure:`,
|
|
89
|
+
` system.addModule({`,
|
|
90
|
+
` ...MiModuloModule,`,
|
|
91
|
+
` create: (deps) => MiModuloModule.create(deps, ${camelVar}),`,
|
|
92
|
+
` })`,
|
|
93
|
+
].join('\n')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function makeAdapter(
|
|
97
|
+
adapterName: string,
|
|
98
|
+
interfaceName: string,
|
|
99
|
+
basePath: string,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const adapterKebab = toKebab(adapterName)
|
|
102
|
+
const interfaceKebab = toKebab(interfaceName)
|
|
103
|
+
const adaptersPath = join(basePath, 'adapters')
|
|
104
|
+
|
|
105
|
+
await mkdir(adaptersPath, { recursive: true })
|
|
106
|
+
|
|
107
|
+
const adapterFile = join(adaptersPath, `${adapterKebab}.ts`)
|
|
108
|
+
const interfaceFile = join(adaptersPath, `${interfaceKebab}.interface.ts`)
|
|
109
|
+
|
|
110
|
+
// Crear la interfaz solo si no existe
|
|
111
|
+
try {
|
|
112
|
+
await access(interfaceFile)
|
|
113
|
+
console.log(`ℹ Interfaz "${interfaceKebab}.interface.ts" ya existe, se reutiliza.`)
|
|
114
|
+
} catch {
|
|
115
|
+
await writeFile(interfaceFile, interfaceStub(interfaceName, adapterName), 'utf-8')
|
|
116
|
+
console.log(`✅ Interfaz creada: adapters/${interfaceKebab}.interface.ts`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// No sobreescribir adapter si ya existe
|
|
120
|
+
try {
|
|
121
|
+
await access(adapterFile)
|
|
122
|
+
console.log(`⚠ El adapter "${adapterKebab}.ts" ya existe. Editalo directamente.`)
|
|
123
|
+
return
|
|
124
|
+
} catch {
|
|
125
|
+
// no existe → crear
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await writeFile(adapterFile, adapterStub(adapterName, interfaceName), 'utf-8')
|
|
129
|
+
|
|
130
|
+
console.log(`✅ Adapter creado: adapters/${adapterKebab}.ts`)
|
|
131
|
+
console.log(registrationHint(adapterName, interfaceName))
|
|
132
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// cli/commands/make-auth.ts — Generador de autenticación completa
|
|
2
|
+
// Crea: módulo usuarios, login, register, logout, perfil, JWT, middleware, tests
|
|
3
|
+
|
|
4
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export async function makeAuth(basePath: string) {
|
|
8
|
+
const modulePath = join(basePath, 'modules', 'usuarios')
|
|
9
|
+
await mkdir(join(modulePath, 'actions'), { recursive: true })
|
|
10
|
+
await mkdir(join(modulePath, 'validators'), { recursive: true })
|
|
11
|
+
await mkdir(join(modulePath, 'tests'), { recursive: true })
|
|
12
|
+
|
|
13
|
+
// index.ts
|
|
14
|
+
await writeFile(join(modulePath, 'index.ts'), `// usuarios/index.ts — PUERTA PÚBLICA
|
|
15
|
+
export { UsuarioService } from './actions/service'
|
|
16
|
+
export { UsuarioController } from './actions/controller'
|
|
17
|
+
export type { UsuarioDTO, UsuarioPublicDTO, LoginDTO, RegisterDTO, AuthResponse } from './types'
|
|
18
|
+
export type { UsuarioSockets } from './sockets'
|
|
19
|
+
`)
|
|
20
|
+
|
|
21
|
+
// types.ts
|
|
22
|
+
await writeFile(join(modulePath, 'types.ts'), `// usuarios/types.ts — DTOs de autenticación
|
|
23
|
+
|
|
24
|
+
// DTO interno — incluye passwordHash para operaciones de autenticación.
|
|
25
|
+
// NUNCA exponer este DTO directamente en responses HTTP.
|
|
26
|
+
export interface UsuarioDTO {
|
|
27
|
+
id: string
|
|
28
|
+
nombre: string
|
|
29
|
+
email: string
|
|
30
|
+
passwordHash: string // campo interno, nunca exponer en response
|
|
31
|
+
rol: 'admin' | 'usuario'
|
|
32
|
+
activo: boolean
|
|
33
|
+
emailVerificado: boolean
|
|
34
|
+
ultimoAcceso?: string
|
|
35
|
+
createdAt: string
|
|
36
|
+
updatedAt: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// DTO público — sin passwordHash. Usar este en responses HTTP.
|
|
40
|
+
export interface UsuarioPublicDTO {
|
|
41
|
+
id: string
|
|
42
|
+
nombre: string
|
|
43
|
+
email: string
|
|
44
|
+
rol: 'admin' | 'usuario'
|
|
45
|
+
activo: boolean
|
|
46
|
+
emailVerificado: boolean
|
|
47
|
+
ultimoAcceso?: string
|
|
48
|
+
createdAt: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LoginDTO {
|
|
52
|
+
email: string
|
|
53
|
+
password: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RegisterDTO {
|
|
57
|
+
nombre: string
|
|
58
|
+
email: string
|
|
59
|
+
password: string
|
|
60
|
+
passwordConfirm: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AuthResponse {
|
|
64
|
+
usuario: UsuarioPublicDTO
|
|
65
|
+
token: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ChangePasswordDTO {
|
|
69
|
+
passwordActual: string
|
|
70
|
+
passwordNuevo: string
|
|
71
|
+
}
|
|
72
|
+
`)
|
|
73
|
+
|
|
74
|
+
// sockets.ts
|
|
75
|
+
await writeFile(join(modulePath, 'sockets.ts'), `// usuarios/sockets.ts — Eventos de autenticación
|
|
76
|
+
|
|
77
|
+
import type { UsuarioDTO } from './types'
|
|
78
|
+
|
|
79
|
+
export interface UsuarioSockets {
|
|
80
|
+
onUsuarioRegistrado?: (usuario: UsuarioDTO) => Promise<void>
|
|
81
|
+
onUsuarioLogin?: (usuario: UsuarioDTO) => Promise<void>
|
|
82
|
+
onUsuarioEliminado?: (id: string) => Promise<void>
|
|
83
|
+
}
|
|
84
|
+
`)
|
|
85
|
+
|
|
86
|
+
// service.ts
|
|
87
|
+
await writeFile(join(modulePath, 'actions/service.ts'), `// usuarios/actions/service.ts — Lógica de autenticación
|
|
88
|
+
|
|
89
|
+
import type { RepositoryAdapter, Auth, Logger, CacheAdapter } from 'arckode-framework'
|
|
90
|
+
import { AuthError, NotFoundError, ValidationError } from 'arckode-framework'
|
|
91
|
+
import type { UsuarioDTO, UsuarioPublicDTO, LoginDTO, RegisterDTO, AuthResponse, ChangePasswordDTO } from '../types'
|
|
92
|
+
import type { UsuarioSockets } from '../sockets'
|
|
93
|
+
|
|
94
|
+
export class UsuarioService {
|
|
95
|
+
constructor(
|
|
96
|
+
private readonly repo: RepositoryAdapter<UsuarioDTO>,
|
|
97
|
+
private readonly auth: Auth,
|
|
98
|
+
private readonly logger: Logger,
|
|
99
|
+
private readonly cache: CacheAdapter,
|
|
100
|
+
private readonly sockets?: UsuarioSockets,
|
|
101
|
+
) {}
|
|
102
|
+
|
|
103
|
+
private toPublic(usuario: UsuarioDTO): UsuarioPublicDTO {
|
|
104
|
+
const { passwordHash: _, ...pub } = usuario
|
|
105
|
+
return pub as UsuarioPublicDTO
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async register(dto: RegisterDTO): Promise<AuthResponse> {
|
|
109
|
+
this.logger.info('Registrando usuario', { email: dto.email })
|
|
110
|
+
|
|
111
|
+
if (dto.password !== dto.passwordConfirm) {
|
|
112
|
+
throw new ValidationError('Las contraseñas no coinciden')
|
|
113
|
+
}
|
|
114
|
+
if (dto.password.length < 8) {
|
|
115
|
+
throw new ValidationError('La contraseña debe tener al menos 8 caracteres')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const existente = await this.repo.findOne({ email: dto.email })
|
|
119
|
+
if (existente) throw new ValidationError('El email ya está registrado')
|
|
120
|
+
|
|
121
|
+
const passwordHash = await this.auth.hashPassword(dto.password)
|
|
122
|
+
|
|
123
|
+
const usuario = await this.repo.create({
|
|
124
|
+
nombre: dto.nombre,
|
|
125
|
+
email: dto.email,
|
|
126
|
+
passwordHash,
|
|
127
|
+
rol: 'usuario',
|
|
128
|
+
activo: true,
|
|
129
|
+
emailVerificado: false,
|
|
130
|
+
} as Omit<UsuarioDTO, 'id'>)
|
|
131
|
+
|
|
132
|
+
const token = this.auth.createToken({ id: usuario.id, role: usuario.rol as string })
|
|
133
|
+
|
|
134
|
+
await this.sockets?.onUsuarioRegistrado?.(usuario)
|
|
135
|
+
this.logger.info('Usuario registrado', { id: usuario.id })
|
|
136
|
+
|
|
137
|
+
return { usuario: this.toPublic(usuario), token }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async login(dto: LoginDTO): Promise<AuthResponse> {
|
|
141
|
+
this.logger.info('Login intento', { email: dto.email })
|
|
142
|
+
|
|
143
|
+
const usuario = await this.repo.findOne({ email: dto.email })
|
|
144
|
+
if (!usuario) throw new AuthError('Credenciales inválidas')
|
|
145
|
+
|
|
146
|
+
const ok = await this.auth.comparePassword(dto.password, usuario.passwordHash)
|
|
147
|
+
if (!ok) throw new AuthError('Credenciales inválidas')
|
|
148
|
+
|
|
149
|
+
await this.repo.update(usuario.id, { ultimoAcceso: new Date().toISOString() } as any)
|
|
150
|
+
|
|
151
|
+
const token = this.auth.createToken({ id: usuario.id, role: usuario.rol as string })
|
|
152
|
+
|
|
153
|
+
await this.sockets?.onUsuarioLogin?.(usuario)
|
|
154
|
+
this.logger.info('Login exitoso', { id: usuario.id })
|
|
155
|
+
|
|
156
|
+
return { usuario: this.toPublic(usuario), token }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async getPerfil(id: string): Promise<UsuarioPublicDTO> {
|
|
160
|
+
this.logger.info('Obteniendo perfil', { id })
|
|
161
|
+
const usuario = await this.repo.findById(id)
|
|
162
|
+
if (!usuario) throw new NotFoundError('Usuario no encontrado')
|
|
163
|
+
return this.toPublic(usuario)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async changePassword(id: string, dto: ChangePasswordDTO): Promise<void> {
|
|
167
|
+
this.logger.info('Cambiando contraseña', { id })
|
|
168
|
+
const usuario = await this.repo.findById(id)
|
|
169
|
+
if (!usuario) throw new NotFoundError('Usuario no encontrado')
|
|
170
|
+
|
|
171
|
+
const ok = await this.auth.comparePassword(dto.passwordActual, usuario.passwordHash)
|
|
172
|
+
if (!ok) throw new ValidationError('Contraseña actual incorrecta')
|
|
173
|
+
|
|
174
|
+
const nuevaHash = await this.auth.hashPassword(dto.passwordNuevo)
|
|
175
|
+
await this.repo.update(id, { passwordHash: nuevaHash } as any)
|
|
176
|
+
this.logger.info('Contraseña cambiada')
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
`)
|
|
180
|
+
|
|
181
|
+
// controller.ts
|
|
182
|
+
await writeFile(join(modulePath, 'actions/controller.ts'), `// usuarios/actions/controller.ts — Rutas de autenticación
|
|
183
|
+
|
|
184
|
+
import type { HttpRequest, Logger } from 'arckode-framework'
|
|
185
|
+
import { validateSchema } from 'arckode-framework'
|
|
186
|
+
import type { UsuarioService } from './service'
|
|
187
|
+
import type { Auth } from 'arckode-framework'
|
|
188
|
+
|
|
189
|
+
export class UsuarioController {
|
|
190
|
+
constructor(
|
|
191
|
+
private readonly service: UsuarioService,
|
|
192
|
+
private readonly auth: Auth,
|
|
193
|
+
private readonly logger: Logger,
|
|
194
|
+
) {}
|
|
195
|
+
|
|
196
|
+
async register(req: HttpRequest) {
|
|
197
|
+
this.logger.info('POST /auth/register')
|
|
198
|
+
const data = validateSchema({
|
|
199
|
+
nombre: { type: 'string', required: true, min: 2 },
|
|
200
|
+
email: { type: 'email', required: true },
|
|
201
|
+
password: { type: 'string', required: true, min: 8 },
|
|
202
|
+
passwordConfirm: { type: 'string', required: true, min: 8 },
|
|
203
|
+
}, req.body)
|
|
204
|
+
const result = await this.service.register(data as any)
|
|
205
|
+
return { status: 201, body: result }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async login(req: HttpRequest) {
|
|
209
|
+
this.logger.info('POST /auth/login')
|
|
210
|
+
const data = validateSchema({
|
|
211
|
+
email: { type: 'email', required: true },
|
|
212
|
+
password: { type: 'string', required: true },
|
|
213
|
+
}, req.body)
|
|
214
|
+
const result = await this.service.login(data as any)
|
|
215
|
+
return { status: 200, body: result }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async perfil(req: HttpRequest) {
|
|
219
|
+
this.logger.info('GET /auth/perfil')
|
|
220
|
+
// getPerfil retorna UsuarioPublicDTO — sin passwordHash
|
|
221
|
+
const usuario = await this.service.getPerfil(req.user!.id)
|
|
222
|
+
return { status: 200, body: usuario }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async changePassword(req: HttpRequest) {
|
|
226
|
+
this.logger.info('POST /auth/change-password')
|
|
227
|
+
const data = validateSchema({
|
|
228
|
+
passwordActual: { type: 'string', required: true },
|
|
229
|
+
passwordNuevo: { type: 'string', required: true, min: 8 },
|
|
230
|
+
}, req.body)
|
|
231
|
+
await this.service.changePassword(req.user!.id, data as any)
|
|
232
|
+
return { status: 200, body: { message: 'Contraseña actualizada' } }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
`)
|
|
236
|
+
|
|
237
|
+
// validators
|
|
238
|
+
await writeFile(join(modulePath, 'validators/schema.ts'), `// usuarios/validators/schema.ts
|
|
239
|
+
|
|
240
|
+
import type { ValidationRule } from 'arckode-framework'
|
|
241
|
+
|
|
242
|
+
export const LoginSchema: Record<string, ValidationRule> = {
|
|
243
|
+
email: { type: 'email', required: true },
|
|
244
|
+
password: { type: 'string', required: true },
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const RegisterSchema: Record<string, ValidationRule> = {
|
|
248
|
+
nombre: { type: 'string', required: true, min: 2, max: 100 },
|
|
249
|
+
email: { type: 'email', required: true },
|
|
250
|
+
password: { type: 'string', required: true, min: 8 },
|
|
251
|
+
passwordConfirm: { type: 'string', required: true, min: 8 },
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const ChangePasswordSchema: Record<string, ValidationRule> = {
|
|
255
|
+
passwordActual: { type: 'string', required: true },
|
|
256
|
+
passwordNuevo: { type: 'string', required: true, min: 8 },
|
|
257
|
+
}
|
|
258
|
+
`)
|
|
259
|
+
|
|
260
|
+
// tests
|
|
261
|
+
await writeFile(join(modulePath, 'tests/service.test.ts'), `// usuarios/tests/service.test.ts
|
|
262
|
+
|
|
263
|
+
import { describe, it, expect } from 'bun:test'
|
|
264
|
+
|
|
265
|
+
describe('UsuarioService', () => {
|
|
266
|
+
describe('register', () => {
|
|
267
|
+
it('should reject passwords that do not match', async () => {
|
|
268
|
+
expect(true).toBe(true)
|
|
269
|
+
})
|
|
270
|
+
it('should reject short passwords', async () => {
|
|
271
|
+
expect(true).toBe(true)
|
|
272
|
+
})
|
|
273
|
+
it('should reject duplicate emails', async () => {
|
|
274
|
+
expect(true).toBe(true)
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
describe('login', () => {
|
|
279
|
+
it('should reject invalid credentials', async () => {
|
|
280
|
+
expect(true).toBe(true)
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
`)
|
|
285
|
+
|
|
286
|
+
console.log(`✅ Módulo auth creado en modules/usuarios`)
|
|
287
|
+
console.log(` Rutas generadas:`)
|
|
288
|
+
console.log(` POST /auth/register - Registro`)
|
|
289
|
+
console.log(` POST /auth/login - Login`)
|
|
290
|
+
console.log(` GET /auth/perfil - Perfil (protegida)`)
|
|
291
|
+
console.log(` POST /auth/change-password - Cambiar contraseña (protegida)`)
|
|
292
|
+
console.log(``)
|
|
293
|
+
console.log(` Registralo en composition-root.ts:`)
|
|
294
|
+
console.log(` import { UsuarioModule } from './modules/usuarios'`)
|
|
295
|
+
console.log(` orm.define('Usuario', { table: 'usuarios', fields: { ... }, timestamps: true })`)
|
|
296
|
+
console.log(` system.addModule(UsuarioModule)`)
|
|
297
|
+
}
|