feat(jts-02): groups.service.ts binden, Transaktionen tragfaehig machen, Absicherung sehend machen

prisma-tenant.extension.ts bekommt withTenantTransaction(prisma, tenantId, fn)
— die interaktive Transaktion auf dem UNgebundenen Client mit set_config als
erster Anweisung direkt auf tx, die in Aufgabe 1 als einzige der drei
gemessenen Formen sowohl die Einzelmessung als auch eine Lastprobe unter
echter Nebenlaeufigkeit bestand (die Array-Form auf dem gebundenen Client
verteilt jede Operation auf eine eigene Teiltransaktion; die interaktive Form
auf dem gebundenen Client brach unter 40 parallelen Aufrufen mit P2028 ab).

rls-access-inventory.spec.ts bekommt eine dritte Erkennung fuer
Modellzugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion
(zwei Formen: direkter Empfaenger.$transaction(async...) und das neue
Hilfsmittel withTenantTransaction(...)) — macht das Paar
(groups.service.ts, tenantModuleActivation) erstmals sichtbar, das bislang
keine Pruefung dieses Projekts je gesehen hat.

groups.service.ts: alle zwoelf Methoden inklusive der drei Transaktionen
(update() isDefault:true, reassignDefaultBeforeDelete(), ensureDefaultGroup())
laufen jetzt ueber den Mandantenkontext. Zaehler und Transaktion in
ensureDefaultGroup() sind gemeinsam gebunden (T-JTS-05). addUserToDefaultGroup()
prueft neu, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) —
die Regel auf GroupMembership prueft nachweislich nur die Gruppenseite.

