diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md index d9c3066263..f4f8e083e1 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -427,14 +427,14 @@ et `user` sont des pre-requis pour la Vague 3 (cluster, project-member). - `GET /api/v1/admin/users` - Liste complete des utilisateurs (admin) - `PATCH /api/v1/admin/users` - Modification utilisateurs (admin) -**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), hooks (adminRole.upsert) +**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), EventEmitter (`adminRole.upsert`) **Dependances entrantes** : `project-member` (importe `logViaSession`), `utils/controller.ts` (importe `logViaSession`, `logViaToken`) **Points d'attention** : - Module critique : `logViaSession` et `logViaToken` sont consommes par le systeme d'auth. Ces fonctions auront ete portees dans l'AuthGuard (Couche 0), donc la dependance entrante est deja resolue -- Hooks utilises (adminRole.upsert) : necessite l'EventEmitter +- Evenement utilise (`adminRole.upsert`) : necessite `@nestjs/event-emitter` - Pre-requis pour `project-member` (Vague 3) **Estimation** : 2 jours @@ -457,11 +457,11 @@ et `user` sont des pre-requis pour la Vague 3 (cluster, project-member). - `GET /api/v1/admin/roles/member-counts` - Comptage des membres par role - `DELETE /api/v1/admin/roles/:roleId` - Suppression d'un role -**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), hooks (adminRole.upsert, adminRole.delete) +**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), EventEmitter (`adminRole.upsert`, `adminRole.delete`) **Dependances entrantes** : Aucune **Points d'attention** : -- Valide le pattern hooks + EventEmitter dans un contexte admin +- Valide le pattern EventEmitter dans un contexte admin - Permissions admin (bitmask) : valide `UserGuard` - Queries a internaliser dans le module diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md index 5d36a235df..8ea198ad25 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md @@ -7,9 +7,7 @@ ## 🎯 Progression globale -![Progress](https://progress-bar.dev/7/?title=modularisation&width=400) - -**~7%** complété (1/18 modules métier migrés, 5/75 routes) +**~24%** complété (4/18 modules métier migrés, 21/75 routes) --- @@ -17,9 +15,9 @@ | Statut | Nombre de modules | % du total | |--------|-------------------|------------| -| ✅ Migré | 1 (ServiceChain) | ~6% | +| ✅ Migré | 4 (ServiceChain, Project, ProjectMembers, AdminRole) | ~22% | | 🚧 En cours | 0 | 0% | -| 📅 Planifié | 17 | ~94% | +| 📅 Planifié | 14 | ~78% | | ⏳ En attente de cartographie | 0 | 0% | --- @@ -48,17 +46,107 @@ profitant de son isolement complet vis-à-vis du reste du codebase. | `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | | `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | +### Project — migré le 2026-05-28 + +Module cœur de gestion des projets (CRUD, secrets, bulk actions). +Regroupe les sous-modules `project-core`, `project-secrets` et `project-bulk` +découpés dans la cartographie Vague 4, implémentés ici en un seul module +unifié pour accélérer la migration. + +- **Routes** : 7 (`/api/v1/projects/...`) +- **Auth** : Token (`x-dso-token`) + guards projet (`ProjectContextGuard`, `ProjectStatusGuard`, `ProjectLockedGuard`) +- **Validation** : Contrats Zod via `projectContract` de `@cpn-console/shared` avec `ZodValidationPipe` +- **Tests** : Controller + Service couverts (Vitest) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/projects/data` | `Manage` (admin) | +| `GET` | `/api/v1/projects` | Authentifié | +| `POST` | `/api/v1/projects` | `ManageProjects` (admin) + type `human` | +| `POST` | `/api/v1/projects-bulk` | `Manage` (admin) | +| `GET` | `/api/v1/projects/:projectId` | Authentifié + projet | +| `GET` | `/api/v1/projects/:projectId/secrets` | Authentifié + projet (statut ≠ archived) | +| `PUT` | `/api/v1/projects/:projectId` | Authentifié + projet (statut ≠ archived) | + +**Infrastructure créée/mise à jour** : +- `ProjectContextGuard` + `Project` decorator : chargement du projet par id/slug, résolution des permissions via bitmask +- `ProjectStatusGuard` + `@RequireProjectStatus()` : filtrage par statut du projet +- `ProjectLockedGuard` : protection contre les modifications de projets verrouillés +- `UserGuard` + `User` decorator : authentification token + injection du contexte utilisateur + +**Différences avec le legacy** : +- Utilisation de `projectContract` de `@cpn-console/shared` pour les schemas de validation (cohérence client/serveur) +- ZodValidationPipe au lieu de la validation ts-rest implicite +- Les routes `secrets` et `bulk` sont fusionnées dans le module Project (pas de sous-modules séparés) +- Pas de `DELETE /api/v1/projects/:projectId` (archivage) dans cette version initiale + +### ProjectMembers — migré le 2026-05-28 + +Module de gestion des membres projet (ajout, modification, suppression, liste). +Dédié aux routes `/api/v1/projects/:projectId/members/...`. + +- **Routes** : 4 (`/api/v1/projects/:projectId/members/...`) +- **Auth** : Token (`x-dso-token`) + guards projet (`ProjectContextGuard`, `ProjectStatusGuard`, `ProjectLockedGuard`) +- **Validation** : Contrats Zod via `projectMemberContract` de `@cpn-console/shared` + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/projects/:projectId/members` | `ListMembers` (admin) | +| `POST` | `/api/v1/projects/:projectId/members` | `ManageMembers` (admin) + statut non archivé | +| `PATCH` | `/api/v1/projects/:projectId/members` | `ManageMembers` (admin) + statut non archivé | +| `DELETE` | `/api/v1/projects/:projectId/members/:userId` | `ManageMembers` (admin) ou auto-suppression + statut non archivé | + +**Fichiers** : +- `src/modules/project/project-members.controller.ts` +- Méthodes ajoutées dans `src/modules/project/project.service.ts` + +**Différences avec le legacy** : +- Recherche par email non disponible (nécessite le hook Keycloak `user.retrieveUserByEmail`, non migré) +- Retour de la liste complète des membres après chaque mutation (cohérence avec le legacy) +- Événements `projectMember.upsert` et `projectMember.delete` via EventEmitter (remplacement du hook `projectMember.upsert`/`projectMember.delete`) + +### AdminRole — migré le 2026-06-12 + +Module de gestion des rôles administrateurs (CRUD + tri + comptage membres). +Dédié aux routes `/api/v1/admin/roles/...`. + +- **Routes** : 5 (`/api/v1/admin/roles/...`) +- **Auth** : Guards admin (`AdminGuard`) + décorateur `@RequireAdminPermission()` +- **Validation** : Contrats Zod via `adminRoleContract` de `@cpn-console/shared` avec `ZodValidationPipe` +- **Tests** : Controller + Service couverts (Vitest) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/admin/roles` | `ListRoles` | +| `POST` | `/api/v1/admin/roles` | `ManageRoles` | +| `PATCH` | `/api/v1/admin/roles` | `ManageRoles` | +| `GET` | `/api/v1/admin/roles/member-counts` | `ManageRoles` | +| `DELETE` | `/api/v1/admin/roles/:roleId` | `ManageRoles` | + +**Infrastructure réutilisée** : +- `AdminGuard` + `@RequireAdminPermission()` : validation des permissions admin +- `AdminService` : validation bitmask + typage utilisateur + +**Fichiers** : +- `src/modules/admin-role/admin-role.controller.ts` +- `src/modules/admin-role/admin-role.service.ts` +- `src/modules/admin-role/admin-role.utils.ts` + +**Différences avec le legacy** : +- Déport de la logique de permissions côté NestJS plutôt que dans le legacy Fastify +- Comptage des membres via query Prisma dédiée au lieu d'agrégat + ### Infrastructure transverse déployée -En support de cette migration, les éléments d'infrastructure suivants ont été +En support de ces migrations, les éléments d'infrastructure suivants ont été créés : - **AuthModule** (`infrastructure/auth/`) : `AuthService` (validation token - SHA256 via Prisma + bearer JWT Keycloak), `DsoTokenModule`, - `KeycloakJwtModule` et décorateur `@AuthUser()` -- **PermissionModule** (`infrastructure/permission/`) : `UserModule` - (`UserGuard`, `UserService`, `UserPolicy`) + `ProjectModule` - (`ProjectGuard`, `ProjectLoaderService`, `ProjectService`, `ProjectPolicy`) + SHA256 via Prisma) + `AdminPermissionGuard` + décorateur + `@RequireAdminPermission()` +- **Guards projet** (`infrastructure/auth/`) : `ProjectContextGuard`, + `ProjectStatusGuard`, `ProjectLockedGuard` + décorateurs `@Project()` et + `@RequireProjectStatus()` - **Nginx strangler** : Reverse proxy configuré pour router les routes migrées vers server-nestjs, le reste vers le legacy - **Docker** : Build order corrigé (shared avant server-nestjs) @@ -78,7 +166,7 @@ créés : ## 📅 Modules planifiés -> Ces informations seront affinées après la cartographie (fin S2) +> Ces informations seront affinées après la cartographie (fin S2). ### Sprint 3-4 (27 janvier - 9 février) - **Module** : Auth @@ -140,9 +228,9 @@ créés : ### Routes par statut - **Total** : ~75 routes métier -- **Migrés** : 5 (~7%) +- **Migrés** : 21 (~28%) - **En cours** : 0 (0%) -- **Restants** : ~70 (~93%) +- **Restants** : ~54 (~72%) --- @@ -154,8 +242,9 @@ créés : | 26/01/2026 | Fin de la cartographie (S2) | | 27/01/2026 | Début modularisation Auth (S3) | | 09/02/2026 | Fin modularisation Auth (S4) - 20% complété | -| 09/03/2026 | Point mi-parcours - 60% complété | -| 26/03/2026 | Migration ServiceChain (OpenCDS) finalisée — 1er module métier migré | +| 28/05/2026 | Migration du module **Project** (7 routes) — CRUD + secrets + bulk | +| 28/05/2026 | Migration du module **ProjectMembers** (4 routes) — membres projet | +| 12/06/2026 | Migration du module **AdminRole** (5 routes) — gestion des rôles admin | | 06/04/2026 | Fin de modularisation - 100% complété | --- @@ -163,7 +252,7 @@ créés : ## 📞 Contacts & Communication ### Canaux -- **Slack** : #backend-modularisation +- **Mattermost** : #backend-modularisation - **Meeting hebdo** : Vendredi 16h (30min) - **Lead technique** : @stephane.trebel @@ -188,6 +277,19 @@ créés : ## 🔄 Historique des changements +### 2026-06-12 +- ✅ Migration du module **AdminRole** — 5 routes : gestion des rôles admin (list, create, patch, member-counts, delete) +- ✅ Mise à jour du contrôleur pour utiliser `AdminGuard` + `@RequireAdminPermission()` +- ✅ Création des utilitaires de mapping `AdminRole` Prisma → `@cpn-console/shared` + +### 2026-05-28 +- ✅ Migration du module **Project** — 7 routes : CRUD projets, secrets, bulk actions, export CSV +- ✅ Migration du module **ProjectMembers** — 4 routes : membres projet (list, add, patch, delete) +- ✅ Création des guards projet : `ProjectContextGuard`, `ProjectStatusGuard`, `ProjectLockedGuard` +- ✅ Création des décorateurs `@Project()` et `@RequireProjectStatus()` +- ✅ Utilisation de `projectContract` de `@cpn-console/shared` pour la validation Zod +- ✅ Build et lint verts (server-nestjs) + ### 2026-04-09 - ✅ Migration du module **ServiceChain (OpenCDS)** — 5 routes, proxy HTTP vers API externe - ✅ Création de l'**AuthModule** (infrastructure/auth/) : auth par token `x-dso-token` @@ -210,5 +312,5 @@ créés : --- -**Version du fichier** : 1.1 +**Version du fichier** : 1.2 **Responsable de mise à jour** : Lead technique backend diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index adcd9691a1..a21051005a 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common' import { ScheduleModule } from '@nestjs/schedule' +import { AdminRoleModule } from './modules/admin-role/admin-role.module' import { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' @@ -19,6 +20,7 @@ import { VersionModule } from './modules/version/version.module' @Module({ imports: [ + AdminRoleModule, InfrastructureModule, HealthzModule, KeycloakModule, diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role-queries.utils.ts b/apps/server-nestjs/src/modules/admin-role/admin-role-queries.utils.ts new file mode 100644 index 0000000000..5ff02cf0c8 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role-queries.utils.ts @@ -0,0 +1,70 @@ +import type { Prisma } from '@prisma/client' + +export const adminRoleSelect = { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, +} satisfies Prisma.AdminRoleSelect + +export type AdminRole = Prisma.AdminRoleGetPayload<{ + select: typeof adminRoleSelect +}> + +export async function getAdminRoleMaxPosition(tx: Prisma.TransactionClient): Promise { + const role = await tx.adminRole.findFirst({ + orderBy: { position: 'desc' }, + select: { position: true }, + }) + + return role?.position ?? -1 +} + +export async function createAdminRole(tx: Prisma.TransactionClient, name: string): Promise<{ role: AdminRole, members: { id: string, email: string, firstName: string, lastName: string }[] }> { + const maxPosition = await getAdminRoleMaxPosition(tx) + const role = await tx.adminRole.create({ + data: { + name, + permissions: 0n, + position: maxPosition + 1, + }, + select: adminRoleSelect, + }) + + const members = await tx.user.findMany({ + where: { adminRoleIds: { has: role.id } }, + select: { id: true, email: true, firstName: true, lastName: true }, + }) + + return { role, members } +} + +export async function getAdminRoleMemberCounts(tx: Prisma.TransactionClient): Promise> { + const roles = await tx.adminRole.findMany({ + where: { oidcGroup: { equals: '' } }, + select: { id: true }, + }) + const roleIds = roles.map(role => role.id) + const users = await tx.user.findMany({ + where: { adminRoleIds: { hasSome: roleIds } }, + select: { adminRoleIds: true }, + }) + + const counts: Record = Object.fromEntries(roleIds.map(roleId => [roleId, 0])) + for (const { adminRoleIds } of users) { + for (const roleId of adminRoleIds) { + if (typeof counts[roleId] === 'number') counts[roleId]++ + } + } + + return counts +} + +export async function getRoles(tx: Prisma.TransactionClient): Promise { + return await tx.adminRole.findMany({ + orderBy: { position: 'asc' }, + select: adminRoleSelect, + }) +} diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts b/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts new file mode 100644 index 0000000000..03833ed810 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role-testing.utils.ts @@ -0,0 +1,59 @@ +import type { AdminRoleService } from './admin-role.service' +import type { adminRoleContract } from '@cpn-console/shared' +import { z } from 'zod' +import type { AdminRole } from './admin-role-queries.utils' + +export type AdminRoleContract = Parameters[0][number] +export type AdminRoleResponse = NonNullable>>[number] + +export interface AdminRoleMember { + id: string + email: string + firstName: string + lastName: string +} + +export function makeAdminRole(overrides: Partial = {}): AdminRole { + return { + id: overrides.id ?? crypto.randomUUID(), + name: overrides.name ?? 'New role', + permissions: overrides.permissions ?? 0n, + position: overrides.position ?? 0, + oidcGroup: overrides.oidcGroup ?? '', + type: overrides.type ?? 'managed', + ...overrides, + } +} + +export function makeAdminRoleMember(overrides: Partial = {}): AdminRoleMember { + return { + id: crypto.randomUUID(), + email: 'user@example.com', + firstName: 'First', + lastName: 'Last', + ...overrides, + } +} + +export function makeCreateAdminRoleBody(overrides: { name?: string } = {}): z.infer { + return { + name: overrides.name ?? 'New role', + } +} + +export function makePatchAdminRoleBody( + role: AdminRole, + overrides: Partial[number]> = {}, +): z.infer[number] { + return { + id: role.id, + name: overrides.name ?? role.name, + permissions: + overrides.permissions + ?? (typeof role.permissions === 'bigint' ? role.permissions.toString() : String(role.permissions)), + position: overrides.position ?? role.position, + oidcGroup: overrides.oidcGroup ?? role.oidcGroup, + type: overrides.type ?? role.type, + ...overrides, + } +} diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.controller.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.controller.ts new file mode 100644 index 0000000000..2bbe993fd5 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.controller.ts @@ -0,0 +1,60 @@ +import type { AdminRole } from '@cpn-console/shared' +import { adminRoleContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { AdminRoleService } from './admin-role.service' + +@Controller('api/v1/admin/roles') +export class AdminRoleController { + constructor( + @Inject(AdminRoleService) private readonly adminRoleService: AdminRoleService, + ) {} + + @Get('') + @UseGuards(UserGuard) + // TODO: ListRoles is intentionally not protected by admin permission because of + // certain behaviours of the legacy client + // @RequireAdminPermission('ListRoles') + async listAdminRoles(): Promise { + return this.adminRoleService.list() + } + + @Post('') + @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async createAdminRole( + @Body(new ZodValidationPipe(adminRoleContract.createAdminRole.body)) body: typeof adminRoleContract.createAdminRole.body._type, + ): Promise { + return this.adminRoleService.create(body) + } + + @Patch('') + @HttpCode(HttpStatus.OK) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async patchAdminRoles( + @Body(new ZodValidationPipe(adminRoleContract.patchAdminRoles.body)) body: typeof adminRoleContract.patchAdminRoles.body._type, + ): Promise { + return this.adminRoleService.patch(body) + } + + @Get('member-counts') + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async adminRoleMemberCounts(): Promise> { + return this.adminRoleService.memberCounts() + } + + @Delete(':roleId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageRoles') + async deleteAdminRole( + @Param('roleId', ParseUUIDPipe) roleId: string, + ): Promise { + await this.adminRoleService.delete(roleId) + } +} diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.module.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.module.ts new file mode 100644 index 0000000000..099f69d651 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AdminRoleController } from './admin-role.controller' +import { AdminRoleService } from './admin-role.service' + +@Module({ + imports: [InfrastructureModule, AuthModule], + controllers: [AdminRoleController], + providers: [AdminRoleService], + exports: [AdminRoleService], +}) +export class AdminRoleModule {} diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts new file mode 100644 index 0000000000..d827f26ca0 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.service.spec.ts @@ -0,0 +1,79 @@ +import { AdminRoleService } from './admin-role.service' +import type { adminRoleContract } from '@cpn-console/shared' +import type { EventEmitter2 } from '@nestjs/event-emitter' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { + makeAdminRole, + makeCreateAdminRoleBody, +} from './admin-role-testing.utils' +import type { PrismaService } from '../infrastructure/database/prisma.service' + +describe('adminRoleService', () => { + let prisma: ReturnType> + let eventEmitter: ReturnType> + + beforeEach(() => { + vi.clearAllMocks() + prisma = mockDeep() + eventEmitter = mockDeep() + eventEmitter.emitAsync.mockResolvedValue([]) + }) + + it('creates a role at the next position and returns the created role', async () => { + const existingRole = makeAdminRole({ + position: 5, + permissions: 4n, + type: 'managed', + }) + + const createdRole = makeAdminRole({ + ...existingRole, + name: 'New role', + position: 6, + permissions: 0n, + }) + + prisma.adminRole.findFirst.mockResolvedValue(existingRole) + prisma.adminRole.create.mockResolvedValue(createdRole) + prisma.adminRole.findUnique.mockResolvedValue(createdRole) + prisma.user.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async callback => callback(prisma)) + + const createBody = makeCreateAdminRoleBody({ name: 'New role' }) + const service = new AdminRoleService(prisma, eventEmitter) + const result = await service.create(createBody) + + expect(result).toEqual( + expect.objectContaining({ + id: existingRole.id, + permissions: '0', + position: 6, + }), + ) + expect(prisma.adminRole.create).toHaveBeenCalledWith({ + data: { + name: 'New role', + permissions: 0n, + position: 6, + }, + select: { + id: true, + name: true, + oidcGroup: true, + permissions: true, + position: true, + type: true, + }, + }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('adminRole.upsert', { + id: existingRole.id, + name: 'New role', + oidcGroup: '', + permissions: 0n, + position: 6, + type: 'managed', + members: [], + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts new file mode 100644 index 0000000000..a54b645cea --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.service.ts @@ -0,0 +1,190 @@ +import type { AdminRole as AdminRoleContract, adminRoleContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { + adminRoleSelect, + createAdminRole, + getAdminRoleMemberCounts, + getRoles, +} from './admin-role-queries.utils' +import { toAdminRole, toAdminRoles } from './admin-role.utils' + +@Injectable() +export class AdminRoleService { + private readonly logger = new Logger(AdminRoleService.name) + + constructor( + @Inject(PrismaService) public readonly prisma: PrismaService, + @Inject(EventEmitter2) public readonly eventEmitter: EventEmitter2, + ) {} + + @StartActiveSpan() + async list(): Promise { + const span = trace.getActiveSpan() + this.logger.log('adminRole.list started') + const roles = await getRoles(this.prisma) + span?.setAttribute('admin_role.count', roles.length) + this.logger.log(`adminRole.list completed (count=${roles.length})`) + return toAdminRoles(roles) + } + + @StartActiveSpan() + async create(body: typeof adminRoleContract.createAdminRole.body._type): Promise { + const span = trace.getActiveSpan() + this.logger.log(`adminRole.create started (name=${body.name})`) + + const { role: createdRole, members: createdMembers } = await this.prisma.$transaction(async (tx) => { + return createAdminRole(tx, body.name) + }) + + await this.eventEmitter.emitAsync('adminRole.upsert', { + ...createdRole, + members: createdMembers.map(({ id, email, firstName, lastName }) => ({ + id, + email, + firstName, + lastName, + })), + }) + + span?.setAttribute('admin_role.id', createdRole.id) + this.logger.log(`adminRole.create completed (id=${createdRole.id})`) + return toAdminRole(createdRole) + } + + @StartActiveSpan() + async patch( + roles: typeof adminRoleContract.patchAdminRoles.body._type, + ): Promise { + const span = trace.getActiveSpan() + this.logger.log(`adminRole.patch started (count=${roles.length})`) + + const patchResult = await this.prisma.$transaction(async (tx) => { + const dbRoles = await getRoles(tx) + const positionsAvailable: number[] = [] + const updatedRoles: Array<{ id: string, data: Prisma.AdminRoleUpdateInput }> = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (!matchingRole) continue + + if (matchingRole.position !== undefined && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + + updatedRoles.push({ + id: dbRole.id, + data: { + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions === undefined ? dbRole.permissions : BigInt(matchingRole.permissions), + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ?? dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + }, + }) + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) { + throw new BadRequestException('Les numéros de position des rôles sont incohérentes') + } + + for (const { id, data } of updatedRoles) { + await tx.adminRole.update({ where: { id }, data }) + } + + const results = await Promise.all(updatedRoles.map(async ({ id }) => { + const role = await tx.adminRole.findFirst({ + where: { id }, + select: adminRoleSelect, + }) + if (!role) { + throw new NotFoundException(`Role with id ${id} not found`) + } + const members = await tx.user.findMany({ + where: { adminRoleIds: { has: id } }, + select: { id: true, email: true, firstName: true, lastName: true }, + }) + + return { role, members: members.map(({ id: memberId, email, firstName, lastName }) => ({ + id: memberId, + email, + firstName, + lastName, + })) } + })) + + return results + }) + + await Promise.all(patchResult.map(({ role, members }) => this.eventEmitter.emitAsync('adminRole.upsert', { + ...role, + members, + }))) + + span?.setAttribute('admin_role.updated.count', patchResult.length) + this.logger.log(`adminRole.patch completed (updated=${patchResult.length})`) + return this.list() + } + + @StartActiveSpan() + async memberCounts() { + const span = trace.getActiveSpan() + this.logger.log('adminRole.memberCounts started') + const counts = await this.prisma.$transaction(async tx => getAdminRoleMemberCounts(tx)) + + span?.setAttribute('admin_role.member_counts.count', Object.keys(counts).length) + this.logger.log(`adminRole.memberCounts completed (roles=${Object.keys(counts).length})`) + return counts + } + + @StartActiveSpan() + async delete(roleId: string): Promise { + const span = trace.getActiveSpan() + this.logger.log(`adminRole.delete started (id=${roleId})`) + + await this.prisma.$transaction(async (tx) => { + const [role] = await tx.adminRole.findMany({ + where: { id: roleId }, + select: adminRoleSelect, + }) + if (!role) { + throw new NotFoundException() + } + + const members = await tx.user.findMany({ + where: { adminRoleIds: { has: roleId } }, + select: { id: true, email: true, firstName: true, lastName: true }, + }) + + const users = await tx.user.findMany({ + where: { adminRoleIds: { has: roleId } }, + select: { id: true, adminRoleIds: true }, + }) + + await this.eventEmitter.emitAsync('adminRole.delete', { + ...role, + members: members.map(({ id, email, firstName, lastName }) => ({ + id, + email, + firstName, + lastName, + })), + }) + + for (const user of users) { + await tx.user.update({ + where: { id: user.id }, + data: { adminRoleIds: user.adminRoleIds.filter(adminRoleId => adminRoleId !== roleId) }, + }) + } + await tx.adminRole.delete({ where: { id: roleId } }) + + span?.setAttribute('admin_role.deleted.user_count', users.length) + this.logger.log(`adminRole.delete completed (id=${roleId}, userCount=${users.length})`) + }) + } +} diff --git a/apps/server-nestjs/src/modules/admin-role/admin-role.utils.ts b/apps/server-nestjs/src/modules/admin-role/admin-role.utils.ts new file mode 100644 index 0000000000..29594ed971 --- /dev/null +++ b/apps/server-nestjs/src/modules/admin-role/admin-role.utils.ts @@ -0,0 +1,17 @@ +import type { AdminRole as AdminRoleContract } from '@cpn-console/shared' +import type { AdminRole as PrismaAdminRole } from './admin-role-queries.utils' + +export function toAdminRole(role: PrismaAdminRole): AdminRoleContract { + return { + id: role.id, + name: role.name, + permissions: role.permissions.toString(), + position: role.position, + oidcGroup: role.oidcGroup, + type: role.type ?? 'managed', + } +} + +export function toAdminRoles(roles: PrismaAdminRole[]): AdminRoleContract[] { + return roles.map(toAdminRole) +} diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts b/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts index 985b381e2b..d170ff59ee 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts @@ -4,8 +4,8 @@ import type { AdminToken, PersonalAccessToken, User } from '@prisma/client' import type { FastifyRequest } from 'fastify' import type { DeepMockProxy } from 'vitest-mock-extended' import type { UserContext } from './auth-user.decorator' -import { mockDeep } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' +import { mockDeep } from 'vitest-mock-extended' export function makeAuthRequest(headers: FastifyRequest['headers'] = {}): FastifyRequest { const request = mockDeep()