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

23 KiB
Raw Blame History

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:

// 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:

// 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:

// 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:

/profile             → Página de perfil (vista)
/profile/edit        → Editar perfil
/settings/account    → Configuración de cuenta (incluye perfil)

Estado (Zustand):

// 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

// 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

// 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

// 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)