From 9d1254cd78c80bb55537e1140929108cb9a14c10 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 5 Aug 2026 11:28:16 +0200 Subject: [PATCH] feat(260805-fok): GroupsService.ensureDefaultGroup(tenantId) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/groups/groups.service.spec.ts | 191 ++++++++++++++++++++- apps/api/src/groups/groups.service.ts | 77 +++++++++ 2 files changed, 262 insertions(+), 6 deletions(-) diff --git a/apps/api/src/groups/groups.service.spec.ts b/apps/api/src/groups/groups.service.spec.ts index 195f7d9..8b57d86 100644 --- a/apps/api/src/groups/groups.service.spec.ts +++ b/apps/api/src/groups/groups.service.spec.ts @@ -18,8 +18,10 @@ function makeFakePrisma() { const memberships = new Map(); const grants = new Map(); const users = new Map(); + const activations = new Map(); let groupCounter = 0; let membershipCounter = 0; + let grantCounter = 0; function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) { return Array.from(groups.values()).find( @@ -27,6 +29,10 @@ function makeFakePrisma() { ); } + function findDefaultGroup(tenantId: string) { + return Array.from(groups.values()).find((g) => g.tenantId === tenantId && g.isDefault); + } + function throwUnique(): never { const err: any = new Error('Unique constraint failed'); err.code = 'P2002'; @@ -39,14 +45,24 @@ function makeFakePrisma() { throw err; } - return { + const fake: any = { __seedUser(user: { id: string; tenantId: string }) { users.set(user.id, user); }, __seedGrant(grant: { id: string; groupId: string }) { grants.set(grant.id, grant); }, + __seedActivation(activation: { + id: string; + tenantId: string; + moduleId: string; + isActive: boolean; + }) { + activations.set(activation.id, activation); + }, group: { + count: async ({ where }: any) => + Array.from(groups.values()).filter((g) => g.tenantId === where.tenantId).length, findMany: async ({ where, include }: any) => { let rows: any[] = Array.from(groups.values()).filter( (g) => g.tenantId === where.tenantId, @@ -76,6 +92,7 @@ function makeFakePrisma() { }, create: async ({ data }: any) => { if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique(); + if (data.isDefault === true && findDefaultGroup(data.tenantId)) throwUnique(); groupCounter += 1; const record = { id: `g-${groupCounter}`, @@ -158,20 +175,68 @@ function makeFakePrisma() { count: async ({ where }: any) => Array.from(memberships.values()).filter((m) => m.groupId === where.groupId).length, }, + tenantModuleActivation: { + findMany: async ({ where }: any) => + Array.from(activations.values()).filter( + (a) => + a.tenantId === where.tenantId && + (where.isActive === undefined || a.isActive === where.isActive), + ), + }, moduleGrant: { count: async ({ where }: any) => Array.from(grants.values()).filter((g) => g.groupId === where.groupId).length, + findMany: async ({ where }: any) => + Array.from(grants.values()).filter( + (g) => + (where?.tenantId === undefined || g.tenantId === where.tenantId) && + (where?.groupId === undefined || g.groupId === where.groupId), + ), + createMany: async ({ data, skipDuplicates }: any) => { + let count = 0; + for (const item of data) { + const exists = Array.from(grants.values()).find( + (g) => + g.tenantId === item.tenantId && + g.moduleId === item.moduleId && + g.groupId === item.groupId, + ); + if (exists) { + if (skipDuplicates) continue; + throwUnique(); + } + grantCounter += 1; + grants.set(`grant-${grantCounter}`, { + id: `grant-${grantCounter}`, + createdAt: new Date(), + userId: null, + ...item, + }); + count += 1; + } + return { count }; + }, }, user: { findMany: async ({ where }: any) => { - const ids: string[] = where.id.in; - return Array.from(users.values()).filter( - (u) => ids.includes(u.id) && u.tenantId === where.tenantId, - ); + if (where?.id?.in) { + const ids: string[] = where.id.in; + return Array.from(users.values()).filter( + (u) => ids.includes(u.id) && u.tenantId === where.tenantId, + ); + } + return Array.from(users.values()).filter((u) => u.tenantId === where.tenantId); }, }, - $transaction: async (ops: Promise[]) => Promise.all(ops), + $transaction: async (opsOrFn: any) => { + if (typeof opsOrFn === 'function') { + return opsOrFn(fake); + } + return Promise.all(opsOrFn); + }, }; + + return fake; } describe('GroupsService', () => { @@ -429,4 +494,118 @@ describe('GroupsService', () => { await expect(service.addUserToDefaultGroup('t1', 'u1')).resolves.not.toThrow(); }); + + // --- ensureDefaultGroup ---------------------------------------------------- + + describe('ensureDefaultGroup()', () => { + it('legt bei einem Mandanten ohne jede Gruppe genau eine Gruppe "Alle Benutzer" mit isDefault:true an', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.ensureDefaultGroup('t-neu'); + + expect(group).not.toBeNull(); + expect(group!.name).toBe('Alle Benutzer'); + expect(group!.isDefault).toBe(true); + const list = await service.listForTenant('t-neu'); + expect(list).toHaveLength(1); + }); + + it('nimmt alle Benutzer DIESES Mandanten als MANUAL-Mitglieder auf; ein Benutzer eines fremden Mandanten wird nicht Mitglied', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + prisma.__seedUser({ id: 'u1', tenantId: 't1' }); + prisma.__seedUser({ id: 'u2', tenantId: 't1' }); + prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' }); + + const group = await service.ensureDefaultGroup('t1'); + + const members = await service.listMembers('t1', group!.id); + expect(members.map((m: any) => m.userId).sort()).toEqual(['u1', 'u2']); + expect(members.every((m: any) => m.source === MembershipSource.MANUAL)).toBe(true); + }); + + it('erzeugt genau einen ModuleGrant je aktiver TenantModuleActivation; isActive:false und fremde Mandanten erzeugen keinen Grant', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + prisma.__seedActivation({ id: 'a1', tenantId: 't1', moduleId: 'mod-a', isActive: true }); + prisma.__seedActivation({ id: 'a2', tenantId: 't1', moduleId: 'mod-b', isActive: false }); + prisma.__seedActivation({ id: 'a3', tenantId: 't2', moduleId: 'mod-c', isActive: true }); + + const group = await service.ensureDefaultGroup('t1'); + + const grants = await (prisma as any).moduleGrant.findMany({ where: { tenantId: 't1' } }); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + tenantId: 't1', + moduleId: 'mod-a', + groupId: group!.id, + userId: null, + }); + }); + + it('Waechter: ein Mandant mit mindestens einer Gruppe ohne isDefault:true wird NICHT angefasst (D-13)', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + await service.create('t1', { name: 'Ohne Markierung' }); + + const result = await service.ensureDefaultGroup('t1'); + + expect(result).toBeNull(); + expect(await service.listForTenant('t1')).toHaveLength(1); + }); + + it('Waechter: ein Mandant mit bereits markierter Standardgruppe wird nicht angefasst', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + const group = await service.create('t1', { name: 'Alle Benutzer' }); + await service.update('t1', group.id, { isDefault: true }); + + const result = await service.ensureDefaultGroup('t1'); + + expect(result).toBeNull(); + expect(await service.listForTenant('t1')).toHaveLength(1); + }); + + it('ist idempotent: zwei aufeinanderfolgende Aufrufe hinterlassen genau eine Gruppe und genau eine Mitgliedschaft pro Benutzer', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + prisma.__seedUser({ id: 'u1', tenantId: 't1' }); + + const first = await service.ensureDefaultGroup('t1'); + const second = await service.ensureDefaultGroup('t1'); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); + const list = await service.listForTenant('t1'); + expect(list).toHaveLength(1); + expect(list[0].memberCount).toBe(1); + }); + + it('faengt einen P2002 aus group.create ab (verlorenes Rennen gegen den partiellen Index) und liefert null statt zu werfen', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + const existing = await service.create('t1', { name: 'Alle Benutzer' }); + await service.update('t1', existing.id, { isDefault: true }); + // group.count wird auf 0 gepatcht, um ein verlorenes Rennen nachzustellen — + // die schon markierte Gruppe existiert im Fake weiterhin und liefert beim + // Anlageversuch denselben P2002 wie der partielle Index in Postgres. + (prisma as any).group.count = async () => 0; + + await expect(service.ensureDefaultGroup('t1')).resolves.toBeNull(); + }); + + it('legt fuer einen Mandanten ganz ohne Benutzer und ohne aktive Module trotzdem die leere Standardgruppe an', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + + const group = await service.ensureDefaultGroup('t-leer'); + + expect(group).not.toBeNull(); + const members = await service.listMembers('t-leer', group!.id); + expect(members).toEqual([]); + const grants = await (prisma as any).moduleGrant.findMany({ where: { tenantId: 't-leer' } }); + expect(grants).toEqual([]); + }); + }); }); diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index 43260f0..a9344a1 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -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,