Aller au contenu

7a/ Revue de Code — PD-32

Tu es développeur senior NestJS/TypeScript, spécialisé dans les architectures backend sécurisées.

TÂCHE

Produis une revue de code pour l'implémentation PD-32 (endpoints GET/PUT /user/profile).

Évalue : - Qualité du code (lisibilité, nommage, structure) - Respect des patterns NestJS (DI, guards, DTOs, modules) - Gestion des erreurs (exceptions, logging) - Maintenabilité et testabilité

Verdict : ✅ CONFORME / ⚠️ RÉSERVES / ❌ NON_CONFORME

AXES D'ANALYSE

  • Patterns NestJS respectés (injection, décorateurs, modules)
  • Nommage cohérent et explicite
  • Gestion des erreurs explicite (pas de catch vide)
  • Logging approprié (debug, warn, error)
  • Pas de code mort ou commenté
  • Séparation des responsabilités (controller thin, service fat)
  • Types TypeScript stricts (pas de any)

CODE CONTRACTS (règles à vérifier)

story_id: PD-32
version: 1
date: 2026-02-05
# Note technique: AppModule est exclu des code contracts (modification triviale d'import de module).

code_contracts:

  - module: user-profile-controller
    owner_agent: agent-developer
    description: "Exposition des endpoints GET/PUT /user/profile"
    interfaces:
      - UserProfileController
    files:
      - "src/modules/user/controllers/user-profile.controller.ts"
    invariants:
      - "INV-32-01: Les endpoints DOIVENT exiger un JWT valide via @UseGuards(AuthenticationGuard)"
      - "INV-32-09: Les endpoints DOIVENT être protégés par @UseGuards(RateLimitGuard)"
      - "INV-32-08: GET DOIT retourner UserProfileResponseDto (name, email, avatar_url, preferences)"
    forbidden:
      - "Accès direct au repository sans passer par UserProfileService"
      - "Exposition de routes avec paramètre userId (uniquement /me via @CurrentUser)"
      - "Bypass des guards par configuration ou décorateurs"

  - module: user-profile-service
    owner_agent: agent-developer
    description: "Logique métier de consultation/mise à jour du profil"
    interfaces:
      - UserProfileService
    files:
      - "src/modules/user/services/user-profile.service.ts"
    invariants:
      - "INV-32-02: Les opérations DOIVENT opérer uniquement sur le profil de l'utilisateur courant (via RLS)"
      - "INV-32-10: Aucune mutation partielle en cas d'erreur (transaction implicite)"
    forbidden:
      - "Requêtes SQL brutes avec concaténation de paramètres"
      - "Exposition de champs sensibles (passwordHash, srpSalt, etc.) dans les retours"
      - "Modification de champs protégés (email, plan, status, id)"

  - module: user-profile-dtos
    owner_agent: agent-developer
    description: "Validation et transformation des données profil"
    interfaces:
      - UpdateUserProfileDto
      - UserProfileResponseDto
      - UserPreferencesDto
    files:
      - "src/modules/user/dto/update-user-profile.dto.ts"
      - "src/modules/user/dto/user-profile-response.dto.ts"
      - "src/modules/user/dto/user-preferences.dto.ts"
    invariants:
      - "INV-32-03: UserProfileResponseDto DOIT exclure tous les champs sensibles"
      - "INV-32-04: Les champs passwordHash, srpSalt, validationToken NE DOIVENT jamais apparaître"
      - "INV-32-05: UpdateUserProfileDto DOIT accepter uniquement name, avatar_url, preferences"
      - "INV-32-06: Tout champ protégé DOIT être rejeté via whitelist/forbidNonWhitelisted"
      - "INV-32-07: UserPreferencesDto DOIT valider strictement le schéma (aucune clé inconnue)"
    forbidden:
      - "Propriété sans décorateur de validation explicite"
      - "Désactivation de whitelist ou forbidNonWhitelisted"
      - "Champs protégés (email, plan, id, status) dans UpdateUserProfileDto"
      - "Oubli de @ValidateNested() et @Type() sur preferences"

  - module: user-entity-extension
    owner_agent: agent-developer
    description: "Extension de l'entité User avec colonnes profil"
    interfaces:
      - User (extension)
    files:
      - "src/modules/auth/entities/user.entity.ts"
    invariants:
      - "INV-32-04: Les champs sensibles DOIVENT avoir @Exclude() pour la sérialisation"
    forbidden:
      - "Suppression ou modification des champs existants (id, email, srpSalt, passwordHash, etc.)"
      - "Colonnes sans valeur par défaut pour les utilisateurs existants"

