feat(260805-fok): GroupsService.ensureDefaultGroup(tenantId)

- Neue Methode ensureDefaultGroup: legt fuer einen Mandanten ohne jede
  Gruppe die Standardgruppe 'Alle Benutzer' (isDefault:true) an, nimmt
  alle Bestandsbenutzer als MANUAL-Mitglieder auf und erzeugt Grants
  fuer alle aktiven Module — derselbe Endzustand wie die drei
  Backfill-INSERTs der Migration 20260804130130
- Waechter prueft ausschliesslich group.count === 0, niemals die
  fehlende isDefault-Markierung (D-13)
- P2002 aus dem partiellen Index Group_one_default_per_tenant wird
  abgefangen und liefert null statt zu werfen (Race-Sicherheit)
- groups.service.spec.ts: Fake erweitert um group.count,
  tenantModuleActivation, moduleGrant.findMany/createMany,
  $transaction mit Callback-Form, plus voller ensureDefaultGroup-Testblock
This commit is contained in:
2026-08-05 11:28:16 +02:00
parent c2e9f67a7d
commit 9d1254cd78
2 changed files with 262 additions and 6 deletions
+77
View File
@@ -250,6 +250,83 @@ export class GroupsService {
});
}
/**
* 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 'Alle Benutzer' (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".
*
* 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 existingCount = await this.prisma.group.count({ where: { tenantId } });
if (existingCount > 0) {
return null;
}
try {
return await this.prisma.$transaction(async (tx) => {
const group = await tx.group.create({
data: { tenantId, name: 'Alle Benutzer', 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) => ({
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) => ({
tenantId,
moduleId: a.moduleId,
groupId: group.id,
userId: null,
})),
skipDuplicates: true,
});
}
return group;
});
} catch (err: any) {
if (err?.code === 'P2002') {
return null;
}
throw err;
}
}
/**
* Legt eine Mitgliedschaft in der als Standard markierten Gruppe des
* Mandanten an (D-11/D-12/D-13). Existiert keine markierte Standardgruppe,