- Configure workspace Git repository with comprehensive .gitignore - Add Odoo as submodule for ERP reference code - Include documentation: SETUP.md, GIT-STRUCTURE.md - Add gitignore templates for projects (backend, frontend, database) - Structure supports independent repos per project/subproject level Workspace includes: - core/ - Reusable patterns, modules, orchestration system - projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.) - knowledge-base/ - Reference code and patterns (includes Odoo submodule) - devtools/ - Development tools and templates - customers/ - Client implementations template 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
464 lines
10 KiB
Markdown
464 lines
10 KiB
Markdown
# Manejo de Errores Backend
|
|
|
|
**Versión:** 1.0.0
|
|
**Última Actualización:** 2025-11-28
|
|
**Aplica a:** apps/backend/src/
|
|
|
|
---
|
|
|
|
## Resumen
|
|
|
|
GAMILIT implementa un sistema de manejo de errores consistente usando excepciones de NestJS, filtros personalizados y logging estructurado.
|
|
|
|
---
|
|
|
|
## Excepciones HTTP de NestJS
|
|
|
|
### Excepciones Incorporadas
|
|
|
|
```typescript
|
|
import {
|
|
BadRequestException, // 400
|
|
UnauthorizedException, // 401
|
|
ForbiddenException, // 403
|
|
NotFoundException, // 404
|
|
ConflictException, // 409
|
|
UnprocessableEntityException, // 422
|
|
InternalServerErrorException, // 500
|
|
} from '@nestjs/common';
|
|
```
|
|
|
|
### Uso Básico
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class UsersService {
|
|
async findOne(id: string): Promise<UserEntity> {
|
|
const user = await this.userRepository.findOne({ where: { id } });
|
|
|
|
if (!user) {
|
|
throw new NotFoundException(`User with ID ${id} not found`);
|
|
}
|
|
|
|
return user;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Excepciones Personalizadas
|
|
|
|
### Definición
|
|
|
|
```typescript
|
|
// shared/exceptions/business.exception.ts
|
|
export class InsufficientCoinsException extends BadRequestException {
|
|
constructor(required: number, available: number) {
|
|
super({
|
|
code: 'INSUFFICIENT_COINS',
|
|
message: `Insufficient ML Coins. Required: ${required}, Available: ${available}`,
|
|
required,
|
|
available,
|
|
});
|
|
}
|
|
}
|
|
|
|
export class AchievementAlreadyUnlockedException extends ConflictException {
|
|
constructor(achievementId: string) {
|
|
super({
|
|
code: 'ACHIEVEMENT_ALREADY_UNLOCKED',
|
|
message: `Achievement ${achievementId} is already unlocked`,
|
|
achievementId,
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### Uso
|
|
|
|
```typescript
|
|
async purchaseComodin(userId: string, comodinId: string): Promise<void> {
|
|
const stats = await this.userStatsRepository.findOne({ where: { userId } });
|
|
const comodin = await this.comodinRepository.findOne({ where: { id: comodinId } });
|
|
|
|
if (stats.mlCoins < comodin.price) {
|
|
throw new InsufficientCoinsException(comodin.price, stats.mlCoins);
|
|
}
|
|
|
|
// Procesar compra...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Filtro Global de Excepciones
|
|
|
|
### Implementación
|
|
|
|
```typescript
|
|
// shared/filters/http-exception.filter.ts
|
|
@Catch()
|
|
export class HttpExceptionFilter implements ExceptionFilter {
|
|
private readonly logger = new Logger(HttpExceptionFilter.name);
|
|
|
|
catch(exception: unknown, host: ArgumentsHost) {
|
|
const ctx = host.switchToHttp();
|
|
const response = ctx.getResponse<Response>();
|
|
const request = ctx.getRequest<Request>();
|
|
|
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
|
let message = 'Internal server error';
|
|
let code = 'INTERNAL_ERROR';
|
|
let errors: any[] = [];
|
|
|
|
if (exception instanceof HttpException) {
|
|
status = exception.getStatus();
|
|
const exceptionResponse = exception.getResponse();
|
|
|
|
if (typeof exceptionResponse === 'object') {
|
|
message = (exceptionResponse as any).message || message;
|
|
code = (exceptionResponse as any).code || this.getCodeFromStatus(status);
|
|
errors = (exceptionResponse as any).errors || [];
|
|
} else {
|
|
message = exceptionResponse;
|
|
}
|
|
}
|
|
|
|
// Log del error
|
|
this.logger.error({
|
|
path: request.url,
|
|
method: request.method,
|
|
status,
|
|
code,
|
|
message,
|
|
userId: request.user?.id,
|
|
stack: exception instanceof Error ? exception.stack : undefined,
|
|
});
|
|
|
|
response.status(status).json({
|
|
statusCode: status,
|
|
code,
|
|
message,
|
|
errors: errors.length > 0 ? errors : undefined,
|
|
timestamp: new Date().toISOString(),
|
|
path: request.url,
|
|
});
|
|
}
|
|
|
|
private getCodeFromStatus(status: number): string {
|
|
const codes: Record<number, string> = {
|
|
400: 'BAD_REQUEST',
|
|
401: 'UNAUTHORIZED',
|
|
403: 'FORBIDDEN',
|
|
404: 'NOT_FOUND',
|
|
409: 'CONFLICT',
|
|
422: 'UNPROCESSABLE_ENTITY',
|
|
500: 'INTERNAL_ERROR',
|
|
};
|
|
return codes[status] || 'UNKNOWN_ERROR';
|
|
}
|
|
}
|
|
```
|
|
|
|
### Registro Global
|
|
|
|
```typescript
|
|
// main.ts
|
|
app.useGlobalFilters(new HttpExceptionFilter());
|
|
```
|
|
|
|
---
|
|
|
|
## Errores de Validación
|
|
|
|
### Formato Automático
|
|
|
|
```typescript
|
|
// main.ts
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
exceptionFactory: (errors: ValidationError[]) => {
|
|
const formattedErrors = errors.map((error) => ({
|
|
field: error.property,
|
|
messages: Object.values(error.constraints || {}),
|
|
}));
|
|
|
|
return new BadRequestException({
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Validation failed',
|
|
errors: formattedErrors,
|
|
});
|
|
},
|
|
}),
|
|
);
|
|
```
|
|
|
|
### Respuesta de Ejemplo
|
|
|
|
```json
|
|
{
|
|
"statusCode": 400,
|
|
"code": "VALIDATION_ERROR",
|
|
"message": "Validation failed",
|
|
"errors": [
|
|
{
|
|
"field": "email",
|
|
"messages": ["email must be a valid email address"]
|
|
},
|
|
{
|
|
"field": "password",
|
|
"messages": ["password must be at least 8 characters"]
|
|
}
|
|
],
|
|
"timestamp": "2025-11-28T10:00:00Z",
|
|
"path": "/api/v1/auth/register"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Manejo en Servicios
|
|
|
|
### Patrón Try-Catch
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class ExerciseSubmissionService {
|
|
private readonly logger = new Logger(ExerciseSubmissionService.name);
|
|
|
|
async submit(userId: string, exerciseId: string, answer: any): Promise<SubmissionResult> {
|
|
try {
|
|
const exercise = await this.exerciseRepository.findOne({
|
|
where: { id: exerciseId },
|
|
});
|
|
|
|
if (!exercise) {
|
|
throw new NotFoundException(`Exercise ${exerciseId} not found`);
|
|
}
|
|
|
|
if (!exercise.isActive) {
|
|
throw new BadRequestException('Exercise is not active');
|
|
}
|
|
|
|
const isCorrect = await this.validateAnswer(exercise, answer);
|
|
|
|
return this.createSubmission(userId, exerciseId, answer, isCorrect);
|
|
} catch (error) {
|
|
if (error instanceof HttpException) {
|
|
throw error; // Re-lanzar excepciones HTTP
|
|
}
|
|
|
|
this.logger.error(`Failed to submit exercise: ${error.message}`, error.stack);
|
|
throw new InternalServerErrorException('Failed to process submission');
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Errores de Base de Datos
|
|
|
|
```typescript
|
|
async create(dto: CreateUserDto): Promise<UserEntity> {
|
|
try {
|
|
const user = this.userRepository.create(dto);
|
|
return await this.userRepository.save(user);
|
|
} catch (error) {
|
|
if (error.code === '23505') { // PostgreSQL unique violation
|
|
throw new ConflictException('Email already exists');
|
|
}
|
|
if (error.code === '23503') { // PostgreSQL foreign key violation
|
|
throw new BadRequestException('Referenced entity does not exist');
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Códigos de Error Personalizados
|
|
|
|
### Catálogo
|
|
|
|
```typescript
|
|
// shared/constants/error-codes.constants.ts
|
|
export const ERROR_CODES = {
|
|
// Auth (1xxx)
|
|
INVALID_CREDENTIALS: 'AUTH_1001',
|
|
TOKEN_EXPIRED: 'AUTH_1002',
|
|
EMAIL_NOT_VERIFIED: 'AUTH_1003',
|
|
ACCOUNT_SUSPENDED: 'AUTH_1004',
|
|
|
|
// Gamification (2xxx)
|
|
INSUFFICIENT_COINS: 'GAM_2001',
|
|
ACHIEVEMENT_LOCKED: 'GAM_2002',
|
|
COMODIN_NOT_AVAILABLE: 'GAM_2003',
|
|
DAILY_LIMIT_REACHED: 'GAM_2004',
|
|
|
|
// Progress (3xxx)
|
|
EXERCISE_NOT_AVAILABLE: 'PROG_3001',
|
|
SESSION_EXPIRED: 'PROG_3002',
|
|
ALREADY_SUBMITTED: 'PROG_3003',
|
|
|
|
// Social (4xxx)
|
|
NOT_CLASSROOM_MEMBER: 'SOC_4001',
|
|
TEAM_FULL: 'SOC_4002',
|
|
CHALLENGE_ENDED: 'SOC_4003',
|
|
} as const;
|
|
```
|
|
|
|
### Uso
|
|
|
|
```typescript
|
|
throw new BadRequestException({
|
|
code: ERROR_CODES.INSUFFICIENT_COINS,
|
|
message: 'Not enough ML Coins to purchase this comodin',
|
|
required: 50,
|
|
available: 30,
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Logging de Errores
|
|
|
|
### Configuración del Logger
|
|
|
|
```typescript
|
|
// shared/utils/logger.util.ts
|
|
import { Logger, LogLevel } from '@nestjs/common';
|
|
|
|
export const createLogger = (context: string): Logger => {
|
|
return new Logger(context);
|
|
};
|
|
|
|
// Niveles según entorno
|
|
export const getLogLevels = (): LogLevel[] => {
|
|
if (process.env.NODE_ENV === 'production') {
|
|
return ['error', 'warn', 'log'];
|
|
}
|
|
return ['error', 'warn', 'log', 'debug', 'verbose'];
|
|
};
|
|
```
|
|
|
|
### Logging Estructurado
|
|
|
|
```typescript
|
|
this.logger.error({
|
|
event: 'SUBMISSION_FAILED',
|
|
userId,
|
|
exerciseId,
|
|
errorCode: 'VALIDATION_FAILED',
|
|
details: validationErrors,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Errores Asíncronos
|
|
|
|
### Manejo de Promesas
|
|
|
|
```typescript
|
|
async processMultipleSubmissions(submissions: SubmissionDto[]): Promise<SubmissionResult[]> {
|
|
const results = await Promise.allSettled(
|
|
submissions.map((sub) => this.processSubmission(sub))
|
|
);
|
|
|
|
const successful: SubmissionResult[] = [];
|
|
const failed: { index: number; error: string }[] = [];
|
|
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successful.push(result.value);
|
|
} else {
|
|
failed.push({ index, error: result.reason.message });
|
|
}
|
|
});
|
|
|
|
if (failed.length > 0 && successful.length === 0) {
|
|
throw new BadRequestException({
|
|
code: 'ALL_SUBMISSIONS_FAILED',
|
|
errors: failed,
|
|
});
|
|
}
|
|
|
|
return successful;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Errores en WebSocket
|
|
|
|
```typescript
|
|
// websocket/notifications.gateway.ts
|
|
@WebSocketGateway()
|
|
export class NotificationsGateway {
|
|
@SubscribeMessage('subscribe')
|
|
handleSubscribe(
|
|
@ConnectedSocket() client: Socket,
|
|
@MessageBody() data: SubscribeDto,
|
|
) {
|
|
try {
|
|
// Validar y suscribir...
|
|
} catch (error) {
|
|
client.emit('error', {
|
|
code: 'SUBSCRIPTION_FAILED',
|
|
message: error.message,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Testing de Errores
|
|
|
|
```typescript
|
|
describe('UsersService', () => {
|
|
describe('findOne', () => {
|
|
it('should throw NotFoundException when user not found', async () => {
|
|
jest.spyOn(repository, 'findOne').mockResolvedValue(null);
|
|
|
|
await expect(service.findOne('non-existent-id'))
|
|
.rejects
|
|
.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('should return user when found', async () => {
|
|
const mockUser = { id: 'uuid', email: 'test@example.com' };
|
|
jest.spyOn(repository, 'findOne').mockResolvedValue(mockUser);
|
|
|
|
const result = await service.findOne('uuid');
|
|
expect(result).toEqual(mockUser);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Buenas Prácticas
|
|
|
|
1. **Nunca exponer stack traces** en producción
|
|
2. **Usar códigos de error** consistentes
|
|
3. **Loggear contexto suficiente** para debugging
|
|
4. **Re-lanzar HttpExceptions** sin modificar
|
|
5. **Convertir errores internos** a excepciones HTTP apropiadas
|
|
6. **Validar temprano** en la capa de controlador
|
|
7. **Mensajes de error** claros y accionables
|
|
|
|
---
|
|
|
|
## Ver También
|
|
|
|
- [API-CONVENTIONS.md](./API-CONVENTIONS.md) - Convenciones de API
|
|
- [ESTRUCTURA-SHARED.md](./ESTRUCTURA-SHARED.md) - Filtros y excepciones compartidas
|