# ET-BACKEND-MGN-001-005: Reset de Contraseña **RF Asociado:** [RF-MGN-001-005](../../requerimientos-funcionales/mgn-001/RF-MGN-001-005-reset-de-contraseña.md) **Módulo:** MGN-001 **Complejidad:** Baja **Story Points:** 2 SP (Backend) **Estado:** Diseñado **Fecha:** 2025-11-24 ## Resumen Técnico Backend API para reset de contraseña. Implementa endpoints RESTful con NestJS, validaciones de negocio, persistencia con Prisma ORM y soporte multi-tenancy con Row Level Security (RLS). ## Stack Tecnológico - **Runtime:** Node.js 20 LTS - **Framework:** NestJS 10.x - **ORM:** Prisma 5.x (con multi-schema support) - **Validación:** class-validator + class-transformer - **Autenticación:** JWT (Passport.js) - **Base de Datos:** PostgreSQL 16 (multi-schema + RLS) - **Testing:** Jest + Supertest - **Documentación:** Swagger/OpenAPI 3.0 ## Arquitectura del Endpoint ### API Endpoints #### POST /api/v1/auth/reset-password ```typescript // Request POST /api/v1/auth/reset-password Authorization: Bearer Content-Type: application/json { // Payload según RF } // Response 201 Created { "data": { ... }, "message": "ResetContraseña creado exitosamente", "timestamp": "2025-11-24T..." } // Response 400 Bad Request { "error": "ValidationError", "message": "Datos inválidos", "details": [...] } // Response 403 Forbidden { "error": "ForbiddenError", "message": "Sin permisos para crear resetcontraseña" } ``` #### GET /api/v1/auth/reset-password ```typescript // Request GET /api/v1/auth/reset-password?page=1&limit=20&sortBy=createdAt&sortOrder=desc Authorization: Bearer // Response 200 OK { "data": [...], "meta": { "page": 1, "limit": 20, "total": 100, "totalPages": 5 } } ``` #### GET /api/v1/auth/reset-password/:id ```typescript // Request GET /api/v1/auth/reset-password/{id} Authorization: Bearer // Response 200 OK { "data": { ... } } // Response 404 Not Found { "error": "NotFoundError", "message": "ResetContraseña no encontrado" } ``` #### PUT /api/v1/auth/reset-password/:id ```typescript // Request PUT /api/v1/auth/reset-password/{id} Authorization: Bearer Content-Type: application/json { // Payload de actualización } // Response 200 OK { "data": { ... }, "message": "ResetContraseña actualizado exitosamente" } ``` #### DELETE /api/v1/auth/reset-password/:id ```typescript // Request DELETE /api/v1/auth/reset-password/{id} Authorization: Bearer // Response 200 OK (soft delete) { "message": "ResetContraseña eliminado exitosamente" } // Response 409 Conflict { "error": "ConflictError", "message": "No se puede eliminar: tiene registros relacionados" } ``` ## Modelo de Datos (Prisma Schema) ```prisma // Basado en el DDL SQL: auth-schema-ddl.sql model ResetContraseña { id String @id @default(uuid()) tenantId String @map("tenant_id") // Campos específicos del negocio name String @db.VarChar(255) code String? @db.VarChar(50) // Auditoría createdAt DateTime @default(now()) @map("created_at") createdBy String? @map("created_by") updatedAt DateTime? @updatedAt @map("updated_at") updatedBy String? @map("updated_by") deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") // Relations tenant Tenant @relation(fields: [tenantId], references: [id]) @@map("auth.password_reset_tokens (token, user_id, expires_at, used_at)") @@index([tenantId]) @@unique([tenantId, code]) } ``` ## DTOs (Data Transfer Objects) ### Create DTO ```typescript // src/modules/mgn-001/dto/create-resetcontraseña.dto.ts import { IsString, IsEmail, IsOptional, MinLength, MaxLength, IsUUID } from 'class-validator'; export class CreateResetContraseñaDto { @IsString() @MinLength(3) @MaxLength(255) name: string; @IsString() @IsOptional() @MaxLength(50) code?: string; // Campos adicionales según RF } ``` ### Update DTO ```typescript // src/modules/mgn-001/dto/update-resetcontraseña.dto.ts import { PartialType } from '@nestjs/mapped-types'; import { CreateResetContraseñaDto } from './create-resetcontraseña.dto'; export class UpdateResetContraseñaDto extends PartialType(CreateResetContraseñaDto) {} ``` ### Response DTO ```typescript // src/modules/mgn-001/dto/resetcontraseña-response.dto.ts export class ResetContraseñaResponseDto { id: string; tenantId: string; name: string; code?: string; createdAt: Date; updatedAt?: Date; } ``` ### Filter DTO ```typescript // src/modules/mgn-001/dto/filter-resetcontraseña.dto.ts import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; import { Type } from 'class-transformer'; export class FilterResetContraseñaDto { @IsOptional() @IsString() search?: string; @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1; @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit?: number = 20; @IsOptional() @IsString() sortBy?: string = 'createdAt'; @IsOptional() @IsString() sortOrder?: 'asc' | 'desc' = 'desc'; } ``` ## Servicios de Negocio ### Service Principal ```typescript // src/modules/mgn-001/services/resetcontraseña.service.ts import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; import { PrismaService } from '@shared/prisma/prisma.service'; import { CreateResetContraseñaDto, UpdateResetContraseñaDto, FilterResetContraseñaDto } from '../dto'; @Injectable() export class ResetContraseñaService { constructor(private readonly prisma: PrismaService) {} async create(tenantId: string, userId: string, dto: CreateResetContraseñaDto) { // 1. Validaciones de negocio await this.validateBusinessRules(tenantId, dto); // 2. Verificar duplicados if (dto.code) { const existing = await this.prisma.resetcontraseña.findFirst({ where: { tenantId, code: dto.code, deletedAt: null, }, }); if (existing) { throw new ConflictException(`ResetContraseña con código ${dto.code} ya existe`); } } // 3. Crear en BD (RLS automático por tenantId) const entity = await this.prisma.resetcontraseña.create({ data: { tenantId, createdBy: userId, ...dto, }, }); // 4. Eventos/side effects (si aplica) // await this.eventEmitter.emit('resetcontraseña.created', entity); return entity; } async findAll(tenantId: string, filters: FilterResetContraseñaDto) { const { page, limit, sortBy, sortOrder, search } = filters; const skip = (page - 1) * limit; const where = { tenantId, deletedAt: null, ...(search && { OR: [ { name: { contains: search, mode: 'insensitive' } }, { code: { contains: search, mode: 'insensitive' } }, ], }), }; const [data, total] = await Promise.all([ this.prisma.resetcontraseña.findMany({ where, skip, take: limit, orderBy: { [sortBy]: sortOrder }, }), this.prisma.resetcontraseña.count({ where }), ]); return { data, meta: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; } async findOne(tenantId: string, id: string) { const entity = await this.prisma.resetcontraseña.findFirst({ where: { id, tenantId, deletedAt: null, }, }); if (!entity) { throw new NotFoundException(`ResetContraseña con ID ${id} no encontrado`); } return entity; } async update(tenantId: string, userId: string, id: string, dto: UpdateResetContraseñaDto) { // 1. Verificar existencia await this.findOne(tenantId, id); // 2. Validar cambios await this.validateBusinessRules(tenantId, dto, id); // 3. Actualizar const entity = await this.prisma.resetcontraseña.update({ where: { id }, data: { ...dto, updatedBy: userId, updatedAt: new Date(), }, }); // 4. Eventos // await this.eventEmitter.emit('resetcontraseña.updated', entity); return entity; } async remove(tenantId: string, userId: string, id: string) { // 1. Verificar existencia await this.findOne(tenantId, id); // 2. Verificar dependencias (opcional) // await this.checkDependencies(id); // 3. Soft delete await this.prisma.resetcontraseña.update({ where: { id }, data: { deletedAt: new Date(), deletedBy: userId, }, }); // 4. Eventos // await this.eventEmitter.emit('resetcontraseña.deleted', { id }); } private async validateBusinessRules( tenantId: string, dto: Partial, excludeId?: string, ) { // Implementar reglas de negocio del RF // Ejemplo: // if (dto.field && !await this.isValidField(dto.field)) { // throw new BadRequestException('Field inválido'); // } } } ``` ## Controller ```typescript // src/modules/mgn-001/controllers/resetcontraseña.controller.ts import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; import { PermissionsGuard } from '@modules/auth/guards/permissions.guard'; import { RequirePermissions } from '@shared/decorators/require-permissions.decorator'; import { CurrentTenant } from '@shared/decorators/current-tenant.decorator'; import { CurrentUser } from '@shared/decorators/current-user.decorator'; import { ResetContraseñaService } from '../services/resetcontraseña.service'; import { CreateResetContraseñaDto, UpdateResetContraseñaDto, FilterResetContraseñaDto, ResetContraseñaResponseDto } from '../dto'; @ApiTags('ResetContraseña') @ApiBearerAuth() @Controller('api/v1/auth/reset-password') @UseGuards(JwtAuthGuard, PermissionsGuard) export class ResetContraseñaController { constructor(private readonly service: ResetContraseñaService) {} @Post() @RequirePermissions('mgn-001.resetcontraseña.create') @ApiOperation({ summary: 'Crear resetcontraseña' }) @ApiResponse({ status: 201, description: 'ResetContraseña creado exitosamente', type: ResetContraseñaResponseDto }) @ApiResponse({ status: 400, description: 'Datos inválidos' }) @ApiResponse({ status: 403, description: 'Sin permisos' }) async create( @CurrentTenant() tenantId: string, @CurrentUser('id') userId: string, @Body() dto: CreateResetContraseñaDto, ) { const entity = await this.service.create(tenantId, userId, dto); return { data: entity, message: 'ResetContraseña creado exitosamente', }; } @Get() @RequirePermissions('mgn-001.resetcontraseña.read') @ApiOperation({ summary: 'Listar resetcontraseñas' }) @ApiResponse({ status: 200, description: 'Lista de resetcontraseñas', type: [ResetContraseñaResponseDto] }) async findAll( @CurrentTenant() tenantId: string, @Query() filters: FilterResetContraseñaDto, ) { return this.service.findAll(tenantId, filters); } @Get(':id') @RequirePermissions('mgn-001.resetcontraseña.read') @ApiOperation({ summary: 'Obtener resetcontraseña por ID' }) @ApiResponse({ status: 200, description: 'ResetContraseña encontrado', type: ResetContraseñaResponseDto }) @ApiResponse({ status: 404, description: 'ResetContraseña no encontrado' }) async findOne( @CurrentTenant() tenantId: string, @Param('id') id: string, ) { const entity = await this.service.findOne(tenantId, id); return { data: entity }; } @Put(':id') @RequirePermissions('mgn-001.resetcontraseña.update') @ApiOperation({ summary: 'Actualizar resetcontraseña' }) @ApiResponse({ status: 200, description: 'ResetContraseña actualizado exitosamente' }) @ApiResponse({ status: 404, description: 'ResetContraseña no encontrado' }) async update( @CurrentTenant() tenantId: string, @CurrentUser('id') userId: string, @Param('id') id: string, @Body() dto: UpdateResetContraseñaDto, ) { const entity = await this.service.update(tenantId, userId, id, dto); return { data: entity, message: 'ResetContraseña actualizado exitosamente', }; } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) @RequirePermissions('mgn-001.resetcontraseña.delete') @ApiOperation({ summary: 'Eliminar resetcontraseña' }) @ApiResponse({ status: 204, description: 'ResetContraseña eliminado exitosamente' }) @ApiResponse({ status: 404, description: 'ResetContraseña no encontrado' }) async remove( @CurrentTenant() tenantId: string, @CurrentUser('id') userId: string, @Param('id') id: string, ) { await this.service.remove(tenantId, userId, id); } } ``` ## Reglas de Negocio (Implementación) ### RN-1: Token de reset expira en 24 horas ```typescript private async validateRN1(dto: Partial): Promise { // Implementación de la regla: Token de reset expira en 24 horas // TODO: Implementar validación específica } ``` ### RN-2: Token solo puede usarse una vez ```typescript private async validateRN2(dto: Partial): Promise { // Implementación de la regla: Token solo puede usarse una vez // TODO: Implementar validación específica } ``` ### RN-3: Nueva contraseña debe cumplir política de seguridad ```typescript private async validateRN3(dto: Partial): Promise { // Implementación de la regla: Nueva contraseña debe cumplir política de seguridad // TODO: Implementar validación específica } ``` ### RN-4: Sistema NO revela si email existe o no (prevenir enumeración) ```typescript private async validateRN4(dto: Partial): Promise { // Implementación de la regla: Sistema NO revela si email existe o no (prevenir enumeración) // TODO: Implementar validación específica } ``` ### RN-5: Múltiples solicitudes de reset invalidan tokens anteriores ```typescript private async validateRN5(dto: Partial): Promise { // Implementación de la regla: Múltiples solicitudes de reset invalidan tokens anteriores // TODO: Implementar validación específica } ``` ## Seguridad ### Multi-Tenancy (RLS) - Prisma middleware automático que agrega `tenantId` a todas las queries - Row Level Security en PostgreSQL valida acceso - Soft delete: usa `deletedAt` en lugar de borrado físico ### Permisos (RBAC) ```typescript @UseGuards(JwtAuthGuard, PermissionsGuard) @RequirePermissions('mgn-001.resetcontraseña.create') @Post() async create(...) { ... } ``` Permisos requeridos: - `mgn-001.resetcontraseña.create` - Crear registros - `mgn-001.resetcontraseña.read` - Leer registros - `mgn-001.resetcontraseña.update` - Actualizar registros - `mgn-001.resetcontraseña.delete` - Eliminar registros ### Validación de Input - DTOs con class-validator para validación automática - Sanitización de strings (XSS prevention) - Rate limiting: 100 requests/min por usuario - SQL Injection prevention: Prisma ORM con prepared statements ### Auditoría Todos los cambios registran: - `createdBy` / `updatedBy` / `deletedBy` - Usuario que ejecuta acción - `createdAt` / `updatedAt` / `deletedAt` - Timestamp de acción - Integración con MGN-014 (Tracking Automático de Cambios) ## Testing ### Unit Tests ```typescript // resetcontraseña.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { ResetContraseñaService } from './resetcontraseña.service'; import { PrismaService } from '@shared/prisma/prisma.service'; import { NotFoundException, ConflictException } from '@nestjs/common'; describe('ResetContraseñaService', () => { let service: ResetContraseñaService; let prisma: PrismaService; const mockPrisma = { resetcontraseña: { create: jest.fn(), findMany: jest.fn(), findFirst: jest.fn(), update: jest.fn(), count: jest.fn(), }, }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ResetContraseñaService, { provide: PrismaService, useValue: mockPrisma }, ], }).compile(); service = module.get(ResetContraseñaService); prisma = module.get(PrismaService); }); afterEach(() => { jest.clearAllMocks(); }); describe('create', () => { it('should create resetcontraseña with valid data', async () => { const tenantId = 'tenant-123'; const userId = 'user-456'; const dto = { name: 'Test ResetContraseña', code: 'TEST' }; const expected = { id: 'id-789', tenantId, ...dto }; mockPrisma.resetcontraseña.findFirst.mockResolvedValue(null); mockPrisma.resetcontraseña.create.mockResolvedValue(expected); const result = await service.create(tenantId, userId, dto); expect(result).toEqual(expected); expect(mockPrisma.resetcontraseña.create).toHaveBeenCalledWith({ data: { tenantId, createdBy: userId, ...dto }, }); }); it('should throw ConflictException when code already exists', async () => { const tenantId = 'tenant-123'; const userId = 'user-456'; const dto = { name: 'Test ResetContraseña', code: 'DUPLICATE' }; mockPrisma.resetcontraseña.findFirst.mockResolvedValue({ id: 'existing' }); await expect(service.create(tenantId, userId, dto)).rejects.toThrow(ConflictException); }); }); describe('findOne', () => { it('should return resetcontraseña when found', async () => { const tenantId = 'tenant-123'; const id = 'id-789'; const expected = { id, tenantId, name: 'Test' }; mockPrisma.resetcontraseña.findFirst.mockResolvedValue(expected); const result = await service.findOne(tenantId, id); expect(result).toEqual(expected); }); it('should throw NotFoundException when not found', async () => { const tenantId = 'tenant-123'; const id = 'non-existent'; mockPrisma.resetcontraseña.findFirst.mockResolvedValue(null); await expect(service.findOne(tenantId, id)).rejects.toThrow(NotFoundException); }); }); describe('update', () => { it('should update resetcontraseña successfully', async () => { const tenantId = 'tenant-123'; const userId = 'user-456'; const id = 'id-789'; const dto = { name: 'Updated Name' }; const existing = { id, tenantId, name: 'Old Name' }; const expected = { ...existing, ...dto }; mockPrisma.resetcontraseña.findFirst.mockResolvedValue(existing); mockPrisma.resetcontraseña.update.mockResolvedValue(expected); const result = await service.update(tenantId, userId, id, dto); expect(result).toEqual(expected); }); }); describe('remove', () => { it('should soft delete resetcontraseña', async () => { const tenantId = 'tenant-123'; const userId = 'user-456'; const id = 'id-789'; const existing = { id, tenantId, name: 'To Delete' }; mockPrisma.resetcontraseña.findFirst.mockResolvedValue(existing); mockPrisma.resetcontraseña.update.mockResolvedValue({ ...existing, deletedAt: new Date() }); await service.remove(tenantId, userId, id); expect(mockPrisma.resetcontraseña.update).toHaveBeenCalledWith({ where: { id }, data: { deletedAt: expect.any(Date), deletedBy: userId, }, }); }); }); }); ``` ### Integration Tests (e2e) ```typescript // resetcontraseña.controller.e2e-spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '@/app.module'; describe('ResetContraseñaController (e2e)', () => { let app: INestApplication; let authToken: string; let tenantId: string; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); // Login para obtener token const loginResponse = await request(app.getHttpServer()) .post('/api/v1/auth/login') .send({ email: 'test@example.com', password: 'Test1234!' }); authToken = loginResponse.body.data.accessToken; tenantId = loginResponse.body.data.user.tenantId; }); afterAll(async () => { await app.close(); }); describe('POST /api/v1/auth/reset-password', () => { it('should create resetcontraseña successfully', () => { return request(app.getHttpServer()) .post('/api/v1/auth/reset-password') .set('Authorization', `Bearer ${authToken}`) .send({ name: 'Test ResetContraseña', code: 'TEST001', }) .expect(201) .expect((res) => { expect(res.body.data).toHaveProperty('id'); expect(res.body.data.name).toBe('Test ResetContraseña'); expect(res.body.message).toContain('creado exitosamente'); }); }); it('should return 400 for invalid data', () => { return request(app.getHttpServer()) .post('/api/v1/auth/reset-password') .set('Authorization', `Bearer ${authToken}`) .send({ name: '' }) // Invalid: name too short .expect(400); }); it('should return 401 without token', () => { return request(app.getHttpServer()) .post('/api/v1/auth/reset-password') .send({ name: 'Test' }) .expect(401); }); }); describe('GET /api/v1/auth/reset-password', () => { it('should return list of resetcontraseñas', () => { return request(app.getHttpServer()) .get('/api/v1/auth/reset-password') .set('Authorization', `Bearer ${authToken}`) .expect(200) .expect((res) => { expect(res.body.data).toBeInstanceOf(Array); expect(res.body.meta).toHaveProperty('page'); expect(res.body.meta).toHaveProperty('total'); }); }); it('should filter by search term', () => { return request(app.getHttpServer()) .get('/api/v1/auth/reset-password?search=test') .set('Authorization', `Bearer ${authToken}`) .expect(200); }); }); describe('GET /api/v1/auth/reset-password/:id', () => { it('should return resetcontraseña by ID', async () => { // Crear primero const createRes = await request(app.getHttpServer()) .post('/api/v1/auth/reset-password') .set('Authorization', `Bearer ${authToken}`) .send({ name: 'Find By ID Test' }); const id = createRes.body.data.id; return request(app.getHttpServer()) .get(`/api/v1/auth/reset-password/${id}`) .set('Authorization', `Bearer ${authToken}`) .expect(200) .expect((res) => { expect(res.body.data.id).toBe(id); }); }); it('should return 404 for non-existent ID', () => { return request(app.getHttpServer()) .get('/api/v1/auth/reset-password/non-existent-id') .set('Authorization', `Bearer ${authToken}`) .expect(404); }); }); describe('PUT /api/v1/auth/reset-password/:id', () => { it('should update resetcontraseña successfully', async () => { // Crear primero const createRes = await request(app.getHttpServer()) .post('/api/v1/auth/reset-password') .set('Authorization', `Bearer ${authToken}`) .send({ name: 'Original Name' }); const id = createRes.body.data.id; return request(app.getHttpServer()) .put(`/api/v1/auth/reset-password/${id}`) .set('Authorization', `Bearer ${authToken}`) .send({ name: 'Updated Name' }) .expect(200) .expect((res) => { expect(res.body.data.name).toBe('Updated Name'); }); }); }); describe('DELETE /api/v1/auth/reset-password/:id', () => { it('should soft delete resetcontraseña', async () => { // Crear primero const createRes = await request(app.getHttpServer()) .post('/api/v1/auth/reset-password') .set('Authorization', `Bearer ${authToken}`) .send({ name: 'To Delete' }); const id = createRes.body.data.id; await request(app.getHttpServer()) .delete(`/api/v1/auth/reset-password/${id}`) .set('Authorization', `Bearer ${authToken}`) .expect(204); // Verificar que no aparece en listados const listRes = await request(app.getHttpServer()) .get('/api/v1/auth/reset-password') .set('Authorization', `Bearer ${authToken}`); const deleted = listRes.body.data.find(item => item.id === id); expect(deleted).toBeUndefined(); }); }); }); ``` ## Performance ### Índices Necesarios ```sql -- Índices definidos en auth-schema-ddl.sql CREATE INDEX idx_resetcontraseña_tenant_code ON auth.resetcontraseña(tenant_id, code); CREATE INDEX idx_resetcontraseña_tenant_name ON auth.resetcontraseña(tenant_id, name); CREATE INDEX idx_resetcontraseña_created_at ON auth.resetcontraseña(created_at DESC); -- Para soft delete CREATE INDEX idx_resetcontraseña_deleted_at ON auth.resetcontraseña(deleted_at) WHERE deleted_at IS NULL; ``` ### Caching (Redis) ```typescript // Para listas maestras estáticas @Cacheable({ ttl: 3600 }) // 1 hora async findAll(tenantId: string, filters: FilterResetContraseñaDto) { // ... } // Invalidación en cambios @CacheEvict({ pattern: 'resetcontraseña:*' }) async update(...) { ... } @CacheEvict({ pattern: 'resetcontraseña:*' }) async remove(...) { ... } ``` ### Paginación - **Default:** 20 items por página - **Máximo:** 100 items por página - **Cursor-based:** Para listados grandes (opcional) ### Query Optimization ```typescript // Usar select para reducir payload async findAll(tenantId: string, filters: FilterResetContraseñaDto) { return this.prisma.resetcontraseña.findMany({ where: { ... }, select: { id: true, name: true, code: true, createdAt: true, // Omitir campos pesados }, }); } ``` ## Logging y Monitoreo ```typescript import { Logger } from '@nestjs/common'; export class ResetContraseñaService { private readonly logger = new Logger(ResetContraseñaService.name); async create(tenantId: string, userId: string, dto: CreateResetContraseñaDto) { this.logger.log({ action: 'resetcontraseña.create', tenantId, userId, data: dto, }); try { const entity = await this.prisma.resetcontraseña.create({ ... }); this.logger.log({ action: 'resetcontraseña.created', tenantId, userId, entityId: entity.id, }); return entity; } catch (error) { this.logger.error({ action: 'resetcontraseña.create.error', tenantId, userId, error: error.message, stack: error.stack, }); throw error; } } } ``` ### Métricas (Prometheus) - `http_requests_total{method="POST",endpoint="/api/v1/auth/reset-password",status="201"}` - `http_request_duration_seconds{endpoint="/api/v1/auth/reset-password"}` - `resetcontraseña_created_total{tenant_id="xxx"}` ## Referencias - [RF Asociado](../../requerimientos-funcionales/mgn-001/RF-MGN-001-005-reset-de-contraseña.md) - [Database Schema](../database-design/schemas/auth-schema-ddl.sql) - [Domain Model](../domain-models/auth-domain.md) - [ADR-001: Stack Tecnológico](../../adr/ADR-001-stack-tecnologico.md) - [ADR-005: API Design](../../adr/ADR-005-api-design.md) ## Dependencias ### Módulos NestJS - `AuthModule` - Autenticación y autorización - `PrismaModule` - ORM y acceso a BD - `LoggerModule` - Logging centralizado - `EventsModule` - Event emitter (opcional) ### RF Bloqueantes - RF-MGN-001-001 (Autenticación de Usuarios) - RF-MGN-001-004 (Multi-Tenancy) ### RF Dependientes - [Listar RF que dependen de este] ## Notas de Implementación - [ ] Crear módulo NestJS: `nest g module mgn-001/resetcontraseña` - [ ] Crear service: `nest g service mgn-001/resetcontraseña` - [ ] Crear controller: `nest g controller mgn-001/resetcontraseña` - [ ] Crear DTOs en `mgn-001/dto/` folder - [ ] Definir Prisma model en `schema.prisma` - [ ] Implementar unit tests (>80% coverage) - [ ] Implementar e2e tests - [ ] Documentar endpoints en Swagger - [ ] Validar con criterios de aceptación del RF - [ ] Code review por Tech Lead - [ ] QA testing ## Estimación - **Backend Development:** 2 SP - **Testing (Unit + e2e):** 1 SP - **Code Review + QA:** 1 SP - **Total:** 5 SP --- **Documento generado:** 2025-11-24 **Versión:** 1.0 **Estado:** Diseñado **Próximo paso:** Implementación