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:
@@ -15,6 +15,7 @@ import { DkvModule } from './dkv/dkv.module';
|
|||||||
import { CertManagerModule } from './cert-manager/cert-manager.module';
|
import { CertManagerModule } from './cert-manager/cert-manager.module';
|
||||||
import { FavoritesModule } from './favorites/favorites.module';
|
import { FavoritesModule } from './favorites/favorites.module';
|
||||||
import { DomaincheckModule } from './domaincheck/domaincheck.module';
|
import { DomaincheckModule } from './domaincheck/domaincheck.module';
|
||||||
|
import { GroupsModule } from './groups/groups.module';
|
||||||
import { ModuleRegistryModule } from './module-registry/module-registry.module';
|
import { ModuleRegistryModule } from './module-registry/module-registry.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
@@ -35,6 +36,7 @@ import { UserModule } from './user/user.module';
|
|||||||
MailModule,
|
MailModule,
|
||||||
LdapModule,
|
LdapModule,
|
||||||
ModuleRegistryModule,
|
ModuleRegistryModule,
|
||||||
|
GroupsModule,
|
||||||
DomaincheckModule,
|
DomaincheckModule,
|
||||||
CertManagerModule,
|
CertManagerModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user