diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9da6863..5ff5e96 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -15,6 +15,7 @@ import { DkvModule } from './dkv/dkv.module'; import { CertManagerModule } from './cert-manager/cert-manager.module'; import { FavoritesModule } from './favorites/favorites.module'; import { DomaincheckModule } from './domaincheck/domaincheck.module'; +import { GroupsModule } from './groups/groups.module'; import { ModuleRegistryModule } from './module-registry/module-registry.module'; import { PrismaModule } from './prisma/prisma.module'; import { SettingsModule } from './settings/settings.module'; @@ -35,6 +36,7 @@ import { UserModule } from './user/user.module'; MailModule, LdapModule, ModuleRegistryModule, + GroupsModule, DomaincheckModule, CertManagerModule, DashboardModule, diff --git a/apps/api/src/groups/dto/add-group-members.dto.ts b/apps/api/src/groups/dto/add-group-members.dto.ts new file mode 100644 index 0000000..b2a21eb --- /dev/null +++ b/apps/api/src/groups/dto/add-group-members.dto.ts @@ -0,0 +1,10 @@ +import { IsArray, IsString } from 'class-validator'; + +/** + * DTO für das manuelle Hinzufügen von Mitgliedern zu einer Gruppe. + */ +export class AddGroupMembersDto { + @IsArray() + @IsString({ each: true }) + userIds!: string[]; +} diff --git a/apps/api/src/groups/dto/create-group.dto.ts b/apps/api/src/groups/dto/create-group.dto.ts new file mode 100644 index 0000000..b917aa1 --- /dev/null +++ b/apps/api/src/groups/dto/create-group.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * DTO für das Anlegen einer Gruppe (PERM-01). + * + * Leerraum wird im Service getrimmt; ein leerer oder nur aus Leerzeichen + * bestehender Name wird dort explizit als BadRequestException abgelehnt + * (Trim passiert NACH der class-validator-Prüfung, deshalb reicht hier + * @IsNotEmpty auf den Rohwert nicht aus, um " " abzufangen). + */ +export class CreateGroupDto { + @IsString() + @IsNotEmpty() + name!: string; +} diff --git a/apps/api/src/groups/dto/update-group.dto.ts b/apps/api/src/groups/dto/update-group.dto.ts new file mode 100644 index 0000000..73e801e --- /dev/null +++ b/apps/api/src/groups/dto/update-group.dto.ts @@ -0,0 +1,23 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +/** + * DTO für das Aktualisieren einer Gruppe: Umbenennen, Standardmarkierung + * setzen/entfernen (D-13), AD-Bindung setzen/lösen (ldapDn: null löst). + * + * @IsOptional() lässt sowohl undefined als auch null unvalidiert durch — + * das ist die Voraussetzung dafür, dass ldapDn: null (Lösen der AD-Bindung) + * gültig bleibt, obwohl @IsString() sonst null ablehnen würde. + */ +export class UpdateGroupDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsString() + ldapDn?: string | null; +} diff --git a/apps/api/src/groups/groups.controller.ts b/apps/api/src/groups/groups.controller.ts new file mode 100644 index 0000000..948499c --- /dev/null +++ b/apps/api/src/groups/groups.controller.ts @@ -0,0 +1,142 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { Request } from 'express'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AddGroupMembersDto } from './dto/add-group-members.dto'; +import { CreateGroupDto } from './dto/create-group.dto'; +import { UpdateGroupDto } from './dto/update-group.dto'; +import { GroupsService } from './groups.service'; + +/** + * REST-Controller für die Gruppenverwaltung (PERM-01, D-14). + * + * tenantId kommt ausschließlich aus dem JWT (req.tenantId ?? req.user?.tenantId), + * niemals aus Body/Params (T-03-04). Jede Route ist rollengeschützt — + * identisch zu den Modul-Aktivierungsrouten in ModuleRegistryController. + */ +@Controller('groups') +export class GroupsController { + constructor(private readonly groupsService: GroupsService) {} + + private getTenantId(req: Request): string { + const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; + if (!tenantId) { + throw new ForbiddenException('No tenant context'); + } + return tenantId; + } + + /** + * GET /groups + * Liste aller Gruppen des Mandanten, alphabetisch, mit Mitgliederzahl. + */ + @Get() + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async list(@Req() req: Request) { + return this.groupsService.listForTenant(this.getTenantId(req)); + } + + /** + * POST /groups + * Legt eine neue Gruppe im Mandanten an. + */ + @Post() + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async create(@Body() dto: CreateGroupDto, @Req() req: Request) { + return this.groupsService.create(this.getTenantId(req), dto); + } + + /** + * PATCH /groups/:id + * Umbenennen, Standardmarkierung setzen/entfernen, AD-Bindung setzen/lösen. + */ + @Patch(':id') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async update( + @Param('id') id: string, + @Body() dto: UpdateGroupDto, + @Req() req: Request, + ) { + return this.groupsService.update(this.getTenantId(req), id, dto); + } + + /** + * DELETE /groups/:id + * Löscht die Gruppe; Mitgliedschaften und Grants folgen per Cascade (D-17). + */ + @Delete(':id') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async remove(@Param('id') id: string, @Req() req: Request) { + return this.groupsService.remove(this.getTenantId(req), id); + } + + /** + * GET /groups/:id/impact + * Zahlenmaterial für den Löschdialog (D-17): { memberCount, grantCount }. + */ + @Get(':id/impact') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async impact(@Param('id') id: string, @Req() req: Request) { + return this.groupsService.getImpact(this.getTenantId(req), id); + } + + /** + * GET /groups/:id/members + * Mitgliederliste inkl. Kern-Benutzerdaten. + */ + @Get(':id/members') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async members(@Param('id') id: string, @Req() req: Request) { + return this.groupsService.listMembers(this.getTenantId(req), id); + } + + /** + * POST /groups/:id/members + * Fügt Mitglieder manuell hinzu (source: MANUAL). + */ + @Post(':id/members') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async addMembers( + @Param('id') id: string, + @Body() dto: AddGroupMembersDto, + @Req() req: Request, + ) { + return this.groupsService.addMembers(this.getTenantId(req), id, dto.userIds); + } + + /** + * DELETE /groups/:id/members/:userId + * Entfernt ein manuell hinzugefügtes Mitglied (LDAP-Mitgliedschaften + * bleiben unberührt, D-19). + */ + @Delete(':id/members/:userId') + @UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async removeMember( + @Param('id') id: string, + @Param('userId') userId: string, + @Req() req: Request, + ) { + await this.groupsService.removeMember(this.getTenantId(req), id, userId); + return { success: true }; + } +} diff --git a/apps/api/src/groups/groups.module.ts b/apps/api/src/groups/groups.module.ts new file mode 100644 index 0000000..05c37bf --- /dev/null +++ b/apps/api/src/groups/groups.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; + +/** + * NestJS-Modul für die Gruppenverwaltung (PERM-01, PERM-06). + * + * Exportiert GroupsService, damit UserModule (Plan 15-02 Task 2) darüber + * addUserToDefaultGroup aufrufen kann — der einzige Codepfad für die + * automatische Standardgruppen-Mitgliedschaft (D-11/D-12). + */ +@Module({ + controllers: [GroupsController], + providers: [GroupsService], + exports: [GroupsService], +}) +export class GroupsModule {} diff --git a/apps/api/src/groups/groups.service.spec.ts b/apps/api/src/groups/groups.service.spec.ts new file mode 100644 index 0000000..195f7d9 --- /dev/null +++ b/apps/api/src/groups/groups.service.spec.ts @@ -0,0 +1,432 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { MembershipSource } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; +import { GroupsService } from './groups.service'; + +/** + * GroupsService.spec — TDD-RED-first Beweis für PERM-01 (D-13/D-17/D-19) + * und die tenantId-Ownership-Invariante (T-15-02/T-15-12). + * + * Hand-rolled In-Memory-Fake im Stil von tender-saved-search.service.spec.ts: + * keine Live-DB, simuliert P2002 (Unique-Verletzung) und P2025 + * (Record-not-found bei einem zweiten remove()-Aufruf) exakt wie ein + * echter Postgres-Client via Prisma-Fehlercodes. + */ + +function makeFakePrisma() { + const groups = new Map(); + const memberships = new Map(); + const grants = new Map(); + const users = new Map(); + let groupCounter = 0; + let membershipCounter = 0; + + function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) { + return Array.from(groups.values()).find( + (g) => g.tenantId === tenantId && g.name === name && g.id !== excludeId, + ); + } + + function throwUnique(): never { + const err: any = new Error('Unique constraint failed'); + err.code = 'P2002'; + throw err; + } + + function throwNotFound(): never { + const err: any = new Error('Record to update/delete not found'); + err.code = 'P2025'; + throw err; + } + + return { + __seedUser(user: { id: string; tenantId: string }) { + users.set(user.id, user); + }, + __seedGrant(grant: { id: string; groupId: string }) { + grants.set(grant.id, grant); + }, + group: { + findMany: async ({ where, include }: any) => { + let rows: any[] = Array.from(groups.values()).filter( + (g) => g.tenantId === where.tenantId, + ); + rows = [...rows].sort((a, b) => a.name.localeCompare(b.name)); + if (include?._count) { + rows = rows.map((g) => ({ + ...g, + _count: { + memberships: Array.from(memberships.values()).filter( + (m) => m.groupId === g.id, + ).length, + }, + })); + } + return rows; + }, + findFirst: async ({ where }: any) => { + return ( + Array.from(groups.values()).find( + (g) => + (where.id === undefined || g.id === where.id) && + g.tenantId === where.tenantId && + (where.isDefault === undefined || g.isDefault === where.isDefault), + ) ?? null + ); + }, + create: async ({ data }: any) => { + if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique(); + groupCounter += 1; + const record = { + id: `g-${groupCounter}`, + ldapDn: null, + isDefault: false, + createdAt: new Date(), + updatedAt: new Date(), + ...data, + }; + groups.set(record.id, record); + return record; + }, + update: async ({ where, data }: any) => { + const existing = groups.get(where.id); + if (!existing) throwNotFound(); + if (data.name && findGroupByTenantAndName(existing.tenantId, data.name, existing.id)) { + throwUnique(); + } + const record = { ...existing, ...data, updatedAt: new Date() }; + groups.set(where.id, record); + return record; + }, + updateMany: async ({ where, data }: any) => { + let count = 0; + for (const g of groups.values()) { + if ( + g.tenantId === where.tenantId && + (where.isDefault === undefined || g.isDefault === where.isDefault) + ) { + Object.assign(g, data); + count += 1; + } + } + return { count }; + }, + delete: async ({ where }: any) => { + const existing = groups.get(where.id); + if (!existing) throwNotFound(); + groups.delete(where.id); + return existing; + }, + }, + groupMembership: { + findMany: async ({ where }: any) => + Array.from(memberships.values()).filter((m) => m.groupId === where.groupId), + createMany: async ({ data, skipDuplicates }: any) => { + let count = 0; + for (const item of data) { + const exists = Array.from(memberships.values()).find( + (m) => m.groupId === item.groupId && m.userId === item.userId, + ); + if (exists) { + if (skipDuplicates) continue; + throwUnique(); + } + membershipCounter += 1; + memberships.set(`m-${membershipCounter}`, { + id: `m-${membershipCounter}`, + createdAt: new Date(), + ...item, + }); + count += 1; + } + return { count }; + }, + deleteMany: async ({ where }: any) => { + let count = 0; + for (const [key, m] of memberships.entries()) { + if ( + m.groupId === where.groupId && + m.userId === where.userId && + (where.source === undefined || m.source === where.source) + ) { + memberships.delete(key); + count += 1; + } + } + return { count }; + }, + count: async ({ where }: any) => + Array.from(memberships.values()).filter((m) => m.groupId === where.groupId).length, + }, + moduleGrant: { + count: async ({ where }: any) => + Array.from(grants.values()).filter((g) => g.groupId === where.groupId).length, + }, + user: { + findMany: async ({ where }: any) => { + const ids: string[] = where.id.in; + return Array.from(users.values()).filter( + (u) => ids.includes(u.id) && u.tenantId === where.tenantId, + ); + }, + }, + $transaction: async (ops: Promise[]) => Promise.all(ops), + }; +} + +describe('GroupsService', () => { + // --- listForTenant ------------------------------------------------------ + + it('listForTenant() liefert die Gruppen nach name aufsteigend, mit memberCount', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + await service.create('t1', { name: 'Zebra' }); + const alpha = await service.create('t1', { name: 'Alpha' }); + prisma.__seedUser({ id: 'u1', tenantId: 't1' }); + await service.addMembers('t1', alpha.id, ['u1']); + + const result = await service.listForTenant('t1'); + + expect(result.map((g) => g.name)).toEqual(['Alpha', 'Zebra']); + expect(result[0].memberCount).toBe(1); + expect(result[1].memberCount).toBe(0); + }); + + it('listForTenant() eines Mandanten ohne Gruppen liefert ein leeres Array', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + expect(await service.listForTenant('t-leer')).toEqual([]); + }); + + // --- create -------------------------------------------------------------- + + it('create() trimmt führenden und abschließenden Leerraum', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const result = await service.create('t1', { name: ' Vertrieb ' }); + + expect(result.name).toBe('Vertrieb'); + }); + + it('create() mit leerem oder nur aus Leerzeichen bestehendem Namen wirft BadRequestException', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + await expect(service.create('t1', { name: '' })).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(service.create('t1', { name: ' ' })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('create() mit einem im Mandanten bereits vergebenen Namen wirft ConflictException (P2002)', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + await service.create('t1', { name: 'Vertrieb' }); + + await expect(service.create('t1', { name: 'Vertrieb' })).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('create() mit demselben Namen in einem anderen Mandanten ist erfolgreich', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + await service.create('t1', { name: 'Vertrieb' }); + const result = await service.create('t2', { name: 'Vertrieb' }); + + expect(result.tenantId).toBe('t2'); + }); + + it('encoding/PERM-01: Gruppennamen werden nicht normalisiert oder kleingeschrieben — "Vertrieb" und "vertrieb" sind zwei Gruppen', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + await service.create('t1', { name: 'Vertrieb' }); + const lower = await service.create('t1', { name: 'vertrieb' }); + + expect(lower.name).toBe('vertrieb'); + }); + + // --- update ---------------------------------------------------------------- + + it('update() benennt um; eine ID eines fremden Mandanten wirft NotFoundException', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.create('t1', { name: 'Alt' }); + const updated = await service.update('t1', group.id, { name: 'Neu' }); + expect(updated.name).toBe('Neu'); + + await expect( + service.update('t-fremd', group.id, { name: 'Uebernahme' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('update() mit isDefault:true setzt die Markierung und entfernt sie von jeder anderen Gruppe desselben Mandanten', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const a = await service.create('t1', { name: 'A' }); + const b = await service.create('t1', { name: 'B' }); + await service.update('t1', a.id, { isDefault: true }); + + const updatedB = await service.update('t1', b.id, { isDefault: true }); + + expect(updatedB.isDefault).toBe(true); + const list = await service.listForTenant('t1'); + const groupA = list.find((g) => g.id === a.id)!; + expect(groupA.isDefault).toBe(false); + }); + + it('update() mit isDefault:false schaltet die Markierung ab, ohne sie irgendwo anders zu setzen', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const a = await service.create('t1', { name: 'A' }); + await service.update('t1', a.id, { isDefault: true }); + + const result = await service.update('t1', a.id, { isDefault: false }); + + expect(result.isDefault).toBe(false); + }); + + it('update() mit ldapDn bindet an eine AD-Gruppe; ldapDn:null löst die Bindung', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.create('t1', { name: 'A' }); + const bound = await service.update('t1', group.id, { + ldapDn: 'CN=A,OU=Groups,DC=ctl,DC=local', + }); + expect(bound.ldapDn).toBe('CN=A,OU=Groups,DC=ctl,DC=local'); + + const unbound = await service.update('t1', group.id, { ldapDn: null }); + expect(unbound.ldapDn).toBeNull(); + }); + + // --- getImpact --------------------------------------------------------- + + it('getImpact() liefert { memberCount, grantCount }; eine ID eines fremden Mandanten wirft NotFoundException', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.create('t1', { name: 'A' }); + prisma.__seedUser({ id: 'u1', tenantId: 't1' }); + await service.addMembers('t1', group.id, ['u1']); + prisma.__seedGrant({ id: 'grant-1', groupId: group.id }); + + const impact = await service.getImpact('t1', group.id); + expect(impact).toEqual({ memberCount: 1, grantCount: 1 }); + + await expect(service.getImpact('t-fremd', group.id)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + // --- remove -------------------------------------------------------------- + + it('remove() löscht die Gruppe; ein zweiter Aufruf auf dieselbe ID wirft NotFoundException statt eines unbehandelten Prisma-Fehlers', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.create('t1', { name: 'A' }); + await service.remove('t1', group.id); + + await expect(service.remove('t1', group.id)).rejects.toBeInstanceOf(NotFoundException); + }); + + // --- addMembers / removeMember ------------------------------------------- + + it('addMembers() legt fehlende Mitgliedschaften mit source:MANUAL an und ist für bereits vorhandene folgenlos', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.create('t1', { name: 'A' }); + prisma.__seedUser({ id: 'u1', tenantId: 't1' }); + + const first = await service.addMembers('t1', group.id, ['u1']); + expect(first.added).toBe(1); + + const second = await service.addMembers('t1', group.id, ['u1']); + expect(second.added).toBe(0); + + const members = await service.listMembers('t1', group.id); + expect(members).toHaveLength(1); + expect(members[0].source).toBe(MembershipSource.MANUAL); + }); + + it('addMembers() mit einer userId eines fremden Mandanten überspringt diese ID', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.create('t1', { name: 'A' }); + prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' }); + + const result = await service.addMembers('t1', group.id, ['u-fremd']); + + expect(result.added).toBe(0); + expect(await service.listMembers('t1', group.id)).toEqual([]); + }); + + it('removeMember() entfernt ausschließlich Mitgliedschaften mit source:MANUAL und lässt LDAP-Mitgliedschaften unberührt', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.create('t1', { name: 'A' }); + prisma.__seedUser({ id: 'u-manual', tenantId: 't1' }); + prisma.__seedUser({ id: 'u-ldap', tenantId: 't1' }); + await service.addMembers('t1', group.id, ['u-manual']); + // LDAP-Mitgliedschaft direkt im Fake seeden (kein Service-Pfad dafür in diesem Plan) + await (prisma as any).groupMembership.createMany({ + data: [{ groupId: group.id, userId: 'u-ldap', source: MembershipSource.LDAP }], + skipDuplicates: true, + }); + + await service.removeMember('t1', group.id, 'u-manual'); + await service.removeMember('t1', group.id, 'u-ldap'); + + const remaining = await service.listMembers('t1', group.id); + expect(remaining).toHaveLength(1); + expect(remaining[0].userId).toBe('u-ldap'); + }); + + it('removeMember() für ein nicht vorhandenes Mitglied ist folgenlos und wirft nicht', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.create('t1', { name: 'A' }); + + await expect(service.removeMember('t1', group.id, 'kein-mitglied')).resolves.not.toThrow(); + }); + + // --- addUserToDefaultGroup ------------------------------------------------- + + it('addUserToDefaultGroup() legt eine Mitgliedschaft in der Gruppe mit isDefault:true an', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.create('t1', { name: 'Alle Benutzer' }); + await service.update('t1', group.id, { isDefault: true }); + + await service.addUserToDefaultGroup('t1', 'u1'); + + const members = await service.listMembers('t1', group.id); + expect(members.map((m: any) => m.userId)).toEqual(['u1']); + }); + + it('addUserToDefaultGroup() bei einem Mandanten ohne markierte Standardgruppe tut nichts und wirft nicht', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + await service.create('t1', { name: 'Keine Standardgruppe' }); + + await expect(service.addUserToDefaultGroup('t1', 'u1')).resolves.not.toThrow(); + }); +}); diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts new file mode 100644 index 0000000..43260f0 --- /dev/null +++ b/apps/api/src/groups/groups.service.ts @@ -0,0 +1,273 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { MembershipSource } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Service für Gruppen-CRUD, Mitgliederverwaltung und die automatische + * Standardgruppen-Mitgliedschaft (PERM-01/PERM-06, D-11/D-12/D-13/D-17/D-19). + * + * Zentrale Regel: jede Lookup-Query mit einer Gruppen-ID filtert zusätzlich + * auf tenantId, nach dem Ownership-Check-Muster aus + * DashboardService.removeWidget — eine ID aus einem fremden Mandanten + * liefert nie einen Treffer, sondern NotFoundException. RLS (aus 15-01) ist + * das zweite Netz, nicht der primäre Schutz (T-15-02/T-15-12). + */ +@Injectable() +export class GroupsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Gruppen eines Mandanten, alphabetisch nach Name, jeweils mit der + * Mitgliederzahl. Ein Mandant ohne Gruppen liefert ein leeres Array. + */ + async listForTenant(tenantId: string) { + const groups = await this.prisma.group.findMany({ + where: { tenantId }, + orderBy: { name: 'asc' }, + include: { _count: { select: { memberships: true } } }, + }); + + return groups.map((g) => ({ + id: g.id, + tenantId: g.tenantId, + name: g.name, + ldapDn: g.ldapDn, + isDefault: g.isDefault, + createdAt: g.createdAt, + updatedAt: g.updatedAt, + memberCount: g._count.memberships, + })); + } + + /** + * Legt eine Gruppe im Mandanten an. Führender/abschließender Leerraum + * wird entfernt; der Name selbst wird weder normalisiert noch + * kleingeschrieben (bewusst anders als Benutzernamen). P2002 (Unique- + * Verletzung auf (tenantId, name)) wird in ConflictException übersetzt — + * der Unique-Index aus 15-01 ist der eigentliche Durchsetzungspunkt. + */ + async create(tenantId: string, data: { name: string }) { + const name = data.name?.trim(); + if (!name) { + throw new BadRequestException('Gruppenname darf nicht leer sein'); + } + + try { + return await this.prisma.group.create({ + data: { tenantId, name }, + }); + } catch (err: any) { + if (err?.code === 'P2002') { + throw new ConflictException( + `Eine Gruppe mit dem Namen '${name}' existiert bereits in diesem Mandanten`, + ); + } + throw err; + } + } + + /** + * Lädt eine Gruppe, mandantengescoped. Eine ID aus einem fremden + * Mandanten liefert NotFoundException statt eines Treffers. + */ + private async findOwned(tenantId: string, id: string) { + const group = await this.prisma.group.findFirst({ + where: { id, tenantId }, + }); + if (!group) { + throw new NotFoundException(`Gruppe '${id}' nicht gefunden`); + } + return group; + } + + /** + * Aktualisiert Name, Standardmarkierung und/oder AD-Bindung. + * + * isDefault:true läuft in einer Transaktion: zuerst updateMany auf alle + * Gruppen des Mandanten mit isDefault:false, dann update der Zielgruppe + * auf true (D-13). Der partielle Unique-Index Group_one_default_per_tenant + * aus 15-01 ist das Sicherheitsnetz gegen parallele Aufrufe, die + * Transaktion ist der normale Pfad. isDefault:false schaltet die + * Markierung nur an dieser einen Gruppe ab, ohne sie irgendwo anders zu + * setzen. ldapDn:null löst eine AD-Bindung. + */ + async update( + tenantId: string, + id: string, + data: { name?: string; isDefault?: boolean; ldapDn?: string | null }, + ) { + await this.findOwned(tenantId, id); + + const updateData: { + name?: string; + ldapDn?: string | null; + isDefault?: boolean; + } = {}; + + if (data.name !== undefined) { + const trimmed = data.name.trim(); + if (!trimmed) { + throw new BadRequestException('Gruppenname darf nicht leer sein'); + } + updateData.name = trimmed; + } + if (data.ldapDn !== undefined) { + updateData.ldapDn = data.ldapDn; + } + + try { + if (data.isDefault === true) { + const [, updated] = await this.prisma.$transaction([ + this.prisma.group.updateMany({ + where: { tenantId, isDefault: true }, + data: { isDefault: false }, + }), + this.prisma.group.update({ + where: { id }, + data: { ...updateData, isDefault: true }, + }), + ]); + return updated; + } + + if (data.isDefault === false) { + updateData.isDefault = false; + } + + return await this.prisma.group.update({ + where: { id }, + data: updateData, + }); + } catch (err: any) { + if (err?.code === 'P2002') { + throw new ConflictException( + `Eine Gruppe mit dem Namen '${updateData.name}' existiert bereits in diesem Mandanten`, + ); + } + throw err; + } + } + + /** + * Zahlenmaterial für den Löschdialog (D-17): Mitgliederzahl und Anzahl + * der Modul-Freigaben dieser Gruppe. + */ + async getImpact(tenantId: string, id: string) { + await this.findOwned(tenantId, id); + + const [memberCount, grantCount] = await Promise.all([ + this.prisma.groupMembership.count({ where: { groupId: id } }), + this.prisma.moduleGrant.count({ where: { groupId: id } }), + ]); + + return { memberCount, grantCount }; + } + + /** + * Löscht eine Gruppe. Mitgliedschaften und Grants verschwinden über die + * in 15-01 definierten onDelete:Cascade-Regeln — kein zusätzliches + * anwendungsseitiges Aufräumen, sonst gäbe es zwei Wahrheiten über das + * Aufräumverhalten. Ein zweiter Aufruf auf dieselbe ID (z.B. bei zwei + * gleichzeitigen DELETE-Anfragen) wirft NotFoundException statt eines + * unbehandelten P2025-Fehlers. + */ + async remove(tenantId: string, id: string) { + await this.findOwned(tenantId, id); + + try { + return await this.prisma.group.delete({ where: { id } }); + } catch (err: any) { + if (err?.code === 'P2025') { + throw new NotFoundException(`Gruppe '${id}' nicht gefunden`); + } + throw err; + } + } + + /** + * Mitglieder einer Gruppe inkl. Kern-Benutzerdaten, für die Anzeige im + * Gruppen-Detail (D-14). + */ + async listMembers(tenantId: string, id: string) { + await this.findOwned(tenantId, id); + + return this.prisma.groupMembership.findMany({ + where: { groupId: id }, + include: { + user: { + select: { id: true, username: true, displayName: true, email: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + } + + /** + * Fügt Mitglieder manuell hinzu (source: MANUAL). Eine userId eines + * fremden Mandanten wird übersprungen und nicht aufgenommen (T-15-12, + * Cross-Tenant-Grant-Injection-Schutz). Bereits vorhandene Mitgliedschaften + * sind über skipDuplicates gegen @@unique([groupId, userId]) folgenlos. + */ + async addMembers(tenantId: string, id: string, userIds: string[]) { + await this.findOwned(tenantId, id); + + const validUsers = await this.prisma.user.findMany({ + where: { id: { in: userIds }, tenantId }, + select: { id: true }, + }); + const validIds = validUsers.map((u) => u.id); + if (validIds.length === 0) { + return { added: 0 }; + } + + const result = await this.prisma.groupMembership.createMany({ + data: validIds.map((userId) => ({ + groupId: id, + userId, + source: MembershipSource.MANUAL, + })), + skipDuplicates: true, + }); + return { added: result.count }; + } + + /** + * Entfernt ein Mitglied — ausschließlich Mitgliedschaften mit + * source: MANUAL (D-19: eine über AD gesteuerte Mitgliedschaft entfernt + * ausschließlich der Sync, nie diese Route). Für ein nicht vorhandenes + * Mitglied folgenlos, wirft nicht. + */ + async removeMember(tenantId: string, id: string, userId: string) { + await this.findOwned(tenantId, id); + + await this.prisma.groupMembership.deleteMany({ + where: { groupId: id, userId, source: MembershipSource.MANUAL }, + }); + } + + /** + * Legt eine Mitgliedschaft in der als Standard markierten Gruppe des + * Mandanten an (D-11/D-12/D-13). Existiert keine markierte Standardgruppe, + * tut die Methode nichts und wirft nicht — wird von UserService.create + * aufgerufen (Plan 15-02 Task 2), muss deshalb aus GroupsModule + * exportiert sein. + */ + async addUserToDefaultGroup(tenantId: string, userId: string) { + const defaultGroup = await this.prisma.group.findFirst({ + where: { tenantId, isDefault: true }, + }); + if (!defaultGroup) { + return; + } + + await this.prisma.groupMembership.createMany({ + data: [{ groupId: defaultGroup.id, userId, source: MembershipSource.MANUAL }], + skipDuplicates: true, + }); + } +}