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:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user