From 2ef9b8638c8865ca248bd7bb0b53961f591496b0 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 6 Aug 2026 15:55:53 +0200 Subject: [PATCH] feat(16-02): standard group handoff building block (D-06) - DEFAULT_GROUP_NAME extracted as shared constant between ensureDefaultGroup() and the new reassignDefaultBeforeDelete() - reassignDefaultBeforeDelete(tenantId, groupId) moves the default marker deterministically (DEFAULT_GROUP_NAME first, else oldest other group by createdAt asc), never deletes, never throws - 6 test cases covering handoff, fallback ordering, no-other-group, non-default no-op, cross-tenant no-op, and P2002 race --- apps/api/src/groups/groups.service.spec.ts | 131 +++++++++++++++++++-- apps/api/src/groups/groups.service.ts | 79 ++++++++++++- 2 files changed, 198 insertions(+), 12 deletions(-) diff --git a/apps/api/src/groups/groups.service.spec.ts b/apps/api/src/groups/groups.service.spec.ts index 8b57d86..d88855d 100644 --- a/apps/api/src/groups/groups.service.spec.ts +++ b/apps/api/src/groups/groups.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { MembershipSource } from '@prisma/client'; import { describe, expect, it } from 'vitest'; -import { GroupsService } from './groups.service'; +import { DEFAULT_GROUP_NAME, GroupsService } from './groups.service'; /** * GroupsService.spec — TDD-RED-first Beweis für PERM-01 (D-13/D-17/D-19) @@ -80,15 +80,26 @@ function makeFakePrisma() { } return rows; }, - findFirst: async ({ where }: any) => { - return ( - Array.from(groups.values()).find( - (g) => - (where.id === undefined || g.id === where.id) && - g.tenantId === where.tenantId && - (where.isDefault === undefined || g.isDefault === where.isDefault), - ) ?? null - ); + findFirst: async ({ where, orderBy }: any) => { + let rows = Array.from(groups.values()).filter((g) => { + if (where.tenantId !== undefined && g.tenantId !== where.tenantId) return false; + if (where.id !== undefined) { + if (typeof where.id === 'object' && where.id !== null) { + if (where.id.not !== undefined && g.id === where.id.not) return false; + } else if (g.id !== where.id) { + return false; + } + } + if (where.isDefault !== undefined && g.isDefault !== where.isDefault) return false; + if (where.name !== undefined && g.name !== where.name) return false; + return true; + }); + if (orderBy?.createdAt === 'asc') { + rows = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + } else if (orderBy?.createdAt === 'desc') { + rows = [...rows].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } + return rows[0] ?? null; }, create: async ({ data }: any) => { if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique(); @@ -608,4 +619,104 @@ describe('GroupsService', () => { expect(grants).toEqual([]); }); }); + + // --- reassignDefaultBeforeDelete (D-06) ------------------------------- + + describe('GroupsService.reassignDefaultBeforeDelete (D-06)', () => { + it('verschiebt die Markierung auf die Gruppe DEFAULT_GROUP_NAME, wenn sie existiert', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + const def = await service.create('t1', { name: DEFAULT_GROUP_NAME }); + const toDelete = await service.create('t1', { name: 'Zu loeschen' }); + await service.update('t1', toDelete.id, { isDefault: true }); + + const result = await service.reassignDefaultBeforeDelete('t1', toDelete.id); + + expect(result).toBe(true); + const list = await service.listForTenant('t1'); + expect(list.find((g) => g.id === def.id)!.isDefault).toBe(true); + expect(list.find((g) => g.id === toDelete.id)!.isDefault).toBe(false); + }); + + it('verschiebt die Markierung auf die aelteste andere Gruppe (createdAt asc), wenn DEFAULT_GROUP_NAME nicht existiert', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + const older = await service.create('t1', { name: 'Aeltere' }); + const newer = await service.create('t1', { name: 'Neuere' }); + const toDelete = await service.create('t1', { name: 'Zu loeschen' }); + await service.update('t1', toDelete.id, { isDefault: true }); + // Deterministische createdAt-Reihenfolge statt auf Systemzeit-Aufloesung + // im Millisekundenbereich zu vertrauen. + await (prisma as any).group.update({ + where: { id: older.id }, + data: { createdAt: new Date(Date.now() - 20000) }, + }); + await (prisma as any).group.update({ + where: { id: newer.id }, + data: { createdAt: new Date(Date.now() - 10000) }, + }); + + const result = await service.reassignDefaultBeforeDelete('t1', toDelete.id); + + expect(result).toBe(true); + const list = await service.listForTenant('t1'); + expect(list.find((g) => g.id === older.id)!.isDefault).toBe(true); + expect(list.find((g) => g.id === newer.id)!.isDefault).toBe(false); + }); + + it('gibt false zurueck und wirft nicht, wenn keine andere Gruppe im Mandanten existiert', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + const only = await service.create('t1', { name: 'Einzige' }); + await service.update('t1', only.id, { isDefault: true }); + + await expect(service.reassignDefaultBeforeDelete('t1', only.id)).resolves.toBe(false); + const list = await service.listForTenant('t1'); + expect(list.find((g) => g.id === only.id)!.isDefault).toBe(true); + }); + + it('ist ein No-Op mit Rueckgabe false fuer eine Gruppe ohne Standardmarkierung', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + const a = await service.create('t1', { name: 'A' }); + const b = await service.create('t1', { name: 'B' }); + await service.update('t1', b.id, { isDefault: true }); + + const result = await service.reassignDefaultBeforeDelete('t1', a.id); + + expect(result).toBe(false); + const list = await service.listForTenant('t1'); + expect(list.find((g) => g.id === b.id)!.isDefault).toBe(true); + }); + + it('gibt false zurueck fuer eine Gruppen-ID eines fremden Mandanten, statt zu werfen', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + const group = await service.create('t1', { name: 'A' }); + await service.update('t1', group.id, { isDefault: true }); + + await expect( + service.reassignDefaultBeforeDelete('t-fremd', group.id), + ).resolves.toBe(false); + const list = await service.listForTenant('t1'); + expect(list.find((g) => g.id === group.id)!.isDefault).toBe(true); + }); + + it('faengt einen P2002 aus der Transaktion ab und liefert false statt zu werfen', async () => { + const prisma = makeFakePrisma(); + const service = new GroupsService(prisma as any); + await service.create('t1', { name: 'Ziel' }); + const toDelete = await service.create('t1', { name: 'Zu loeschen' }); + await service.update('t1', toDelete.id, { isDefault: true }); + prisma.group.update = async () => { + const err: any = new Error('Unique constraint failed'); + err.code = 'P2002'; + throw err; + }; + + await expect( + service.reassignDefaultBeforeDelete('t1', toDelete.id), + ).resolves.toBe(false); + }); + }); }); diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index a9344a1..b957307 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -7,6 +7,15 @@ import { import { MembershipSource } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +/** + * 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). @@ -254,7 +263,7 @@ 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 + * 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 @@ -283,7 +292,7 @@ export class GroupsService { try { return await this.prisma.$transaction(async (tx) => { const group = await tx.group.create({ - data: { tenantId, name: 'Alle Benutzer', isDefault: true }, + data: { tenantId, name: DEFAULT_GROUP_NAME, isDefault: true }, }); const users = await tx.user.findMany({ @@ -327,6 +336,72 @@ export class GroupsService { } } + /** + * 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. + * + * Dieselbe Zwei-Schritt-Transaktionsform wie update() (isDefault:true): + * erst updateMany auf isDefault:false für den ganzen Mandanten, dann + * update der Zielgruppe auf isDefault:true. 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 group = await this.prisma.group.findFirst({ + where: { id: groupId, tenantId }, + }); + if (!group || !group.isDefault) { + return false; + } + + const target = + (await this.prisma.group.findFirst({ + where: { tenantId, id: { not: groupId }, name: DEFAULT_GROUP_NAME }, + })) ?? + (await this.prisma.group.findFirst({ + where: { tenantId, id: { not: groupId } }, + orderBy: { createdAt: 'asc' }, + })); + + if (!target) { + return false; + } + + try { + await this.prisma.$transaction([ + this.prisma.group.updateMany({ + where: { tenantId, isDefault: true }, + data: { isDefault: false }, + }), + this.prisma.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,