636fe0df8f
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf apps/web+packages, noUselessEscapeInRegex, useConst, useExponentiationOperator) sowie fuenf ungesicherte Regeln (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate, useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei fehlender Sitzung geprueft) - noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60, die Fallmarke dokumentiert Absicht) - Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts), fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten) - Sechs weitere, im Plan nicht namentlich gelistete aber gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich bereinigt (groups.service.spec.ts, cert-manager.test.tsx, ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/ noUnusedImports/noUnusedFunctionParameters zu erreichen Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...). Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten 621/542 — eine Differenz von 1, weil das Streichen des Namens aus `catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint --force 5/5. Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben): - force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad, nicht die HTTP-Methode - change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf der Seite stehen (keine Weiterleitung, keine Aktualisierung der Benutzerablage) - VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst sich doppelt ausloesen - SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
521 lines
20 KiB
TypeScript
521 lines
20 KiB
TypeScript
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<boolean> {
|
|
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,
|
|
});
|
|
}
|
|
}
|