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
+273
View File
@@ -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,
});
}
}