CODE SOURCE À REVIEWER

user-profile.controller.ts

import { Body, Controller, Get, Put, UseGuards, Logger } from '@nestjs/common';
import {
  ApiBearerAuth,
  ApiOperation,
  ApiResponse,
  ApiTags,
} from '@nestjs/swagger';

import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { UserProfileService } from '../services/user-profile.service';
import { UpdateUserProfileDto } from '../dto/update-user-profile.dto';
import { UserProfileResponseDto } from '../dto/user-profile-response.dto';

/**
 * JWT payload structure
 */
interface JwtPayload {
  sub: string;
  email: string;
}

/**
 * Controller pour la gestion du profil utilisateur - PD-32
 *
 * Endpoints:
 * - GET /user/profile - Récupérer le profil de l'utilisateur courant
 * - PUT /user/profile - Mettre à jour le profil de l'utilisateur courant
 *
 * INV-32-01: Authentification JWT obligatoire via @UseGuards(JwtAuthGuard)
 * INV-32-09: Protection rate limiting (global, via app configuration)
 *
 * @see PD-32-specification.md §5 Contrat d'API
 */
@ApiTags('User')
@Controller('user')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class UserProfileController {
  private readonly logger = new Logger(UserProfileController.name);

  constructor(private readonly userProfileService: UserProfileService) {}

  /**
   * GET /user/profile - Récupère le profil de l'utilisateur courant
   */
  @Get('profile')
  @ApiOperation({
    summary: 'Récupérer le profil utilisateur',
    description: 'Retourne le profil de l\'utilisateur authentifié',
  })
  @ApiResponse({
    status: 200,
    description: 'Profil récupéré avec succès',
    type: UserProfileResponseDto,
  })
  @ApiResponse({ status: 401, description: 'Non authentifié' })
  @ApiResponse({ status: 404, description: 'Utilisateur non trouvé' })
  async getProfile(@CurrentUser() user: JwtPayload): Promise<UserProfileResponseDto> {
    this.logger.debug(`GET /user/profile for ${user.sub.substring(0, 8)}...`);
    return this.userProfileService.getProfile(user.sub);
  }

  /**
   * PUT /user/profile - Met à jour le profil de l'utilisateur courant
   */
  @Put('profile')
  @ApiOperation({
    summary: 'Mettre à jour le profil utilisateur',
    description: 'Met à jour le profil de l\'utilisateur authentifié. Seuls name, avatar_url et preferences sont modifiables.',
  })
  @ApiResponse({
    status: 200,
    description: 'Profil mis à jour avec succès',
    type: UserProfileResponseDto,
  })
  @ApiResponse({ status: 400, description: 'Données invalides ou champs interdits' })
  @ApiResponse({ status: 401, description: 'Non authentifié' })
  @ApiResponse({ status: 404, description: 'Utilisateur non trouvé' })
  async updateProfile(
    @CurrentUser() user: JwtPayload,
    @Body() dto: UpdateUserProfileDto,
  ): Promise<UserProfileResponseDto> {
    this.logger.debug(`PUT /user/profile for ${user.sub.substring(0, 8)}...`);
    return this.userProfileService.updateProfile(user.sub, dto);
  }
}

