import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { MembershipSource } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; /** * Name der automatisch angelegten Standardgruppe (D-13). Geteilte Wahrheit * zwischen ensureDefaultGroup() (legt sie an, falls der Mandant noch keine * Gruppe hat) und reassignDefaultBeforeDelete() (bevorzugtes Handoff-Ziel, * D-06) — zwei getrennte Literale würden bei einer Umbenennung * auseinanderlaufen. */ export const DEFAULT_GROUP_NAME = 'Alle Benutzer'; /** * 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). * * Mandantengebunden (WINDOWS #20 Etappe 2, 260909-jts): jede Methode * erzeugt ihren Mandantenkontext aus dem uebergebenen Mandanten und fuehrt * ihre Abfragen darauf aus, wie im Bereich `ldap` (ldap.service.ts, * ldap-config.service.ts) vorgemacht. Gebundene Clients werden NICHT * zwischen Methoden weitergereicht — jede Methode erzeugt ihren eigenen. * * Die beiden mehrschrittigen Aenderungen (Standardmarkierung umsetzen in * update(); vor einer Loeschung verschieben in reassignDefaultBeforeDelete()) * sowie der Aufbau der Standardgruppe (ensureDefaultGroup()) laufen ueber * `withTenantTransaction()` statt ueber die Array-Form von `$transaction` * auf einem gebundenen Client — gemessen in Aufgabe 1 (260909-jts): * die Array-Form auf dem gebundenen Client verteilt jede enthaltene * Modell-Operation auf eine EIGENE Teiltransaktion (siehe * prisma-tenant.extension.ts), `withTenantTransaction()` ist die einzige * der drei gemessenen Formen, die sowohl die Einzelmessung als auch eine * Lastprobe unter echter Nebenlaeufigkeit bestand. */ @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 tenantPrisma = forTenant(this.prisma, tenantId) as any; // Explizit als any[] annotiert (nicht nur der Rueckgabewert von await): // ohne diese Array-Verankerung inferiert TypeScript den Rueckgabewert // dieser Methode als bloss `any` statt `any[]`, und Aufrufer, die auf // dem Ergebnis `.find()` aufrufen, wuerden TS7006 (impliziter any-Typ // im Callback-Parameter) melden, obwohl der gebundene Client bewusst // `any` ist (siehe forTenant()-Aufrufe in dieser Datei). const groups: any[] = await tenantPrisma.group.findMany({ where: { tenantId }, orderBy: { name: 'asc' }, include: { _count: { select: { memberships: true } } }, }); return groups.map((g: any) => ({ id: g.id, tenantId: g.tenantId, name: g.name, internalName: g.internalName, 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'); } const tenantPrisma = forTenant(this.prisma, tenantId) as any; try { return await tenantPrisma.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 tenantPrisma = forTenant(this.prisma, tenantId) as any; const group = await tenantPrisma.group.findFirst({ where: { id, tenantId }, }); if (!group) { throw new NotFoundException(`Gruppe '${id}' nicht gefunden`); } return group; } /** * Aktualisiert Name, Standardmarkierung und/oder internen Anzeigenamen. * * isDefault:true läuft über `withTenantTransaction()`: zuerst updateMany * auf alle Gruppen des Mandanten mit isDefault:false, dann update der * Zielgruppe auf true (D-13) — beide Schritte auf demselben gebundenen * Transaktionsparameter, gemessen in Aufgabe 1 als tragfaehige Form. 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. * * Namenssperre (D-03/D-07): trägt die geladene Gruppe einen gesetzten * ldapObjectGuid ODER ldapDn, ist sie aus dem Verzeichnis importiert — * ein `name` im Request wird dann mit BadRequestException abgelehnt, * BEVOR die Leerstring-Prüfung läuft. Das ldapDn-Kriterium deckt eine * Alt-Bindung aus Plan 15-06 ab, die den neuen Rekonziliations-Schritt * (syncBoundGroupsForTenant, Legacy-Backfill) noch nicht durchlaufen hat * und deshalb noch keinen ldapObjectGuid trägt — ohne diese zweite * Bedingung wäre die Sperre bis zum ersten Sync-Lauf nur eine * UI-Konvention (WR-01, 16-REVIEW.md). Das ist eine Backend-Invariante, * kein UI-Feld-Disable: ein direkter API-Aufruf kommt an ihr nicht * vorbei. * * internalName (D-04) läuft unabhängig von dieser Sperre — jede Gruppe, * importiert oder lokal, darf ihn setzen. undefined lässt die Spalte * unangetastet, null oder ein leerer/nur-Leerzeichen-String normalisiert * auf null (nie ein leerer Anzeigename), ein nicht-leerer String wird * getrimmt gespeichert. */ async update( tenantId: string, id: string, data: { name?: string; isDefault?: boolean; internalName?: string | null }, ) { const existing = await this.findOwned(tenantId, id); const updateData: { name?: string; internalName?: string | null; isDefault?: boolean; } = {}; if (data.name !== undefined) { if (existing.ldapObjectGuid || existing.ldapDn) { throw new BadRequestException( 'Der Name einer aus dem Verzeichnis übernommenen Gruppe wird dort gepflegt und kann hier nicht geändert werden', ); } const trimmed = data.name.trim(); if (!trimmed) { throw new BadRequestException('Gruppenname darf nicht leer sein'); } updateData.name = trimmed; } if (data.internalName !== undefined) { if (data.internalName === null) { updateData.internalName = null; } else { const trimmed = data.internalName.trim(); updateData.internalName = trimmed === '' ? null : trimmed; } } try { if (data.isDefault === true) { const updated = await withTenantTransaction(this.prisma, tenantId, async (tx: any) => { await tx.group.updateMany({ where: { tenantId, isDefault: true }, data: { isDefault: false }, }); return tx.group.update({ where: { id }, data: { ...updateData, isDefault: true }, }); }); return updated; } if (data.isDefault === false) { updateData.isDefault = false; } const tenantPrisma = forTenant(this.prisma, tenantId) as any; return await tenantPrisma.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 tenantPrisma = forTenant(this.prisma, tenantId) as any; const [memberCount, grantCount] = await Promise.all([ tenantPrisma.groupMembership.count({ where: { groupId: id } }), tenantPrisma.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); const tenantPrisma = forTenant(this.prisma, tenantId) as any; try { return await tenantPrisma.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); const tenantPrisma = forTenant(this.prisma, tenantId) as any; return tenantPrisma.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 tenantPrisma = forTenant(this.prisma, tenantId) as any; const validUsers = await tenantPrisma.user.findMany({ where: { id: { in: userIds }, tenantId }, select: { id: true }, }); const validIds = validUsers.map((u: any) => u.id); if (validIds.length === 0) { return { added: 0 }; } const result = await tenantPrisma.groupMembership.createMany({ data: validIds.map((userId: string) => ({ 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); const tenantPrisma = forTenant(this.prisma, tenantId) as any; await tenantPrisma.groupMembership.deleteMany({ where: { groupId: id, userId, source: MembershipSource.MANUAL }, }); } /** * Stellt für einen Mandanten OHNE JEDE Gruppe denselben Endzustand her, * den die drei Backfill-INSERTs der Migration * 20260804130130_add_groups_and_module_grants pro Mandant herstellen: * eine Gruppe DEFAULT_GROUP_NAME (isDefault:true), alle Bestandsbenutzer als * MANUAL-Mitglieder und Grants für alle aktiven Module. Aufrufer: * TenantService.create (frischer Mandant) und * AdminSeedService.ensureDefaultGroupsForAllTenants (Startup-Reparatur * für Installationen, die die Migration ohne Mandanten durchlaufen haben). * * Der Wächter prüft AUSSCHLIESSLICH auf group.count === 0 — niemals auf * das Fehlen der isDefault-Markierung. D-13 erlaubt dem Admin * ausdrücklich, die Markierung abzuhängen oder auf eine andere Gruppe * umzuhängen; ein Mandant mit mindestens einer Gruppe hat diese * Entscheidung bereits getroffen und wird hier nie wieder angefasst. * Rückgabe null bedeutet in jedem Fall "nichts zu tun". * * Zaehler UND Transaktion sind BEIDE ueber denselben Mandanten gebunden * (T-JTS-05, 260909-jts): der Waechter ist umgekehrt gepolt — ein zu * kleines Leseergebnis wuerde hier zu ZU VIEL Schreiben fuehren (eine * zweite Standardgruppe samt Mitgliedschaften ALLER Benutzer und * Freigaben ALLER aktiven Module). Zaehler und Schreibteil duerfen * deshalb nie unterschiedlich gebunden sein. * * Race-Sicherheit: zwei gleichzeitige Aufrufe (z.B. Startup-Reparatur und * eine parallele Mandanten-Anlage) können beide group.count === 0 lesen. * Der partielle Unique-Index Group_one_default_per_tenant (15-01) bleibt * der eigentliche Durchsetzungspunkt; hier wird nur der resultierende * P2002 des Verlierers abgefangen und in null übersetzt, statt ihn zu * propagieren. */ async ensureDefaultGroup(tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; const existingCount = await tenantPrisma.group.count({ where: { tenantId } }); if (existingCount > 0) { return null; } try { return await withTenantTransaction(this.prisma, tenantId, async (tx: any) => { const group = await tx.group.create({ data: { tenantId, name: DEFAULT_GROUP_NAME, isDefault: true }, }); const users = await tx.user.findMany({ where: { tenantId }, select: { id: true }, }); if (users.length > 0) { await tx.groupMembership.createMany({ data: users.map((u: any) => ({ groupId: group.id, userId: u.id, source: MembershipSource.MANUAL, })), skipDuplicates: true, }); } const activations = await tx.tenantModuleActivation.findMany({ where: { tenantId, isActive: true }, select: { moduleId: true }, }); if (activations.length > 0) { await tx.moduleGrant.createMany({ data: activations.map((a: any) => ({ tenantId, moduleId: a.moduleId, groupId: group.id, userId: null, })), skipDuplicates: true, }); } return group; }); } catch (err: any) { if (err?.code === 'P2002') { return null; } throw err; } } /** * Verschiebt die Standardmarkierung weg von der übergebenen Gruppe, BEVOR * der Aufrufer sie löscht (D-06). Löscht selbst nichts — meldet * ausschließlich per Rückgabewert, ob sie etwas verschoben hat. * * Bewusst NICHT findOwned(): dessen NotFoundException ist für HTTP * gebaut und würde einen Batch-Sync-Lauf (Plan 16-03) abbrechen. Eine * Gruppen-ID aus einem fremden Mandanten oder eine nicht markierte * Gruppe ist stattdessen ein folgenloses No-Op mit Rückgabe false. * * Zielauswahl: zuerst DEFAULT_GROUP_NAME (falls im Mandanten vorhanden * und nicht die zu löschende Gruppe selbst), sonst die älteste andere * Gruppe (orderBy createdAt asc) — dieser Determinismus garantiert, dass * zwei Läufe über denselben Bestand dieselbe Gruppe wählen. Existiert * keine andere Gruppe, gibt die Methode false zurück; der Aufrufer ruft * danach ensureDefaultGroup(tenantId), um den Mandanten neu aufzubauen. * * Die drei Lesezugriffe UND die zweischrittige Änderung (updateMany auf * isDefault:false, dann update der Zielgruppe auf isDefault:true) laufen * auf demselben gebundenen Mandanten — die Änderung über * `withTenantTransaction()` (260909-jts, Aufgabe 1/2), dasselbe Muster * wie bei update()'s isDefault:true-Zweig. Der partielle Unique-Index * Group_one_default_per_tenant (15-01) bleibt der eigentliche * Durchsetzungspunkt; ein daraus resultierender P2002 wird als "hat sich * schon jemand anderes gekümmert" behandelt und liefert false statt zu * werfen — exakt das Muster aus ensureDefaultGroup(). */ async reassignDefaultBeforeDelete(tenantId: string, groupId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId) as any; const group = await tenantPrisma.group.findFirst({ where: { id: groupId, tenantId }, }); if (!group?.isDefault) { return false; } const target = (await tenantPrisma.group.findFirst({ where: { tenantId, id: { not: groupId }, name: DEFAULT_GROUP_NAME }, })) ?? (await tenantPrisma.group.findFirst({ where: { tenantId, id: { not: groupId } }, orderBy: { createdAt: 'asc' }, })); if (!target) { return false; } try { await withTenantTransaction(this.prisma, tenantId, async (tx: any) => { await tx.group.updateMany({ where: { tenantId, isDefault: true }, data: { isDefault: false }, }); await tx.group.update({ where: { id: target.id }, data: { isDefault: true }, }); }); return true; } catch (err: any) { if (err?.code === 'P2002') { return false; } throw err; } } /** * 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. * * Prüft zusätzlich, dass der Zielbenutzer zu DIESEM Mandanten gehört * (T-JTS-02, 260909-jts/260910-jab): die Policy auf GroupMembership prüfte * bis 260910-jab ausschließlich die Gruppenseite * (`groupId IN (SELECT id FROM "Group" WHERE tenantId = ...)`) — die * Benutzerseite NICHT (Befund E, gemessen in Aufgabe 1 von 260909-jts). * Seit 20260910120000_rls_widen_membership_grant_and_platform_read prüft * die Datenbankregel selbst BEIDE Seiten — diese Anwendungsprüfung bleibt * trotzdem bestehen: der Schalter ist weiterhin aus (#18), die * Datenbankregel wirkt heute nicht. Nach dem Vorbild von addMembers() zwei * Methoden höher: Zielbenutzer auf den Mandanten filtern, bei keinem * Treffer folgenlos zurückkehren statt zu werfen. */ async addUserToDefaultGroup(tenantId: string, userId: string) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; const defaultGroup = await tenantPrisma.group.findFirst({ where: { tenantId, isDefault: true }, }); if (!defaultGroup) { return; } const targetUser = await tenantPrisma.user.findFirst({ where: { id: userId, tenantId }, }); if (!targetUser) { return; } await tenantPrisma.groupMembership.createMany({ data: [{ groupId: defaultGroup.id, userId, source: MembershipSource.MANUAL }], skipDuplicates: true, }); } }