feat(15-02): GroupsModule — CRUD für Gruppen, Mitgliedschaften und Löschauswirkung

- GroupsService: listForTenant/create/update/remove/getImpact/listMembers/addMembers/removeMember/addUserToDefaultGroup, jede Query tenantId-gescoped (T-15-02/T-15-12)
- isDefault:true läuft in einer Transaktion (updateMany+update), D-13
- getImpact liefert { memberCount, grantCount } für den Löschdialog (D-17)
- removeMember beschränkt sich auf source:MANUAL (D-19)
- GroupsController: 8 rollengeschützte Routen unter /groups
- 19 Tests in groups.service.spec.ts, hand-rolled In-Memory-Fake
This commit is contained in:
2026-08-04 15:21:41 +02:00
parent 79c83eae5f
commit 69494d7549
8 changed files with 914 additions and 0 deletions
@@ -0,0 +1,10 @@
import { IsArray, IsString } from 'class-validator';
/**
* DTO für das manuelle Hinzufügen von Mitgliedern zu einer Gruppe.
*/
export class AddGroupMembersDto {
@IsArray()
@IsString({ each: true })
userIds!: string[];
}
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* DTO für das Anlegen einer Gruppe (PERM-01).
*
* Leerraum wird im Service getrimmt; ein leerer oder nur aus Leerzeichen
* bestehender Name wird dort explizit als BadRequestException abgelehnt
* (Trim passiert NACH der class-validator-Prüfung, deshalb reicht hier
* @IsNotEmpty auf den Rohwert nicht aus, um " " abzufangen).
*/
export class CreateGroupDto {
@IsString()
@IsNotEmpty()
name!: string;
}
@@ -0,0 +1,23 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
/**
* DTO für das Aktualisieren einer Gruppe: Umbenennen, Standardmarkierung
* setzen/entfernen (D-13), AD-Bindung setzen/lösen (ldapDn: null löst).
*
* @IsOptional() lässt sowohl undefined als auch null unvalidiert durch —
* das ist die Voraussetzung dafür, dass ldapDn: null (Lösen der AD-Bindung)
* gültig bleibt, obwohl @IsString() sonst null ablehnen würde.
*/
export class UpdateGroupDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsString()
ldapDn?: string | null;
}
+142
View File
@@ -0,0 +1,142 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Request } from 'express';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AddGroupMembersDto } from './dto/add-group-members.dto';
import { CreateGroupDto } from './dto/create-group.dto';
import { UpdateGroupDto } from './dto/update-group.dto';
import { GroupsService } from './groups.service';
/**
* REST-Controller für die Gruppenverwaltung (PERM-01, D-14).
*
* tenantId kommt ausschließlich aus dem JWT (req.tenantId ?? req.user?.tenantId),
* niemals aus Body/Params (T-03-04). Jede Route ist rollengeschützt —
* identisch zu den Modul-Aktivierungsrouten in ModuleRegistryController.
*/
@Controller('groups')
export class GroupsController {
constructor(private readonly groupsService: GroupsService) {}
private getTenantId(req: Request): string {
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
return tenantId;
}
/**
* GET /groups
* Liste aller Gruppen des Mandanten, alphabetisch, mit Mitgliederzahl.
*/
@Get()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async list(@Req() req: Request) {
return this.groupsService.listForTenant(this.getTenantId(req));
}
/**
* POST /groups
* Legt eine neue Gruppe im Mandanten an.
*/
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateGroupDto, @Req() req: Request) {
return this.groupsService.create(this.getTenantId(req), dto);
}
/**
* PATCH /groups/:id
* Umbenennen, Standardmarkierung setzen/entfernen, AD-Bindung setzen/lösen.
*/
@Patch(':id')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async update(
@Param('id') id: string,
@Body() dto: UpdateGroupDto,
@Req() req: Request,
) {
return this.groupsService.update(this.getTenantId(req), id, dto);
}
/**
* DELETE /groups/:id
* Löscht die Gruppe; Mitgliedschaften und Grants folgen per Cascade (D-17).
*/
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Param('id') id: string, @Req() req: Request) {
return this.groupsService.remove(this.getTenantId(req), id);
}
/**
* GET /groups/:id/impact
* Zahlenmaterial für den Löschdialog (D-17): { memberCount, grantCount }.
*/
@Get(':id/impact')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async impact(@Param('id') id: string, @Req() req: Request) {
return this.groupsService.getImpact(this.getTenantId(req), id);
}
/**
* GET /groups/:id/members
* Mitgliederliste inkl. Kern-Benutzerdaten.
*/
@Get(':id/members')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async members(@Param('id') id: string, @Req() req: Request) {
return this.groupsService.listMembers(this.getTenantId(req), id);
}
/**
* POST /groups/:id/members
* Fügt Mitglieder manuell hinzu (source: MANUAL).
*/
@Post(':id/members')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async addMembers(
@Param('id') id: string,
@Body() dto: AddGroupMembersDto,
@Req() req: Request,
) {
return this.groupsService.addMembers(this.getTenantId(req), id, dto.userIds);
}
/**
* DELETE /groups/:id/members/:userId
* Entfernt ein manuell hinzugefügtes Mitglied (LDAP-Mitgliedschaften
* bleiben unberührt, D-19).
*/
@Delete(':id/members/:userId')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async removeMember(
@Param('id') id: string,
@Param('userId') userId: string,
@Req() req: Request,
) {
await this.groupsService.removeMember(this.getTenantId(req), id, userId);
return { success: true };
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service';
/**
* NestJS-Modul für die Gruppenverwaltung (PERM-01, PERM-06).
*
* Exportiert GroupsService, damit UserModule (Plan 15-02 Task 2) darüber
* addUserToDefaultGroup aufrufen kann — der einzige Codepfad für die
* automatische Standardgruppen-Mitgliedschaft (D-11/D-12).
*/
@Module({
controllers: [GroupsController],
providers: [GroupsService],
exports: [GroupsService],
})
export class GroupsModule {}
+432
View File
@@ -0,0 +1,432 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { MembershipSource } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { GroupsService } from './groups.service';
/**
* GroupsService.spec — TDD-RED-first Beweis für PERM-01 (D-13/D-17/D-19)
* und die tenantId-Ownership-Invariante (T-15-02/T-15-12).
*
* Hand-rolled In-Memory-Fake im Stil von tender-saved-search.service.spec.ts:
* 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.
*/
function makeFakePrisma() {
const groups = new Map<string, any>();
const memberships = new Map<string, any>();
const grants = new Map<string, any>();
const users = new Map<string, any>();
let groupCounter = 0;
let membershipCounter = 0;
function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) {
return Array.from(groups.values()).find(
(g) => g.tenantId === tenantId && g.name === name && g.id !== excludeId,
);
}
function throwUnique(): never {
const err: any = new Error('Unique constraint failed');
err.code = 'P2002';
throw err;
}
function throwNotFound(): never {
const err: any = new Error('Record to update/delete not found');
err.code = 'P2025';
throw err;
}
return {
__seedUser(user: { id: string; tenantId: string }) {
users.set(user.id, user);
},
__seedGrant(grant: { id: string; groupId: string }) {
grants.set(grant.id, grant);
},
group: {
findMany: async ({ where, include }: any) => {
let rows: any[] = Array.from(groups.values()).filter(
(g) => g.tenantId === where.tenantId,
);
rows = [...rows].sort((a, b) => a.name.localeCompare(b.name));
if (include?._count) {
rows = rows.map((g) => ({
...g,
_count: {
memberships: Array.from(memberships.values()).filter(
(m) => m.groupId === g.id,
).length,
},
}));
}
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
);
},
create: async ({ data }: any) => {
if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique();
groupCounter += 1;
const record = {
id: `g-${groupCounter}`,
ldapDn: null,
isDefault: false,
createdAt: new Date(),
updatedAt: new Date(),
...data,
};
groups.set(record.id, record);
return record;
},
update: async ({ where, data }: any) => {
const existing = groups.get(where.id);
if (!existing) throwNotFound();
if (data.name && findGroupByTenantAndName(existing.tenantId, data.name, existing.id)) {
throwUnique();
}
const record = { ...existing, ...data, updatedAt: new Date() };
groups.set(where.id, record);
return record;
},
updateMany: async ({ where, data }: any) => {
let count = 0;
for (const g of groups.values()) {
if (
g.tenantId === where.tenantId &&
(where.isDefault === undefined || g.isDefault === where.isDefault)
) {
Object.assign(g, data);
count += 1;
}
}
return { count };
},
delete: async ({ where }: any) => {
const existing = groups.get(where.id);
if (!existing) throwNotFound();
groups.delete(where.id);
return existing;
},
},
groupMembership: {
findMany: async ({ where }: any) =>
Array.from(memberships.values()).filter((m) => m.groupId === where.groupId),
createMany: async ({ data, skipDuplicates }: any) => {
let count = 0;
for (const item of data) {
const exists = Array.from(memberships.values()).find(
(m) => m.groupId === item.groupId && m.userId === item.userId,
);
if (exists) {
if (skipDuplicates) continue;
throwUnique();
}
membershipCounter += 1;
memberships.set(`m-${membershipCounter}`, {
id: `m-${membershipCounter}`,
createdAt: new Date(),
...item,
});
count += 1;
}
return { count };
},
deleteMany: async ({ where }: any) => {
let count = 0;
for (const [key, m] of memberships.entries()) {
if (
m.groupId === where.groupId &&
m.userId === where.userId &&
(where.source === undefined || m.source === where.source)
) {
memberships.delete(key);
count += 1;
}
}
return { count };
},
count: async ({ where }: any) =>
Array.from(memberships.values()).filter((m) => m.groupId === where.groupId).length,
},
moduleGrant: {
count: async ({ where }: any) =>
Array.from(grants.values()).filter((g) => g.groupId === where.groupId).length,
},
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,
);
},
},
$transaction: async (ops: Promise<any>[]) => Promise.all(ops),
};
}
describe('GroupsService', () => {
// --- listForTenant ------------------------------------------------------
it('listForTenant() liefert die Gruppen nach name aufsteigend, mit memberCount', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Zebra' });
const alpha = await service.create('t1', { name: 'Alpha' });
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
await service.addMembers('t1', alpha.id, ['u1']);
const result = await service.listForTenant('t1');
expect(result.map((g) => g.name)).toEqual(['Alpha', 'Zebra']);
expect(result[0].memberCount).toBe(1);
expect(result[1].memberCount).toBe(0);
});
it('listForTenant() eines Mandanten ohne Gruppen liefert ein leeres Array', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
expect(await service.listForTenant('t-leer')).toEqual([]);
});
// --- create --------------------------------------------------------------
it('create() trimmt führenden und abschließenden Leerraum', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const result = await service.create('t1', { name: ' Vertrieb ' });
expect(result.name).toBe('Vertrieb');
});
it('create() mit leerem oder nur aus Leerzeichen bestehendem Namen wirft BadRequestException', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await expect(service.create('t1', { name: '' })).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(service.create('t1', { name: ' ' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('create() mit einem im Mandanten bereits vergebenen Namen wirft ConflictException (P2002)', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Vertrieb' });
await expect(service.create('t1', { name: 'Vertrieb' })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('create() mit demselben Namen in einem anderen Mandanten ist erfolgreich', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Vertrieb' });
const result = await service.create('t2', { name: 'Vertrieb' });
expect(result.tenantId).toBe('t2');
});
it('encoding/PERM-01: Gruppennamen werden nicht normalisiert oder kleingeschrieben — "Vertrieb" und "vertrieb" sind zwei Gruppen', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Vertrieb' });
const lower = await service.create('t1', { name: 'vertrieb' });
expect(lower.name).toBe('vertrieb');
});
// --- update ----------------------------------------------------------------
it('update() benennt um; eine ID eines fremden Mandanten wirft NotFoundException', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'Alt' });
const updated = await service.update('t1', group.id, { name: 'Neu' });
expect(updated.name).toBe('Neu');
await expect(
service.update('t-fremd', group.id, { name: 'Uebernahme' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('update() mit isDefault:true setzt die Markierung und entfernt sie von jeder anderen Gruppe desselben Mandanten', 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', a.id, { isDefault: true });
const updatedB = await service.update('t1', b.id, { isDefault: true });
expect(updatedB.isDefault).toBe(true);
const list = await service.listForTenant('t1');
const groupA = list.find((g) => g.id === a.id)!;
expect(groupA.isDefault).toBe(false);
});
it('update() mit isDefault:false schaltet die Markierung ab, ohne sie irgendwo anders zu setzen', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const a = await service.create('t1', { name: 'A' });
await service.update('t1', a.id, { isDefault: true });
const result = await service.update('t1', a.id, { isDefault: false });
expect(result.isDefault).toBe(false);
});
it('update() mit ldapDn bindet an eine AD-Gruppe; ldapDn:null löst die Bindung', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
const bound = await service.update('t1', group.id, {
ldapDn: 'CN=A,OU=Groups,DC=ctl,DC=local',
});
expect(bound.ldapDn).toBe('CN=A,OU=Groups,DC=ctl,DC=local');
const unbound = await service.update('t1', group.id, { ldapDn: null });
expect(unbound.ldapDn).toBeNull();
});
// --- getImpact ---------------------------------------------------------
it('getImpact() liefert { memberCount, grantCount }; eine ID eines fremden Mandanten wirft NotFoundException', 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']);
prisma.__seedGrant({ id: 'grant-1', groupId: group.id });
const impact = await service.getImpact('t1', group.id);
expect(impact).toEqual({ memberCount: 1, grantCount: 1 });
await expect(service.getImpact('t-fremd', group.id)).rejects.toBeInstanceOf(
NotFoundException,
);
});
// --- remove --------------------------------------------------------------
it('remove() löscht die Gruppe; ein zweiter Aufruf auf dieselbe ID wirft NotFoundException statt eines unbehandelten Prisma-Fehlers', 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);
await expect(service.remove('t1', group.id)).rejects.toBeInstanceOf(NotFoundException);
});
// --- addMembers / removeMember -------------------------------------------
it('addMembers() legt fehlende Mitgliedschaften mit source:MANUAL an und ist für bereits vorhandene folgenlos', 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' });
const first = await service.addMembers('t1', group.id, ['u1']);
expect(first.added).toBe(1);
const second = await service.addMembers('t1', group.id, ['u1']);
expect(second.added).toBe(0);
const members = await service.listMembers('t1', group.id);
expect(members).toHaveLength(1);
expect(members[0].source).toBe(MembershipSource.MANUAL);
});
it('addMembers() mit einer userId eines fremden Mandanten überspringt diese ID', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' });
const result = await service.addMembers('t1', group.id, ['u-fremd']);
expect(result.added).toBe(0);
expect(await service.listMembers('t1', group.id)).toEqual([]);
});
it('removeMember() entfernt ausschließlich Mitgliedschaften mit source:MANUAL und lässt LDAP-Mitgliedschaften unberührt', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
prisma.__seedUser({ id: 'u-manual', tenantId: 't1' });
prisma.__seedUser({ id: 'u-ldap', tenantId: 't1' });
await service.addMembers('t1', group.id, ['u-manual']);
// LDAP-Mitgliedschaft direkt im Fake seeden (kein Service-Pfad dafür in diesem Plan)
await (prisma as any).groupMembership.createMany({
data: [{ groupId: group.id, userId: 'u-ldap', source: MembershipSource.LDAP }],
skipDuplicates: true,
});
await service.removeMember('t1', group.id, 'u-manual');
await service.removeMember('t1', group.id, 'u-ldap');
const remaining = await service.listMembers('t1', group.id);
expect(remaining).toHaveLength(1);
expect(remaining[0].userId).toBe('u-ldap');
});
it('removeMember() für ein nicht vorhandenes Mitglied ist folgenlos und wirft nicht', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await expect(service.removeMember('t1', group.id, 'kein-mitglied')).resolves.not.toThrow();
});
// --- addUserToDefaultGroup -------------------------------------------------
it('addUserToDefaultGroup() legt eine Mitgliedschaft in der Gruppe mit isDefault:true an', 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 });
await service.addUserToDefaultGroup('t1', 'u1');
const members = await service.listMembers('t1', group.id);
expect(members.map((m: any) => m.userId)).toEqual(['u1']);
});
it('addUserToDefaultGroup() bei einem Mandanten ohne markierte Standardgruppe tut nichts und wirft nicht', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Keine Standardgruppe' });
await expect(service.addUserToDefaultGroup('t1', 'u1')).resolves.not.toThrow();
});
});
+273
View File
@@ -0,0 +1,273 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { MembershipSource } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
/**
* 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).
*/
@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 groups = await this.prisma.group.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
include: { _count: { select: { memberships: true } } },
});
return groups.map((g) => ({
id: g.id,
tenantId: g.tenantId,
name: g.name,
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');
}
try {
return await this.prisma.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 group = await this.prisma.group.findFirst({
where: { id, tenantId },
});
if (!group) {
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
}
return group;
}
/**
* Aktualisiert Name, Standardmarkierung und/oder AD-Bindung.
*
* 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. ldapDn:null löst eine AD-Bindung.
*/
async update(
tenantId: string,
id: string,
data: { name?: string; isDefault?: boolean; ldapDn?: string | null },
) {
await this.findOwned(tenantId, id);
const updateData: {
name?: string;
ldapDn?: string | null;
isDefault?: boolean;
} = {};
if (data.name !== undefined) {
const trimmed = data.name.trim();
if (!trimmed) {
throw new BadRequestException('Gruppenname darf nicht leer sein');
}
updateData.name = trimmed;
}
if (data.ldapDn !== undefined) {
updateData.ldapDn = data.ldapDn;
}
try {
if (data.isDefault === true) {
const [, updated] = await this.prisma.$transaction([
this.prisma.group.updateMany({
where: { tenantId, isDefault: true },
data: { isDefault: false },
}),
this.prisma.group.update({
where: { id },
data: { ...updateData, isDefault: true },
}),
]);
return updated;
}
if (data.isDefault === false) {
updateData.isDefault = false;
}
return await this.prisma.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 [memberCount, grantCount] = await Promise.all([
this.prisma.groupMembership.count({ where: { groupId: id } }),
this.prisma.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);
try {
return await this.prisma.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);
return this.prisma.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 validUsers = await this.prisma.user.findMany({
where: { id: { in: userIds }, tenantId },
select: { id: true },
});
const validIds = validUsers.map((u) => u.id);
if (validIds.length === 0) {
return { added: 0 };
}
const result = await this.prisma.groupMembership.createMany({
data: validIds.map((userId) => ({
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);
await this.prisma.groupMembership.deleteMany({
where: { groupId: id, userId, source: MembershipSource.MANUAL },
});
}
/**
* 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.
*/
async addUserToDefaultGroup(tenantId: string, userId: string) {
const defaultGroup = await this.prisma.group.findFirst({
where: { tenantId, isDefault: true },
});
if (!defaultGroup) {
return;
}
await this.prisma.groupMembership.createMany({
data: [{ groupId: defaultGroup.id, userId, source: MembershipSource.MANUAL }],
skipDuplicates: true,
});
}
}