erp-construccion/docs/02-definicion-modulos/MAI-001-fundamentos/historias-usuario/US-FUND-002-perfiles-usuario-construccion.md

740 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<void>;
updateProfile: (data: UpdateProfileDto) => Promise<void>;
uploadPhoto: (file: File) => Promise<void>;
deletePhoto: () => Promise<void>;
setPrimaryConstructora: (constructoraId: string) => Promise<void>;
}
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(<ProfileEditForm profile={mockProfile} onSave={jest.fn()} />);
expect(screen.getByDisplayValue('Juan Pérez')).toBeInTheDocument();
expect(screen.getByDisplayValue('5512345678')).toBeInTheDocument();
});
it('submits updated data on save', async () => {
const onSave = jest.fn();
render(<ProfileEditForm profile={mockProfile} onSave={onSave} />);
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(<ProfileEditForm profile={mockProfile} onSave={jest.fn()} />);
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(<ProfileEditForm profile={mockProfile} onSave={jest.fn()} />);
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)