feat(15-02): GroupsModule — CRUD für Gruppen, Mitgliedschaften und Löschauswirkung

- GroupsService: listForTenant/create/update/remove/getImpact/listMembers/addMembers/removeMember/addUserToDefaultGroup, jede Query tenantId-gescoped (T-15-02/T-15-12)
- isDefault:true läuft in einer Transaktion (updateMany+update), D-13
- getImpact liefert { memberCount, grantCount } für den Löschdialog (D-17)
- removeMember beschränkt sich auf source:MANUAL (D-19)
- GroupsController: 8 rollengeschützte Routen unter /groups
- 19 Tests in groups.service.spec.ts, hand-rolled In-Memory-Fake
This commit is contained in:
2026-08-04 15:21:41 +02:00
parent 79c83eae5f
commit 69494d7549
8 changed files with 914 additions and 0 deletions
+142
View File
@@ -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 };
}
}