ML Engine Updates: - Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records - Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence) - Backtest results: +176.71R profit with aggressive_filter strategy Documentation Consolidation: - Created docs/99-analisis/_MAP.md index with 13 new analysis documents - Consolidated inventories: removed duplicates from orchestration/inventarios/ - Updated ML_INVENTORY.yml with BTCUSD metrics and training results - Added execution reports: FASE11-BTCUSD, correction issues, alignment validation Architecture & Integration: - Updated all module documentation with NEXUS v3.4 frontmatter - Fixed _MAP.md indexes across all folders - Updated orchestration plans and traces Files: 229 changed, 5064 insertions(+), 1872 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1899 lines
40 KiB
Markdown
1899 lines
40 KiB
Markdown
---
|
|
id: "ET-EDU-002"
|
|
title: "API REST Education Module"
|
|
type: "Specification"
|
|
status: "Done"
|
|
rf_parent: "RF-EDU-002"
|
|
epic: "OQI-002"
|
|
version: "1.0"
|
|
created_date: "2025-12-05"
|
|
updated_date: "2026-01-04"
|
|
---
|
|
|
|
# ET-EDU-002: API REST - Endpoints del Módulo Educativo
|
|
|
|
**Versión:** 1.0.0
|
|
**Fecha:** 2025-12-05
|
|
**Épica:** OQI-002 - Módulo Educativo
|
|
**Componente:** Backend
|
|
|
|
---
|
|
|
|
## Descripción
|
|
|
|
Define todos los endpoints REST del módulo educativo de Trading Platform, implementados en Express.js con TypeScript. Incluye autenticación JWT, validación de datos, paginación, filtros y manejo de errores.
|
|
|
|
---
|
|
|
|
## Arquitectura
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ API Gateway │
|
|
│ (Express.js + TypeScript) │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
│ │ Middleware Stack │ │
|
|
│ │ - JWT Authentication │ │
|
|
│ │ - Rate Limiting │ │
|
|
│ │ - Request Validation (Zod) │ │
|
|
│ │ - Error Handling │ │
|
|
│ │ - CORS │ │
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ Courses │ │ Enrollments │ │ Progress │ │
|
|
│ │ Controller │ │ Controller │ │ Controller │ │
|
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
|
│ │ │ │ │
|
|
│ v v v │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ Courses │ │ Enrollments │ │ Progress │ │
|
|
│ │ Service │ │ Service │ │ Service │ │
|
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
|
│ │ │ │ │
|
|
│ v v v │
|
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
|
│ │ PostgreSQL (education schema) │ │
|
|
│ └──────────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ Quizzes │ │Certificates │ │Achievements │ │
|
|
│ │ Controller │ │ Controller │ │ Controller │ │
|
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Especificación Detallada
|
|
|
|
### Base URL
|
|
|
|
```
|
|
Production: https://api.trading.ai/v1/education
|
|
Development: http://localhost:3000/v1/education
|
|
```
|
|
|
|
### Convenciones
|
|
|
|
- **Formato**: JSON
|
|
- **Encoding**: UTF-8
|
|
- **Versionado**: Prefijo `/v1`
|
|
- **Autenticación**: Bearer Token (JWT)
|
|
- **Rate Limit**: 100 req/min por IP, 1000 req/hour por usuario autenticado
|
|
|
|
---
|
|
|
|
## Endpoints
|
|
|
|
### 1. CATEGORIES
|
|
|
|
#### GET /categories
|
|
|
|
Obtener todas las categorías activas.
|
|
|
|
**Auth:** No requerido
|
|
|
|
**Query Parameters:**
|
|
```typescript
|
|
{
|
|
include_inactive?: boolean; // Admin only, default: false
|
|
parent_id?: string; // Filtrar por categoría padre
|
|
}
|
|
```
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: Array<{
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
description: string | null;
|
|
parent_id: string | null;
|
|
display_order: number;
|
|
icon_url: string | null;
|
|
color: string | null;
|
|
course_count: number; // Número de cursos en la categoría
|
|
}>,
|
|
metadata: {
|
|
total: number;
|
|
timestamp: string;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET /categories/:slug
|
|
|
|
Obtener categoría por slug con sus cursos.
|
|
|
|
**Auth:** No requerido
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
description: string | null;
|
|
parent_id: string | null;
|
|
icon_url: string | null;
|
|
color: string | null;
|
|
courses: Array<{
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
short_description: string;
|
|
thumbnail_url: string;
|
|
difficulty_level: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
|
duration_minutes: number;
|
|
total_lessons: number;
|
|
avg_rating: number;
|
|
is_free: boolean;
|
|
price_usd: number | null;
|
|
}>;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. COURSES
|
|
|
|
#### GET /courses
|
|
|
|
Listar cursos con filtros y paginación.
|
|
|
|
**Auth:** No requerido
|
|
|
|
**Query Parameters:**
|
|
```typescript
|
|
{
|
|
page?: number; // default: 1
|
|
limit?: number; // default: 20, max: 100
|
|
category?: string; // slug de categoría
|
|
difficulty?: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
|
is_free?: boolean;
|
|
search?: string; // Búsqueda en título y descripción
|
|
sort_by?: 'newest' | 'popular' | 'rating' | 'title';
|
|
instructor_id?: string;
|
|
}
|
|
```
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: Array<{
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
short_description: string;
|
|
thumbnail_url: string;
|
|
category: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
};
|
|
difficulty_level: string;
|
|
duration_minutes: number;
|
|
total_modules: number;
|
|
total_lessons: number;
|
|
total_enrollments: number;
|
|
avg_rating: number;
|
|
total_reviews: number;
|
|
instructor_name: string;
|
|
is_free: boolean;
|
|
price_usd: number | null;
|
|
xp_reward: number;
|
|
published_at: string;
|
|
}>,
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
total_pages: number;
|
|
has_next: boolean;
|
|
has_prev: boolean;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET /courses/:slug
|
|
|
|
Obtener detalle completo de un curso.
|
|
|
|
**Auth:** No requerido
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
short_description: string;
|
|
full_description: string;
|
|
thumbnail_url: string;
|
|
trailer_url: string | null;
|
|
category: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
};
|
|
difficulty_level: string;
|
|
duration_minutes: number;
|
|
prerequisites: string[];
|
|
learning_objectives: string[];
|
|
instructor: {
|
|
id: string;
|
|
name: string;
|
|
avatar_url: string | null;
|
|
bio: string | null;
|
|
};
|
|
is_free: boolean;
|
|
price_usd: number | null;
|
|
xp_reward: number;
|
|
published_at: string;
|
|
|
|
// Estadísticas
|
|
total_modules: number;
|
|
total_lessons: number;
|
|
total_enrollments: number;
|
|
avg_rating: number;
|
|
total_reviews: number;
|
|
|
|
// Módulos con lecciones
|
|
modules: Array<{
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
display_order: number;
|
|
duration_minutes: number;
|
|
is_locked: boolean;
|
|
lessons: Array<{
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
content_type: 'video' | 'article' | 'interactive' | 'quiz';
|
|
video_duration_seconds: number | null;
|
|
display_order: number;
|
|
is_preview: boolean;
|
|
xp_reward: number;
|
|
}>;
|
|
}>;
|
|
|
|
// Estado del usuario (si está autenticado)
|
|
user_enrollment?: {
|
|
is_enrolled: boolean;
|
|
progress_percentage: number;
|
|
status: 'active' | 'completed' | 'expired' | 'cancelled';
|
|
enrolled_at: string;
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
#### POST /courses
|
|
|
|
Crear nuevo curso (solo instructores/admin).
|
|
|
|
**Auth:** Required (instructor/admin)
|
|
|
|
**Request Body:**
|
|
```typescript
|
|
{
|
|
title: string; // min: 10, max: 200
|
|
short_description: string; // min: 50, max: 500
|
|
full_description: string; // min: 100
|
|
category_id: string; // UUID
|
|
difficulty_level: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
|
thumbnail_url?: string;
|
|
trailer_url?: string;
|
|
prerequisites?: string[];
|
|
learning_objectives: string[]; // min: 3
|
|
is_free?: boolean;
|
|
price_usd?: number;
|
|
xp_reward?: number;
|
|
}
|
|
```
|
|
|
|
**Response 201:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
slug: string;
|
|
status: 'draft';
|
|
// ... resto de campos del curso
|
|
}
|
|
}
|
|
```
|
|
|
|
#### PATCH /courses/:id
|
|
|
|
Actualizar curso (solo instructor owner/admin).
|
|
|
|
**Auth:** Required (owner/admin)
|
|
|
|
**Request Body:** Partial de POST /courses
|
|
|
|
**Response 200:** Same as GET /courses/:slug
|
|
|
|
#### DELETE /courses/:id
|
|
|
|
Eliminar curso (solo admin).
|
|
|
|
**Auth:** Required (admin)
|
|
|
|
**Response 204:** No Content
|
|
|
|
#### PATCH /courses/:id/publish
|
|
|
|
Publicar curso (cambiar status a published).
|
|
|
|
**Auth:** Required (owner/admin)
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
status: 'published';
|
|
published_at: string;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3. MODULES
|
|
|
|
#### POST /courses/:courseId/modules
|
|
|
|
Crear módulo en un curso.
|
|
|
|
**Auth:** Required (course owner/admin)
|
|
|
|
**Request Body:**
|
|
```typescript
|
|
{
|
|
title: string;
|
|
description: string;
|
|
display_order: number;
|
|
duration_minutes?: number;
|
|
is_locked?: boolean;
|
|
unlock_after_module_id?: string;
|
|
}
|
|
```
|
|
|
|
**Response 201:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
course_id: string;
|
|
title: string;
|
|
description: string;
|
|
display_order: number;
|
|
is_locked: boolean;
|
|
created_at: string;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### PATCH /modules/:id
|
|
|
|
Actualizar módulo.
|
|
|
|
**Auth:** Required (course owner/admin)
|
|
|
|
**Request Body:** Partial de POST
|
|
|
|
**Response 200:** Same as POST response
|
|
|
|
#### DELETE /modules/:id
|
|
|
|
Eliminar módulo (cascade elimina lecciones).
|
|
|
|
**Auth:** Required (course owner/admin)
|
|
|
|
**Response 204:** No Content
|
|
|
|
#### PATCH /modules/:id/reorder
|
|
|
|
Reordenar módulos de un curso.
|
|
|
|
**Auth:** Required (course owner/admin)
|
|
|
|
**Request Body:**
|
|
```typescript
|
|
{
|
|
new_order: number;
|
|
}
|
|
```
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
display_order: number;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4. LESSONS
|
|
|
|
#### POST /modules/:moduleId/lessons
|
|
|
|
Crear lección en un módulo.
|
|
|
|
**Auth:** Required (course owner/admin)
|
|
|
|
**Request Body:**
|
|
```typescript
|
|
{
|
|
title: string;
|
|
description: string;
|
|
content_type: 'video' | 'article' | 'interactive' | 'quiz';
|
|
|
|
// Para video
|
|
video_url?: string;
|
|
video_duration_seconds?: number;
|
|
video_provider?: 'vimeo' | 's3';
|
|
video_id?: string;
|
|
|
|
// Para article
|
|
article_content?: string;
|
|
|
|
// Recursos
|
|
attachments?: Array<{
|
|
name: string;
|
|
url: string;
|
|
type: string;
|
|
size: number;
|
|
}>;
|
|
|
|
display_order: number;
|
|
is_preview?: boolean;
|
|
is_mandatory?: boolean;
|
|
xp_reward?: number;
|
|
}
|
|
```
|
|
|
|
**Response 201:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
module_id: string;
|
|
title: string;
|
|
content_type: string;
|
|
display_order: number;
|
|
xp_reward: number;
|
|
created_at: string;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET /lessons/:id
|
|
|
|
Obtener detalle de lección.
|
|
|
|
**Auth:** Required (enrolled users o lecciones preview)
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
module_id: string;
|
|
title: string;
|
|
description: string;
|
|
content_type: string;
|
|
|
|
// Video
|
|
video_url?: string;
|
|
video_duration_seconds?: number;
|
|
video_provider?: string;
|
|
video_id?: string;
|
|
|
|
// Article
|
|
article_content?: string;
|
|
|
|
// Recursos
|
|
attachments?: Array<{
|
|
name: string;
|
|
url: string;
|
|
type: string;
|
|
size: number;
|
|
}>;
|
|
|
|
is_preview: boolean;
|
|
xp_reward: number;
|
|
|
|
// Progreso del usuario
|
|
user_progress?: {
|
|
is_completed: boolean;
|
|
last_position_seconds: number;
|
|
watch_percentage: number;
|
|
last_viewed_at: string;
|
|
};
|
|
|
|
// Navegación
|
|
previous_lesson?: {
|
|
id: string;
|
|
title: string;
|
|
};
|
|
next_lesson?: {
|
|
id: string;
|
|
title: string;
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
#### PATCH /lessons/:id
|
|
|
|
Actualizar lección.
|
|
|
|
**Auth:** Required (course owner/admin)
|
|
|
|
**Request Body:** Partial de POST
|
|
|
|
**Response 200:** Same as GET /lessons/:id
|
|
|
|
#### DELETE /lessons/:id
|
|
|
|
Eliminar lección.
|
|
|
|
**Auth:** Required (course owner/admin)
|
|
|
|
**Response 204:** No Content
|
|
|
|
---
|
|
|
|
### 5. ENROLLMENTS
|
|
|
|
#### POST /enrollments
|
|
|
|
Enrollarse en un curso.
|
|
|
|
**Auth:** Required
|
|
|
|
**Request Body:**
|
|
```typescript
|
|
{
|
|
course_id: string;
|
|
}
|
|
```
|
|
|
|
**Response 201:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
user_id: string;
|
|
course_id: string;
|
|
status: 'active';
|
|
progress_percentage: 0;
|
|
enrolled_at: string;
|
|
total_lessons: number;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Error 409:** Ya está enrollado en el curso
|
|
|
|
#### GET /enrollments
|
|
|
|
Obtener enrollments del usuario autenticado.
|
|
|
|
**Auth:** Required
|
|
|
|
**Query Parameters:**
|
|
```typescript
|
|
{
|
|
status?: 'active' | 'completed' | 'expired' | 'cancelled';
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
```
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: Array<{
|
|
id: string;
|
|
course: {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
thumbnail_url: string;
|
|
instructor_name: string;
|
|
};
|
|
status: string;
|
|
progress_percentage: number;
|
|
completed_lessons: number;
|
|
total_lessons: number;
|
|
enrolled_at: string;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
total_xp_earned: number;
|
|
}>,
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET /enrollments/:id
|
|
|
|
Obtener detalle de enrollment con progreso completo.
|
|
|
|
**Auth:** Required (owner/admin)
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
course: {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
thumbnail_url: string;
|
|
};
|
|
status: string;
|
|
progress_percentage: number;
|
|
completed_lessons: number;
|
|
total_lessons: number;
|
|
enrolled_at: string;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
total_xp_earned: number;
|
|
|
|
// Progreso por módulo
|
|
modules_progress: Array<{
|
|
module_id: string;
|
|
module_title: string;
|
|
total_lessons: number;
|
|
completed_lessons: number;
|
|
progress_percentage: number;
|
|
lessons_progress: Array<{
|
|
lesson_id: string;
|
|
lesson_title: string;
|
|
is_completed: boolean;
|
|
watch_percentage: number;
|
|
last_viewed_at: string | null;
|
|
}>;
|
|
}>;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### DELETE /enrollments/:id
|
|
|
|
Cancelar enrollment.
|
|
|
|
**Auth:** Required (owner/admin)
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
status: 'cancelled';
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 6. PROGRESS
|
|
|
|
#### POST /progress
|
|
|
|
Actualizar progreso en una lección.
|
|
|
|
**Auth:** Required
|
|
|
|
**Request Body:**
|
|
```typescript
|
|
{
|
|
lesson_id: string;
|
|
enrollment_id: string;
|
|
last_position_seconds?: number; // Para videos
|
|
is_completed?: boolean;
|
|
watch_percentage?: number;
|
|
}
|
|
```
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
lesson_id: string;
|
|
is_completed: boolean;
|
|
last_position_seconds: number;
|
|
watch_percentage: number;
|
|
xp_earned: number; // Si se completó la lección
|
|
updated_at: string;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET /progress/course/:courseId
|
|
|
|
Obtener progreso del usuario en un curso.
|
|
|
|
**Auth:** Required
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
enrollment_id: string;
|
|
course_id: string;
|
|
progress_percentage: number;
|
|
completed_lessons: number;
|
|
total_lessons: number;
|
|
total_xp_earned: number;
|
|
lessons: Array<{
|
|
lesson_id: string;
|
|
lesson_title: string;
|
|
module_title: string;
|
|
is_completed: boolean;
|
|
watch_percentage: number;
|
|
last_viewed_at: string | null;
|
|
completed_at: string | null;
|
|
}>;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 7. QUIZZES
|
|
|
|
#### GET /quizzes/:id
|
|
|
|
Obtener quiz (sin respuestas correctas).
|
|
|
|
**Auth:** Required (enrolled)
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
module_id?: string;
|
|
lesson_id?: string;
|
|
title: string;
|
|
description: string;
|
|
passing_score_percentage: number;
|
|
max_attempts: number | null;
|
|
time_limit_minutes: number | null;
|
|
xp_reward: number;
|
|
xp_perfect_score_bonus: number;
|
|
|
|
// Estadísticas del usuario
|
|
user_attempts: Array<{
|
|
id: string;
|
|
score_percentage: number;
|
|
is_passed: boolean;
|
|
completed_at: string;
|
|
}>;
|
|
remaining_attempts: number | null;
|
|
best_score: number | null;
|
|
|
|
// Preguntas (sin respuestas correctas)
|
|
questions: Array<{
|
|
id: string;
|
|
question_text: string;
|
|
question_type: 'multiple_choice' | 'true_false' | 'multiple_select' | 'fill_blank' | 'code_challenge';
|
|
options?: Array<{
|
|
id: string;
|
|
text: string;
|
|
}>;
|
|
image_url?: string;
|
|
code_snippet?: string;
|
|
points: number;
|
|
display_order: number;
|
|
}>;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### POST /quizzes/:id/attempts
|
|
|
|
Iniciar intento de quiz.
|
|
|
|
**Auth:** Required (enrolled)
|
|
|
|
**Response 201:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
quiz_id: string;
|
|
started_at: string;
|
|
time_limit_expires_at: string | null;
|
|
questions: Array<{
|
|
id: string;
|
|
question_text: string;
|
|
question_type: string;
|
|
options?: Array<{ id: string; text: string }>;
|
|
points: number;
|
|
}>;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Error 403:** No quedan intentos disponibles
|
|
|
|
#### POST /quizzes/attempts/:attemptId/submit
|
|
|
|
Enviar respuestas del quiz.
|
|
|
|
**Auth:** Required (owner)
|
|
|
|
**Request Body:**
|
|
```typescript
|
|
{
|
|
answers: Array<{
|
|
question_id: string;
|
|
answer: string | string[]; // string[] para multiple_select
|
|
}>;
|
|
}
|
|
```
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
is_completed: true;
|
|
is_passed: boolean;
|
|
score_points: number;
|
|
max_points: number;
|
|
score_percentage: number;
|
|
time_taken_seconds: number;
|
|
xp_earned: number;
|
|
completed_at: string;
|
|
|
|
// Desglose de respuestas
|
|
results: Array<{
|
|
question_id: string;
|
|
question_text: string;
|
|
user_answer: string | string[];
|
|
is_correct: boolean;
|
|
points_earned: number;
|
|
max_points: number;
|
|
explanation: string;
|
|
}>;
|
|
|
|
// Nuevo logro si aplica
|
|
achievement_earned?: {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
badge_icon_url: string;
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET /quizzes/attempts/:attemptId
|
|
|
|
Obtener resultado de un intento completado.
|
|
|
|
**Auth:** Required (owner/admin)
|
|
|
|
**Response 200:** Same as POST submit response
|
|
|
|
---
|
|
|
|
### 8. CERTIFICATES
|
|
|
|
#### GET /certificates
|
|
|
|
Obtener certificados del usuario.
|
|
|
|
**Auth:** Required
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: Array<{
|
|
id: string;
|
|
certificate_number: string;
|
|
course_title: string;
|
|
completion_date: string;
|
|
final_score: number;
|
|
certificate_url: string;
|
|
verification_code: string;
|
|
issued_at: string;
|
|
}>
|
|
}
|
|
```
|
|
|
|
#### GET /certificates/:id
|
|
|
|
Obtener detalle de certificado.
|
|
|
|
**Auth:** Required (owner/admin) o público con verification_code
|
|
|
|
**Query Parameters:**
|
|
```typescript
|
|
{
|
|
verification_code?: string; // Para acceso público
|
|
}
|
|
```
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
id: string;
|
|
certificate_number: string;
|
|
user_name: string;
|
|
course_title: string;
|
|
instructor_name: string;
|
|
completion_date: string;
|
|
final_score: number;
|
|
total_xp_earned: number;
|
|
certificate_url: string;
|
|
verification_code: string;
|
|
is_verified: boolean;
|
|
issued_at: string;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET /certificates/verify/:certificateNumber
|
|
|
|
Verificar autenticidad de un certificado (público).
|
|
|
|
**Auth:** No requerido
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
is_valid: boolean;
|
|
certificate_number: string;
|
|
user_name: string;
|
|
course_title: string;
|
|
completion_date: string;
|
|
issued_at: string;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 9. ACHIEVEMENTS
|
|
|
|
#### GET /achievements
|
|
|
|
Obtener logros del usuario autenticado.
|
|
|
|
**Auth:** Required
|
|
|
|
**Query Parameters:**
|
|
```typescript
|
|
{
|
|
achievement_type?: 'course_completion' | 'quiz_perfect_score' | 'streak_milestone' | 'level_up' | 'special_event';
|
|
}
|
|
```
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: Array<{
|
|
id: string;
|
|
achievement_type: string;
|
|
title: string;
|
|
description: string;
|
|
badge_icon_url: string;
|
|
xp_bonus: number;
|
|
earned_at: string;
|
|
metadata: {
|
|
course_title?: string;
|
|
quiz_score?: number;
|
|
streak_days?: number;
|
|
new_level?: number;
|
|
};
|
|
}>,
|
|
summary: {
|
|
total_achievements: number;
|
|
total_xp_from_achievements: number;
|
|
by_type: {
|
|
course_completion: number;
|
|
quiz_perfect_score: number;
|
|
streak_milestone: number;
|
|
level_up: number;
|
|
special_event: number;
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET /achievements/available
|
|
|
|
Obtener logros disponibles para desbloquear.
|
|
|
|
**Auth:** Required
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: Array<{
|
|
title: string;
|
|
description: string;
|
|
badge_icon_url: string;
|
|
xp_bonus: number;
|
|
requirements: string;
|
|
progress_percentage: number;
|
|
}>
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 10. GAMIFICATION
|
|
|
|
#### GET /gamification/profile
|
|
|
|
Obtener perfil de gamificación del usuario.
|
|
|
|
**Auth:** Required
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: {
|
|
user_id: string;
|
|
total_xp: number;
|
|
current_level: number;
|
|
xp_for_next_level: number;
|
|
xp_progress_percentage: number;
|
|
|
|
// Estadísticas
|
|
courses_completed: number;
|
|
courses_in_progress: number;
|
|
total_learning_time_minutes: number;
|
|
|
|
// Racha
|
|
current_streak_days: number;
|
|
longest_streak_days: number;
|
|
last_activity_date: string;
|
|
|
|
// Logros
|
|
total_achievements: number;
|
|
recent_achievements: Array<{
|
|
id: string;
|
|
title: string;
|
|
badge_icon_url: string;
|
|
earned_at: string;
|
|
}>;
|
|
|
|
// Ranking
|
|
global_rank: number | null;
|
|
percentile: number;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### GET /gamification/leaderboard
|
|
|
|
Obtener tabla de clasificación.
|
|
|
|
**Auth:** Required
|
|
|
|
**Query Parameters:**
|
|
```typescript
|
|
{
|
|
period?: 'all_time' | 'month' | 'week';
|
|
limit?: number; // default: 100
|
|
}
|
|
```
|
|
|
|
**Response 200:**
|
|
```typescript
|
|
{
|
|
success: true,
|
|
data: Array<{
|
|
rank: number;
|
|
user_id: string;
|
|
user_name: string;
|
|
avatar_url: string | null;
|
|
total_xp: number;
|
|
current_level: number;
|
|
courses_completed: number;
|
|
achievements_count: number;
|
|
}>,
|
|
user_position: {
|
|
rank: number;
|
|
total_xp: number;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Interfaces/Tipos
|
|
|
|
### Request/Response Types
|
|
|
|
```typescript
|
|
// ==================== COMMON ====================
|
|
|
|
export interface ApiResponse<T> {
|
|
success: boolean;
|
|
data: T;
|
|
error?: {
|
|
code: string;
|
|
message: string;
|
|
details?: any;
|
|
};
|
|
}
|
|
|
|
export interface PaginatedResponse<T> {
|
|
success: boolean;
|
|
data: T[];
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
total_pages: number;
|
|
has_next: boolean;
|
|
has_prev: boolean;
|
|
};
|
|
}
|
|
|
|
// ==================== COURSES ====================
|
|
|
|
export interface CourseListItem {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
short_description: string;
|
|
thumbnail_url: string;
|
|
category: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
};
|
|
difficulty_level: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
|
duration_minutes: number;
|
|
total_modules: number;
|
|
total_lessons: number;
|
|
total_enrollments: number;
|
|
avg_rating: number;
|
|
total_reviews: number;
|
|
instructor_name: string;
|
|
is_free: boolean;
|
|
price_usd: number | null;
|
|
xp_reward: number;
|
|
published_at: string;
|
|
}
|
|
|
|
export interface CourseDetail extends CourseListItem {
|
|
full_description: string;
|
|
trailer_url: string | null;
|
|
prerequisites: string[];
|
|
learning_objectives: string[];
|
|
instructor: {
|
|
id: string;
|
|
name: string;
|
|
avatar_url: string | null;
|
|
bio: string | null;
|
|
};
|
|
modules: ModuleWithLessons[];
|
|
user_enrollment?: {
|
|
is_enrolled: boolean;
|
|
progress_percentage: number;
|
|
status: EnrollmentStatus;
|
|
enrolled_at: string;
|
|
};
|
|
}
|
|
|
|
export interface CreateCourseDto {
|
|
title: string;
|
|
short_description: string;
|
|
full_description: string;
|
|
category_id: string;
|
|
difficulty_level: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
|
thumbnail_url?: string;
|
|
trailer_url?: string;
|
|
prerequisites?: string[];
|
|
learning_objectives: string[];
|
|
is_free?: boolean;
|
|
price_usd?: number;
|
|
xp_reward?: number;
|
|
}
|
|
|
|
// ==================== MODULES ====================
|
|
|
|
export interface Module {
|
|
id: string;
|
|
course_id: string;
|
|
title: string;
|
|
description: string;
|
|
display_order: number;
|
|
duration_minutes: number;
|
|
is_locked: boolean;
|
|
unlock_after_module_id: string | null;
|
|
}
|
|
|
|
export interface ModuleWithLessons extends Module {
|
|
lessons: Lesson[];
|
|
}
|
|
|
|
export interface CreateModuleDto {
|
|
title: string;
|
|
description: string;
|
|
display_order: number;
|
|
duration_minutes?: number;
|
|
is_locked?: boolean;
|
|
unlock_after_module_id?: string;
|
|
}
|
|
|
|
// ==================== LESSONS ====================
|
|
|
|
export type LessonContentType = 'video' | 'article' | 'interactive' | 'quiz';
|
|
|
|
export interface Lesson {
|
|
id: string;
|
|
module_id: string;
|
|
title: string;
|
|
description: string;
|
|
content_type: LessonContentType;
|
|
video_url?: string;
|
|
video_duration_seconds?: number;
|
|
video_provider?: string;
|
|
video_id?: string;
|
|
article_content?: string;
|
|
attachments?: Attachment[];
|
|
display_order: number;
|
|
is_preview: boolean;
|
|
is_mandatory: boolean;
|
|
xp_reward: number;
|
|
}
|
|
|
|
export interface LessonDetail extends Lesson {
|
|
user_progress?: {
|
|
is_completed: boolean;
|
|
last_position_seconds: number;
|
|
watch_percentage: number;
|
|
last_viewed_at: string;
|
|
};
|
|
previous_lesson?: {
|
|
id: string;
|
|
title: string;
|
|
};
|
|
next_lesson?: {
|
|
id: string;
|
|
title: string;
|
|
};
|
|
}
|
|
|
|
export interface Attachment {
|
|
name: string;
|
|
url: string;
|
|
type: string;
|
|
size: number;
|
|
}
|
|
|
|
export interface CreateLessonDto {
|
|
title: string;
|
|
description: string;
|
|
content_type: LessonContentType;
|
|
video_url?: string;
|
|
video_duration_seconds?: number;
|
|
video_provider?: 'vimeo' | 's3';
|
|
video_id?: string;
|
|
article_content?: string;
|
|
attachments?: Attachment[];
|
|
display_order: number;
|
|
is_preview?: boolean;
|
|
is_mandatory?: boolean;
|
|
xp_reward?: number;
|
|
}
|
|
|
|
// ==================== ENROLLMENTS ====================
|
|
|
|
export type EnrollmentStatus = 'active' | 'completed' | 'expired' | 'cancelled';
|
|
|
|
export interface Enrollment {
|
|
id: string;
|
|
user_id: string;
|
|
course_id: string;
|
|
status: EnrollmentStatus;
|
|
progress_percentage: number;
|
|
completed_lessons: number;
|
|
total_lessons: number;
|
|
enrolled_at: string;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
expires_at: string | null;
|
|
total_xp_earned: number;
|
|
}
|
|
|
|
export interface EnrollmentWithCourse extends Enrollment {
|
|
course: {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
thumbnail_url: string;
|
|
instructor_name: string;
|
|
};
|
|
}
|
|
|
|
// ==================== PROGRESS ====================
|
|
|
|
export interface Progress {
|
|
id: string;
|
|
user_id: string;
|
|
lesson_id: string;
|
|
enrollment_id: string;
|
|
is_completed: boolean;
|
|
last_position_seconds: number;
|
|
total_watch_time_seconds: number;
|
|
watch_percentage: number;
|
|
first_viewed_at: string | null;
|
|
last_viewed_at: string | null;
|
|
completed_at: string | null;
|
|
}
|
|
|
|
export interface UpdateProgressDto {
|
|
lesson_id: string;
|
|
enrollment_id: string;
|
|
last_position_seconds?: number;
|
|
is_completed?: boolean;
|
|
watch_percentage?: number;
|
|
}
|
|
|
|
// ==================== QUIZZES ====================
|
|
|
|
export type QuestionType = 'multiple_choice' | 'true_false' | 'multiple_select' | 'fill_blank' | 'code_challenge';
|
|
|
|
export interface Quiz {
|
|
id: string;
|
|
module_id?: string;
|
|
lesson_id?: string;
|
|
title: string;
|
|
description: string;
|
|
passing_score_percentage: number;
|
|
max_attempts: number | null;
|
|
time_limit_minutes: number | null;
|
|
shuffle_questions: boolean;
|
|
shuffle_answers: boolean;
|
|
show_correct_answers: boolean;
|
|
xp_reward: number;
|
|
xp_perfect_score_bonus: number;
|
|
}
|
|
|
|
export interface QuizQuestion {
|
|
id: string;
|
|
quiz_id: string;
|
|
question_text: string;
|
|
question_type: QuestionType;
|
|
options?: Array<{
|
|
id: string;
|
|
text: string;
|
|
isCorrect?: boolean; // Solo para admin
|
|
}>;
|
|
correct_answer?: string;
|
|
explanation: string;
|
|
image_url?: string;
|
|
code_snippet?: string;
|
|
points: number;
|
|
display_order: number;
|
|
}
|
|
|
|
export interface QuizAttempt {
|
|
id: string;
|
|
user_id: string;
|
|
quiz_id: string;
|
|
enrollment_id: string | null;
|
|
is_completed: boolean;
|
|
is_passed: boolean;
|
|
user_answers: any;
|
|
score_points: number;
|
|
max_points: number;
|
|
score_percentage: number;
|
|
started_at: string;
|
|
completed_at: string | null;
|
|
time_taken_seconds: number | null;
|
|
xp_earned: number;
|
|
}
|
|
|
|
export interface SubmitQuizDto {
|
|
answers: Array<{
|
|
question_id: string;
|
|
answer: string | string[];
|
|
}>;
|
|
}
|
|
|
|
// ==================== CERTIFICATES ====================
|
|
|
|
export interface Certificate {
|
|
id: string;
|
|
certificate_number: string;
|
|
user_name: string;
|
|
course_title: string;
|
|
instructor_name: string;
|
|
completion_date: string;
|
|
final_score: number;
|
|
total_xp_earned: number;
|
|
certificate_url: string;
|
|
verification_code: string;
|
|
is_verified: boolean;
|
|
issued_at: string;
|
|
}
|
|
|
|
// ==================== ACHIEVEMENTS ====================
|
|
|
|
export type AchievementType = 'course_completion' | 'quiz_perfect_score' | 'streak_milestone' | 'level_up' | 'special_event';
|
|
|
|
export interface Achievement {
|
|
id: string;
|
|
user_id: string;
|
|
achievement_type: AchievementType;
|
|
title: string;
|
|
description: string;
|
|
badge_icon_url: string;
|
|
metadata: any;
|
|
course_id?: string;
|
|
quiz_id?: string;
|
|
xp_bonus: number;
|
|
earned_at: string;
|
|
}
|
|
|
|
// ==================== GAMIFICATION ====================
|
|
|
|
export interface GamificationProfile {
|
|
user_id: string;
|
|
total_xp: number;
|
|
current_level: number;
|
|
xp_for_next_level: number;
|
|
xp_progress_percentage: number;
|
|
courses_completed: number;
|
|
courses_in_progress: number;
|
|
total_learning_time_minutes: number;
|
|
current_streak_days: number;
|
|
longest_streak_days: number;
|
|
last_activity_date: string;
|
|
total_achievements: number;
|
|
recent_achievements: Achievement[];
|
|
global_rank: number | null;
|
|
percentile: number;
|
|
}
|
|
|
|
export interface LeaderboardEntry {
|
|
rank: number;
|
|
user_id: string;
|
|
user_name: string;
|
|
avatar_url: string | null;
|
|
total_xp: number;
|
|
current_level: number;
|
|
courses_completed: number;
|
|
achievements_count: number;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Configuración
|
|
|
|
### Variables de Entorno
|
|
|
|
```bash
|
|
# Server
|
|
PORT=3000
|
|
NODE_ENV=production
|
|
|
|
# Database
|
|
DATABASE_URL=postgresql://user:password@localhost:5432/trading
|
|
DATABASE_SCHEMA=education
|
|
|
|
# JWT
|
|
JWT_SECRET=your-super-secret-key
|
|
JWT_EXPIRES_IN=7d
|
|
|
|
# Rate Limiting
|
|
RATE_LIMIT_WINDOW_MS=60000
|
|
RATE_LIMIT_MAX_REQUESTS=100
|
|
RATE_LIMIT_AUTHENTICATED_MAX=1000
|
|
|
|
# CORS
|
|
CORS_ORIGIN=https://trading.ai,https://app.trading.ai
|
|
CORS_CREDENTIALS=true
|
|
|
|
# External Services
|
|
VIMEO_API_KEY=xxx
|
|
AWS_S3_BUCKET=trading-videos
|
|
AWS_CLOUDFRONT_URL=https://cdn.trading.ai
|
|
|
|
# Email (para certificados)
|
|
SMTP_HOST=smtp.sendgrid.net
|
|
SMTP_PORT=587
|
|
SMTP_USER=apikey
|
|
SMTP_PASS=xxx
|
|
|
|
# Redis (para caching)
|
|
REDIS_URL=redis://localhost:6379
|
|
REDIS_TTL=3600
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencias
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"express": "^4.18.2",
|
|
"typescript": "^5.3.3",
|
|
"@types/express": "^4.17.21",
|
|
"zod": "^3.22.4",
|
|
"pg": "^8.11.3",
|
|
"jsonwebtoken": "^9.0.2",
|
|
"bcrypt": "^5.1.1",
|
|
"express-rate-limit": "^7.1.5",
|
|
"helmet": "^7.1.0",
|
|
"cors": "^2.8.5",
|
|
"dotenv": "^16.3.1",
|
|
"winston": "^3.11.0",
|
|
"redis": "^4.6.12",
|
|
"axios": "^1.6.5"
|
|
},
|
|
"devDependencies": {
|
|
"@types/node": "^20.10.6",
|
|
"@types/jsonwebtoken": "^9.0.5",
|
|
"@types/bcrypt": "^5.0.2",
|
|
"nodemon": "^3.0.2",
|
|
"ts-node": "^10.9.2",
|
|
"jest": "^29.7.0",
|
|
"@types/jest": "^29.5.11",
|
|
"supertest": "^6.3.3",
|
|
"@types/supertest": "^6.0.2"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Consideraciones de Seguridad
|
|
|
|
### 1. Autenticación
|
|
|
|
```typescript
|
|
// Middleware de autenticación JWT
|
|
export const authenticate = async (
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
try {
|
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
|
|
|
if (!token) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: {
|
|
code: 'UNAUTHORIZED',
|
|
message: 'Token no proporcionado'
|
|
}
|
|
});
|
|
}
|
|
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
|
|
req.user = decoded;
|
|
next();
|
|
} catch (error) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: {
|
|
code: 'INVALID_TOKEN',
|
|
message: 'Token inválido o expirado'
|
|
}
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
### 2. Autorización
|
|
|
|
```typescript
|
|
// Middleware de autorización por rol
|
|
export const authorize = (...roles: string[]) => {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
if (!req.user || !roles.includes(req.user.role)) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: {
|
|
code: 'FORBIDDEN',
|
|
message: 'No tienes permisos para esta acción'
|
|
}
|
|
});
|
|
}
|
|
next();
|
|
};
|
|
};
|
|
|
|
// Verificar ownership del recurso
|
|
export const verifyOwnership = async (
|
|
req: Request,
|
|
res: Response,
|
|
next: NextFunction
|
|
) => {
|
|
const resourceId = req.params.id;
|
|
const userId = req.user.id;
|
|
|
|
// Verificar que el recurso pertenece al usuario
|
|
const resource = await db.query(
|
|
'SELECT user_id FROM education.enrollments WHERE id = $1',
|
|
[resourceId]
|
|
);
|
|
|
|
if (resource.rows[0]?.user_id !== userId && req.user.role !== 'admin') {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: {
|
|
code: 'FORBIDDEN',
|
|
message: 'No tienes acceso a este recurso'
|
|
}
|
|
});
|
|
}
|
|
|
|
next();
|
|
};
|
|
```
|
|
|
|
### 3. Validación de Input
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
|
|
// Schema de validación para crear curso
|
|
export const CreateCourseSchema = z.object({
|
|
title: z.string().min(10).max(200),
|
|
short_description: z.string().min(50).max(500),
|
|
full_description: z.string().min(100),
|
|
category_id: z.string().uuid(),
|
|
difficulty_level: z.enum(['beginner', 'intermediate', 'advanced', 'expert']),
|
|
thumbnail_url: z.string().url().optional(),
|
|
trailer_url: z.string().url().optional(),
|
|
prerequisites: z.array(z.string().uuid()).optional(),
|
|
learning_objectives: z.array(z.string()).min(3),
|
|
is_free: z.boolean().optional(),
|
|
price_usd: z.number().min(0).optional(),
|
|
xp_reward: z.number().min(0).optional()
|
|
});
|
|
|
|
// Middleware de validación
|
|
export const validate = (schema: z.ZodSchema) => {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
schema.parse(req.body);
|
|
next();
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: {
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Datos de entrada inválidos',
|
|
details: error.errors
|
|
}
|
|
});
|
|
}
|
|
next(error);
|
|
}
|
|
};
|
|
};
|
|
```
|
|
|
|
### 4. Rate Limiting
|
|
|
|
```typescript
|
|
import rateLimit from 'express-rate-limit';
|
|
|
|
// Rate limiter general
|
|
export const generalLimiter = rateLimit({
|
|
windowMs: 60 * 1000, // 1 minuto
|
|
max: 100,
|
|
message: {
|
|
success: false,
|
|
error: {
|
|
code: 'RATE_LIMIT_EXCEEDED',
|
|
message: 'Demasiadas solicitudes, intenta más tarde'
|
|
}
|
|
}
|
|
});
|
|
|
|
// Rate limiter para autenticados
|
|
export const authLimiter = rateLimit({
|
|
windowMs: 60 * 60 * 1000, // 1 hora
|
|
max: 1000,
|
|
skip: (req) => !req.user,
|
|
message: {
|
|
success: false,
|
|
error: {
|
|
code: 'RATE_LIMIT_EXCEEDED',
|
|
message: 'Límite de solicitudes excedido'
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
### 5. SQL Injection Prevention
|
|
|
|
- Usar **prepared statements** siempre
|
|
- Usar ORM (Prisma, TypeORM) con queries parametrizadas
|
|
- Nunca concatenar strings para queries SQL
|
|
|
|
### 6. XSS Prevention
|
|
|
|
- Sanitizar todo input del usuario
|
|
- Usar headers de seguridad (Helmet.js)
|
|
- Content Security Policy
|
|
|
|
### 7. CORS
|
|
|
|
```typescript
|
|
import cors from 'cors';
|
|
|
|
app.use(cors({
|
|
origin: process.env.CORS_ORIGIN?.split(','),
|
|
credentials: true,
|
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
allowedHeaders: ['Content-Type', 'Authorization']
|
|
}));
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Estrategia
|
|
|
|
1. **Unit Tests**: Servicios y controladores
|
|
2. **Integration Tests**: Endpoints completos
|
|
3. **E2E Tests**: Flujos de usuario completos
|
|
|
|
### Ejemplo de Test
|
|
|
|
```typescript
|
|
import request from 'supertest';
|
|
import { app } from '../app';
|
|
import { generateAuthToken } from '../utils/auth';
|
|
|
|
describe('POST /enrollments', () => {
|
|
let authToken: string;
|
|
let courseId: string;
|
|
|
|
beforeAll(async () => {
|
|
// Setup test data
|
|
authToken = generateAuthToken({ id: 'test-user-id', role: 'student' });
|
|
courseId = await createTestCourse();
|
|
});
|
|
|
|
it('should enroll user in course successfully', async () => {
|
|
const response = await request(app)
|
|
.post('/v1/education/enrollments')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ course_id: courseId })
|
|
.expect(201);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body.data.course_id).toBe(courseId);
|
|
expect(response.body.data.status).toBe('active');
|
|
});
|
|
|
|
it('should return 401 without authentication', async () => {
|
|
await request(app)
|
|
.post('/v1/education/enrollments')
|
|
.send({ course_id: courseId })
|
|
.expect(401);
|
|
});
|
|
|
|
it('should return 409 if already enrolled', async () => {
|
|
// Enroll first time
|
|
await request(app)
|
|
.post('/v1/education/enrollments')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ course_id: courseId });
|
|
|
|
// Try to enroll again
|
|
const response = await request(app)
|
|
.post('/v1/education/enrollments')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ course_id: courseId })
|
|
.expect(409);
|
|
|
|
expect(response.body.error.code).toBe('ALREADY_ENROLLED');
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Códigos de Error
|
|
|
|
| Código | HTTP | Descripción |
|
|
|--------|------|-------------|
|
|
| UNAUTHORIZED | 401 | Token no proporcionado o inválido |
|
|
| FORBIDDEN | 403 | Sin permisos para esta acción |
|
|
| NOT_FOUND | 404 | Recurso no encontrado |
|
|
| ALREADY_ENROLLED | 409 | Ya enrollado en el curso |
|
|
| VALIDATION_ERROR | 400 | Datos de entrada inválidos |
|
|
| RATE_LIMIT_EXCEEDED | 429 | Límite de solicitudes excedido |
|
|
| NO_ATTEMPTS_REMAINING | 403 | Sin intentos disponibles para quiz |
|
|
| QUIZ_TIME_EXPIRED | 400 | Tiempo límite del quiz expirado |
|
|
| COURSE_NOT_PUBLISHED | 403 | Curso no está publicado |
|
|
| LESSON_LOCKED | 403 | Lección bloqueada (completar anteriores) |
|
|
| INTERNAL_SERVER_ERROR | 500 | Error interno del servidor |
|
|
|
|
---
|
|
|
|
**Fin de Especificación ET-EDU-002**
|