Construire le premier domaine métier complet protégé par le système RBAC mis en place lors du Sprint 1.
À l'issue du Sprint 2 :
✓ Gestion des utilisateurs ✓ Gestion du profil ✓ Gestion des adresses ✓ Préférences utilisateur ✓ Paramètres notifications ✓ Sessions utilisateur ✓ Historique connexions ✓ Administration utilisateurs
UsersModule ProfilesModule PreferencesModule NotificationModule SessionsModule
User UserProfile Address UserPreference NotificationSetting UserSession ConnectionHistory
Compléter le modèle User existant avec les informations de profil et de personnalisation.
Ajouter :
model UserProfile {
id String @id @default(uuid())
userId String @unique
avatarUrl String?
birthDate DateTime?
gender String?
language String?
timezone String?
biography String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
@relation(
fields:[userId],
references:[id]
)
}
Ajouter dans :
model User
profile UserProfile?
model Address {
id String @id @default(uuid())
userId String
label String
addressLine1 String
addressLine2 String?
postalCode String
city String
state String?
country String
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
@relation(
fields:[userId],
references:[id]
)
@@index([userId])
}
Ajouter :
addresses Address[]
model UserPreference {
id String @id @default(uuid())
userId String @unique
theme String @default("light")
language String @default("fr")
timezone String @default("Europe/Paris")
dateFormat String @default("DD/MM/YYYY")
currency String @default("EUR")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
@relation(
fields:[userId],
references:[id]
)
}
Ajouter :
preferences UserPreference?
model NotificationSetting {
id String @id @default(uuid())
userId String @unique
emailEnabled Boolean @default(true)
smsEnabled Boolean @default(false)
pushEnabled Boolean @default(true)
marketingEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
@relation(
fields:[userId],
references:[id]
)
}
Ajouter :
notificationSettings NotificationSetting?
model ConnectionHistory {
id String @id @default(uuid())
userId String
ipAddress String?
country String?
city String?
userAgent String?
connectedAt DateTime
disconnectedAt DateTime?
success Boolean
user User
@relation(
fields:[userId],
references:[id]
)
@@index([userId])
@@index([connectedAt])
}
Ajouter :
connectionHistory ConnectionHistory[]
Compléter :
model Session
sessionToken String? @unique deviceName String? platform String? isCurrent Boolean @default(false)
npx prisma migrate dev \
--name user_management
npx prisma generate
Créer :
src/modules/users ├── application │ ├── domain │ ├── infrastructure │ ├── presentation │ └── users.module.ts
UsersService ProfileService PreferencesService SessionsService
UsersController ProfileController PreferencesController SessionsController
GET /users
GET /users/{id}
POST /users
PUT /users/{id}
DELETE /users/{id}
users.read users.create users.update users.delete
GET /profile PUT /profile
Avatar Nom Prénom Téléphone Date naissance Langue Fuseau horaire
GET /profile/addresses
POST /profile/addresses
PUT /profile/addresses/{id}
DELETE /profile/addresses/{id}
GET /preferences PUT /preferences
Langue Devise Format date Fuseau horaire Thème
GET /notifications/settings PUT /notifications/settings
Email SMS Push Marketing
GET /sessions
DELETE /sessions/{id}
DELETE /sessions
Lister sessions Déconnexion appareil Déconnexion globale
GET /connection-history
Date IP Pays Navigateur Résultat
Tous les endpoints doivent utiliser :
@UseGuards( JwtAuthGuard, PermissionsGuard )
@Permissions( 'users.read' )
Le Sprint 2 est terminé lorsque :
✓ CRUD utilisateurs ✓ Profil utilisateur ✓ Adresses ✓ Préférences ✓ Notifications ✓ Sessions ✓ Historique connexions ✓ Swagger documenté ✓ Tests unitaires ✓ Tests E2E
Étendre le modèle d'authentification du Sprint 1 afin d'ajouter :
UserProfile Address UserPreference NotificationSetting ConnectionHistory
et enrichir :
User Session
pour préparer la gestion complète des utilisateurs.
model User
profile UserProfile? addresses Address[] preferences UserPreference? notificationSettings NotificationSetting? connectionHistory ConnectionHistory[]
Les relations User deviennent :
userRoles UserRole[] refreshTokens RefreshToken[] sessions Session[] profile UserProfile? addresses Address[] preferences UserPreference? notificationSettings NotificationSetting? connectionHistory ConnectionHistory[]
model UserProfile {
id String @id @default(uuid())
userId String @unique
avatarUrl String?
birthDate DateTime?
gender String?
language String?
timezone String?
biography String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
@relation(
fields: [userId],
references: [id],
onDelete: Cascade
)
}
model Address {
id String @id @default(uuid())
userId String
label String
addressLine1 String
addressLine2 String?
postalCode String
city String
state String?
country String
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
@relation(
fields: [userId],
references: [id],
onDelete: Cascade
)
@@index([userId])
@@index([country])
@@index([city])
}
model UserPreference {
id String @id @default(uuid())
userId String @unique
theme String @default("light")
language String @default("fr")
timezone String @default("Europe/Paris")
dateFormat String @default("DD/MM/YYYY")
currency String @default("EUR")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
@relation(
fields: [userId],
references: [id],
onDelete: Cascade
)
}
model NotificationSetting {
id String @id @default(uuid())
userId String @unique
emailEnabled Boolean @default(true)
smsEnabled Boolean @default(false)
pushEnabled Boolean @default(true)
marketingEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
@relation(
fields: [userId],
references: [id],
onDelete: Cascade
)
}
model ConnectionHistory {
id String @id @default(uuid())
userId String
ipAddress String?
country String?
city String?
userAgent String?
connectedAt DateTime
disconnectedAt DateTime?
success Boolean
user User
@relation(
fields: [userId],
references: [id],
onDelete: Cascade
)
@@index([userId])
@@index([connectedAt])
@@index([success])
}
model Session
sessionToken String? @unique deviceName String? platform String? isCurrent Boolean @default(false)
Le modèle Session devient capable de gérer :
Multi-appareils Historique appareils Déconnexion ciblée Déconnexion globale
cd apps/api npx prisma validate
The schema at prisma/schema.prisma is valid
npx prisma format
npx prisma migrate dev \
--name user_management
prisma/migrations
└── xxxx_user_management
└── migration.sql
npx prisma generate
docker exec -it postgres psql -U postgres
\c rental_platform
\d "UserProfile" \d "Address" \d "UserPreference" \d "NotificationSetting" \d "ConnectionHistory" \d "Session"
Lors de la création d'un utilisateur, prévoir automatiquement :
UserProfile UserPreference NotificationSetting
afin d'éviter les valeurs nulles dans l'application.
Créer automatiquement :
Profil vide Préférences par défaut Notifications par défaut
pour chaque utilisateur.
Le Sprint 2-A.1 est terminé lorsque :
✓ UserProfile créé ✓ Address créée ✓ UserPreference créée ✓ NotificationSetting créée ✓ ConnectionHistory créée ✓ Session enrichie ✓ Migration exécutée ✓ Prisma Client généré ✓ Validation Prisma verte
schema.prisma migration user_management Prisma Client mis à jour
Garantir qu'un utilisateur dispose systématiquement d'un environnement fonctionnel dès sa création.
À l'issue de cette étape :
✓ UserProfile créé automatiquement ✓ UserPreference créé automatiquement ✓ NotificationSetting créé automatiquement ✓ Seed enrichi ✓ Register enrichi ✓ Aucun compte incomplet
Après le Sprint 2-A.1 :
User ✓ UserProfile ✗ UserPreference ✗ NotificationSetting ✗
sont créés séparément.
Les appels :
GET /profile GET /preferences GET /notifications/settings
peuvent retourner :
NULL
et générer des erreurs.
Créer automatiquement :
UserProfile UserPreference NotificationSetting
lors :
Seed principal Register utilisateur
src/modules/users/domain/services user-factory.service.ts
Centraliser :
Création User Création Profile Création Preferences Création Notifications
createDefaultUserEnvironment( userId: string )
@Injectable()
export class UserFactoryService {
constructor(
private readonly prisma:
PrismaService
) {}
async createDefaultUserEnvironment(
userId: string
) {
await this.prisma.userProfile.create({
data: {
userId
}
});
await this.prisma.userPreference.create({
data: {
userId,
theme: 'light',
language: 'fr',
timezone:
'Europe/Paris',
dateFormat:
'DD/MM/YYYY',
currency: 'EUR'
}
});
await this.prisma.notificationSetting.create({
data: {
userId,
emailEnabled: true,
pushEnabled: true,
smsEnabled: false,
marketingEnabled: false
}
});
}
}
providers: [ UserFactoryService ]
imports: [ UsersModule ]
Dans :
AuthService
constructor(
...
private readonly
userFactoryService:
UserFactoryService
)
const user = await this.prisma.user.create(...)
await this.userFactoryService
.createDefaultUserEnvironment(
user.id
);
Create User ↓ Create Profile ↓ Create Preferences ↓ Create Notification Settings ↓ Assign Role ↓ Generate JWT
prisma/seed.ts
Ajouter :
const profileExists =
await prisma.userProfile.findUnique({
where: {
userId: admin.id
}
});
if (!profileExists) {
await prisma.userProfile.create({
data: {
userId: admin.id
}
});
}
const preferenceExists =
await prisma.userPreference.findUnique({
where: {
userId: admin.id
}
});
if (!preferenceExists) {
await prisma.userPreference.create({
data: {
userId: admin.id,
language: 'fr',
timezone:
'Europe/Paris',
currency: 'EUR'
}
});
}
const settingsExists =
await prisma.notificationSetting.findUnique({
where: {
userId: admin.id
}
});
if (!settingsExists) {
await prisma.notificationSetting.create({
data: {
userId: admin.id
}
});
}
npx prisma db seed
Starting seed... Tenant created Permissions created Roles created Admin created Profile created Preferences created Notification settings created Seed completed
npx prisma studio
User ↓ UserProfile ↓ UserPreference ↓ NotificationSetting
SELECT * FROM "UserProfile";
SELECT * FROM "UserPreference";
SELECT * FROM "NotificationSetting";
POST /auth/register
Après inscription :
SELECT * FROM "UserProfile" WHERE "userId" = 'new-user-id';
1 ligne
Créer un événement métier :
UserCreated
UserCreated ↓ CreateUserProfile ↓ CreateUserPreferences ↓ CreateNotificationSettings
via :
Domain Events CQRS EventBus
afin de découpler complètement Auth et Users.
Le Sprint 2-A.2 est terminé lorsque :
✓ UserProfile créé automatiquement ✓ UserPreference créé automatiquement ✓ NotificationSetting créé automatiquement ✓ Seed mis à jour ✓ Register mis à jour ✓ Aucun compte incomplet
UserFactoryService AuthService enrichi seed.ts enrichi Création automatique du profil utilisateur
Implémenter le premier module métier complet de la plateforme.
À l'issue de cette étape :
✓ UsersModule ✓ UsersController ✓ UsersService ✓ DTO ✓ CRUD Utilisateurs ✓ RBAC ✓ Swagger ✓ Validation
src/modules/users
users ├── application │ │ └── dto │ │ ├── create-user.dto.ts │ ├── update-user.dto.ts │ ├── user-response.dto.ts │ └── users-query.dto.ts │ ├── domain │ │ └── services │ │ └── users.service.ts │ ├── presentation │ │ └── controllers │ │ └── users.controller.ts │ └── users.module.ts
application/dto/create-user.dto.ts
import {
IsEmail,
IsString,
MinLength,
IsOptional
} from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@MinLength(8)
password: string;
@IsString()
firstName: string;
@IsString()
lastName: string;
@IsOptional()
phone?: string;
}
application/dto/update-user.dto.ts
import {
IsOptional,
IsString
} from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@IsString()
firstName?: string;
@IsOptional()
@IsString()
lastName?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
status?: string;
}
application/dto/user-response.dto.ts
export class UserResponseDto {
id: string;
tenantId: string;
email: string;
firstName: string;
lastName: string;
phone?: string;
status: string;
emailVerified: boolean;
createdAt: Date;
}
application/dto/users-query.dto.ts
import {
IsOptional,
IsNumberString
} from 'class-validator';
export class UsersQueryDto {
@IsOptional()
search?: string;
@IsOptional()
@IsNumberString()
page?: string;
@IsOptional()
@IsNumberString()
limit?: string;
}
domain/services/users.service.ts
@Injectable()
export class UsersService {
constructor(
private readonly prisma:
PrismaService,
private readonly passwordService:
PasswordService,
private readonly userFactoryService:
UserFactoryService
) {}
}
findAll() findOne() create() update() remove()
async findAll(
query: UsersQueryDto
) {
const page =
Number(query.page ?? 1);
const limit =
Number(query.limit ?? 20);
const skip =
(page - 1) * limit;
return this.prisma.user.findMany({
skip,
take: limit,
where: {
deletedAt: null
},
orderBy: {
createdAt: 'desc'
}
});
}
async findOne(
id: string
) {
const user =
await this.prisma.user.findUnique({
where: { id }
});
if (!user) {
throw new NotFoundException(
'User not found'
);
}
return user;
}
async create(
dto: CreateUserDto
) {
const tenant =
await this.prisma.tenant.findUnique({
where: {
code: 'MAIN'
}
});
const passwordHash =
await this.passwordService.hash(
dto.password
);
const user =
await this.prisma.user.create({
data: {
tenantId: tenant.id,
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
phone: dto.phone,
status: 'ACTIVE'
}
});
await this.userFactoryService
.createDefaultUserEnvironment(
user.id
);
return user;
}
async update(
id: string,
dto: UpdateUserDto
) {
await this.findOne(id);
return this.prisma.user.update({
where: { id },
data: dto
});
}
async remove(
id: string
) {
await this.findOne(id);
return this.prisma.user.update({
where: { id },
data: {
deletedAt:
new Date(),
status:
'INACTIVE'
}
});
}
presentation/controllers/users.controller.ts
@ApiTags('Users')
@Controller('users')
@UseGuards(
JwtAuthGuard,
PermissionsGuard
)
export class UsersController {
constructor(
private readonly usersService:
UsersService
) {}
}
@Get() @Permissions( 'users.read' )
findAll(
@Query()
query: UsersQueryDto
) {
return this.usersService
.findAll(query);
}
@Get(':id')
@Permissions(
'users.read'
)
findOne(
@Param('id')
id: string
) {
return this.usersService
.findOne(id);
}
@Post() @Permissions( 'users.create' )
create(
@Body()
dto: CreateUserDto
) {
return this.usersService
.create(dto);
}
@Put(':id')
@Permissions(
'users.update'
)
update(
@Param('id')
id: string,
@Body()
dto: UpdateUserDto
) {
return this.usersService
.update(
id,
dto
);
}
@Delete(':id')
@Permissions(
'users.delete'
)
remove(
@Param('id')
id: string
) {
return this.usersService
.remove(id);
}
users.module.ts
@Module({
imports: [
PrismaModule,
AuthModule
],
controllers: [
UsersController
],
providers: [
UsersService,
UserFactoryService
],
exports: [
UsersService
]
})
export class UsersModule {}
Les endpoints suivants doivent apparaître :
GET /users
GET /users/{id}
POST /users
PUT /users/{id}
DELETE /users/{id}
Doit pouvoir :
Créer Modifier Lister Supprimer
des utilisateurs.
Doit recevoir :
{
"statusCode": 403,
"message": "Forbidden resource"
}
Le Sprint 2-B.1 est terminé lorsque :
✓ UsersModule créé ✓ UsersController créé ✓ UsersService créé ✓ CRUD fonctionnel ✓ RBAC actif ✓ Swagger documenté ✓ Soft Delete opérationnel
UsersModule UsersController UsersService CreateUserDto UpdateUserDto UserResponseDto UsersQueryDto
Transformer le CRUD utilisateur basique en véritable service Enterprise.
À l'issue de cette étape :
✓ Pagination avancée ✓ Recherche multi-champs ✓ Tri dynamique ✓ Filtres ✓ Isolation Multi-Tenant ✓ Audit Logs ✓ Soft Delete sécurisé ✓ Réponses paginées
Le service actuel :
findAll()
ne gère que :
Pagination simple
et ne protège pas encore :
Isolation tenant Recherche Audit Tri
users-query.dto.ts
import {
IsOptional,
IsString,
IsNumberString
} from 'class-validator';
export class UsersQueryDto {
@IsOptional()
search?: string;
@IsOptional()
status?: string;
@IsOptional()
emailVerified?: string;
@IsOptional()
role?: string;
@IsOptional()
sortBy?: string;
@IsOptional()
sortOrder?: 'asc' | 'desc';
@IsOptional()
@IsNumberString()
page?: string;
@IsOptional()
@IsNumberString()
limit?: string;
}
application/dto/paginated-response.dto.ts
export class PaginatedResponseDto<T> {
data: T[];
page: number;
limit: number;
total: number;
totalPages: number;
}
Aucun utilisateur ne doit voir :
Tenant A ↓ Tenant B
Dans le contrôleur :
@Req() request
const tenantId = request.user.tenantId;
async findAll( tenantId: string, query: UsersQueryDto )
const where: Prisma.UserWhereInput = {
tenantId,
deletedAt: null
};
if (query.search) {
where.OR = [
{
email: {
contains: query.search,
mode: 'insensitive'
}
},
{
firstName: {
contains: query.search,
mode: 'insensitive'
}
},
{
lastName: {
contains: query.search,
mode: 'insensitive'
}
}
];
}
if (query.status) {
where.status =
query.status;
}
if (
query.emailVerified !==
undefined
) {
where.emailVerified =
query.emailVerified ===
'true';
}
const page = Number(query.page ?? 1); const limit = Number(query.limit ?? 20); const skip = (page - 1) * limit;
const sortBy = query.sortBy ?? 'createdAt'; const sortOrder = query.sortOrder ?? 'desc';
orderBy: {
[sortBy]:
sortOrder
}
const total =
await this.prisma.user.count({
where
});
const users =
await this.prisma.user.findMany({
where,
skip,
take: limit,
orderBy: {
[sortBy]:
sortOrder
}
});
return {
data: users,
page,
limit,
total,
totalPages:
Math.ceil(
total / limit
)
};
async findOne( tenantId: string, id: string )
const user =
await this.prisma.user.findFirst({
where: {
id,
tenantId,
deletedAt: null
}
});
Impossible d'accéder à un utilisateur :
Autre tenant
await this.findOne( tenantId, id );
avant toute modification.
await this.findOne( tenantId, id );
avant suppression.
Le modèle :
AuditLog
sera créé lors de la Phase 2-I.
Créer :
shared/interfaces/audit.interface.ts
export interface AuditEvent {
action: string;
entityType: string;
entityId: string;
userId: string;
timestamp: Date;
}
Après :
create()
this.logger.log({
action: 'USER_CREATED',
entityType: 'User',
entityId: user.id
});
USER_UPDATED
USER_DELETED
findAll(
@Req()
req,
@Query()
query
) {
return this.usersService.findAll(
req.user.tenantId,
query
);
}
Pour :
findOne() update() remove()
Paramètres :
search status emailVerified sortBy sortOrder page limit
dans :
@ApiQuery()
GET /users?search=john
GET /users?page=2&limit=50
GET /users?sortBy=email&sortOrder=asc
GET /users?status=ACTIVE
Dans :
model User
@@index([tenantId]) @@index([email]) @@index([status]) @@index([createdAt]) @@index([deletedAt])
Recherche Pagination Tri Filtre Isolation tenant Soft Delete
Le Sprint 2-B.2 est terminé lorsque :
✓ Pagination Enterprise ✓ Recherche multi-champs ✓ Tri dynamique ✓ Filtres ✓ Multi-Tenant Security ✓ Audit préparé ✓ Swagger enrichi ✓ Tests verts
UsersQueryDto PaginatedResponseDto UsersService Enterprise Recherche Filtres Pagination Isolation Multi-Tenant
Implémenter la gestion complète du profil utilisateur basée sur :
UserProfile
À l'issue de cette étape :
✓ ProfileModule ✓ ProfileController ✓ ProfileService ✓ GET /profile ✓ PUT /profile ✓ Gestion Avatar ✓ Gestion Informations personnelles ✓ Swagger ✓ RBAC
src/modules/profile
profile ├── application │ │ └── dto │ │ ├── update-profile.dto.ts │ └── profile-response.dto.ts │ ├── domain │ │ └── services │ │ └── profile.service.ts │ ├── presentation │ │ └── controllers │ │ └── profile.controller.ts │ └── profile.module.ts
application/dto/update-profile.dto.ts
import {
IsOptional,
IsString,
IsDateString,
IsUrl
} from 'class-validator';
export class UpdateProfileDto {
@IsOptional()
@IsUrl()
avatarUrl?: string;
@IsOptional()
@IsDateString()
birthDate?: string;
@IsOptional()
@IsString()
gender?: string;
@IsOptional()
@IsString()
language?: string;
@IsOptional()
@IsString()
timezone?: string;
@IsOptional()
@IsString()
biography?: string;
@IsOptional()
@IsString()
firstName?: string;
@IsOptional()
@IsString()
lastName?: string;
@IsOptional()
@IsString()
phone?: string;
}
application/dto/profile-response.dto.ts
export class ProfileResponseDto {
id: string;
email: string;
firstName: string;
lastName: string;
phone?: string;
avatarUrl?: string;
birthDate?: Date;
gender?: string;
language?: string;
timezone?: string;
biography?: string;
emailVerified: boolean;
}
domain/services/profile.service.ts
@Injectable()
export class ProfileService {
constructor(
private readonly prisma:
PrismaService
) {}
}
getProfile() updateProfile()
async getProfile(
userId: string,
tenantId: string
) {
const user =
await this.prisma.user.findFirst({
where: {
id: userId,
tenantId,
deletedAt: null
},
include: {
profile: true
}
});
if (!user) {
throw new NotFoundException(
'User not found'
);
}
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
phone: user.phone,
emailVerified:
user.emailVerified,
avatarUrl:
user.profile?.avatarUrl,
birthDate:
user.profile?.birthDate,
gender:
user.profile?.gender,
language:
user.profile?.language,
timezone:
user.profile?.timezone,
biography:
user.profile?.biography
};
async updateProfile( userId: string, tenantId: string, dto: UpdateProfileDto )
await this.prisma.user.update({
where: {
id: userId
},
data: {
firstName:
dto.firstName,
lastName:
dto.lastName,
phone:
dto.phone
}
});
await this.prisma.userProfile.update({
where: {
userId
},
data: {
avatarUrl:
dto.avatarUrl,
birthDate:
dto.birthDate,
gender:
dto.gender,
language:
dto.language,
timezone:
dto.timezone,
biography:
dto.biography
}
});
return this.getProfile( userId, tenantId );
presentation/controllers/profile.controller.ts
@ApiTags('Profile')
@Controller('profile')
@UseGuards(
JwtAuthGuard
)
@ApiBearerAuth()
@Get()
getProfile(
@Req() req
) {
return this.profileService
.getProfile(
req.user.id,
req.user.tenantId
);
}
@Put()
updateProfile(
@Req() req,
@Body()
dto: UpdateProfileDto
) {
return this.profileService
.updateProfile(
req.user.id,
req.user.tenantId,
dto
);
}
@ApiOperation({
summary:
'Get current profile'
})
@ApiOperation({
summary:
'Update current profile'
})
@ApiOkResponse({
type:
ProfileResponseDto
})
profile.module.ts
@Module({
imports: [
PrismaModule,
AuthModule
],
controllers: [
ProfileController
],
providers: [
ProfileService
],
exports: [
ProfileService
]
})
export class ProfileModule {}
imports: [ ... ProfileModule ]
GET /profile Authorization: Bearer xxx
{
"id": "...",
"email": "john@test.com",
"firstName": "John",
"lastName": "Doe",
"avatarUrl": null,
"language": "fr",
"timezone": "Europe/Paris"
}
PUT /profile
{
"avatarUrl":
"https://cdn.app/avatar.jpg",
"language":
"fr",
"timezone":
"Europe/Paris",
"biography":
"Property manager"
}
Profil mis à jour.
Un utilisateur :
Ne peut modifier Que son propre profil
Grâce à :
req.user.id
aucun identifiant utilisateur n'est exposé dans l'API.
Événement :
PROFILE_UPDATED
qui sera connecté plus tard à :
AuditLog EntityHistory
du Sprint 19.
Le Sprint 2-C.1 est terminé lorsque :
✓ ProfileModule créé ✓ ProfileController créé ✓ ProfileService créé ✓ GET /profile opérationnel ✓ PUT /profile opérationnel ✓ Swagger documenté ✓ Sécurité validée
ProfileModule ProfileController ProfileService UpdateProfileDto ProfileResponseDto
Transformer le profil utilisateur en profil Enterprise complet.
À l'issue de cette étape :
✓ Upload Avatar ✓ Validation image ✓ Suppression Avatar ✓ Stockage MinIO ✓ Compatibilité S3 ✓ Historique modifications ✓ Audit des changements ✓ URLs sécurisées
Client ↓ Upload Avatar ↓ Validation ↓ Storage Service ↓ MinIO / S3 ↓ UserProfile ↓ AuditLog
src/modules/storage
storage ├── domain │ │ └── services │ │ └── storage.service.ts │ ├── infrastructure │ │ └── providers │ │ └── minio.provider.ts │ └── storage.module.ts
npm install minio
MINIO_ENDPOINT=localhost MINIO_PORT=9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=avatars MINIO_SSL=false
minio: image: minio/minio container_name: minio command: server /data ports: - "9000:9000" - "9001:9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin volumes: - minio-data:/data
infrastructure/providers/minio.provider.ts
import * as Minio
from 'minio';
export const MinioProvider = {
provide: 'MINIO',
useFactory: () => {
return new Minio.Client({
endPoint:
process.env.MINIO_ENDPOINT,
port:
Number(
process.env.MINIO_PORT
),
useSSL:
process.env.MINIO_SSL ===
'true',
accessKey:
process.env.MINIO_ACCESS_KEY,
secretKey:
process.env.MINIO_SECRET_KEY
});
}
};
storage.service.ts
uploadAvatar() deleteAvatar() generateUrl()
async uploadAvatar( file: Express.Multer.File, userId: string )
const fileName =
`avatars/${userId}/${
randomUUID()
}.jpg`;
await this.minioClient.putObject( process.env.MINIO_BUCKET, fileName, file.buffer );
return fileName;
jpg jpeg png webp
5 MB max
Créer :
shared/validators/avatar.validator.ts
export const ALLOWED_TYPES = [ 'image/jpeg', 'image/png', 'image/webp' ];
if (
!ALLOWED_TYPES.includes(
file.mimetype
)
) {
throw new BadRequestException(
'Invalid image'
);
}
application/dto/avatar-response.dto.ts
export class AvatarResponseDto {
avatarUrl: string;
}
POST /profile/avatar
@Post('avatar')
@UseInterceptors(
FileInterceptor(
'file'
)
)
@ApiConsumes( 'multipart/form-data' )
uploadAvatar(
@Req() req,
@UploadedFile()
file
) {
return this.profileService
.uploadAvatar(
req.user.id,
file
);
}
async uploadAvatar(
userId: string,
file:
Express.Multer.File
)
const avatarUrl =
await this.storageService
.uploadAvatar(
file,
userId
);
await this.prisma.userProfile.update({
where: {
userId
},
data: {
avatarUrl
}
});
DELETE /profile/avatar
async deleteAvatar( userId: string )
Load Profile ↓ Delete Object MinIO ↓ avatarUrl = null
Ne jamais exposer :
bucket interne
presignedGetObject()
const url =
await this.minioClient
.presignedGetObject(
bucket,
fileName,
3600
);
ProfileHistory
model ProfileHistory {
id String @id @default(uuid())
userId String
changedField String
oldValue String?
newValue String?
changedAt DateTime @default(now())
}
profileHistory ProfileHistory[]
Ajouter :
PROFILE_UPDATED
Champ Ancienne valeur Nouvelle valeur Date
AVATAR_UPLOADED AVATAR_DELETED
GET /profile PUT /profile POST /profile/avatar DELETE /profile/avatar
jpg valide ↓ 200
png valide ↓ 200
pdf ↓ 400
10MB ↓ 400
Cette architecture est compatible :
AWS S3 MinIO Azure Blob Storage Google Cloud Storage
via simple remplacement du :
StorageProvider
Le Sprint 2-C.2 est terminé lorsque :
✓ Upload Avatar ✓ Suppression Avatar ✓ Validation image ✓ MinIO opérationnel ✓ URLs sécurisées ✓ Historisation profil ✓ Audit prêt ✓ Swagger documenté
StorageModule StorageService MinioProvider Avatar Upload Avatar Delete ProfileHistory Audit Profile
Implémenter la gestion complète des adresses utilisateur.
À l'issue de cette étape :
✓ AddressModule ✓ AddressController ✓ AddressService ✓ CRUD Adresses ✓ Adresse par défaut ✓ Validation pays ✓ Validation code postal ✓ Swagger ✓ Sécurité utilisateur
src/modules/address
address ├── application │ │ └── dto │ │ ├── create-address.dto.ts │ ├── update-address.dto.ts │ └── address-response.dto.ts │ ├── domain │ │ └── services │ │ └── address.service.ts │ ├── presentation │ │ └── controllers │ │ └── address.controller.ts │ └── address.module.ts
application/dto/create-address.dto.ts
import {
IsString,
IsOptional,
IsBoolean
} from 'class-validator';
export class CreateAddressDto {
@IsString()
label: string;
@IsString()
addressLine1: string;
@IsOptional()
@IsString()
addressLine2?: string;
@IsString()
postalCode: string;
@IsString()
city: string;
@IsOptional()
@IsString()
state?: string;
@IsString()
country: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
application/dto/update-address.dto.ts
import {
PartialType
} from '@nestjs/swagger';
import {
CreateAddressDto
} from './create-address.dto';
export class UpdateAddressDto
extends PartialType(
CreateAddressDto
) {}
application/dto/address-response.dto.ts
export class AddressResponseDto {
id: string;
label: string;
addressLine1: string;
addressLine2?: string;
postalCode: string;
city: string;
state?: string;
country: string;
isDefault: boolean;
createdAt: Date;
}
domain/services/address.service.ts
@Injectable()
export class AddressService {
constructor(
private readonly prisma:
PrismaService
) {}
}
findAll() create() update() remove() setDefault()
shared/constants/countries.constants.ts
export const SUPPORTED_COUNTRIES = [ 'FR', 'BE', 'CH', 'ES', 'IT', 'DE', 'GB', 'US', 'CA' ];
if (
!SUPPORTED_COUNTRIES.includes(
dto.country
)
) {
throw new BadRequestException(
'Unsupported country'
);
}
shared/validators/postal-code.validator.ts
const frenchPostalCode =
/^[0-9]{5}$/;
if (
dto.country === 'FR' &&
!frenchPostalCode.test(
dto.postalCode
)
) {
throw new BadRequestException(
'Invalid postal code'
);
}
async findAll(
userId: string
) {
return this.prisma.address.findMany({
where: {
userId
},
orderBy: [
{
isDefault:
'desc'
},
{
createdAt:
'desc'
}
]
});
}
private async resetDefaultAddress( userId: string )
await this.prisma.address.updateMany({
where: {
userId
},
data: {
isDefault: false
}
});
async create( userId: string, dto: CreateAddressDto )
if (dto.isDefault) {
await this.resetDefaultAddress(
userId
);
}
return this.prisma.address.create({
data: {
userId,
...dto
}
});
const address =
await this.prisma.address.findFirst({
where: {
id,
userId
}
});
if (!address) {
throw new NotFoundException(
'Address not found'
);
}
if (dto.isDefault) {
await this.resetDefaultAddress(
userId
);
}
return this.prisma.address.update({
where: {
id
},
data: dto
});
await this.prisma.address.delete({
where: {
id
}
});
presentation/controllers/address.controller.ts
@ApiTags('Addresses')
@Controller(
'profile/addresses'
)
@UseGuards(
JwtAuthGuard
)
@ApiBearerAuth()
@Get()
findAll(
@Req() req
) {
return this.addressService.findAll(
req.user.id
);
}
@Post()
create(
@Req() req,
@Body()
dto: CreateAddressDto
) {
return this.addressService.create(
req.user.id,
dto
);
}
@Put(':id')
update(
@Req() req,
@Param('id')
id: string,
@Body()
dto: UpdateAddressDto
) {
return this.addressService.update(
req.user.id,
id,
dto
);
}
@Delete(':id')
remove(
@Req() req,
@Param('id')
id: string
) {
return this.addressService.remove(
req.user.id,
id
);
}
address.module.ts
@Module({
imports: [
PrismaModule,
AuthModule
],
controllers: [
AddressController
],
providers: [
AddressService
],
exports: [
AddressService
]
})
export class AddressModule {}
GET /profile/addresses
POST /profile/addresses
PUT /profile/addresses/{id}
DELETE /profile/addresses/{id}
{
"label": "Domicile",
"addressLine1": "10 rue Victor Hugo",
"postalCode": "75001",
"city": "Paris",
"country": "FR",
"isDefault": true
}
Adresse créée Adresse par défaut appliquée Validation pays OK Validation code postal OK
Prévoir :
Google Places OpenStreetMap Validation géographique Latitude Longitude Géocodage inverse
afin d'améliorer :
Facturation Réservations Revenue Management CRM
Le Sprint 2-D.1 est terminé lorsque :
✓ AddressModule créé ✓ AddressController créé ✓ AddressService créé ✓ CRUD adresses opérationnel ✓ Adresse par défaut opérationnelle ✓ Validation pays opérationnelle ✓ Validation code postal opérationnelle ✓ Swagger documenté ✓ Tests verts
AddressModule AddressController AddressService CreateAddressDto UpdateAddressDto AddressResponseDto
Transformer le système d'adresses en composant Enterprise géolocalisé.
À l'issue de cette étape :
✓ Latitude ✓ Longitude ✓ Géocodage ✓ Reverse Geocoding ✓ Validation géographique ✓ Normalisation adresse ✓ OpenStreetMap ✓ Google Places (optionnel) ✓ Recherche cartographique
User Address ↓ Validation ↓ Geocoding Service ↓ OpenStreetMap ou Google Places ↓ Latitude / Longitude ↓ Address Storage
Dans :
model Address
latitude Decimal? @db.Decimal(10,7)
longitude Decimal? @db.Decimal(10,7)
formattedAddress String?
streetNumber String?
route String?
region String?
county String?
geocodedAt DateTime?
placeId String?
validationStatus String? @default("PENDING")
PENDING VALIDATED FAILED
npx prisma migrate dev \
--name address_geolocation
npx prisma generate
src/modules/geocoding ├── application │ ├── domain │ │ └── services │ │ └── geocoding.service.ts │ ├── infrastructure │ │ └── providers │ │ ├── osm.provider.ts │ └── google.provider.ts │ └── geocoding.module.ts
npm install @nestjs/axios
OSM_BASE_URL= https://nominatim.openstreetmap.org
GOOGLE_MAPS_API_KEY=
domain/models/geocoding-result.ts
export interface GeocodingResult {
latitude: number;
longitude: number;
formattedAddress:
string;
streetNumber?: string;
route?: string;
city?: string;
region?: string;
postalCode?: string;
country?: string;
placeId?: string;
}
@Injectable()
export class GeocodingService {
async geocode(
address: string
)
async reverseGeocode(
latitude: number,
longitude: number
)
}
GET /search
q format=jsonv2 limit=1
const response =
await this.httpService.axiosRef.get(
`${baseUrl}/search`,
{
params: {
q: address,
format: 'jsonv2',
limit: 1
}
}
);
return {
latitude:
Number(result.lat),
longitude:
Number(result.lon),
formattedAddress:
result.display_name
};
shared/services/address-validation.service.ts
validateAddress( dto )
Pays valide Ville valide Code postal cohérent Adresse trouvée Coordonnées trouvées
throw new BadRequestException( 'Address validation failed' );
Entrée :
10 rue victor hugo 75001 paris
10 Rue Victor Hugo, 75001 Paris, France
normalizeAddress( dto )
formattedAddress
constructor(
private readonly prisma:
PrismaService,
private readonly geocodingService:
GeocodingService
)
Ajouter :
const addressString =
`${dto.addressLine1}
${dto.postalCode}
${dto.city}
${dto.country}`;
const geocoded =
await this.geocodingService
.geocode(
addressString
);
latitude: geocoded.latitude, longitude: geocoded.longitude, formattedAddress: geocoded.formattedAddress, validationStatus: 'VALIDATED', geocodedAt: new Date()
validationStatus: 'FAILED'
Même si :
OpenStreetMap indisponible
reverseGeocode( lat, lng )
Properties Reservations CRM Mobile App
GET /profile/addresses/nearby
lat lng radius
Pour :
Recherche biens Recherche clients Recherche agences
GET /profile/addresses
retourne désormais :
{
"latitude": 48.864716,
"longitude": 2.349014,
"formattedAddress":
"10 Rue Victor Hugo, Paris"
}
AddressResponseDto
latitude?: number; longitude?: number; formattedAddress?: string; validationStatus?: string;
Le modèle devient compatible :
Multi-pays Multi-régions Multi-devises Fiscalité Facturation CRM Revenue Management Channel Manager
Le Sprint 2-D.2 est terminé lorsque :
✓ Latitude stockée ✓ Longitude stockée ✓ Géocodage automatique ✓ Reverse Geocoding ✓ Validation adresse ✓ Normalisation adresse ✓ OpenStreetMap intégré ✓ Swagger mis à jour ✓ Tests verts
GeocodingModule GeocodingService AddressValidationService Address enrichie OpenStreetMap Integration Reverse Geocoding
Implémenter la gestion complète des préférences utilisateur basée sur :
UserPreference
À l'issue de cette étape :
✓ PreferencesModule ✓ PreferencesController ✓ PreferencesService ✓ GET /preferences ✓ PUT /preferences ✓ Langue ✓ Devise ✓ Fuseau horaire ✓ Format date ✓ Thème
src/modules/preferences
preferences ├── application │ │ └── dto │ │ ├── update-preferences.dto.ts │ └── preferences-response.dto.ts │ ├── domain │ │ └── services │ │ └── preferences.service.ts │ ├── presentation │ │ └── controllers │ │ └── preferences.controller.ts │ └── preferences.module.ts
application/dto/update-preferences.dto.ts
import {
IsOptional,
IsString,
IsIn
} from 'class-validator';
export class UpdatePreferencesDto {
@IsOptional()
@IsString()
language?: string;
@IsOptional()
@IsString()
currency?: string;
@IsOptional()
@IsString()
timezone?: string;
@IsOptional()
@IsString()
dateFormat?: string;
@IsOptional()
@IsIn([
'light',
'dark',
'system'
])
theme?: string;
}
application/dto/preferences-response.dto.ts
export class PreferencesResponseDto {
language: string;
currency: string;
timezone: string;
dateFormat: string;
theme: string;
createdAt: Date;
updatedAt: Date;
}
shared/constants/languages.constants.ts
export const SUPPORTED_LANGUAGES = [ 'fr', 'en', 'es', 'de', 'it', 'pt', 'nl' ];
shared/constants/currencies.constants.ts
export const SUPPORTED_CURRENCIES = [ 'EUR', 'USD', 'GBP', 'CHF', 'CAD' ];
shared/constants/timezones.constants.ts
export const SUPPORTED_TIMEZONES = [ 'Europe/Paris', 'Europe/London', 'Europe/Berlin', 'America/New_York', 'America/Montreal', 'Asia/Tokyo' ];
domain/services/preferences.service.ts
@Injectable()
export class PreferencesService {
constructor(
private readonly prisma:
PrismaService
) {}
}
getPreferences() updatePreferences()
async getPreferences(
userId: string
) {
const preferences =
await this.prisma
.userPreference
.findUnique({
where: {
userId
}
});
if (!preferences) {
throw new NotFoundException(
'Preferences not found'
);
}
return preferences;
}
private validatePreferences(
dto:
UpdatePreferencesDto
)
if (
dto.language &&
!SUPPORTED_LANGUAGES
.includes(
dto.language
)
) {
throw new BadRequestException(
'Unsupported language'
);
}
if (
dto.currency &&
!SUPPORTED_CURRENCIES
.includes(
dto.currency
)
) {
throw new BadRequestException(
'Unsupported currency'
);
}
if (
dto.timezone &&
!SUPPORTED_TIMEZONES
.includes(
dto.timezone
)
) {
throw new BadRequestException(
'Unsupported timezone'
);
}
async updatePreferences(
userId: string,
dto:
UpdatePreferencesDto
)
this.validatePreferences( dto );
await this.prisma
.userPreference
.update({
where: {
userId
},
data: {
language:
dto.language,
currency:
dto.currency,
timezone:
dto.timezone,
dateFormat:
dto.dateFormat,
theme:
dto.theme
}
});
return this.getPreferences( userId );
Ajouter plus tard :
PreferenceHistory
afin de suivre :
Changements langue Changements devise Changements fuseau
presentation/controllers/preferences.controller.ts
@ApiTags('Preferences')
@Controller(
'preferences'
)
@UseGuards(
JwtAuthGuard
)
@ApiBearerAuth()
@Get()
getPreferences(
@Req() req
) {
return this.preferencesService
.getPreferences(
req.user.id
);
}
@Put()
updatePreferences(
@Req() req,
@Body()
dto:
UpdatePreferencesDto
) {
return this.preferencesService
.updatePreferences(
req.user.id,
dto
);
}
@ApiOperation({
summary:
'Get current user preferences'
})
@ApiOperation({
summary:
'Update current user preferences'
})
@ApiOkResponse({
type:
PreferencesResponseDto
})
preferences.module.ts
@Module({
imports: [
PrismaModule,
AuthModule
],
controllers: [
PreferencesController
],
providers: [
PreferencesService
],
exports: [
PreferencesService
]
})
export class PreferencesModule {}
imports: [ ... PreferencesModule ]
GET /preferences
{
"language": "fr",
"currency": "EUR",
"timezone": "Europe/Paris",
"dateFormat": "DD/MM/YYYY",
"theme": "light"
}
PUT /preferences
{
"language": "en",
"currency": "USD",
"timezone": "America/New_York",
"theme": "dark"
}
Préférences mises à jour
Ces préférences seront utilisées plus tard par :
InternationalizationModule LocalizationModule MultiCurrencyModule NotificationModule ReportingModule
des Sprints :
15 20
Le Sprint 2-E.1 est terminé lorsque :
✓ PreferencesModule créé ✓ PreferencesController créé ✓ PreferencesService créé ✓ GET /preferences opérationnel ✓ PUT /preferences opérationnel ✓ Validation langue ✓ Validation devise ✓ Validation fuseau ✓ Swagger documenté ✓ Tests verts
PreferencesModule PreferencesController PreferencesService UpdatePreferencesDto PreferencesResponseDto Validation Langues Validation Devises Validation Fuseaux
Faire évoluer le système de préférences utilisateur vers un moteur complet d'internationalisation.
À l'issue de cette étape :
✓ Locales ✓ Formats régionaux ✓ Formats monétaires ✓ Formats numériques ✓ Formats horaires ✓ Détection navigateur ✓ Préférences automatiques ✓ Compatibilité Sprint 15 ✓ Compatibilité Sprint 20
Browser ↓ Locale Detection ↓ Preferences Service ↓ User Preferences ↓ Localization Engine ↓ UI Rendering
Dans :
model UserPreference
locale String? @default("fr-FR")
numberFormat String? @default("fr-FR")
currencyFormat String? @default("fr-FR")
timeFormat String? @default("24H")
weekStartsOn Int? @default(1)
measurementSystem String? @default("METRIC")
browserLanguage String?
browserTimezone String?
autoDetectLocale Boolean @default(true)
autoDetectTimezone Boolean @default(true)
npx prisma migrate dev \
--name preferences_internationalization
npx prisma generate
locale?: string; numberFormat?: string; currencyFormat?: string; timeFormat?: string; weekStartsOn?: number; measurementSystem?: string; autoDetectLocale?: boolean; autoDetectTimezone?: boolean;
locale: string; numberFormat: string; currencyFormat: string; timeFormat: string; weekStartsOn: number; measurementSystem: string; browserLanguage?: string; browserTimezone?: string; autoDetectLocale: boolean; autoDetectTimezone: boolean;
shared/constants/locales.constants.ts
export const SUPPORTED_LOCALES = [ 'fr-FR', 'en-US', 'en-GB', 'de-DE', 'es-ES', 'it-IT', 'pt-PT', 'nl-NL' ];
export const TIME_FORMATS = [ '12H', '24H' ];
export const MEASUREMENT_SYSTEMS = [ 'METRIC', 'IMPERIAL' ];
shared/services locale-detection.service.ts
detectLanguage() detectTimezone() detectLocale()
Accept-Language
detectLanguage(
request: Request
) {
return request.headers[
'accept-language'
];
}
X-Timezone
Europe/Paris America/New_York Asia/Tokyo
fr-FR ↓ language = fr currency = EUR timezone = Europe/Paris numberFormat = fr-FR
if (
!SUPPORTED_LOCALES.includes(
dto.locale
)
) {
throw new BadRequestException(
'Unsupported locale'
);
}
if (
!TIME_FORMATS.includes(
dto.timeFormat
)
) {
throw new BadRequestException(
'Invalid time format'
);
}
if (
!MEASUREMENT_SYSTEMS.includes(
dto.measurementSystem
)
) {
throw new BadRequestException(
'Invalid measurement system'
);
}
User Login ↓ No Preferences ↓ Detect Browser ↓ Apply Defaults ↓ Persist Preferences
initializePreferencesFromBrowser( request )
browserLanguage browserTimezone locale
shared/services/date-format.service.ts
formatDate( date, preferences )
fr-FR ↓ 31/12/2026
en-US ↓ 12/31/2026
shared/services/currency-format.service.ts
1500.50 ↓ 1 500,50 €
1500.50 ↓ $1,500.50
shared/services/number-format.service.ts
1000000 ↓ 1 000 000
1000000 ↓ 1,000,000
shared/constants currency-locales.ts
export const DEFAULT_CURRENCY_BY_LOCALE = {
'fr-FR': 'EUR',
'en-US': 'USD',
'en-GB': 'GBP',
'de-DE': 'EUR'
};
Créer :
locale currency timezone
à partir :
Browser Locale
si disponible.
GET /preferences
{
"language": "fr",
"locale": "fr-FR",
"currency": "EUR",
"timezone": "Europe/Paris",
"numberFormat": "fr-FR",
"currencyFormat": "fr-FR",
"timeFormat": "24H",
"measurementSystem": "METRIC"
}
PUT /preferences
{
"locale": "en-US",
"currency": "USD",
"timezone": "America/New_York",
"timeFormat": "12H",
"measurementSystem": "IMPERIAL"
}
Country Currency Language Timezone Translation Localization Multi-Site Multi-Régions
Elles seront utilisées par :
InternationalizationModule LocalizationModule MultiCurrencyModule EnterpriseReleaseModule
Le Sprint 2-E.2 est terminé lorsque :
✓ Locales supportées ✓ Détection navigateur ✓ Formats régionaux ✓ Formats monétaires ✓ Formats numériques ✓ Formats horaires ✓ Préférences automatiques ✓ Swagger documenté ✓ Compatible Sprint 15 ✓ Compatible Sprint 20
LocaleDetectionService DateFormatService CurrencyFormatService NumberFormatService UserPreference Enterprise Détection automatique navigateur Support internationalisation
Implémenter la gestion complète des préférences de notifications utilisateur basée sur :
NotificationSetting
À l'issue de cette étape :
✓ NotificationSettingsModule ✓ NotificationSettingsController ✓ NotificationSettingsService ✓ GET /notifications/settings ✓ PUT /notifications/settings ✓ Notifications Email ✓ Notifications SMS ✓ Notifications Push ✓ Préférences Marketing ✓ Swagger
src/modules/notification-settings
notification-settings ├── application │ │ └── dto │ │ ├── update-notification-settings.dto.ts │ └── notification-settings-response.dto.ts │ ├── domain │ │ └── services │ │ └── notification-settings.service.ts │ ├── presentation │ │ └── controllers │ │ └── notification-settings.controller.ts │ └── notification-settings.module.ts
model NotificationSetting
model NotificationSetting {
id String @id @default(uuid())
userId String @unique
emailEnabled Boolean @default(true)
smsEnabled Boolean @default(false)
pushEnabled Boolean @default(true)
marketingEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
@relation(
fields:[userId],
references:[id],
onDelete: Cascade
)
}
reservationEmails Boolean @default(true) paymentEmails Boolean @default(true) marketingEmails Boolean @default(false) systemEmails Boolean @default(true) reservationSms Boolean @default(false) paymentSms Boolean @default(false) securitySms Boolean @default(true) pushReservations Boolean @default(true) pushPayments Boolean @default(true) pushMarketing Boolean @default(false)
npx prisma migrate dev \
--name notification_settings_enterprise
npx prisma generate
application/dto/update-notification-settings.dto.ts
import {
IsOptional,
IsBoolean
} from 'class-validator';
export class UpdateNotificationSettingsDto {
@IsOptional()
@IsBoolean()
emailEnabled?: boolean;
@IsOptional()
@IsBoolean()
smsEnabled?: boolean;
@IsOptional()
@IsBoolean()
pushEnabled?: boolean;
@IsOptional()
@IsBoolean()
marketingEnabled?: boolean;
@IsOptional()
@IsBoolean()
reservationEmails?: boolean;
@IsOptional()
@IsBoolean()
paymentEmails?: boolean;
@IsOptional()
@IsBoolean()
marketingEmails?: boolean;
@IsOptional()
@IsBoolean()
systemEmails?: boolean;
@IsOptional()
@IsBoolean()
reservationSms?: boolean;
@IsOptional()
@IsBoolean()
paymentSms?: boolean;
@IsOptional()
@IsBoolean()
securitySms?: boolean;
@IsOptional()
@IsBoolean()
pushReservations?: boolean;
@IsOptional()
@IsBoolean()
pushPayments?: boolean;
@IsOptional()
@IsBoolean()
pushMarketing?: boolean;
}
application/dto/notification-settings-response.dto.ts
export class NotificationSettingsResponseDto {
emailEnabled: boolean;
smsEnabled: boolean;
pushEnabled: boolean;
marketingEnabled: boolean;
reservationEmails: boolean;
paymentEmails: boolean;
marketingEmails: boolean;
systemEmails: boolean;
reservationSms: boolean;
paymentSms: boolean;
securitySms: boolean;
pushReservations: boolean;
pushPayments: boolean;
pushMarketing: boolean;
createdAt: Date;
updatedAt: Date;
}
domain/services/notification-settings.service.ts
@Injectable()
export class NotificationSettingsService {
constructor(
private readonly prisma:
PrismaService
) {}
}
getSettings() updateSettings()
async getSettings(
userId: string
) {
const settings =
await this.prisma
.notificationSetting
.findUnique({
where: {
userId
}
});
if (!settings) {
throw new NotFoundException(
'Notification settings not found'
);
}
return settings;
}
async updateSettings(
userId: string,
dto:
UpdateNotificationSettingsDto
) {
await this.prisma
.notificationSetting
.update({
where: {
userId
},
data: dto
});
return this.getSettings(
userId
);
}
presentation/controllers notification-settings.controller.ts
@ApiTags('Notification Settings')
@Controller(
'notifications/settings'
)
@UseGuards(
JwtAuthGuard
)
@ApiBearerAuth()
@Get()
getSettings(
@Req() req
) {
return this
.notificationSettingsService
.getSettings(
req.user.id
);
}
@Put()
updateSettings(
@Req() req,
@Body()
dto:
UpdateNotificationSettingsDto
) {
return this
.notificationSettingsService
.updateSettings(
req.user.id,
dto
);
}
notification-settings.module.ts
@Module({
imports: [
PrismaModule,
AuthModule
],
controllers: [
NotificationSettingsController
],
providers: [
NotificationSettingsService
],
exports: [
NotificationSettingsService
]
})
export class NotificationSettingsModule {}
imports: [ ... NotificationSettingsModule ]
Les endpoints suivants apparaissent :
GET /notifications/settings PUT /notifications/settings
GET /notifications/settings Authorization: Bearer xxx
{
"emailEnabled": true,
"smsEnabled": false,
"pushEnabled": true,
"marketingEnabled": false,
"reservationEmails": true,
"paymentEmails": true,
"systemEmails": true
}
PUT /notifications/settings
{
"marketingEnabled": true,
"marketingEmails": true,
"pushMarketing": true
}
Préférences enregistrées
Les champs :
marketingEnabled marketingEmails pushMarketing
seront utilisés par :
Consent MarketingCampaign CommunicationModule
afin de respecter :
RGPD Opt-in Opt-out
Ces paramètres seront exploités par :
EmailModule SmsModule PushModule CampaignModule
du Sprint 9.
Le Sprint 2-F.1 est terminé lorsque :
✓ NotificationSettingsModule créé ✓ NotificationSettingsController créé ✓ NotificationSettingsService créé ✓ GET opérationnel ✓ PUT opérationnel ✓ Préférences Email ✓ Préférences SMS ✓ Préférences Push ✓ Préférences Marketing ✓ Swagger documenté
NotificationSettingsModule NotificationSettingsController NotificationSettingsService UpdateNotificationSettingsDto NotificationSettingsResponseDto
Transformer le système de notifications en centre de préférences Enterprise complet.
À l'issue de cette étape :
✓ Multi-canaux ✓ Fréquence configurable ✓ Digest quotidien ✓ Digest hebdomadaire ✓ Silence Hours ✓ Catégories ✓ Consentements RGPD ✓ Préférences avancées ✓ Compatible Sprint 9 ✓ Compatible Sprint 19
Notification Event ↓ Preference Center ↓ Consent Validation ↓ Channel Selection ↓ Delivery Rules ↓ Email SMS Push Webhook
Dans :
model NotificationSetting
notificationFrequency String
@default("REALTIME")
dailyDigestEnabled Boolean
@default(false)
weeklyDigestEnabled Boolean
@default(false)
quietHoursEnabled Boolean
@default(false)
quietHoursStart String?
quietHoursEnd String?
mobilePushEnabled Boolean
@default(true)
desktopPushEnabled Boolean
@default(true)
gdprConsentGiven Boolean
@default(false)
gdprConsentAt DateTime?
marketingConsentGiven Boolean
@default(false)
marketingConsentAt DateTime?
model NotificationCategory {
id String
@id
@default(uuid())
code String
@unique
name String
description String?
createdAt DateTime
@default(now())
updatedAt DateTime
@updatedAt
preferences NotificationCategoryPreference[]
}
model NotificationCategoryPreference {
id String
@id
@default(uuid())
notificationSettingId String
categoryId String
emailEnabled Boolean
@default(true)
smsEnabled Boolean
@default(false)
pushEnabled Boolean
@default(true)
notificationSetting NotificationSetting
@relation(
fields:[notificationSettingId],
references:[id],
onDelete:Cascade
)
category NotificationCategory
@relation(
fields:[categoryId],
references:[id],
onDelete:Cascade
)
@@unique([
notificationSettingId,
categoryId
])
}
Dans :
model NotificationSetting
categoryPreferences NotificationCategoryPreference[]
npx prisma migrate dev \
--name notification_preferences_enterprise
npx prisma generate
shared/constants notification-frequency.constants.ts
export const NOTIFICATION_FREQUENCIES = [ 'REALTIME', 'HOURLY', 'DAILY', 'WEEKLY' ];
shared/constants notification-categories.constants.ts
export const NOTIFICATION_CATEGORIES = [ 'RESERVATION', 'PAYMENT', 'SECURITY', 'SYSTEM', 'MARKETING', 'CRM', 'PROPERTY', 'OWNER', 'CHANNEL_MANAGER' ];
Dans :
UpdateNotificationSettingsDto
notificationFrequency?: string; dailyDigestEnabled?: boolean; weeklyDigestEnabled?: boolean; quietHoursEnabled?: boolean; quietHoursStart?: string; quietHoursEnd?: string; mobilePushEnabled?: boolean; desktopPushEnabled?: boolean; gdprConsentGiven?: boolean; marketingConsentGiven?: boolean;
update-notification-category.dto.ts
export class UpdateNotificationCategoryDto {
categoryCode: string;
emailEnabled: boolean;
smsEnabled: boolean;
pushEnabled: boolean;
}
if (
!NOTIFICATION_FREQUENCIES.includes(
dto.notificationFrequency
)
) {
throw new BadRequestException(
'Invalid notification frequency'
);
}
if (
dto.quietHoursEnabled &&
(!dto.quietHoursStart ||
!dto.quietHoursEnd)
) {
throw new BadRequestException(
'Quiet hours invalid'
);
}
if (
dto.gdprConsentGiven ===
true
) {
data.gdprConsentAt =
new Date();
}
if (
dto.marketingConsentGiven ===
true
) {
data.marketingConsentAt =
new Date();
}
GET /notifications/categories
[
{
"code":"PAYMENT",
"emailEnabled":true,
"smsEnabled":false,
"pushEnabled":true
}
]
PUT /notifications/categories
{
"categoryCode":"PAYMENT",
"emailEnabled":true,
"smsEnabled":true,
"pushEnabled":true
}
POST /notifications/consents
RGPD Marketing Partenaires Newsletter
Dans :
prisma/seed.ts
RESERVATION PAYMENT SECURITY SYSTEM MARKETING CRM PROPERTY
Créer automatiquement :
NotificationCategoryPreference
pour chaque catégorie.
NotificationPreferenceResolver
Déterminer :
Canal ↓ Autorisé ? ↓ Consentement ? ↓ Silence Hours ? ↓ Digest ? ↓ Envoi
PAYMENT ↓ Email ↓ Push ↓ Pas SMS
selon les préférences.
GET /notifications/settings PUT /notifications/settings GET /notifications/categories PUT /notifications/categories POST /notifications/consents
EmailModule SmsModule PushModule CampaignModule MarketingModule
Compatible :
Consent Audit Compliance RGPD RetentionPolicy
Le Sprint 2-F.2 est terminé lorsque :
✓ Fréquences configurables ✓ Daily Digest ✓ Weekly Digest ✓ Quiet Hours ✓ Notification Categories ✓ Consentements RGPD ✓ Centre de préférences ✓ Seed enrichi ✓ Swagger documenté
NotificationCategory NotificationCategoryPreference Notification Preferences Center GDPR Consent Management Digest Configuration Quiet Hours NotificationPreferenceResolver
Implémenter la gestion complète des sessions utilisateur basée sur :
Session ConnectionHistory
À l'issue de cette étape :
✓ UserSessionModule ✓ UserSessionController ✓ UserSessionService ✓ Sessions actives ✓ Déconnexion appareil ✓ Déconnexion globale ✓ Historique sessions ✓ Sécurité multi-appareils ✓ Swagger
src/modules/user-sessions
user-sessions ├── application │ │ └── dto │ │ ├── session-response.dto.ts │ └── revoke-session.dto.ts │ ├── domain │ │ └── services │ │ └── user-session.service.ts │ ├── presentation │ │ └── controllers │ │ └── user-session.controller.ts │ └── user-session.module.ts
model Session
id String userId String sessionToken String? ipAddress String? userAgent String? deviceName String? platform String? isCurrent Boolean lastSeenAt DateTime? revokedAt DateTime? createdAt DateTime
application/dto/session-response.dto.ts
export class SessionResponseDto {
id: string;
deviceName?: string;
platform?: string;
ipAddress?: string;
userAgent?: string;
isCurrent: boolean;
lastSeenAt?: Date;
createdAt: Date;
revokedAt?: Date;
}
application/dto/revoke-session.dto.ts
export class RevokeSessionDto {
sessionId: string;
}
domain/services/user-session.service.ts
@Injectable()
export class UserSessionService {
constructor(
private readonly prisma:
PrismaService
) {}
}
getSessions() revokeSession() revokeAllSessions() getConnectionHistory()
async getSessions(
userId: string
) {
return this.prisma.session.findMany({
where: {
userId,
revokedAt: null
},
orderBy: {
lastSeenAt: 'desc'
}
});
}
async revokeSession( userId: string, sessionId: string )
const session =
await this.prisma.session.findFirst({
where: {
id: sessionId,
userId
}
});
if (!session) {
throw new NotFoundException(
'Session not found'
);
}
await this.prisma.session.update({
where: {
id: sessionId
},
data: {
revokedAt:
new Date(),
isCurrent:
false
}
});
await this.prisma.refreshToken.updateMany({
where: {
userId,
revokedAt: null
},
data: {
revokedAt:
new Date()
}
});
Dans une future évolution, les refresh tokens devront être liés à :
Session
pour une révocation ciblée.
async revokeAllSessions( userId: string )
await this.prisma.session.updateMany({
where: {
userId,
revokedAt: null
},
data: {
revokedAt:
new Date(),
isCurrent:
false
}
});
await this.prisma.refreshToken.updateMany({
where: {
userId,
revokedAt: null
},
data: {
revokedAt:
new Date()
}
});
async getConnectionHistory(
userId: string
) {
return this.prisma
.connectionHistory
.findMany({
where: {
userId
},
orderBy: {
connectedAt:
'desc'
},
take: 100
});
}
presentation/controllers user-session.controller.ts
@ApiTags('Sessions')
@Controller('sessions')
@UseGuards(
JwtAuthGuard
)
@ApiBearerAuth()
@Get()
getSessions(
@Req() req
) {
return this
.userSessionService
.getSessions(
req.user.id
);
}
@Delete(':id')
revokeSession(
@Req() req,
@Param('id')
id: string
) {
return this
.userSessionService
.revokeSession(
req.user.id,
id
);
}
@Delete()
revokeAllSessions(
@Req() req
) {
return this
.userSessionService
.revokeAllSessions(
req.user.id
);
}
GET /sessions/history
@Get('history')
getHistory(
@Req() req
) {
return this
.userSessionService
.getConnectionHistory(
req.user.id
);
}
Créer :
ConnectionHistory
await prisma.connectionHistory.create({
data: {
userId: user.id,
ipAddress:
session.ipAddress,
userAgent:
session.userAgent,
connectedAt:
new Date(),
success: true
}
});
Ajouter :
disconnectedAt: new Date()
dans :
ConnectionHistory
shared/services device-detection.service.ts
Depuis :
User-Agent
Chrome Windows Safari MacOS Chrome Android Safari iPhone
user-session.module.ts
@Module({
imports: [
PrismaModule,
AuthModule
],
controllers: [
UserSessionController
],
providers: [
UserSessionService
],
exports: [
UserSessionService
]
})
export class UserSessionModule {}
GET /sessions
GET /sessions/history
DELETE /sessions/{id}
DELETE /sessions
Connexion sur :
Chrome
et :
Mobile
2 sessions visibles
DELETE :
/sessions/{id}
Session révoquée
DELETE :
/sessions
Toutes les sessions révoquées
Les informations collectées seront utilisées par :
SecurityIncident Risk AuditLog ComplianceAudit
pour :
Détection fraude Analyse risques Historique sécurité
Le Sprint 2-G.1 est terminé lorsque :
✓ Sessions actives ✓ Historique connexions ✓ Déconnexion appareil ✓ Déconnexion globale ✓ Device detection ✓ Swagger documenté ✓ Tests verts
UserSessionModule UserSessionController UserSessionService Session Management API Connection History API Device Detection Service
Transformer le système de sessions en plateforme de sécurité Enterprise.
À l'issue de cette étape :
✓ Session Risk Score ✓ Trusted Devices ✓ Validation 2FA ✓ Détection anomalies ✓ Détection géographique ✓ Alertes sécurité ✓ Session Policies ✓ Préparation Sprint 19
Login ↓ Security Engine ↓ Risk Analysis ↓ Geo Analysis ↓ Device Analysis ↓ Policy Evaluation ↓ Allow / Challenge / Block
model TrustedDevice {
id String
@id
@default(uuid())
userId String
deviceFingerprint String
deviceName String?
platform String?
browser String?
lastIpAddress String?
lastCountry String?
trustedAt DateTime
@default(now())
lastSeenAt DateTime?
isActive Boolean
@default(true)
createdAt DateTime
@default(now())
updatedAt DateTime
@updatedAt
user User
@relation(
fields:[userId],
references:[id],
onDelete:Cascade
)
@@index([userId])
@@unique([
userId,
deviceFingerprint
])
}
model SecurityAlert {
id String
@id
@default(uuid())
userId String
sessionId String?
severity String
alertType String
title String
description String?
resolved Boolean
@default(false)
resolvedAt DateTime?
metadata Json?
createdAt DateTime
@default(now())
user User
@relation(
fields:[userId],
references:[id],
onDelete:Cascade
)
@@index([userId])
@@index([severity])
@@index([alertType])
@@index([resolved])
}
Dans :
model Session
deviceFingerprint String?
riskScore Int
@default(0)
riskLevel String?
@default("LOW")
country String?
city String?
latitude Decimal?
@db.Decimal(10,7)
longitude Decimal?
@db.Decimal(10,7)
isTrustedDevice Boolean
@default(false)
requires2fa Boolean
@default(false)
securityFlags Json?
lastActivityAt DateTime?
Dans :
model User
trustedDevices TrustedDevice[] securityAlerts SecurityAlert[]
npx prisma migrate dev \
--name session_security_enterprise
npx prisma generate
src/modules/security
security ├── domain │ │ └── services │ │ ├── risk-engine.service.ts │ ├── geo-security.service.ts │ ├── trusted-device.service.ts │ └── security-alert.service.ts │ └── security.module.ts
risk-engine.service.ts
calculateRiskScore( context )
+30 points
+20 points
+15 points
+10 points
+25 points
+50 points
0 - 29 LOW
30 - 59 MEDIUM
60+ HIGH
isTrusted() registerTrustedDevice() revokeTrustedDevice() getTrustedDevices()
à partir de :
User-Agent Platform Browser Screen Resolution Timezone
sha256( fingerprint )
Login ↓ Trusted Device ? ↓ Yes ↓ Risk Reduced
New Device ↓ Risk Increased
Risk HIGH ↓ 2FA obligatoire
if (
riskScore >= 60
) {
requires2fa = true;
}
Compatible avec :
TOTP SMS OTP Email OTP Authenticator App
resolveLocation() detectImpossibleTravel() detectCountryChange()
Paris ↓ 5 minutes ↓ New York
HIGH RISK
France ↓ Russie
Security Alert
createAlert() resolveAlert() listAlerts()
NEW_DEVICE IMPOSSIBLE_TRAVEL HIGH_RISK_LOGIN COUNTRY_CHANGE MULTIPLE_FAILED_LOGINS SESSION_HIJACK_ATTEMPT
LOW MEDIUM HIGH CRITICAL
model SessionPolicy {
id String
@id
@default(uuid())
tenantId String
name String
maxSessionsPerUser Int
@default(10)
sessionDurationMin Int
@default(1440)
require2fa Boolean
@default(false)
allowNewDevices Boolean
@default(true)
allowForeignCountries Boolean
@default(true)
createdAt DateTime
@default(now())
updatedAt DateTime
@updatedAt
tenant Tenant
@relation(
fields:[tenantId],
references:[id]
)
}
Login ↓ Load Policy ↓ Validate Rules ↓ Create Session
Max Sessions = 3 Require 2FA = true Foreign Countries = false
Max Sessions = 10 Require 2FA = false
GET /sessions/trusted-devices
DELETE /sessions/trusted-devices/{id}
GET /security/alerts
PUT /security/alerts/{id}/resolve
Trusted Devices Security Alerts Session Policies
SecurityIncident Risk ComplianceAudit SecurityPolicy AuditLog
Compatible :
Enterprise Security SOC2 ISO27001 Zero Trust Risk Management
Le Sprint 2-G.2 est terminé lorsque :
✓ Risk Score ✓ Trusted Devices ✓ Détection géographique ✓ Alertes sécurité ✓ Validation 2FA ✓ Session Policies ✓ Swagger documenté ✓ Compatible Sprint 19
TrustedDevice SecurityAlert SessionPolicy RiskEngineService GeoSecurityService TrustedDeviceService SecurityAlertService
Mettre en place un système complet d'analyse des connexions et de traçabilité utilisateur.
À l'issue de cette étape :
✓ Historique des connexions ✓ Historique des échecs ✓ Analyse sécurité ✓ Audit utilisateur ✓ Statistiques ✓ Détection comportements anormaux ✓ Préparation Sprint 19
Authentication ↓ ConnectionHistory ↓ Security Analysis ↓ Audit Engine ↓ Statistics Engine ↓ Monitoring Dashboard
model ConnectionHistory
sessionId String?
tenantId String?
failureReason String?
authenticationType String?
riskScore Int?
riskLevel String?
country String?
region String?
city String?
latitude Decimal?
@db.Decimal(10,7)
longitude Decimal?
@db.Decimal(10,7)
deviceFingerprint String?
browser String?
platform String?
connectionDuration Int?
metadata Json?
Dans :
model ConnectionHistory
session Session?
@relation(
fields:[sessionId],
references:[id]
)
@@index([userId]) @@index([connectedAt]) @@index([success]) @@index([country]) @@index([riskLevel]) @@index([tenantId])
npx prisma migrate dev \
--name connection_history_enterprise
npx prisma generate
src/modules/connection-history ├── application │ │ └── dto │ │ ├── connection-query.dto.ts │ ├── security-analysis.dto.ts │ └── connection-statistics.dto.ts │ ├── domain │ │ └── services │ │ └── connection-history.service.ts │ ├── presentation │ │ └── controllers │ │ └── connection-history.controller.ts │ └── connection-history.module.ts
connection-query.dto.ts
export class ConnectionQueryDto {
page?: number;
limit?: number;
success?: boolean;
country?: string;
riskLevel?: string;
from?: Date;
to?: Date;
}
connection-history.service.ts
@Injectable()
export class ConnectionHistoryService {
constructor(
private readonly prisma:
PrismaService
) {}
}
getConnections() getSecurityAnalysis() getStatistics() getFailedConnections()
async getConnections(
userId: string,
query:
ConnectionQueryDto
)
return this.prisma
.connectionHistory
.findMany({
where: {
userId
},
orderBy: {
connectedAt:
'desc'
},
take:
query.limit ?? 50
});
async getFailedConnections( userId: string )
return this.prisma
.connectionHistory
.findMany({
where: {
userId,
success: false
},
orderBy: {
connectedAt:
'desc'
}
});
security-analysis.dto.ts
export class SecurityAnalysisDto {
totalConnections: number;
failedConnections: number;
uniqueCountries: number;
uniqueDevices: number;
highRiskConnections: number;
lastLoginAt?: Date;
}
async getSecurityAnalysis( userId: string )
const totalConnections =
await this.prisma
.connectionHistory
.count({
where: {
userId
}
});
const failedConnections =
await this.prisma
.connectionHistory
.count({
where: {
userId,
success: false
}
});
const highRiskConnections =
await this.prisma
.connectionHistory
.count({
where: {
userId,
riskLevel:
'HIGH'
}
});
const devices =
await this.prisma
.connectionHistory
.findMany({
where: {
userId
},
distinct: [
'deviceFingerprint'
]
});
uniqueDevices: devices.length
connection-statistics.dto.ts
export class ConnectionStatisticsDto {
dailyConnections: number;
weeklyConnections: number;
monthlyConnections: number;
successRate: number;
averageRiskScore: number;
}
async getStatistics( userId: string )
Connexions jour Connexions semaine Connexions mois Taux succès Score moyen
Le modèle :
AuditLog
a été défini lors de :
Phase 2-I
LOGIN_SUCCESS LOGIN_FAILED LOGOUT SESSION_REVOKED PASSWORD_CHANGED 2FA_ENABLED 2FA_DISABLED
shared/interfaces audit-event.interface.ts
export interface AuditEvent {
userId: string;
action: string;
entityType: string;
entityId?: string;
metadata?: Record<
string,
unknown
>;
}
connection-history.controller.ts
@ApiTags('Connections')
@Controller(
'connections'
)
@UseGuards(
JwtAuthGuard
)
@ApiBearerAuth()
@Get()
Historique complet
@Get('security')
Analyse sécurité
@Get('statistics')
Statistiques
@Get('failed')
Historique échecs
connection-history.module.ts
@Module({
imports: [
PrismaModule,
AuthModule
],
controllers: [
ConnectionHistoryController
],
providers: [
ConnectionHistoryService
],
exports: [
ConnectionHistoryService
]
})
export class ConnectionHistoryModule {}
GET /connections GET /connections/failed GET /connections/security GET /connections/statistics
{
"totalConnections": 342,
"failedConnections": 17,
"uniqueCountries": 4,
"uniqueDevices": 6,
"highRiskConnections": 2,
"lastLoginAt":
"2026-06-10T09:12:00Z"
}
{
"dailyConnections": 8,
"weeklyConnections": 41,
"monthlyConnections": 132,
"successRate": 95.2,
"averageRiskScore": 14.8
}
Les données collectées alimenteront :
Risk SecurityIncident ComplianceAudit SecurityPolicy AuditLog
pour :
SOC2 ISO27001 RGPD Enterprise Security
Le Sprint 2-H.1 est terminé lorsque :
✓ ConnectionHistoryModule créé ✓ Historique connexions ✓ Historique échecs ✓ Analyse sécurité ✓ Statistiques ✓ Audit préparé ✓ Swagger documenté ✓ Tests verts
ConnectionHistoryModule ConnectionHistoryController ConnectionHistoryService SecurityAnalysisDto ConnectionStatisticsDto AuditEvent Interface
Finaliser la couche sécurité utilisateur Enterprise en ajoutant :
ThreatDetectionService BehaviorAnalysisService FraudDetection Impossible Travel Brute Force Detection Account Takeover Detection Risk Dashboard
À l'issue de cette étape :
✓ Détection comportementale ✓ Détection fraude ✓ Impossible Travel ✓ Brute Force Detection ✓ Account Takeover Detection ✓ Risk Dashboard ✓ Score de menace ✓ Préparation SOC2 ✓ Préparation ISO27001
Login Event ↓ Threat Engine ↓ Behavior Analysis ↓ Fraud Detection ↓ Risk Scoring ↓ Security Alert ↓ Security Dashboard
model ThreatEvent {
id String
@id
@default(uuid())
userId String?
sessionId String?
threatType String
severity String
riskScore Int
title String
description String?
detectedAt DateTime
@default(now())
resolved Boolean
@default(false)
resolvedAt DateTime?
metadata Json?
user User?
@relation(
fields:[userId],
references:[id]
)
@@index([userId])
@@index([threatType])
@@index([severity])
@@index([detectedAt])
}
model UserBehaviorProfile {
id String
@id
@default(uuid())
userId String
@unique
usualCountries Json?
usualDevices Json?
usualLoginHours Json?
averageRiskScore Float
@default(0)
lastAnalyzedAt DateTime?
createdAt DateTime
@default(now())
updatedAt DateTime
@updatedAt
user User
@relation(
fields:[userId],
references:[id],
onDelete:Cascade
)
}
Dans :
model User
behaviorProfile UserBehaviorProfile? threatEvents ThreatEvent[]
npx prisma migrate dev \
--name threat_detection
npx prisma generate
security ├── domain │ │ └── services │ │ ├── threat-detection.service.ts │ ├── behavior-analysis.service.ts │ ├── fraud-detection.service.ts │ ├── account-takeover.service.ts │ └── risk-dashboard.service.ts
shared/constants threat-types.constants.ts
export const THREAT_TYPES = [ 'IMPOSSIBLE_TRAVEL', 'BRUTE_FORCE', 'ACCOUNT_TAKEOVER', 'SUSPICIOUS_DEVICE', 'SUSPICIOUS_COUNTRY', 'TOKEN_ABUSE', 'SESSION_HIJACK', 'PASSWORD_SPRAY' ];
behavior-analysis.service.ts
buildBehaviorProfile() analyzeLoginBehavior() detectAnomaly()
Pays habituels Appareils habituels Fuseaux horaires Horaires habituels IPs habituelles
ConnectionHistory
des :
90 derniers jours
Connexion habituelle : France Chrome 08h - 19h
Vietnam Tor Browser 03h12
Anomalie détectée
fraud-detection.service.ts
detectFraud() detectImpossibleTravel() detectBruteForce()
Connexion 1 Paris ↓ 15 minutes ↓ Connexion 2 New York
IMPOSSIBLE_TRAVEL
distanceKm / elapsedHours
> 900 km/h
10 échecs ↓ 15 minutes
success = false
sur :
ConnectionHistory
BRUTE_FORCE
account-takeover.service.ts
Nouveau pays + Nouvel appareil + Risque élevé + Modification mot de passe
ACCOUNT_TAKEOVER
Pays inconnu +30 Nouvel appareil +25 IP inconnue +15 Mot de passe changé +20 Total = 90
90 = CRITICAL
threat-detection.service.ts
analyzeLogin( context )
Login ↓ Behavior Analysis ↓ Fraud Detection ↓ Risk Engine ↓ Threat Events ↓ Security Alerts
await prisma.threatEvent.create({
data: {
userId,
threatType:
'IMPOSSIBLE_TRAVEL',
severity:
'HIGH',
riskScore:
85
}
});
risk-dashboard.dto.ts
export class RiskDashboardDto {
activeThreats: number;
criticalThreats: number;
blockedLogins: number;
averageRiskScore: number;
suspiciousDevices: number;
suspiciousCountries: number;
}
getUserRiskDashboard() getTenantRiskDashboard()
ThreatEvent SecurityAlert ConnectionHistory
{
"activeThreats": 4,
"criticalThreats": 1,
"blockedLogins": 12,
"averageRiskScore": 37.2,
"suspiciousDevices": 2,
"suspiciousCountries": 3
}
SecurityController
GET /security/threats GET /security/dashboard GET /security/behavior GET /security/fraud
security.read security.manage
SUPER_ADMIN SECURITY_ADMIN
si :
ThreatEvent.severity = HIGH
ou :
CRITICAL
Email Push SMS
(selon préférences utilisateur)
RiskScore >= 90 ↓ Bloquer session ↓ Forcer 2FA ↓ Créer SecurityAlert
Risk SecurityIncident ComplianceAudit SecurityPolicy AuditLog
SOC2 ISO27001 RGPD NIS2 Zero Trust
Le Sprint 2-H.2 est terminé lorsque :
✓ ThreatEvent créé ✓ UserBehaviorProfile créé ✓ ThreatDetectionService ✓ BehaviorAnalysisService ✓ FraudDetectionService ✓ AccountTakeoverService ✓ RiskDashboardService ✓ Dashboard sécurité ✓ Détection menaces ✓ Swagger documenté
ThreatEvent UserBehaviorProfile ThreatDetectionService BehaviorAnalysisService FraudDetectionService AccountTakeoverService RiskDashboardService SecurityController
Le domaine :
Users Profiles Addresses Preferences Notifications Sessions Security Audit
est désormais entièrement couvert au niveau Enterprise.