user-profile.service.ts

import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { plainToInstance } from 'class-transformer';

import { RlsQueryService } from '../../../database/services/rls-query.service';
import { User } from '../../auth/entities/user.entity';
import { UpdateUserProfileDto } from '../dto/update-user-profile.dto';
import { UserProfileResponseDto } from '../dto/user-profile-response.dto';
import { UserPreferencesDto } from '../dto/user-preferences.dto';

/**
 * Service de gestion du profil utilisateur - PD-32
 *
 * INV-32-02: Opère uniquement sur le profil de l'utilisateur courant via RLS
 * INV-32-10: Aucune mutation partielle en cas d'erreur (transaction implicite)
 */
@Injectable()
export class UserProfileService {
  private readonly logger = new Logger(UserProfileService.name);

  constructor(
    private readonly rlsQueryService: RlsQueryService,
    private readonly dataSource: DataSource,
  ) {}

  async getProfile(userId: string): Promise<UserProfileResponseDto> {
    this.logger.debug(`Getting profile for user ${userId.substring(0, 8)}...`);

    const user = await this.rlsQueryService.findOneWithRls(User, {
      where: { id: userId },
      select: ['id', 'name', 'email', 'avatarUrl', 'preferences'],
    });

    if (!user) {
      this.logger.warn(`User not found: ${userId.substring(0, 8)}...`);
      throw new NotFoundException({
        error: 'ERR-32-01',
        message: 'Utilisateur non trouvé',
      });
    }

    return plainToInstance(
      UserProfileResponseDto,
      {
        name: user.name,
        email: user.email,
        avatarUrl: user.avatarUrl,
        preferences: user.preferences ?? {},
      },
      { excludeExtraneousValues: true },
    );
  }

  async updateProfile(
    userId: string,
    dto: UpdateUserProfileDto,
  ): Promise<UserProfileResponseDto> {
    this.logger.debug(`Updating profile for user ${userId.substring(0, 8)}...`);

    return this.dataSource.transaction(async (manager) => {
      await manager.query('SET LOCAL app.current_user_id = $1', [userId]);

      const user = await manager.findOne(User, {
        where: { id: userId },
        select: ['id', 'name', 'email', 'avatarUrl', 'preferences'],
      });

      if (!user) {
        this.logger.warn(`User not found for update: ${userId.substring(0, 8)}...`);
        throw new NotFoundException({
          error: 'ERR-32-01',
          message: 'Utilisateur non trouvé',
        });
      }

      if (dto.name !== undefined) {
        user.name = dto.name;
      }
      if (dto.avatarUrl !== undefined) {
        user.avatarUrl = dto.avatarUrl;
      }
      if (dto.preferences !== undefined) {
        user.preferences = this.mergePreferences(
          user.preferences as Record<string, unknown>,
          dto.preferences,
        );
      }

      const updated = await manager.save(User, user);
      this.logger.log(`Profile updated for user ${userId.substring(0, 8)}...`);

      return plainToInstance(
        UserProfileResponseDto,
        {
          name: updated.name,
          email: updated.email,
          avatarUrl: updated.avatarUrl,
          preferences: updated.preferences ?? {},
        },
        { excludeExtraneousValues: true },
      );
    });
  }

  private mergePreferences(
    existing: Record<string, unknown>,
    incoming: UserPreferencesDto,
  ): Record<string, unknown> {
    const merged = { ...existing };

    if (incoming.locale !== undefined) {
      merged.locale = incoming.locale;
    }
    if (incoming.timezone !== undefined) {
      merged.timezone = incoming.timezone;
    }
    if (incoming.security !== undefined) {
      merged.security = {
        ...(existing.security as Record<string, unknown> | undefined),
        ...incoming.security,
      };
    }
    if (incoming.notifications !== undefined) {
      merged.notifications = {
        ...(existing.notifications as Record<string, unknown> | undefined),
        ...incoming.notifications,
      };
    }

    return merged;
  }
}

