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