# US-FUND-002: Perfiles de Usuario de Construcción **Épica:** MAI-001 - Fundamentos **Sprint:** Sprint 1-2 (Semanas 1-2) **Story Points:** 5 SP **Presupuesto:** $1,800 MXN **Prioridad:** Alta **Estado:** 🚧 Planificado --- ## Descripción Como **usuario del sistema de gestión de obra**, quiero **ver y editar mi perfil profesional** para **mantener mi información de contacto actualizada y mostrar mi rol en la constructora**. **Contexto del Alcance Inicial:** El MVP incluye perfiles básicos con información esencial para construcción: nombre, email, rol en constructora(s), foto, teléfono. No incluye currículum, certificaciones, historial de proyectos o configuraciones avanzadas, que se agregarán en extensiones futuras. **Diferencias con GAMILIT:** - Multi-tenancy: Usuario puede tener diferentes roles en diferentes constructoras - Información adicional: teléfono, especialidad (para ingenieros/residentes) - Sin gamificación (no hay XP, coins, badges en perfil) --- ## Criterios de Aceptación - [ ] **CA-01:** El usuario puede ver su perfil con: nombre completo, email, teléfono, foto, constructoras asociadas - [ ] **CA-02:** El usuario puede editar: fullName, phone, foto de perfil - [ ] **CA-03:** El email NO es editable (requeriría re-verificación) - [ ] **CA-04:** El rol NO es editable por el usuario (solo admin puede cambiar) - [ ] **CA-05:** La foto de perfil puede subirse (max 5MB, formatos: jpg, png, webp) - [ ] **CA-06:** Si no hay foto, se muestra avatar por defecto (iniciales del nombre) - [ ] **CA-07:** Se muestra lista de constructoras donde el usuario tiene acceso con su rol en cada una - [ ] **CA-08:** Los cambios se guardan en la base de datos - [ ] **CA-09:** Se muestra mensaje de confirmación al guardar cambios - [ ] **CA-10:** Se valida que fullName no esté vacío - [ ] **CA-11:** Se valida formato de teléfono (10 dígitos, México) - [ ] **CA-12:** La foto se redimensiona automáticamente a 200x200px - [ ] **CA-13:** Usuario puede marcar una constructora como "principal" (pre-seleccionada al login) --- ## Especificaciones Técnicas ### Backend (NestJS) **Endpoints:** ``` GET /api/user/profile - Headers: Authorization: Bearer {token} - Response: { user: { id, email, fullName, phone, photoUrl, createdAt }, constructoras: [ { constructoraId, nombre, logoUrl, role, isPrimary, status } ] } PATCH /api/user/profile - Headers: Authorization: Bearer {token} - Body: { fullName?, phone? } - Response: { user: { ... } } POST /api/user/profile/photo - Headers: Authorization: Bearer {token}, Content-Type: multipart/form-data - Body: FormData with 'photo' file - Response: { photoUrl: string } DELETE /api/user/profile/photo - Headers: Authorization: Bearer {token} - Response: { message: "Photo deleted" } PATCH /api/user/set-primary-constructora - Headers: Authorization: Bearer {token} - Body: { constructoraId: string } - Response: { message: "Primary constructora updated" } ``` **Servicios:** - **ProfileService:** Gestión de perfiles de usuario - **FileUploadService:** Manejo de uploads de imágenes (reutilizado) - **ImageProcessingService:** Redimensionamiento de imágenes (reutilizado) - **ConstructoraService:** Obtener constructoras del usuario **Entidades:** ```typescript // Perfil global @Entity('profiles', { schema: 'auth_management' }) class Profile { @PrimaryGeneratedColumn('uuid') id: string; @Column({ unique: true }) email: string; @Column({ name: 'full_name' }) fullName: string; @Column({ nullable: true }) phone: string; @Column({ name: 'photo_url', nullable: true }) photoUrl?: string; @Column({ name: 'photo_key', nullable: true }) photoKey?: string; // Para storage local o S3 @Column({ type: 'enum', enum: UserStatus, default: 'pending' }) status: UserStatus; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } // Relación con constructoras (ya existe) @Entity('user_constructoras', { schema: 'auth_management' }) class UserConstructora { // ... campos existentes de ET-AUTH-003 role: ConstructionRole; isPrimary: boolean; status: UserStatus; } ``` **Validaciones:** ```typescript // apps/backend/src/modules/user/dto/update-profile.dto.ts import { IsString, IsOptional, Length, Matches } from 'class-validator'; export class UpdateProfileDto { @IsString() @IsOptional() @Length(3, 255) fullName?: string; @IsString() @IsOptional() @Matches(/^[0-9]{10}$/, { message: 'Teléfono debe tener 10 dígitos (formato México)', }) phone?: string; } ``` ### Frontend (React + Vite) **Componentes:** ```typescript // apps/frontend/src/features/profile/ProfileView.tsx - Muestra información del usuario (solo lectura) - Lista de constructoras con badges de rol - Badge especial para constructora principal - Botón "Editar perfil" // apps/frontend/src/features/profile/ProfileEditForm.tsx - Formulario de edición con React Hook Form + Zod - Campos: fullName, phone - Validación en tiempo real - Preview de cambios antes de guardar // apps/frontend/src/features/profile/PhotoUpload.tsx - Upload drag-and-drop con react-dropzone - Preview de imagen - Crop/ajuste de imagen (opcional en MVP) - Indicador de progreso // apps/frontend/src/components/ui/AvatarWithInitials.tsx - Avatar con iniciales si no hay foto - Colores generados por hash del nombre - Tamaños: sm, md, lg, xl // apps/frontend/src/features/profile/ConstructorasList.tsx - Lista de constructoras del usuario - Badge de rol (director, engineer, etc.) - Icono de estrella para constructora principal - Click para marcar como principal ``` **Rutas:** ```typescript /profile → Página de perfil (vista) /profile/edit → Editar perfil /settings/account → Configuración de cuenta (incluye perfil) ``` **Estado (Zustand):** ```typescript // apps/frontend/src/stores/profile-store.ts interface ProfileStore { profile: UserProfile | null; constructoras: UserConstructoraAccess[]; loading: boolean; uploadingPhoto: boolean; // Actions fetchProfile: () => Promise; updateProfile: (data: UpdateProfileDto) => Promise; uploadPhoto: (file: File) => Promise; deletePhoto: () => Promise; setPrimaryConstructora: (constructoraId: string) => Promise; } interface UserProfile { id: string; email: string; fullName: string; phone: string | null; photoUrl: string | null; createdAt: string; } interface UserConstructoraAccess { constructoraId: string; nombre: string; logoUrl: string | null; role: ConstructionRole; isPrimary: boolean; status: UserStatus; } ``` **UI/UX:** - Card con información del usuario - Grid de 2 columnas: Info personal | Constructoras - Botones: "Editar perfil", "Cambiar foto" - Upload drag-and-drop con preview - Loading states durante operaciones - Toast notifications para confirmaciones ### Almacenamiento de Archivos **Opción Inicial (Alcance MVP):** - Archivos guardados localmente en `/uploads/profile-photos/` - Nombres generados con UUID: `{userId}-{timestamp}-{uuid}.jpg` - Public URL servida por Express: `/static/profile-photos/{photoKey}` - Organización: `/uploads/profile-photos/YYYY/MM/` (por mes) **Limpieza:** - Al subir nueva foto, eliminar foto anterior del disco - Orphan cleanup job: eliminar fotos sin usuario (cron semanal) **Opción Futura:** - Migración a AWS S3 o CloudFlare R2 (Fase 2) --- ## Dependencias **Antes:** - ✅ US-FUND-001 (Autenticación JWT - requiere usuario autenticado) - ✅ RF-AUTH-003 (Multi-tenancy - necesita constructoras) **Después:** - US-FUND-003 (Dashboard - muestra foto de perfil y nombre) - Todas las historias usan la foto y nombre del usuario **Bloqueos:** - Ninguno (puede implementarse en Sprint 1-2) --- ## Definición de Hecho (DoD) - [ ] Endpoints implementados y documentados (Swagger) - [ ] Validaciones en backend (DTO, file size, file type) - [ ] Upload de archivos funcional con Sharp - [ ] Redimensionamiento automático a 200x200px - [ ] Componentes de frontend implementados - [ ] Zustand store con acciones de perfil - [ ] Tests unitarios backend (>80% coverage) - [ ] Tests E2E para edición de perfil - [ ] Tests frontend (React Testing Library) - [ ] Responsive design (mobile, tablet, desktop) - [ ] Manejo de errores (file too large, invalid format, network error) - [ ] Loading states y feedback visual - [ ] Documentación de API en Swagger - [ ] Code review aprobado - [ ] Desplegado en staging y validado --- ## Notas del Alcance Inicial ### Incluido en MVP ✅ - ✅ Campos básicos: fullName, email, phone, photo - ✅ Lista de constructoras con roles - ✅ Marcar constructora como principal - ✅ Upload y preview de foto - ✅ Avatar con iniciales si no hay foto - ✅ Storage local de imágenes ### NO Incluido en MVP ❌ - ❌ Currículum vitae o bio extensa - ❌ Certificaciones profesionales (ingeniero civil, arquitecto, etc.) - ❌ Historial de proyectos completados - ❌ Estadísticas de desempeño - ❌ Configuraciones de privacidad - ❌ Redes sociales (LinkedIn, etc.) - ❌ Preferencias de notificaciones (en US-FUND-005) - ❌ Crop avanzado de imagen (solo redimensionamiento) - ❌ Storage en la nube (S3) ### Extensiones Futuras ⚠️ - ⚠️ **Fase 2:** Perfil profesional extendido (bio, certificaciones, experiencia) - ⚠️ **Fase 2:** Historial de proyectos y métricas - ⚠️ **Fase 2:** Integración con LinkedIn - ⚠️ **Fase 2:** Migración a S3 para storage --- ## Tareas de Implementación ### Backend (Estimado: 10h) **Total Backend:** 10h (~2.5 SP) - [ ] **Tarea B.1:** Endpoints de perfil - Estimado: 4h - [ ] Subtarea B.1.1: GET /user/profile con datos de perfil + constructoras - 1.5h - [ ] Subtarea B.1.2: PATCH /user/profile con validación de DTO - 1h - [ ] Subtarea B.1.3: PATCH /user/set-primary-constructora - 1h - [ ] Subtarea B.1.4: Documentación Swagger de endpoints - 0.5h - [ ] **Tarea B.2:** Sistema de upload de archivos - Estimado: 4h - [ ] Subtarea B.2.1: Reutilizar FileUploadService de GAMILIT - 0.5h - [ ] Subtarea B.2.2: POST /user/profile/photo con validación (5MB, jpg/png/webp) - 1.5h - [ ] Subtarea B.2.3: DELETE /user/profile/photo y cleanup de archivo - 1h - [ ] Subtarea B.2.4: Organización por mes: /uploads/profile-photos/YYYY/MM/ - 0.5h - [ ] Subtarea B.2.5: Cleanup de foto anterior al subir nueva - 0.5h - [ ] **Tarea B.3:** Procesamiento de imágenes - Estimado: 2h - [ ] Subtarea B.3.1: Reutilizar ImageProcessingService con Sharp - 0.5h - [ ] Subtarea B.3.2: Redimensionamiento a 200x200 manteniendo aspect ratio - 1h - [ ] Subtarea B.3.3: Optimización de calidad y compresión - 0.5h ### Frontend (Estimado: 7h) **Total Frontend:** 7h (~1.75 SP) - [ ] **Tarea F.1:** Componentes de perfil - Estimado: 4h - [ ] Subtarea F.1.1: ProfileView con grid de info personal + constructoras - 1.5h - [ ] Subtarea F.1.2: ProfileEditForm con React Hook Form + Zod - 1.5h - [ ] Subtarea F.1.3: AvatarWithInitials reutilizado de GAMILIT - 0.5h - [ ] Subtarea F.1.4: ConstructorasList con badges de rol y estrella primary - 1h - [ ] Subtarea F.1.5: Navegación /profile y /profile/edit - 0.5h - [ ] **Tarea F.2:** Upload de foto de perfil - Estimado: 2h - [ ] Subtarea F.2.1: PhotoUpload con react-dropzone - 1h - [ ] Subtarea F.2.2: Preview de imagen antes de guardar - 0.5h - [ ] Subtarea F.2.3: ProfileStore en Zustand con métodos CRUD - 0.5h - [ ] **Tarea F.3:** Responsive y UX - Estimado: 1h - [ ] Subtarea F.3.1: Diseño responsive (mobile-first) - 0.5h - [ ] Subtarea F.3.2: Loading states y skeleton loaders - 0.25h - [ ] Subtarea F.3.3: Toast notifications para confirmaciones - 0.25h ### Testing (Estimado: 3h) **Total Testing:** 3h (~0.75 SP) - [ ] **Tarea T.1:** Tests unitarios backend - Estimado: 1.5h - [ ] Subtarea T.1.1: Tests de ProfileService (fetch, update) - 0.5h - [ ] Subtarea T.1.2: Tests de FileUploadService (validación) - 0.5h - [ ] Subtarea T.1.3: Tests de setPrimaryConstructora - 0.5h - [ ] **Tarea T.2:** Tests E2E - Estimado: 1h - [ ] Subtarea T.2.1: Tests de endpoints GET/PATCH profile - 0.5h - [ ] Subtarea T.2.2: Tests de upload de foto (success y errores) - 0.5h - [ ] **Tarea T.3:** Tests frontend - Estimado: 0.5h - [ ] Subtarea T.3.1: Tests de ProfileEditForm (React Testing Library) - 0.25h - [ ] Subtarea T.3.2: Tests de PhotoUpload - 0.25h --- ## Resumen de Horas | Categoría | Estimado | Story Points | |-----------|----------|--------------| | Backend | 10h | 2.5 SP | | Frontend | 7h | 1.75 SP | | Testing | 3h | 0.75 SP | | **TOTAL** | **20h** | **5 SP** | **Validación:** 5 SP × 4h/SP = 20 horas estimadas ✅ --- ## Cronograma Propuesto **Sprint:** Sprint 1-2 (Semanas 1-2) **Duración:** 2.5 días **Equipo:** - 1 Backend developer (10h) - 1 Frontend developer (7h) - QA compartido (3h) **Hitos:** - Día 1: Endpoints backend + upload de foto - Día 2: Frontend (componentes + store) - Día 2.5: Testing + ajustes --- ## Testing ### Tests Unitarios Backend ```typescript // apps/backend/src/modules/user/services/profile.service.spec.ts describe('ProfileService', () => { it('should fetch user profile with constructoras', async () => { const profile = await profileService.getProfile(userId); expect(profile.user).toBeDefined(); expect(profile.constructoras).toBeArray(); }); it('should update fullName and phone', async () => { const updated = await profileService.updateProfile(userId, { fullName: 'Juan Pérez García', phone: '5512345678', }); expect(updated.fullName).toBe('Juan Pérez García'); }); it('should NOT update email', async () => { await expect( profileService.updateProfile(userId, { email: 'new@email.com' } as any) ).rejects.toThrow(); }); it('should handle photo upload', async () => { const result = await profileService.uploadPhoto(userId, mockFile); expect(result.photoUrl).toContain('/static/profile-photos/'); }); it('should delete old photo when uploading new one', async () => { await profileService.uploadPhoto(userId, mockFile1); const oldPhotoKey = (await getProfile(userId)).photoKey; await profileService.uploadPhoto(userId, mockFile2); expect(fs.existsSync(`/uploads/profile-photos/${oldPhotoKey}`)).toBe(false); }); it('should set primary constructora', async () => { await profileService.setPrimaryConstructora(userId, constructoraId); const uc = await getUserConstructora(userId, constructoraId); expect(uc.isPrimary).toBe(true); // Solo una debe ser primary const allUc = await getAllUserConstructoras(userId); const primaryCount = allUc.filter(c => c.isPrimary).length; expect(primaryCount).toBe(1); }); }); describe('FileUploadService', () => { it('should validate file size (max 5MB)', async () => { const largeFile = createMockFile(6 * 1024 * 1024); // 6MB await expect(fileUploadService.validateFile(largeFile)).rejects.toThrow('File too large'); }); it('should validate file type (jpg, png, webp)', async () => { const pdfFile = createMockFile(1024, 'application/pdf'); await expect(fileUploadService.validateFile(pdfFile)).rejects.toThrow('Invalid file type'); }); it('should generate unique filename', () => { const filename1 = fileUploadService.generateFilename(userId, 'image.jpg'); const filename2 = fileUploadService.generateFilename(userId, 'image.jpg'); expect(filename1).not.toBe(filename2); }); }); describe('ImageProcessingService', () => { it('should resize image to 200x200', async () => { const processed = await imageService.resize(mockBuffer, 200, 200); const metadata = await sharp(processed).metadata(); expect(metadata.width).toBe(200); expect(metadata.height).toBe(200); }); it('should maintain aspect ratio with cover mode', async () => { const processed = await imageService.resize(mockWideBuffer, 200, 200); const metadata = await sharp(processed).metadata(); expect(metadata.width).toBe(200); expect(metadata.height).toBe(200); }); }); ``` ### Tests E2E ```typescript // apps/backend/test/user/profile.e2e-spec.ts describe('Profile API (E2E)', () => { let app: INestApplication; let user: User; let token: string; beforeAll(async () => { // Setup app user = await createUser(); token = await getAuthToken(user); }); describe('GET /user/profile', () => { it('should return user profile with constructoras', async () => { const response = await request(app.getHttpServer()) .get('/user/profile') .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body.user).toMatchObject({ id: user.id, email: user.email, fullName: expect.any(String), }); expect(response.body.constructoras).toBeArray(); }); it('should return 401 without token', async () => { await request(app.getHttpServer()) .get('/user/profile') .expect(401); }); }); describe('PATCH /user/profile', () => { it('should update fullName and phone', async () => { const response = await request(app.getHttpServer()) .patch('/user/profile') .set('Authorization', `Bearer ${token}`) .send({ fullName: 'Juan Pérez Actualizado', phone: '5512345678', }) .expect(200); expect(response.body.fullName).toBe('Juan Pérez Actualizado'); expect(response.body.phone).toBe('5512345678'); }); it('should reject invalid phone format', async () => { await request(app.getHttpServer()) .patch('/user/profile') .set('Authorization', `Bearer ${token}`) .send({ phone: '123' }) // Muy corto .expect(400); }); it('should not allow email change', async () => { await request(app.getHttpServer()) .patch('/user/profile') .set('Authorization', `Bearer ${token}`) .send({ email: 'new@email.com' }) .expect(400); }); }); describe('POST /user/profile/photo', () => { it('should upload photo successfully', async () => { const response = await request(app.getHttpServer()) .post('/user/profile/photo') .set('Authorization', `Bearer ${token}`) .attach('photo', './test/fixtures/avatar.jpg') .expect(201); expect(response.body.photoUrl).toMatch(/\/static\/profile-photos\/.+\.jpg$/); }); it('should reject file larger than 5MB', async () => { await request(app.getHttpServer()) .post('/user/profile/photo') .set('Authorization', `Bearer ${token}`) .attach('photo', './test/fixtures/large-image.jpg') // 6MB .expect(400); }); it('should reject non-image files', async () => { await request(app.getHttpServer()) .post('/user/profile/photo') .set('Authorization', `Bearer ${token}`) .attach('photo', './test/fixtures/document.pdf') .expect(400); }); }); describe('DELETE /user/profile/photo', () => { it('should delete photo and revert to default', async () => { // Upload first await request(app.getHttpServer()) .post('/user/profile/photo') .set('Authorization', `Bearer ${token}`) .attach('photo', './test/fixtures/avatar.jpg'); // Then delete const response = await request(app.getHttpServer()) .delete('/user/profile/photo') .set('Authorization', `Bearer ${token}`) .expect(200); // Verify photo is null const profile = await request(app.getHttpServer()) .get('/user/profile') .set('Authorization', `Bearer ${token}`); expect(profile.body.user.photoUrl).toBeNull(); }); }); }); ``` ### Tests Frontend ```typescript // apps/frontend/src/features/profile/ProfileEditForm.spec.tsx import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { ProfileEditForm } from './ProfileEditForm'; describe('ProfileEditForm', () => { const mockProfile = { id: '123', email: 'user@test.com', fullName: 'Juan Pérez', phone: '5512345678', }; it('renders current user data', () => { render(); expect(screen.getByDisplayValue('Juan Pérez')).toBeInTheDocument(); expect(screen.getByDisplayValue('5512345678')).toBeInTheDocument(); }); it('submits updated data on save', async () => { const onSave = jest.fn(); render(); const nameInput = screen.getByLabelText(/nombre completo/i); fireEvent.change(nameInput, { target: { value: 'Juan Pérez García' } }); const saveButton = screen.getByText(/guardar/i); fireEvent.click(saveButton); await waitFor(() => { expect(onSave).toHaveBeenCalledWith({ fullName: 'Juan Pérez García', phone: '5512345678', }); }); }); it('shows validation errors for invalid phone', async () => { render(); const phoneInput = screen.getByLabelText(/teléfono/i); fireEvent.change(phoneInput, { target: { value: '123' } }); fireEvent.blur(phoneInput); await waitFor(() => { expect(screen.getByText(/10 dígitos/i)).toBeInTheDocument(); }); }); it('disables email field', () => { render(); const emailInput = screen.getByDisplayValue('user@test.com'); expect(emailInput).toBeDisabled(); }); }); ``` --- ## Estimación **Desglose de Esfuerzo (5 SP = ~2.5 días = 20h):** - Backend endpoints + validations: 0.5 días (4h) - File upload + processing: 0.5 días (4h) - Multi-tenancy (set primary): 0.25 días (2h) - Frontend components: 0.5 días (4h) - Photo upload UI: 0.375 días (3h) - Testing: 0.375 días (3h) **Riesgos:** - ⚠️ File upload puede tener edge cases (conexión lenta, archivos corruptos) - ⚠️ Lógica de "primary constructora" requiere cuidado (constraint unique) - ⚠️ Cleanup de fotos antiguas debe ser robusto (no eliminar si falla upload) **Mitigaciones:** - ✅ Reutilizar FileUploadService y ImageProcessingService de GAMILIT - ✅ Transacción para cambiar primary constructora - ✅ Tests E2E exhaustivos de upload --- ## Recursos Externos **Librerías Backend:** - `multer` (file upload middleware para Express) - `sharp` (procesamiento de imágenes - resize, optimize) - `uuid` (generar nombres únicos de archivo) **Librerías Frontend:** - `react-dropzone` (drag & drop upload con preview) - `react-hook-form` (manejo de formularios) - `zod` (validación de esquemas) **Assets:** - Avatar placeholder SVG (si no hay foto) - Iconos de cámara para botón de upload --- **Creado:** 2025-11-17 **Actualizado:** 2025-11-17 **Responsable:** Equipo Fullstack **Reutilización GAMILIT:** 75% (estructura similar, adaptar multi-tenancy)