update-user-profile.dto.ts

import { ApiPropertyOptional } from '@nestjs/swagger';
import {
  IsOptional,
  IsString,
  IsUrl,
  MaxLength,
  ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { UserPreferencesDto } from './user-preferences.dto';

/**
 * DTO pour la mise à jour du profil utilisateur - PD-32
 *
 * INV-32-05: Accepte uniquement name, avatar_url, preferences
 * INV-32-06: Champs protégés rejetés via whitelist/forbidNonWhitelisted
 */
export class UpdateUserProfileDto {
  @ApiPropertyOptional({
    description: "Nom d'affichage de l'utilisateur",
    example: 'Jean Dupont',
    maxLength: 100,
  })
  @IsOptional()
  @IsString()
  @MaxLength(100)
  name?: string;

  @ApiPropertyOptional({
    name: 'avatar_url',
    description: "URL de l'avatar (HTTPS uniquement)",
    example: 'https://example.com/avatar.png',
  })
  @IsOptional()
  @IsUrl({ protocols: ['https'], require_protocol: true })
  @MaxLength(2048)
  avatarUrl?: string;

  @ApiPropertyOptional({
    description: 'Préférences utilisateur',
    type: UserPreferencesDto,
  })
  @IsOptional()
  @ValidateNested()
  @Type(() => UserPreferencesDto)
  preferences?: UserPreferencesDto;
}

user-profile-response.dto.ts

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
import { UserPreferencesDto } from './user-preferences.dto';

/**
 * DTO pour la réponse du profil utilisateur - PD-32
 *
 * INV-32-03: Exclut tous les champs sensibles
 * INV-32-04: passwordHash, srpSalt, validationToken jamais exposés
 * INV-32-08: Retourne exactement name, email, avatar_url, preferences
 */
export class UserProfileResponseDto {
  @ApiPropertyOptional({
    description: "Nom d'affichage de l'utilisateur",
    example: 'Jean Dupont',
  })
  @Expose()
  name?: string;

  @ApiProperty({
    description: "Email de l'utilisateur (lecture seule)",
    example: 'user@example.com',
  })
  @Expose()
  email!: string;

  @ApiPropertyOptional({
    name: 'avatar_url',
    description: "URL de l'avatar",
    example: 'https://example.com/avatar.png',
  })
  @Expose({ name: 'avatarUrl' })
  avatarUrl?: string;

  @ApiProperty({
    description: 'Préférences utilisateur',
    type: UserPreferencesDto,
    default: {},
  })
  @Expose()
  @Type(() => UserPreferencesDto)
  preferences!: UserPreferencesDto;
}

user-preferences.dto.ts

import { ApiPropertyOptional } from '@nestjs/swagger';
import {
  IsBoolean,
  IsIn,
  IsOptional,
  IsString,
  MaxLength,
  ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';

const VALID_LOCALES = ['fr', 'en'] as const;

export class SecurityPreferencesDto {
  @ApiPropertyOptional({
    description: 'Activer les notifications de connexion par email',
    example: true,
  })
  @IsOptional()
  @IsBoolean()
  loginNotifications?: boolean;

  @ApiPropertyOptional({
    description: "Délai d'expiration de session en minutes",
    example: 30,
  })
  @IsOptional()
  sessionTimeout?: number;
}

export class NotificationPreferencesDto {
  @ApiPropertyOptional({
    description: 'Notifications par email',
    example: true,
  })
  @IsOptional()
  @IsBoolean()
  email?: boolean;

  @ApiPropertyOptional({
    description: 'Notifications push',
    example: false,
  })
  @IsOptional()
  @IsBoolean()
  push?: boolean;
}

/**
 * DTO pour les préférences utilisateur - PD-32
 *
 * INV-32-07: Validation stricte du schéma, aucune clé inconnue autorisée
 */
export class UserPreferencesDto {
  @ApiPropertyOptional({
    description: "Locale de l'utilisateur",
    example: 'fr',
    enum: VALID_LOCALES,
  })
  @IsOptional()
  @IsIn(VALID_LOCALES)
  locale?: (typeof VALID_LOCALES)[number];

  @ApiPropertyOptional({
    description: 'Fuseau horaire IANA',
    example: 'Europe/Paris',
  })
  @IsOptional()
  @IsString()
  @MaxLength(50)
  timezone?: string;

  @ApiPropertyOptional({
    description: 'Préférences de sécurité',
    type: SecurityPreferencesDto,
  })
  @IsOptional()
  @ValidateNested()
  @Type(() => SecurityPreferencesDto)
  security?: SecurityPreferencesDto;

  @ApiPropertyOptional({
    description: 'Préférences de notification',
    type: NotificationPreferencesDto,
  })
  @IsOptional()
  @ValidateNested()
  @Type(() => NotificationPreferencesDto)
  notifications?: NotificationPreferencesDto;
}

user.entity.ts (extrait PD-32)

import {
  Column,
  CreateDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { Exclude } from 'class-transformer';

export const CURRENT_SRP_VERSION = 1;

export enum UserStatus {
  PENDING_VALIDATION = 'PENDING_VALIDATION',
  ACTIVE = 'ACTIVE',
  SUSPENDED = 'SUSPENDED',
  DELETED = 'DELETED',
}

@Entity('users', { schema: 'vault_secure' })
export class User {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Column({ type: 'text', unique: true })
  email!: string;

  @Exclude()
  @Column({ name: 'srp_salt', type: 'bytea' })
  srpSalt!: Buffer;

  @Exclude()
  @Column({ name: 'password_hash', type: 'bytea' })
  passwordHash!: Buffer;

  @Column({ type: 'text', default: 'free' })
  plan!: string;

  /**
   * Nom d'affichage de l'utilisateur - PD-32
   */
  @Column({ type: 'text', nullable: true })
  name?: string;

  /**
   * URL de l'avatar utilisateur - PD-32
   */
  @Column({ name: 'avatar_url', type: 'text', nullable: true })
  avatarUrl?: string;

  /**
   * Préférences utilisateur - PD-32
   */
  @Column({ type: 'jsonb', default: () => "'{}'" })
  preferences!: Record<string, unknown>;

  @Column({ name: 'srp_version', type: 'smallint', default: CURRENT_SRP_VERSION })
  srpVersion!: number;

  @Column({
    type: 'enum',
    enum: UserStatus,
    enumName: 'user_status',
    default: UserStatus.PENDING_VALIDATION,
  })
  status!: UserStatus;

  @Exclude()
  @Column({ name: 'validation_token', type: 'uuid', nullable: true })
  validationToken?: string;

  @Column({ name: 'validation_token_expires_at', type: 'timestamptz', nullable: true })
  validationTokenExpiresAt?: Date;

  @Column({ name: 'email_validated_at', type: 'timestamptz', nullable: true })
  emailValidatedAt?: Date;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt!: Date;
}

ARTEFACT À PRODUIRE

➡️ PD-32-review-code.md

FORMAT DE SORTIE

# PD-32 — Revue de Code

## Résumé
| Critère | Statut |
|---------|--------|
| Patterns NestJS | ✅/⚠️/❌ |
| Qualité code | ✅/⚠️/❌ |
| Gestion erreurs | ✅/⚠️/❌ |
| Maintenabilité | ✅/⚠️/❌ |

**Verdict** : ...

## Points positifs
- ...

## Points à améliorer
| ID | Description | Fichier | Gravité |
|----|-------------|---------|---------|
| R-01 | ... | ... | MAJEUR/MINEUR |

## Détail par fichier
### controller.ts
...
### service.ts
...