groups.service.spec.ts bekommt zwei unterscheidbare Clients ueber demselben
Speicher-Fake (Muster aus 260909-ipc, auf die interaktive Form uebertragen)
und 13 neue Bindungsnachweise; alle 42 Bestandstests bleiben gruen.
Klassifikationsdokument nachgezogen. 737 Tests und die Typpruefung gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 15:01:24 +02:00
parent fd0b9f7d21
commit 7f08b27eea
6 changed files with 624 additions and 54 deletions
+236 -1
View File
@@ -1,6 +1,6 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { MembershipSource } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { DEFAULT_GROUP_NAME, GroupsService } from './groups.service';
/**
@@ -11,7 +11,28 @@ import { DEFAULT_GROUP_NAME, GroupsService } from './groups.service';
* keine Live-DB, simuliert P2002 (Unique-Verletzung) und P2025
* (Record-not-found bei einem zweiten remove()-Aufruf) exakt wie ein
* echter Postgres-Client via Prisma-Fehlercodes.
*
* Bindung an forTenant()/withTenantTransaction() (260909-jts, Befund C):
* anders als bei ldap-config.service.spec.ts (einfacher Identitaets-Mock
* `forTenant: vi.fn((p) => p)`) braucht diese Datei einen Mock, der den
* gebundenen Client als ZWEITES, von `prisma` UNTERSCHEIDBARES Objekt ueber
* DEMSELBEN Speicher liefert — sonst waeren ein Aufruf ueber den
* ungebundenen Fake und ein Aufruf ueber den (mit reiner Identitaet)
* "gebundenen" Client nicht auseinanderzuhalten, und ein vergessener
* Bindungsaufruf faellt in keinem Test auf. `makeFakePrisma()` bekommt dafuer
* `__makeBoundClient(tenantId)` (liefert je Modell einen protokollierenden
* Wrapper um dieselben Maps) und `__withTenantTransaction(tenantId, fn)`
* (reicht denselben gebundenen Client als Transaktionsparameter durch,
* Muster aus 260909-ipc uebertragen auf die interaktive Form dieses
* Bereichs). `forTenant`/`withTenantTransaction` selbst werden gemockt,
* damit der Fake nicht durch die echte `$extends`-Implementierung muss.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
withTenantTransaction: vi.fn((prisma: any, tenantId: string, fn: (tx: any) => any) =>
prisma.__withTenantTransaction(tenantId, fn),
),
}));
function makeFakePrisma() {
const groups = new Map<string, any>();
@@ -22,6 +43,7 @@ function makeFakePrisma() {
let groupCounter = 0;
let membershipCounter = 0;
let grantCounter = 0;
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) {
return Array.from(groups.values()).find(
@@ -240,6 +262,13 @@ function makeFakePrisma() {
}
return Array.from(users.values()).filter((u) => u.tenantId === where.tenantId);
},
findFirst: async ({ where }: any) => {
return (
Array.from(users.values()).find(
(u) => u.id === where.id && u.tenantId === where.tenantId,
) ?? null
);
},
},
$transaction: async (opsOrFn: any) => {
if (typeof opsOrFn === 'function') {
@@ -247,11 +276,52 @@ function makeFakePrisma() {
}
return Promise.all(opsOrFn);
},
// --- Bindungsnachweis (260909-jts, Befund C) ---------------------------
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
const bound: any = { __isBoundClient: true, __tenantId: tenantId };
for (const modelName of BOUND_MODEL_NAMES) {
const model = fake[modelName];
const wrapped: any = {};
for (const method of Object.keys(model)) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: modelName, method });
return model[method](...args);
};
}
bound[modelName] = wrapped;
}
return bound;
},
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
return fn(fake.__makeBoundClient(tenantId));
},
};
return fake;
}
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
* Herkunfts-Tenant protokollierenden Wrapper versieht. */
const BOUND_MODEL_NAMES = ['group', 'groupMembership', 'moduleGrant', 'tenantModuleActivation', 'user'];
/**
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
* lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen
* Fake). Ein vergessener `forTenant()`/`withTenantTransaction()`-Aufruf
* hinterlaesst hier KEINEN Eintrag und laesst den Test fehlschlagen.
*/
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
const found = prisma.__boundCallLog.some(
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
);
expect(
found,
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(true);
}
describe('GroupsService', () => {
// --- listForTenant ------------------------------------------------------
@@ -617,6 +687,10 @@ describe('GroupsService', () => {
const group = await service.create('t1', { name: 'Alle Benutzer' });
await service.update('t1', group.id, { isDefault: true });
// Befund E/T-JTS-02 (260909-jts): die Methode prueft seither, dass der
// Zielbenutzer zum Mandanten gehoert — ohne einen seed hier waere
// dieser Test wieder die Luecke, die er einst unbemerkt durchliess.
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
await service.addUserToDefaultGroup('t1', 'u1');
@@ -847,3 +921,164 @@ describe('GroupsService', () => {
});
});
});
// --- Bindung an forTenant()/withTenantTransaction() (260909-jts, Aufgabe 2) ---
describe('GroupsService — Bindung an forTenant()/withTenantTransaction() (260909-jts)', () => {
it('listForTenant() bindet group.findMany an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.listForTenant('t1');
expectBoundCall(prisma, 't1', 'group', 'findMany');
});
it('create() bindet group.create an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'A' });
expectBoundCall(prisma, 't1', 'group', 'create');
});
it('update() (Name/internalName, kein isDefault) bindet findOwned und group.update an den uebergebenen Mandanten', 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, { internalName: 'X' });
expectBoundCall(prisma, 't1', 'group', 'findFirst');
expectBoundCall(prisma, 't1', 'group', 'update');
});
it('update() mit isDefault:true laeuft als EINE withTenantTransaction — beide Teilschritte (updateMany, update) landen am gebundenen Client', 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 });
expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction');
expectBoundCall(prisma, 't1', 'group', 'updateMany');
expectBoundCall(prisma, 't1', 'group', 'update');
});
it('getImpact() bindet findOwned, groupMembership.count und moduleGrant.count an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await service.getImpact('t1', group.id);
expectBoundCall(prisma, 't1', 'group', 'findFirst');
expectBoundCall(prisma, 't1', 'groupMembership', 'count');
expectBoundCall(prisma, 't1', 'moduleGrant', 'count');
});
it('remove() bindet findOwned und group.delete an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await service.remove('t1', group.id);
expectBoundCall(prisma, 't1', 'group', 'findFirst');
expectBoundCall(prisma, 't1', 'group', 'delete');
});
it('listMembers() bindet findOwned und groupMembership.findMany an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await service.listMembers('t1', group.id);
expectBoundCall(prisma, 't1', 'group', 'findFirst');
expectBoundCall(prisma, 't1', 'groupMembership', 'findMany');
});
it('addMembers() bindet findOwned, user.findMany und groupMembership.createMany an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
await service.addMembers('t1', group.id, ['u1']);
expectBoundCall(prisma, 't1', 'group', 'findFirst');
expectBoundCall(prisma, 't1', 'user', 'findMany');
expectBoundCall(prisma, 't1', 'groupMembership', 'createMany');
});
it('removeMember() bindet findOwned und groupMembership.deleteMany an den uebergebenen Mandanten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await service.removeMember('t1', group.id, 'u1');
expectBoundCall(prisma, 't1', 'group', 'findFirst');
expectBoundCall(prisma, 't1', 'groupMembership', 'deleteMany');
});
it('ensureDefaultGroup() bindet den Zaehler UND alle vier Schritte der Transaktion an denselben Mandanten (T-JTS-05)', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
prisma.__seedActivation({ id: 'a1', tenantId: 't1', moduleId: 'mod-a', isActive: true });
await service.ensureDefaultGroup('t1');
expectBoundCall(prisma, 't1', 'group', 'count');
expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction');
expectBoundCall(prisma, 't1', 'group', 'create');
expectBoundCall(prisma, 't1', 'user', 'findMany');
expectBoundCall(prisma, 't1', 'groupMembership', 'createMany');
expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany');
expectBoundCall(prisma, 't1', 'moduleGrant', 'createMany');
});
it('reassignDefaultBeforeDelete() bindet die drei Lesezugriffe UND die Transaktion an denselben Mandanten', 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 });
await service.reassignDefaultBeforeDelete('t1', toDelete.id);
expectBoundCall(prisma, 't1', 'group', 'findFirst');
expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction');
expectBoundCall(prisma, 't1', 'group', 'updateMany');
expectBoundCall(prisma, 't1', 'group', 'update');
});
it('addUserToDefaultGroup() bindet group.findFirst und groupMembership.createMany an den uebergebenen Mandanten', 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 });
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
await service.addUserToDefaultGroup('t1', 'u1');
expectBoundCall(prisma, 't1', 'group', 'findFirst');
expectBoundCall(prisma, 't1', 'groupMembership', 'createMany');
});
it('addUserToDefaultGroup() mit einem Zielbenutzer eines fremden Mandanten legt KEINE Mitgliedschaft an und wirft nicht (Befund E, T-JTS-02)', 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 });
prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' });
await expect(service.addUserToDefaultGroup('t1', 'u-fremd')).resolves.not.toThrow();
const members = await service.listMembers('t1', group.id);
expect(members).toEqual([]);
});
});
+107 -45
View File
@@ -6,6 +6,7 @@ import {
} 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
@@ -25,6 +26,23 @@ export const DEFAULT_GROUP_NAME = 'Alle Benutzer';
* 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 {
@@ -35,13 +53,20 @@ export class GroupsService {
* Mitgliederzahl. Ein Mandant ohne Gruppen liefert ein leeres Array.
*/
async listForTenant(tenantId: string) {
const groups = await this.prisma.group.findMany({
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) => ({
return groups.map((g: any) => ({
id: g.id,
tenantId: g.tenantId,
name: g.name,
@@ -67,8 +92,9 @@ export class GroupsService {
throw new BadRequestException('Gruppenname darf nicht leer sein');
}
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
return await this.prisma.group.create({
return await tenantPrisma.group.create({
data: { tenantId, name },
});
} catch (err: any) {
@@ -86,7 +112,8 @@ export class GroupsService {
* Mandanten liefert NotFoundException statt eines Treffers.
*/
private async findOwned(tenantId: string, id: string) {
const group = await this.prisma.group.findFirst({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const group = await tenantPrisma.group.findFirst({
where: { id, tenantId },
});
if (!group) {
@@ -98,13 +125,14 @@ export class GroupsService {
/**
* Aktualisiert Name, Standardmarkierung und/oder internen Anzeigenamen.
*
* 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.
* 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 —
@@ -160,16 +188,16 @@ export class GroupsService {
try {
if (data.isDefault === true) {
const [, updated] = await this.prisma.$transaction([
this.prisma.group.updateMany({
const updated = await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
await tx.group.updateMany({
where: { tenantId, isDefault: true },
data: { isDefault: false },
}),
this.prisma.group.update({
});
return tx.group.update({
where: { id },
data: { ...updateData, isDefault: true },
}),
]);
});
});
return updated;
}
@@ -177,7 +205,8 @@ export class GroupsService {
updateData.isDefault = false;
}
return await this.prisma.group.update({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return await tenantPrisma.group.update({
where: { id },
data: updateData,
});
@@ -198,9 +227,10 @@ export class GroupsService {
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([
this.prisma.groupMembership.count({ where: { groupId: id } }),
this.prisma.moduleGrant.count({ where: { groupId: id } }),
tenantPrisma.groupMembership.count({ where: { groupId: id } }),
tenantPrisma.moduleGrant.count({ where: { groupId: id } }),
]);
return { memberCount, grantCount };
@@ -217,8 +247,9 @@ export class GroupsService {
async remove(tenantId: string, id: string) {
await this.findOwned(tenantId, id);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
try {
return await this.prisma.group.delete({ where: { id } });
return await tenantPrisma.group.delete({ where: { id } });
} catch (err: any) {
if (err?.code === 'P2025') {
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
@@ -234,7 +265,8 @@ export class GroupsService {
async listMembers(tenantId: string, id: string) {
await this.findOwned(tenantId, id);
return this.prisma.groupMembership.findMany({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
return tenantPrisma.groupMembership.findMany({
where: { groupId: id },
include: {
user: {
@@ -254,17 +286,18 @@ export class GroupsService {
async addMembers(tenantId: string, id: string, userIds: string[]) {
await this.findOwned(tenantId, id);
const validUsers = await this.prisma.user.findMany({
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) => u.id);
const validIds = validUsers.map((u: any) => u.id);
if (validIds.length === 0) {
return { added: 0 };
}
const result = await this.prisma.groupMembership.createMany({
data: validIds.map((userId) => ({
const result = await tenantPrisma.groupMembership.createMany({
data: validIds.map((userId: string) => ({
groupId: id,
userId,
source: MembershipSource.MANUAL,
@@ -283,7 +316,8 @@ export class GroupsService {
async removeMember(tenantId: string, id: string, userId: string) {
await this.findOwned(tenantId, id);
await this.prisma.groupMembership.deleteMany({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
await tenantPrisma.groupMembership.deleteMany({
where: { groupId: id, userId, source: MembershipSource.MANUAL },
});
}
@@ -305,6 +339,13 @@ export class GroupsService {
* 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
@@ -313,13 +354,14 @@ export class GroupsService {
* propagieren.
*/
async ensureDefaultGroup(tenantId: string) {
const existingCount = await this.prisma.group.count({ where: { tenantId } });
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const existingCount = await tenantPrisma.group.count({ where: { tenantId } });
if (existingCount > 0) {
return null;
}
try {
return await this.prisma.$transaction(async (tx) => {
return await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
const group = await tx.group.create({
data: { tenantId, name: DEFAULT_GROUP_NAME, isDefault: true },
});
@@ -330,7 +372,7 @@ export class GroupsService {
});
if (users.length > 0) {
await tx.groupMembership.createMany({
data: users.map((u) => ({
data: users.map((u: any) => ({
groupId: group.id,
userId: u.id,
source: MembershipSource.MANUAL,
@@ -345,7 +387,7 @@ export class GroupsService {
});
if (activations.length > 0) {
await tx.moduleGrant.createMany({
data: activations.map((a) => ({
data: activations.map((a: any) => ({
tenantId,
moduleId: a.moduleId,
groupId: group.id,
@@ -382,16 +424,20 @@ export class GroupsService {
* 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
* 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<boolean> {
const group = await this.prisma.group.findFirst({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const group = await tenantPrisma.group.findFirst({
where: { id: groupId, tenantId },
});
if (!group || !group.isDefault) {
@@ -399,10 +445,10 @@ export class GroupsService {
}
const target =
(await this.prisma.group.findFirst({
(await tenantPrisma.group.findFirst({
where: { tenantId, id: { not: groupId }, name: DEFAULT_GROUP_NAME },
})) ??
(await this.prisma.group.findFirst({
(await tenantPrisma.group.findFirst({
where: { tenantId, id: { not: groupId } },
orderBy: { createdAt: 'asc' },
}));
@@ -412,16 +458,16 @@ export class GroupsService {
}
try {
await this.prisma.$transaction([
this.prisma.group.updateMany({
await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
await tx.group.updateMany({
where: { tenantId, isDefault: true },
data: { isDefault: false },
}),
this.prisma.group.update({
});
await tx.group.update({
where: { id: target.id },
data: { isDefault: true },
}),
]);
});
});
return true;
} catch (err: any) {
if (err?.code === 'P2002') {
@@ -437,16 +483,32 @@ export class GroupsService {
* 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
* (Befund E, T-JTS-02, 260909-jts): die ausgelieferte Policy auf
* GroupMembership prüft ausschließlich die Gruppenseite
* (`groupId IN (SELECT id FROM "Group" WHERE tenantId = ...)`), gemessen
* in Aufgabe 1 — die Benutzerseite prüft sie 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 defaultGroup = await this.prisma.group.findFirst({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const defaultGroup = await tenantPrisma.group.findFirst({
where: { tenantId, isDefault: true },
});
if (!defaultGroup) {
return;
}
await this.prisma.groupMembership.createMany({
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,
});