diff --git a/back/app/Http/Controllers/Administracion/UserController.php b/back/app/Http/Controllers/Administracion/UserController.php new file mode 100644 index 0000000..8cf8a00 --- /dev/null +++ b/back/app/Http/Controllers/Administracion/UserController.php @@ -0,0 +1,237 @@ +filled('buscar')) { + $query->where(function ($q) use ($request) { + $q->where('name', 'like', "%{$request->buscar}%") + ->orWhere('email', 'like', "%{$request->buscar}%"); + }); + } + + if ($request->filled('rol')) { + $query->whereHas('roles', function ($q) use ($request) { + $q->where('name', $request->rol); + }); + } + + $usuarios = $query->orderBy('id', 'desc')->paginate(15); + + $usuarios->getCollection()->transform(function ($user) { + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'roles' => $user->getRoleNames(), + 'created_at' => $user->created_at, + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => $usuarios, + ]); + } catch (\Exception $e) { + Log::error('Error al listar usuarios', ['error' => $e->getMessage()]); + return response()->json(['success' => false, 'message' => 'Error al obtener usuarios'], 500); + } + } + + public function store(Request $request) + { + try { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255|regex:/^[\pL\s\-]+$/u', + 'email' => 'required|email|unique:users,email|max:255', + 'password' => 'required|string|min:8|confirmed', + 'rol' => 'required|string|exists:roles,name', + ], [ + 'name.regex' => 'El nombre solo puede contener letras y espacios.', + 'rol.exists' => 'El rol seleccionado no existe.', + 'email.unique' => 'Este correo ya está registrado.', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'errors' => $validator->errors()], 422); + } + + $user = User::create([ + 'name' => strip_tags(trim($request->name)), + 'email' => strtolower(trim($request->email)), + 'password' => Hash::make($request->password), + ]); + + $user->assignRole($request->rol); + + Log::info('Usuario creado por admin', [ + 'admin_id' => $request->user()->id, + 'nuevo_user_id' => $user->id, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Usuario creado correctamente', + 'data' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'roles' => $user->getRoleNames(), + 'created_at' => $user->created_at, + ], + ], 201); + } catch (\Exception $e) { + Log::error('Error al crear usuario', ['error' => $e->getMessage()]); + return response()->json(['success' => false, 'message' => 'Error al crear usuario'], 500); + } + } + + public function update(Request $request, $id) + { + try { + $user = User::findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255|regex:/^[\pL\s\-]+$/u', + 'email' => 'required|email|max:255|unique:users,email,' . $user->id, + 'rol' => 'required|string|exists:roles,name', + ], [ + 'name.regex' => 'El nombre solo puede contener letras y espacios.', + 'rol.exists' => 'El rol seleccionado no existe.', + 'email.unique' => 'Este correo ya está registrado.', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'errors' => $validator->errors()], 422); + } + + $user->update([ + 'name' => strip_tags(trim($request->name)), + 'email' => strtolower(trim($request->email)), + ]); + + $user->syncRoles([$request->rol]); + + Log::info('Usuario actualizado', [ + 'admin_id' => $request->user()->id, + 'user_id' => $user->id, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Usuario actualizado correctamente', + 'data' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'roles' => $user->getRoleNames(), + 'created_at' => $user->created_at, + ], + ]); + } catch (\Exception $e) { + Log::error('Error al actualizar usuario', ['error' => $e->getMessage()]); + return response()->json(['success' => false, 'message' => 'Error al actualizar usuario'], 500); + } + } + + public function changePassword(Request $request, $id) + { + try { + $user = User::findOrFail($id); + $authUser = $request->user(); + + $rules = ['password' => 'required|string|min:8|confirmed']; + + if ($authUser->id === $user->id) { + $rules['current_password'] = 'required|string'; + } + + $validator = Validator::make($request->all(), $rules); + + if ($validator->fails()) { + return response()->json(['success' => false, 'errors' => $validator->errors()], 422); + } + + if ($authUser->id === $user->id) { + if (!Hash::check($request->current_password, $user->password)) { + return response()->json([ + 'success' => false, + 'errors' => ['current_password' => ['La contraseña actual no es correcta']], + ], 422); + } + } else { + // Al cambiar la contraseña de otro usuario, revocar sus tokens + $user->tokens()->delete(); + } + + $user->update(['password' => Hash::make($request->password)]); + + Log::info('Contraseña cambiada', [ + 'admin_id' => $authUser->id, + 'user_id' => $user->id, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Contraseña actualizada correctamente', + ]); + } catch (\Exception $e) { + Log::error('Error al cambiar contraseña', ['error' => $e->getMessage()]); + return response()->json(['success' => false, 'message' => 'Error al cambiar contraseña'], 500); + } + } + + public function destroy(Request $request, $id) + { + try { + $user = User::findOrFail($id); + + if ($request->user()->id === $user->id) { + return response()->json([ + 'success' => false, + 'message' => 'No puedes eliminar tu propia cuenta', + ], 403); + } + + $user->tokens()->delete(); + $user->delete(); + + Log::info('Usuario eliminado', [ + 'admin_id' => $request->user()->id, + 'user_id' => $id, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Usuario eliminado correctamente', + ]); + } catch (\Exception $e) { + Log::error('Error al eliminar usuario', ['error' => $e->getMessage()]); + return response()->json(['success' => false, 'message' => 'Error al eliminar usuario'], 500); + } + } + + public function roles() + { + try { + $roles = Role::orderBy('name')->get(['id', 'name']); + return response()->json(['success' => true, 'data' => $roles]); + } catch (\Exception $e) { + return response()->json(['success' => false, 'message' => 'Error al obtener roles'], 500); + } + } +} diff --git a/back/routes/api.php b/back/routes/api.php index 3c1476c..a328cd3 100644 --- a/back/routes/api.php +++ b/back/routes/api.php @@ -21,6 +21,7 @@ use App\Http\Controllers\Administracion\NoticiaController; use App\Http\Controllers\Administracion\ProcesoAdmisionResultadoArchivoController; use App\Http\Controllers\Administracion\ComunicadoController; use App\Http\Controllers\WebController; +use App\Http\Controllers\Administracion\UserController; Route::get('/user', function (Request $request) { return $request->user(); @@ -228,4 +229,14 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () { Route::delete('/comunicados/imagenes/{imagenId}', [ComunicadoController::class, 'destroyImagen']); }); -Route::middleware('auth:sanctum')->get('/postulante/observacion', [PostulanteAuthController::class, 'miObservacion']); \ No newline at end of file +Route::middleware('auth:sanctum')->get('/postulante/observacion', [PostulanteAuthController::class, 'miObservacion']); + +// Admin: gestión de usuarios +Route::middleware('auth:sanctum')->prefix('admin')->group(function () { + Route::get('/roles', [UserController::class, 'roles']); + Route::get('/usuarios', [UserController::class, 'index']); + Route::post('/usuarios', [UserController::class, 'store']); + Route::put('/usuarios/{id}', [UserController::class, 'update']); + Route::patch('/usuarios/{id}/cambiar-password', [UserController::class, 'changePassword']); + Route::delete('/usuarios/{id}', [UserController::class, 'destroy']); +}); \ No newline at end of file diff --git a/front/src/router/index.js b/front/src/router/index.js index 429f366..374564f 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -214,6 +214,11 @@ const routes = [ path: '/admin/dashboard/comunicados', name: 'ComunicadosAdmin', component: () => import('../views/administrador/comunicados/ComunicadosAdmin.vue') + }, + { + path: '/admin/dashboard/usuarios', + name: 'UsuariosList', + component: () => import('../views/administrador/usuarios/UsuariosList.vue') } ] diff --git a/front/src/store/usuariosStore.js b/front/src/store/usuariosStore.js new file mode 100644 index 0000000..0a18dfd --- /dev/null +++ b/front/src/store/usuariosStore.js @@ -0,0 +1,64 @@ +import { defineStore } from 'pinia' +import api from '../axios' + +export const useUsuariosStore = defineStore('usuarios', { + state: () => ({ + usuarios: [], + roles: [], + loading: false, + pagination: { + current: 1, + pageSize: 15, + total: 0, + }, + }), + + actions: { + async fetchUsuarios(params = {}) { + this.loading = true + try { + const response = await api.get('/admin/usuarios', { params }) + const { data } = response.data + this.usuarios = data.data + this.pagination = { + current: data.current_page, + pageSize: data.per_page, + total: data.total, + } + } catch (error) { + throw error + } finally { + this.loading = false + } + }, + + async fetchRoles() { + try { + const response = await api.get('/admin/roles') + this.roles = response.data.data + } catch (error) { + throw error + } + }, + + async createUsuario(payload) { + const response = await api.post('/admin/usuarios', payload) + return response.data + }, + + async updateUsuario(id, payload) { + const response = await api.put(`/admin/usuarios/${id}`, payload) + return response.data + }, + + async changePassword(id, payload) { + const response = await api.patch(`/admin/usuarios/${id}/cambiar-password`, payload) + return response.data + }, + + async deleteUsuario(id) { + const response = await api.delete(`/admin/usuarios/${id}`) + return response.data + }, + }, +}) diff --git a/front/src/views/administrador/layout/layout.vue b/front/src/views/administrador/layout/layout.vue index 993ba56..7655e4e 100644 --- a/front/src/views/administrador/layout/layout.vue +++ b/front/src/views/administrador/layout/layout.vue @@ -432,7 +432,7 @@ const handleMenuSelect = ({ key }) => { 'resultados': { name: 'AcademiaResultados' }, 'reportes': { name: 'AcademiaReportes' }, 'config-academia': { name: 'AcademiaConfig' }, - 'usuarios': { name: 'AcademiaUsuarios' } + 'usuarios': { name: 'UsuariosList' } } if (routes[key]) { diff --git a/front/src/views/administrador/usuarios/UsuariosList.vue b/front/src/views/administrador/usuarios/UsuariosList.vue new file mode 100644 index 0000000..e92495d --- /dev/null +++ b/front/src/views/administrador/usuarios/UsuariosList.vue @@ -0,0 +1,540 @@ + + + + + diff --git a/temporal_revision_tecnica.md b/temporal_revision_tecnica.md new file mode 100644 index 0000000..d29e7b9 --- /dev/null +++ b/temporal_revision_tecnica.md @@ -0,0 +1,704 @@ +# ANÁLISIS TÉCNICO EXHAUSTIVO - PROYECTO DIRECCIÓN DE ADMISIÓN 2026 + +> Generado: 2026-04-08 + +--- + +## 1. ESTRUCTURA GENERAL DEL PROYECTO + +``` +/direccion_de_admision_2026/ +├── back/ # Backend Laravel 12 +│ ├── app/ +│ │ ├── Models/ # 21 modelos Eloquent +│ │ ├── Http/Controllers/ # Controladores REST +│ │ └── Services/ # Lógica de negocio +│ ├── database/ +│ │ ├── migrations/ # 9 migraciones +│ │ └── seeders/ +│ ├── routes/api.php # Definición de endpoints API +│ ├── config/ # Configuraciones +│ ├── composer.json # Dependencias PHP +│ └── Dockerfile # Multi-stage build +├── front/ # Frontend Vue 3 + Vite +│ ├── src/ +│ │ ├── views/ # Vistas por rol (admin, postulante) +│ │ ├── components/ # Componentes reutilizables +│ │ ├── store/ # 14 stores Pinia +│ │ ├── router/index.js # Rutas y guards +│ │ └── axios.js # Instancia HTTP autenticada +│ ├── package.json +│ ├── vite.config.js +│ └── Dockerfile +├── nginx/ # Configuración Nginx +├── mysql/ # Configuración MySQL +├── docker-compose.prod.yml # Orquestación producción +├── docker-compose.yml # Desarrollo local +└── .github/workflows/ # CI/CD +``` + +--- + +## 2. STACK TÉCNICO - VERSIONES EXACTAS + +### Backend (Laravel 12) + +| Librería | Versión | Propósito | +|----------|---------|----------| +| **laravel/framework** | ^12.0 | Framework principal | +| **laravel/sanctum** | ^4.2 | API tokens + autenticación | +| **spatie/laravel-permission** | ^6.24 | Gestión de roles/permisos | +| **simplesoftwareio/simple-qrcode** | ^4.2 | Generación códigos QR | +| **laravel/tinker** | ^2.10.1 | REPL para desarrollo | + +**PHP:** ^8.2 (Dockerfile especifica 8.4-fpm-alpine) +**Base de datos:** MySQL 8.0 +**ORM:** Eloquent (nativo) +**Autenticación:** Laravel Sanctum (tokens bearer) + +### Frontend (Vue 3) + +| Librería | Versión | Propósito | +|----------|---------|----------| +| **vue** | ^3.5.24 | Framework frontend | +| **vue-router** | ^4.6.4 | Enrutamiento SPA | +| **pinia** | ^3.0.4 | State management | +| **ant-design-vue** | ^4.2.6 | Componentes UI | +| **axios** | ^1.13.3 | Cliente HTTP | +| **chart.js** | ^4.5.1 | Gráficos | +| **vue-chartjs** | ^5.3.3 | Binding Vue para Chart.js | +| **@vueup/vue-quill** | ^1.2.0 | Editor WYSIWYG | +| **marked** | ^17.0.1 | Parser markdown | +| **markdown-it** | ^14.1.0 | Markdown avanzado | +| **dayjs** | ^1.11.19 | Utilidades fecha/hora | +| **vue-qrcode** | ^2.2.2 | Generador QR | +| **katex** | ^0.16.28 | Renderizado LaTeX | + +**Build Tool:** Vite 7.2.4 +**Package Manager:** npm + +--- + +## 3. BACKEND - ARQUITECTURA LARAVEL 12 + +### 3.1 Modelos y Relaciones (21 modelos) + +#### Modelos Núcleo de Admisión + +| Modelo | Tabla | Relaciones Clave | Propósito | +|--------|-------|------------------|----------| +| **ProcesoAdmision** | procesos_admision | hasMany(ProcesoAdmisionDetalle), hasMany(ProcesoAdmisionResultadoArchivo), hasMany(ResultadoAdmision) | Gestiona procesos de admisión (convocatorias) | +| **ProcesoAdmisionDetalle** | proceso_admision_detalles | belongsTo(ProcesoAdmision) | Detalles informativos de procesos (requisitos, cronograma, etc.) | +| **ProcesoAdmisionResultadoArchivo** | proceso_admision_resultado_archivos | belongsTo(ProcesoAdmision) | Archivos de resultados organizados por slot/sedes | +| **ResultadoAdmision** | resultados_admision | belongsTo(ProcesoAdmision), belongsTo(AreaAdmision) | Resultados finales de admisión de postulantes | +| **Proceso** | procesos | belongsTo(Calificacion), belongsToMany(Area, via area_proceso) | Procesos de examen asociados a áreas | +| **Area** | areas | belongsToMany(Curso), belongsToMany(Examen), belongsToMany(Proceso) | Áreas temáticas (Biomedicas, Ingeniería) | +| **Curso** | cursos | belongsToMany(Area), hasMany(Pregunta) | Cursos dentro de áreas (Matemática, Comunicación) | +| **Pregunta** | preguntas | belongsTo(Curso) | Preguntas de examen con opciones múltiples | +| **PreguntaAsignada** | preguntas_asignadas | belongsTo(Examen) | Asociación pregunta-examen | + +#### Modelos de Usuarios + +| Modelo | Tabla | Relaciones | Propósito | +|--------|-------|-----------|----------| +| **User** | users | belongsToMany(Academia), hasMany(IntentoExamen) | Administradores/Staff (usa Spatie roles) | +| **Postulante** | postulantes | hasMany(Examen) | Postulantes que rinden exámenes | +| **AreaAdmision** | areas_admision | hasMany(ResultadoAdmision) | Áreas en contexto de admisión (diferente a Area de exámenes) | + +#### Modelos de Evaluación + +| Modelo | Tabla | Relaciones | Propósito | +|--------|-------|-----------|----------| +| **Examen** | examenes | belongsTo(Postulante), belongsTo(AreaProceso), belongsTo(Pago), hasMany(PreguntaAsignada) | Examen renderizado por postulante | +| **Calificacion** | calificaciones | hasMany(Proceso) | Esquemas de puntuación (puntos por correcta, incorrecta, nula) | +| **ResultadoExamen** | resultados_examenes | — | Resultados parciales de exámenes | +| **Pago** | pagos | belongsTo(Postulante) | Pagos de procesos que requieren arancel | + +#### Modelos de Contenido + +| Modelo | Tabla | Relaciones | Propósito | +|--------|-------|-----------|----------| +| **Noticia** | noticias | Standalone (SoftDeletes) | Noticias publicables en web pública | +| **Comunicado** | comunicados | hasMany(ComunicadoImagen) | Comunicados activos con imágenes | +| **ComunicadoImagen** | comunicado_imagenes | belongsTo(Comunicado) | Imágenes asociadas a comunicados | + +#### Modelos Intermedios y Apoyo + +| Modelo | Tabla | Propósito | +|--------|-------|----------| +| **ReglaAreaProceso** | reglas_area_proceso | Define reglas de asignación de preguntas por área-proceso | +| **ResultadoAdmisionCarga** | resultados_admision_carga | Almacena detalles de cursos en resultados de admisión | + +### 3.2 Esquema de Base de Datos (30+ tablas) + +``` +areas -- Catálogo de áreas (Biomedicas, Ingenierías) +cursos -- Catálogo de cursos (Matemática, Comunicación) +area_curso -- M2M: área-curso +procesos -- Procesos de examen +area_proceso -- M2M: área-proceso (pivot con ID) +preguntas -- Preguntas de examen +preguntas_asignadas -- Preguntas asignadas a examen del postulante +examenes -- Exámenes rendidos +postulantes -- Usuarios que rinden exámenes +pagos -- Control de pagos +resultados_examenes -- Resultados de exámenes + +procesos_admision -- Procesos de admisión (convocatorias) +proceso_admision_detalles -- Detalles de procesos (requisitos, cronograma) +proceso_admision_resultado_archivos -- Archivos de resultados por sede/slot +resultados_admision -- Resultados de admisión +areas_admision -- Áreas de admisión + +noticias -- Artículos publicables +comunicados -- Comunicados activos +comunicado_imagenes -- Imágenes en comunicados + +users -- Administradores +roles, permissions -- Spatie permission tables +model_has_roles, model_has_permissions +personal_access_tokens -- Tokens Sanctum +``` + +**Restricciones de Integridad:** +- `ON DELETE CASCADE` en relaciones examenes → postulantes, preguntas_asignadas → examenes +- `UNIQUE CONSTRAINTS` en `area_proceso(area_id, proceso_id)` y `area_curso` +- `UNIQUE KEY` en `proceso_admision_resultado_archivos(proceso_admision_id, orden)` + +--- + +## 4. API REST - ENDPOINTS COMPLETOS (73 rutas) + +### Autenticación Admin +``` +POST /api/register +POST /api/login +POST /api/logout [auth:sanctum] +GET /api/me [auth:sanctum] +POST /api/refresh-token [auth:sanctum] +GET /api/user [auth:sanctum] +``` + +### Autenticación Postulante +``` +POST /api/postulante/register +POST /api/postulante/login +POST /api/postulante/logout [auth:sanctum] +GET /api/postulante/me [auth:sanctum] +GET /api/postulante/pagos [auth:sanctum] +GET /api/postulante/mis-procesos [auth:sanctum] +GET /api/postulante/observacion [auth:sanctum] +GET /api/mis-procesos/{id}/avance [auth:sanctum] +``` + +### Procesos de Examen +``` +GET /api/procesos [auth:sanctum] +POST /api/procesos [auth:sanctum] +GET /api/procesos/{id} [auth:sanctum] +PUT /api/procesos/{id} [auth:sanctum] +PATCH /api/procesos/{id}/toggle-activo [auth:sanctum] +DELETE /api/procesos/{id} [auth:sanctum] +``` + +### Admin - Áreas +``` +GET /api/admin/areas [auth:sanctum] +POST /api/admin/areas [auth:sanctum] +GET /api/admin/areas/{id} [auth:sanctum] +PUT /api/admin/areas/{id} [auth:sanctum] +DELETE /api/admin/areas/{id} [auth:sanctum] +PATCH /api/admin/areas/{id}/toggle [auth:sanctum] +POST /api/admin/areas/{area}/vincular-cursos [auth:sanctum] +POST /api/admin/areas/{area}/desvincular-curso [auth:sanctum] +GET /api/admin/areas/{area}/cursos-disponibles [auth:sanctum] +POST /api/admin/areas/{area}/vincular-procesos [auth:sanctum] +GET /api/admin/areas/{area}/procesos-disponibles [auth:sanctum] +POST /api/admin/areas/{area}/desvincular-procesos [auth:sanctum] +``` + +### Admin - Cursos +``` +GET /api/admin/cursos [auth:sanctum] +POST /api/admin/cursos [auth:sanctum] +GET /api/admin/cursos/{id} [auth:sanctum] +PUT /api/admin/cursos/{id} [auth:sanctum] +DELETE /api/admin/cursos/{id} [auth:sanctum] +PATCH /api/admin/cursos/{id}/toggle [auth:sanctum] +``` + +### Admin - Preguntas +``` +GET /api/admin/cursos/{id}/preguntas [auth:sanctum] +POST /api/admin/preguntas [auth:sanctum] +GET /api/admin/preguntas/{id} [auth:sanctum] +PUT /api/admin/preguntas/{id} [auth:sanctum] +DELETE /api/admin/preguntas/{id} [auth:sanctum] +``` + +### Admin - Noticias +``` +GET /api/admin/noticias [auth:sanctum] +GET /api/admin/noticias/{id} [auth:sanctum] +POST /api/admin/noticias [auth:sanctum] +PUT /api/admin/noticias/{id} [auth:sanctum] +DELETE /api/admin/noticias/{id} [auth:sanctum] +``` + +### Admin - Procesos de Admisión +``` +GET /api/admin/procesos-admision [auth:sanctum] +POST /api/admin/procesos-admision [auth:sanctum] +GET /api/admin/procesos-admision/{id} [auth:sanctum] +PUT/PATCH /api/admin/procesos-admision/{id} [auth:sanctum] +DELETE /api/admin/procesos-admision/{id} [auth:sanctum] + +GET /api/admin/procesos-admision/{id}/detalles [auth:sanctum] +POST /api/admin/procesos-admision/{id}/detalles [auth:sanctum] +GET /api/admin/detalles-admision/{id} [auth:sanctum] +PUT/PATCH /api/admin/detalles-admision/{id} [auth:sanctum] +DELETE /api/admin/detalles-admision/{id} [auth:sanctum] +``` + +### Admin - Resultados (Archivos) +``` +GET /api/admin/proceso-resultado/{id}/archivos [auth:sanctum] +POST /api/admin/proceso-resultado/{id}/archivos [auth:sanctum] +DELETE /api/admin/proceso-resultado/archivos/{id} [auth:sanctum] +GET /api/proceso-resultado/{id}/archivos [público] +``` + +### Admin - Comunicados +``` +GET /api/admin/comunicados [auth:sanctum] +POST /api/admin/comunicados [auth:sanctum] +PUT/PATCH /api/admin/comunicados/{id} [auth:sanctum] +DELETE /api/admin/comunicados/{id} [auth:sanctum] +PATCH /api/admin/comunicados/{id}/toggle-activo [auth:sanctum] +DELETE /api/admin/comunicados/imagenes/{id} [auth:sanctum] +``` + +### Examen Online +``` +GET /api/examen/procesos [auth:sanctum] +GET /api/examen/areas [auth:sanctum] +GET /api/examen/actual [auth:sanctum] +POST /api/examen/crear [auth:sanctum] +POST /api/examen/{id}/generar-preguntas [auth:sanctum] +GET /api/examen/{id}/preguntas [auth:sanctum] +POST /api/examen/iniciar [auth:sanctum] +POST /api/examen/pregunta/{id}/responder [auth:sanctum] +POST /api/examen/{id}/finalizar [auth:sanctum] +POST /api/examen/{id}/calificar [auth:sanctum] +``` + +### Reglas de Examen +``` +GET /api/area-proceso/areasprocesos [auth:sanctum] +GET /api/area-proceso/{id}/reglas [auth:sanctum] +POST /api/area-proceso/{id}/reglas [auth:sanctum] +POST /api/area-proceso/{id}/reglas/multiple [auth:sanctum] +PUT /api/reglas/{id} [auth:sanctum] +DELETE /api/reglas/{id} [auth:sanctum] +``` + +### Calificaciones +``` +GET /api/calificaciones [auth:sanctum] +POST /api/calificaciones [auth:sanctum] +GET /api/calificaciones/{id} [auth:sanctum] +PUT /api/calificaciones/{id} [auth:sanctum] +DELETE /api/calificaciones/{id} [auth:sanctum] +``` + +### Postulantes (Admin) +``` +GET /api/admin/postulantes [auth:sanctum] +PUT /api/admin/postulantes/{id} [auth:sanctum] +``` + +### Web Pública +``` +GET /api/procesos-admision [público] +GET /api/noticias [público] +GET /api/noticias/{slug} [público] +GET /api/comunicados/activo [público] +GET /api/procesos-disponibles-preinscripcion [auth:sanctum] +``` + +--- + +## 5. AUTENTICACIÓN Y AUTORIZACIÓN + +### Flujo Admin +1. `POST /api/login` con email/password +2. `AuthController` verifica credenciales, revoca tokens previos +3. Genera token con `createToken('api_token', ['*'], now()->addHours(12))` +4. Devuelve token + user + roles/permisos (Spatie) + +### Flujo Postulante +1. `POST /api/postulante/login` +2. Token con expiración 1 hora +3. `Device-Id` único para detección multi-dispositivo +4. Guard separado: `postulante_token` en localStorage + +### Roles (Spatie Permission) +- `administrador` — acceso a panel admin +- `superadmin` — acceso extendido +- Permisos granulares via `model_has_permissions` + +--- + +## 6. FRONTEND - ARQUITECTURA VUE 3 + +### 6.1 Rutas (23 rutas principales) + +``` +/ (público) → WebPage (SPA pública) +/login-postulante → LoginView +/proceso-resultado → ProcesoResultado +/modalidades/cepreuna → Cepreuna +/modalidades/extraordinario → Extraordinario +/modalidades/general → General + +/portal-postulante (requiresAuth) + ├─ / → Dashboard postulante + ├─ /test → Selector proceso/área + ├─ /examen/:examenId → PreguntasExamen (examen online) + ├─ /resultados/:examenId → Resultados (calificaciones) + ├─ /mis-procesos → MisProcesos (admisión) + └─ /mis-procesos-estado → AvanceProceso + +/admin/dashboard (requiresAuth + role:administrador) + ├─ / → Dashboard admin + ├─ /areas → AreasList (CRUD) + ├─ /cursos → CursosList (CRUD) + ├─ /cursos/:id/preguntas → PreguntasCursoView + ├─ /procesos → ProcesosList (CRUD) + ├─ /reglas → ReglasList (CRUD) + ├─ /procesos-admision → ProcesosAdmisionList (CRUD) + ├─ /procesos/:id/detalles → ProcesoAdmisionDetalles + ├─ /lista-calificacion → CalificacionTest + ├─ /lista-postulantes → ListPostulantes + ├─ /noticias → NoticiasAdmin (CRUD) + └─ /comunicados → ComunicadosAdmin (CRUD) + +/superadmin/dashboard → Dashboard superadmin +/unauthorized → 403 +/:pathMatch(.*) → 404 +``` + +**Guards:** +- `requiresAuth` — verifica token en localStorage, redirige a login +- `guest` — evita acceso a login si ya autenticado +- `role` — valida rol, redirige a `/403` si no aplica + +### 6.2 Stores Pinia (14 stores) + +| Store | API | Propósito principal | +|-------|-----|---------------------| +| `user.js` | Options | Auth admin, roles, persistencia localStorage | +| `postulanteStore.js` | Composition | Auth postulante, Device-Id | +| `examen.store.js` | Options | Flujo completo de examen online | +| `procesosAdmisionStore.js` | Options | CRUD procesos admisión (admin) | +| `area.store.js` | Options | CRUD áreas | +| `curso.store.js` | Options | CRUD cursos | +| `pregunta.store.js` | Options | CRUD preguntas | +| `proceso.store.js` | Options | CRUD procesos examen | +| `reglaAreaProceso.store.js` | Options | Reglas de asignación de preguntas | +| `noticiasStore.js` | Options | CRUD noticias (admin) | +| `noticiasPublicas.store.js` | Options | Noticias para web pública | +| `procesoAdmisionResultado.store.js` | Options | Upload/fetch archivos de resultados | +| `comunicadosStore.js` | Options | CRUD comunicados | +| `web.js` | Options | Estado web pública (procesos, comunicado activo) | + +### 6.3 Instancias Axios + +**`axios.js`** (Admin): +- `baseURL`: `VITE_API_URL` +- Request interceptor: inyecta `Bearer ${localStorage.token}` +- Response interceptor: 401 → clearAuth + redirect login; 403 → redirect /unauthorized + +**`axiosPostulante.js`** (Postulante): +- Token en `postulante_token` +- Header `Device-Id` opcional + +### 6.4 Componentes Principales + +| Componente | Líneas aprox. | Función | +|-----------|--------------|---------| +| `ConvocatoriasSection.vue` | ~1,457 | Tarjetas de procesos, modal detalles, archivos multi-sede | +| `PreguntasExamen.vue` | ~598 | Examen online con timer, 1 pregunta/vez, Markdown+LaTeX | +| `ProcessSection.vue` | ~365 | Timeline visual del cronograma | +| `ComunicadoModal.vue` | ~207 | Modal carrusel de imágenes, auto-cierre por fecha | +| `HeroSection.vue` | — | Banner con card dinámica (v-if hayResultados) | +| `ProcesoResultado.vue` | — | Sección pública por proceso con archivos v-for | +| `ProcesosAdmisionList.vue` | — | Tabla admin con modales inline para CRUD + Resultados | + +--- + +## 7. FLUJOS FUNCIONALES + +### 7.1 Gestión de Procesos de Admisión + +``` +Admin crea ProcesoAdmision + ├─ título, descripción, slug único + ├─ Fechas: pre-inscripción → inscripción → examen → resultados + ├─ Imágenes: imagen (JPG/PNG), banner, brochure PDF + ├─ Links externos: pre-inscripción, inscripción, resultados, reglamento + ├─ Estados: nuevo → publicado → en_proceso → finalizado → cancelado + └─ ProcesoAdmisionDetalle: requisitos, cronograma, etc. + +Postulante visualiza en página pública + └─ GET /api/procesos-admision (solo publicados, latest first) + └─ ConvocatoriasSection: tarjetas + modal detalles + +Admin gestiona resultados + └─ ProcesoAdmisionResultadoArchivo por sede/slot + ├─ Upload PDF por slot (1-6) por proceso + └─ Descarga pública: GET /api/proceso-resultado/{id}/archivos +``` + +### 7.2 Examen Online (Flujo Completo) + +``` +1. Login postulante → Dashboard (procesos disponibles donde no ha rendido) +2. GET /api/examen/procesos → seleccionar proceso +3. GET /api/examen/areas?proceso_id={id} → seleccionar área +4. POST /api/examen/crear { area_proceso_id } → crea Examen (estado: pendiente) +5. POST /api/examen/{id}/generar-preguntas → selección aleatoria por ReglaAreaProceso +6. GET /api/examen/{id}/preguntas → lista sin respuesta_correcta visible +7. POST /api/examen/iniciar → hora_inicio = NOW, estado = en_curso +8. POST /api/examen/pregunta/{id}/responder { respuesta } → valida + puntúa (Calificacion) +9. POST /api/examen/{id}/finalizar → estado = finalizado, suma puntaje +10. POST /api/examen/{id}/calificar → computa estadísticas: + ├─ total_correctas / incorrectas / nulas + ├─ total_puntos, porcentaje_correctas + ├─ calificacion_sobre_20 + └─ correctas_por_curso (breakdown) +11. Resultados.vue: gráficos Chart.js por curso + ranking +``` + +**Restricciones:** +- Máximo `intentos_maximos` (campo de Proceso) +- Requiere `Pago` si `proceso.requiere_pago = true` +- Timer bloquea respuestas al expirar `duracion_minutos` + +### 7.3 Gestión de Archivos de Resultados (Multi-sede) + +``` +Modelo: ProcesoAdmisionResultadoArchivo +Campos: proceso_admision_id, nombre, file_path, orden (UNIQUE por proceso) + +Admin: + ├─ Modal "Resultados" en ProcesosAdmisionList (6 slots pre-nombrados) + ├─ Slots nombrados por: generarNombreSlot(orden, proceso) en store + │ slot 1 → "Resultados Sábado {fecha_examen1}" + │ slot 2 → "Resultados CONADIS" + │ slots 3-6 → slugs personalizados + └─ customRequest de Ant Design: requiere onSuccess()/onError() + +Postulante: + ├─ GET /api/proceso-resultado/{id}/archivos (público) + ├─ archivosPorProceso en web.js store → mapa por procesoId + ├─ fetchArchivosMultiples con Promise.allSettled (multi-sede) + └─ HeroSection: card con v-if="hayResultados" (≥1 archivo) +``` + +### 7.4 Gestión de Contenido (Noticias + Comunicados) + +**Noticias:** +- CRUD admin con editor WYSIWYG (vue-quill) +- Campo `slug` único, `contenido` markdown +- Filtros: categoría, tag_color, destacado, publicado, orden +- Web pública: GET /api/noticias, GET /api/noticias/{slug} + +**Comunicados:** +- Múltiples imágenes carrusel (ComunicadoImagen) +- Solo 1 activo a la vez: GET /api/comunicados/activo +- Fecha_inicio / fecha_fin de vigencia +- ComunicadoModal.vue: auto-cierre, botón de acción + +--- + +## 8. INFRAESTRUCTURA Y DESPLIEGUE + +### 8.1 Docker Compose (Producción) + +| Servicio | Imagen | Puerto | RAM | +|----------|--------|--------|-----| +| **nginx** | nginx:alpine | 127.0.0.1:8080 | 64MB | +| **backend** | ghcr.io/…/backend:latest | 9000 (interno) | 1GB | +| **frontend** | ghcr.io/…/frontend:latest | interno | 64MB | +| **mysql** | mysql:8.0 | 127.0.0.1:3306 | 512MB | + +**Volúmenes:** `mysql_data` (BD), `backend_storage` (archivos subidos) +**Red:** Bridge `admision_net` +**Health checks:** PHP ping (backend), mysqladmin ping (mysql) + +### 8.2 Dockerfile Backend (Multi-Stage) + +```dockerfile +# Stage 1: Composer (sin dev, optimizado) +FROM composer:2 AS vendor +RUN composer install --no-dev --optimize-autoloader --ignore-platform-reqs + +# Stage 2: PHP 8.4-fpm-alpine +FROM php:8.4-fpm-alpine +# Extensiones: PDO MySQL, BCMath, MBString, GD, cURL, ZIP, XML, Intl +# OPcache: 128MB, 10k archivos, validate_timestamps=0 +# Upload: 20MB archivos, 25MB POST +# PHP-FPM: 15 max_children, soporte 100+ concurrentes +# Entrypoint: config:cache + route:cache → php-fpm +``` + +### 8.3 Dockerfile Frontend + +```dockerfile +FROM node:20-alpine AS build +RUN npm ci && npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +``` + +### 8.4 Nginx + +```nginx +# Frontend SPA +location / { try_files $uri $uri/ /index.html; } + +# API (proxy a PHP-FPM backend) +location /api { proxy_pass http://backend:9000; } + +# Storage público +location /storage { alias /var/www/html/storage/app/public; } +``` + +--- + +## 9. PATRONES DE DESARROLLO + +### 9.1 Convenciones Backend + +- Validación en controladores (`Validator::make`), no Form Requests +- Respuestas JSON estándar: `{ success, data/message, errors }` +- HTTP status codes semánticos (200, 201, 400, 401, 403, 422, 500) +- Accessors para URLs de storage: `getImagenUrlAttribute()` +- `$casts` en modelos para tipos (boolean, datetime, integer, array) +- `ON DELETE CASCADE` en FK críticas +- `->latest()` para orden cronológico inverso + +### 9.2 Convenciones Frontend + +- Componentes: PascalCase | Métodos/props: camelCase +- Stores Pinia Options API (mayoría) — patrón: fetch → state → computed +- Modales admin: inline en el mismo archivo `.vue` (patrón establecido) +- `axios` (admin) vs `axiosPostulante` (público/postulante) — instancias separadas +- Páginas públicas completas en `WebPageSections/navbarcontent/` +- Datos sensibles de auth solo en localStorage (no Pinia, no sessionStorage) + +### 9.3 Patrones de Componentes + +```vue +// Patrón típico: Store + Component +const store = useProcesoAdmisionStore() +onMounted(() => store.fetchProcesos({ page: 1 })) + +// Patrón: Tabla paginada (AntD) + + +// Patrón: Modal inline (no rutas separadas) + +``` + +### 9.4 Seguridad + +- Bearer tokens con expiración (12h admin, 1h postulante) +- Validación siempre en servidor (no confiar en cliente) +- `BCRYPT_ROUNDS=12` para passwords +- `revoke tokens` al logout (`$user->tokens()->delete()`) +- Storage `public` disk — sin acceso a archivos privados vía HTTP +- CORS configurado via `config/cors.php` + +--- + +## 10. OBSERVACIONES: FORTALEZAS Y MEJORAS + +### Fortalezas + +1. **Arquitectura modular** — separación clara backend/frontend, cada uno con Dockerfile propio +2. **Auth robusta** — Sanctum + Spatie, tokens con expiración, guards de router +3. **State management ordenado** — 14 stores Pinia con responsabilidades claras +4. **Multi-sede** — ProcesoAdmisionResultadoArchivo con orden y Promise.allSettled +5. **Docker optimizado** — multi-stage builds, healthchecks, límites RAM +6. **Rich content** — soporte Markdown + LaTeX en preguntas de examen +7. **Patrón de modales inline** — consistente en todo el admin +8. **Encoding correcto** — fix UTF-8 para tildes en archivos Windows-1252 + +### Mejoras Sugeridas + +#### Backend + +| Área | Problema | Solución recomendada | +|------|----------|---------------------| +| Form Requests | Validación no reutilizable | Crear clases `FormRequest` | +| Rate Limiting | Sin throttle | `ThrottleRequests` middleware | +| Tests | Sin cobertura visible | PHPUnit para flujos críticos (examen, auth) | +| N+1 Queries | Riesgo en relaciones | Usar `with()` en índices con relaciones | +| Caché | `database` driver (lento) | Redis para sesiones y caché | +| API Docs | Sin OpenAPI | `scribe-php` o `l5-swagger` | +| Servicios | Lógica en controllers | `PagoService`, `ExamenService` | + +#### Frontend + +| Área | Problema | Solución recomendada | +|------|----------|---------------------| +| TypeScript | Sin tipos | Migración gradual a TS | +| Tests | Sin cobertura | Vitest + Vue Test Utils | +| Composables | Lógica en componentes grandes | Extraer composables reutilizables | +| Lazy loading | Sin code splitting visible | Dynamic imports en rutas | +| Accesibilidad | No auditado | WCAG 2.1 audit | +| i18n | Solo español | `vue-i18n` si se requiere multiidioma | + +#### Infraestructura + +| Área | Problema | Solución recomendada | +|------|----------|---------------------| +| Backup | Sin automatización | Cron + mysqldump → S3/volumen | +| Logs | Solo internos | Centralizar (ELK/CloudWatch) | +| SSL | No visible | Let's Encrypt + Certbot | +| Secrets | .env en repo | Secret manager o .env.local ignorado | +| Monitoreo | Sin métricas | Prometheus + Grafana | + +--- + +## 11. RESUMEN EJECUTIVO + +### Stack Definitivo +- **Backend:** Laravel 12 + Sanctum + Spatie + MySQL 8.0 +- **Frontend:** Vue 3.5 + Vite 7 + Pinia + Ant Design Vue 4 +- **DevOps:** Docker Compose, Nginx, GitHub Actions +- **Base de datos:** 30+ tablas con integridad referencial + +### Funcionalidades Implementadas +1. Procesos de Admisión multi-sede con archivos de resultados por slot +2. Exámenes online con timer, generación dinámica, calificación automática +3. Portal Admin con CRUD completo (áreas, cursos, preguntas, procesos, noticias) +4. Portal Postulante (dashboard, examen, resultados Chart.js, seguimiento admisión) +5. Página Web Pública (convocatorias, noticias, comunicados, descarga resultados) + +### Métricas +- 73 endpoints API REST +- 21 modelos Eloquent +- 14 stores Pinia +- 23 rutas Vue Router +- 30+ tablas en BD +- Soporte 100+ usuarios concurrentes (PHP-FPM config) + +### Estado del Proyecto +- **Etapa:** MVP con iteraciones activas (commits recientes) +- **Calidad:** Código limpio y consistente, sin tests automatizados +- **Seguridad:** Auth/Authz implementada, validación servidor +- **Escalabilidad:** Containerizado, DB con FK integridad, caché configurable