feat: CRM Berechtigungsmodell — konfigurierbares Sichtbarkeitsmodell (OWN/TEAM/ALL)

Implementiert pro-Entity Sichtbarkeitssteuerung für Companies, Contacts, Deals
und Activities mit Rollen-basierter Zugriffskontrolle (ADMIN sieht alles,
TEAM_LEAD mindestens Team-Sicht, READONLY nur Lesezugriff).

- JWT Payload um tenantRole + department erweitert (Core + CRM)
- Team-Members-Endpoint im Core Service (GET /users/team-members)
- VisibilityModule mit Redis-Cache (CRM Service)
- ReadonlyGuard als globaler Guard (CRM Service)
- buildVisibilityFilter Utility für Prisma WHERE-Filterung
- Admin-Einstellungsseite /admin/crm-settings (Frontend)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-14 22:20:53 +01:00
parent c987ce87c0
commit de4af77c5c
31 changed files with 1044 additions and 17 deletions

View file

@ -7,6 +7,8 @@ export interface JwtPayload {
role: string;
tenantId?: string;
tenantSlug?: string;
tenantRole?: string; // ADMIN | MEMBER | VIEWER | TEAM_LEAD | READONLY
department?: string; // User-Abteilung (fuer TEAM-Visibility)
jti: string; // Token-ID fuer Revocation
iat: number;
exp: number;

View file

@ -148,6 +148,8 @@ export class AuthService {
role: user.role,
tenantId: primaryMembership?.tenant.id,
tenantSlug: primaryMembership?.tenant.slug,
tenantRole: primaryMembership?.tenantRole,
department: user.department ?? undefined,
});
this.logger.log(`Login erfolgreich: ${user.email}`);
@ -197,6 +199,8 @@ export class AuthService {
role: payload.role,
tenantId: payload.tenantId,
tenantSlug: payload.tenantSlug,
tenantRole: payload.tenantRole,
department: payload.department,
});
} catch (error) {
if (error instanceof UnauthorizedException) throw error;
@ -381,8 +385,10 @@ export class AuthService {
firstName: string;
lastName: string;
role: string;
department?: string | null;
twoFactorEnabled: boolean;
tenantMemberships?: Array<{
tenantRole: string;
tenant: { id: string; slug: string };
}>;
};
@ -473,6 +479,8 @@ export class AuthService {
role: user.role,
tenantId: primaryMembership?.tenant.id,
tenantSlug: primaryMembership?.tenant.slug,
tenantRole: primaryMembership?.tenantRole,
department: user.department ?? undefined,
});
return {

View file

@ -69,6 +69,24 @@ export class UsersController {
return { message: 'Passwort erfolgreich geändert' };
}
/**
* GET /api/v1/users/team-members
* User-IDs im gleichen Department abrufen (fuer TEAM-Visibility).
* Gibt nur IDs zurueck, keine sensiblen Daten.
*/
@Get('team-members')
@ApiOperation({ summary: 'Team-Mitglieder (gleiche Abteilung) abrufen' })
async getTeamMembers(@CurrentUser() user: JwtPayload) {
if (!user.tenantId || !user.department) {
return { data: { userIds: [user.sub] } };
}
const userIds = await this.usersService.findUserIdsByDepartment(
user.tenantId,
user.department,
);
return { data: { userIds } };
}
/**
* GET /api/v1/users
* Alle User auflisten (nur PLATFORM_ADMIN).

View file

@ -309,4 +309,29 @@ export class UsersService {
},
};
}
/**
* User-IDs nach Abteilung und Tenant abrufen.
* Wird fuer TEAM-Visibility im CRM-Service genutzt.
*/
async findUserIdsByDepartment(
tenantId: string,
department: string,
): Promise<string[]> {
const users = await this.prisma.user.findMany({
where: {
department,
isActive: true,
tenantMemberships: {
some: {
tenant: { id: tenantId },
isActive: true,
},
},
},
select: { id: true },
});
return users.map((u) => u.id);
}
}

View file

@ -890,3 +890,28 @@ model TradeEvent {
@@map("trade_events")
@@schema("app_crm")
}
// --------------------------------------------------------
// Phase 2.5 — CRM Visibility Settings
// --------------------------------------------------------
enum VisibilityLevel {
OWN
TEAM
ALL
@@schema("app_crm")
}
model CrmVisibilitySetting {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
entity String @db.VarChar(50) // COMPANY, CONTACT, DEAL, ACTIVITY
level VisibilityLevel @default(ALL)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([tenantId, entity])
@@index([tenantId])
@@map("crm_visibility_settings")
@@schema("app_crm")
}

View file

@ -0,0 +1,21 @@
-- Migration: 20260314_crm_visibility
-- Beschreibung: CRM Visibility Settings (Sichtbarkeitssteuerung OWN/TEAM/ALL)
-- CreateEnum
CREATE TYPE "app_crm"."VisibilityLevel" AS ENUM ('OWN', 'TEAM', 'ALL');
-- CreateTable
CREATE TABLE "app_crm"."crm_visibility_settings" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL,
"entity" VARCHAR(50) NOT NULL,
"level" "app_crm"."VisibilityLevel" NOT NULL DEFAULT 'ALL',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "crm_visibility_settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "crm_visibility_settings_tenant_id_entity_key" ON "app_crm"."crm_visibility_settings"("tenant_id", "entity");
CREATE INDEX "crm_visibility_settings_tenant_id_idx" ON "app_crm"."crm_visibility_settings"("tenant_id");

View file

@ -7,11 +7,13 @@ import {
Body,
Param,
Query,
Req,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import {
ApiTags,
ApiOperation,
@ -56,10 +58,14 @@ export class ActivitiesController {
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: QueryActivitiesDto,
@Req() req: Request,
) {
const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', '');
const result = await this.activitiesService.findAll(
user.tenantId!,
query,
user,
bearerToken,
);
return paginatedResponse(
result.data,

View file

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
import { VisibilityModule } from '../visibility/visibility.module';
@Module({
imports: [VisibilityModule],
controllers: [ActivitiesController],
providers: [ActivitiesService],
exports: [ActivitiesService],

View file

@ -7,11 +7,18 @@ import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { CreateActivityDto } from './dto/create-activity.dto';
import { UpdateActivityDto } from './dto/update-activity.dto';
import { QueryActivitiesDto } from './dto/query-activities.dto';
import { Prisma } from '.prisma/crm-client';
import { VisibilityService } from '../visibility/visibility.service';
import { TeamResolverService } from '../visibility/team-resolver.service';
import { JwtPayload } from '../common/decorators/current-user.decorator';
import { Prisma, VisibilityLevel } from '.prisma/crm-client';
@Injectable()
export class ActivitiesService {
constructor(private readonly prisma: CrmPrismaService) {}
constructor(
private readonly prisma: CrmPrismaService,
private readonly visibilityService: VisibilityService,
private readonly teamResolver: TeamResolverService,
) {}
async create(tenantId: string, userId: string, dto: CreateActivityDto) {
// Mindestens contactId oder companyId muss gesetzt sein
@ -64,12 +71,47 @@ export class ActivitiesService {
});
}
async findAll(tenantId: string, query: QueryActivitiesDto) {
async findAll(
tenantId: string,
query: QueryActivitiesDto,
user?: JwtPayload,
bearerToken?: string,
) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const where: Prisma.ActivityWhereInput = { tenantId };
// Visibility-Filter: Activities filtern ueber createdBy (kein Owner-Table)
if (user) {
const level = await this.visibilityService.getLevel(tenantId, 'ACTIVITY');
const isAdmin =
user.role === 'PLATFORM_ADMIN' ||
user.role === 'TENANT_ADMIN' ||
user.tenantRole === 'ADMIN';
if (!isAdmin && level !== VisibilityLevel.ALL) {
let effectiveLevel = level;
if (user.tenantRole === 'TEAM_LEAD' && level === VisibilityLevel.OWN) {
effectiveLevel = VisibilityLevel.TEAM;
}
if (effectiveLevel === VisibilityLevel.OWN) {
where.createdBy = user.sub;
} else if (effectiveLevel === VisibilityLevel.TEAM) {
let teamIds = [user.sub];
if (bearerToken) {
teamIds = await this.teamResolver.getTeamMemberIds(
user.sub,
user.department,
bearerToken,
);
}
where.createdBy = { in: teamIds };
}
}
}
// Aggregierter Company-Feed: direkte + verknuepfte Kontakt-Aktivitaeten
if (query.companyId && query.includeContacts) {
const contactIds = await this.prisma.contact

View file

@ -7,6 +7,7 @@ import { CrmPrismaModule } from './prisma/crm-prisma.module';
import { RedisModule } from './redis/redis.module';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { ReadonlyGuard } from './auth/guards/readonly.guard';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { HealthModule } from './health/health.module';
import { ContactsModule } from './contacts/contacts.module';
@ -27,6 +28,7 @@ import { EnrichmentModule } from './enrichment/enrichment.module';
import { ContractsModule } from './contracts/contracts.module';
import { GraphModule } from './graph/graph.module';
import { DealTypesModule } from './deal-types/deal-types.module';
import { VisibilityModule } from './visibility/visibility.module';
@Module({
imports: [
@ -57,12 +59,17 @@ import { DealTypesModule } from './deal-types/deal-types.module';
ContractsModule,
GraphModule,
DealTypesModule,
VisibilityModule,
],
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: ReadonlyGuard,
},
{
provide: APP_FILTER,
useClass: GlobalExceptionFilter,

View file

@ -0,0 +1,43 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtPayload } from '../../common/decorators/current-user.decorator';
/**
* ReadonlyGuard: Blockiert alle schreibenden Requests (POST, PATCH, PUT, DELETE)
* fuer User mit tenantRole === 'READONLY'.
*
* GET-Requests werden immer durchgelassen.
*/
@Injectable()
export class ReadonlyGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const method = request.method as string;
// GET-Requests sind immer erlaubt
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
return true;
}
const user = request.user as JwtPayload | undefined;
if (!user) {
return true; // Kein User → andere Guards handeln das
}
// READONLY-User duerfen nicht schreiben
if (user.tenantRole === 'READONLY') {
throw new ForbiddenException(
'Ihr Benutzerkonto hat nur Leserechte. Schreiboperationen sind nicht erlaubt.',
);
}
return true;
}
}

View file

@ -7,6 +7,8 @@ export interface JwtPayload {
role: string;
tenantId?: string;
tenantSlug?: string;
tenantRole?: string; // ADMIN | MEMBER | VIEWER | TEAM_LEAD | READONLY
department?: string; // User-Abteilung (fuer TEAM-Visibility)
jti: string;
iat: number;
exp: number;

View file

@ -0,0 +1,76 @@
import { VisibilityLevel } from '.prisma/crm-client';
import { JwtPayload } from '../decorators/current-user.decorator';
/**
* Erzeugt einen Prisma WHERE-Filter basierend auf dem Sichtbarkeitslevel.
*
* - ALL: Kein zusaetzlicher Filter (alle Tenant-Daten sichtbar)
* - OWN: Nur Datensaetze, bei denen der User Owner/Member ist
* - TEAM: Datensaetze aller Team-Mitglieder (gleiche Abteilung)
*
* ADMIN und PLATFORM_ADMIN sehen immer alles.
* TEAM_LEAD sieht mindestens TEAM-Level.
*/
export function buildVisibilityFilter(params: {
user: JwtPayload;
level: VisibilityLevel;
ownerRelation: string; // z.B. 'companyOwners', 'contactOwners', 'dealOwners'
teamMemberIds?: string[]; // User-IDs im gleichen Department
}): Record<string, unknown> {
const { user, level, ownerRelation, teamMemberIds } = params;
const tenantId = user.tenantId;
// Admins sehen immer alles
if (
user.role === 'PLATFORM_ADMIN' ||
user.role === 'TENANT_ADMIN' ||
user.tenantRole === 'ADMIN'
) {
return { tenantId };
}
// Effektives Level bestimmen (TEAM_LEAD sieht mindestens TEAM)
let effectiveLevel = level;
if (user.tenantRole === 'TEAM_LEAD' && level === VisibilityLevel.OWN) {
effectiveLevel = VisibilityLevel.TEAM;
}
switch (effectiveLevel) {
case VisibilityLevel.ALL:
return { tenantId };
case VisibilityLevel.OWN:
return {
tenantId,
[ownerRelation]: {
some: { userId: user.sub },
},
};
case VisibilityLevel.TEAM: {
// Wenn Team-Member-IDs vorhanden: nach diesen filtern
if (teamMemberIds && teamMemberIds.length > 0) {
// Eigene ID immer einschliessen
const allIds = teamMemberIds.includes(user.sub)
? teamMemberIds
: [...teamMemberIds, user.sub];
return {
tenantId,
[ownerRelation]: {
some: { userId: { in: allIds } },
},
};
}
// Fallback: nur eigene Daten (wenn kein Department/Team aufgeloest werden konnte)
return {
tenantId,
[ownerRelation]: {
some: { userId: user.sub },
},
};
}
default:
return { tenantId };
}
}

View file

@ -7,11 +7,13 @@ import {
Body,
Param,
Query,
Req,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import {
ApiTags,
ApiOperation,
@ -68,10 +70,14 @@ export class CompaniesController {
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: QueryCompaniesDto,
@Req() req: Request,
) {
const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', '');
const result = await this.companiesService.findAll(
user.tenantId!,
query,
user,
bearerToken,
);
return paginatedResponse(
result.data,

View file

@ -5,9 +5,10 @@ import { CrmPrismaModule } from '../prisma/crm-prisma.module';
import { LexwareModule } from '../lexware/lexware.module';
import { OwnersModule } from '../owners/owners.module';
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
import { VisibilityModule } from '../visibility/visibility.module';
@Module({
imports: [CrmPrismaModule, LexwareModule, OwnersModule, CustomFieldsModule],
imports: [CrmPrismaModule, LexwareModule, OwnersModule, CustomFieldsModule, VisibilityModule],
controllers: [CompaniesController],
providers: [CompaniesService],
exports: [CompaniesService],

View file

@ -6,7 +6,11 @@ import { QueryCompaniesDto } from './dto/query-companies.dto';
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
import { Prisma } from '.prisma/crm-client';
import { VisibilityService } from '../visibility/visibility.service';
import { TeamResolverService } from '../visibility/team-resolver.service';
import { buildVisibilityFilter } from '../common/utils/build-visibility-filter';
import { JwtPayload } from '../common/decorators/current-user.decorator';
import { Prisma, VisibilityLevel } from '.prisma/crm-client';
import { EntityStatus } from '../common/dto/contact-info.dto';
@Injectable()
@ -17,6 +21,8 @@ export class CompaniesService {
private readonly prisma: CrmPrismaService,
private readonly lexwareContacts: LexwareContactsService,
private readonly customFieldsService: CustomFieldsService,
private readonly visibilityService: VisibilityService,
private readonly teamResolver: TeamResolverService,
) {}
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
@ -108,11 +114,38 @@ export class CompaniesService {
});
}
async findAll(tenantId: string, query: QueryCompaniesDto) {
async findAll(
tenantId: string,
query: QueryCompaniesDto,
user?: JwtPayload,
bearerToken?: string,
) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const where: Prisma.CompanyWhereInput = { tenantId };
// Visibility-Filter aufbauen
let baseWhere: Record<string, unknown> = { tenantId };
if (user) {
const level = await this.visibilityService.getLevel(tenantId, 'COMPANY');
if (level !== VisibilityLevel.ALL) {
let teamMemberIds: string[] | undefined;
if (level === VisibilityLevel.TEAM && bearerToken) {
teamMemberIds = await this.teamResolver.getTeamMemberIds(
user.sub,
user.department,
bearerToken,
);
}
baseWhere = buildVisibilityFilter({
user,
level,
ownerRelation: 'owners',
teamMemberIds,
});
}
}
const where: Prisma.CompanyWhereInput = baseWhere as Prisma.CompanyWhereInput;
if (query.industry) {
where.industry = { contains: query.industry, mode: 'insensitive' };

View file

@ -66,8 +66,10 @@ export class ContactsController {
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: QueryContactsDto,
@Req() req: Request,
) {
const result = await this.contactsService.findAll(user.tenantId!, query);
const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', '');
const result = await this.contactsService.findAll(user.tenantId!, query, user, bearerToken);
return paginatedResponse(
result.data,
result.total,

View file

@ -5,9 +5,10 @@ import { LexwareModule } from '../lexware/lexware.module';
import { OwnersModule } from '../owners/owners.module';
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
import { GraphModule } from '../graph/graph.module';
import { VisibilityModule } from '../visibility/visibility.module';
@Module({
imports: [LexwareModule, OwnersModule, CustomFieldsModule, GraphModule],
imports: [LexwareModule, OwnersModule, CustomFieldsModule, GraphModule, VisibilityModule],
controllers: [ContactsController],
providers: [ContactsService],
exports: [ContactsService],

View file

@ -7,7 +7,11 @@ import { LexwareContactsService } from '../lexware/lexware-contacts.service';
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
import { Prisma } from '.prisma/crm-client';
import { VisibilityService } from '../visibility/visibility.service';
import { TeamResolverService } from '../visibility/team-resolver.service';
import { buildVisibilityFilter } from '../common/utils/build-visibility-filter';
import { JwtPayload } from '../common/decorators/current-user.decorator';
import { Prisma, VisibilityLevel } from '.prisma/crm-client';
import { EntityStatus } from '../common/dto/contact-info.dto';
@Injectable()
@ -19,6 +23,8 @@ export class ContactsService {
private readonly lexwareContacts: LexwareContactsService,
private readonly eventPublisher: CrmEventPublisher,
private readonly customFieldsService: CustomFieldsService,
private readonly visibilityService: VisibilityService,
private readonly teamResolver: TeamResolverService,
) {}
async create(tenantId: string, userId: string, dto: CreateContactDto) {
@ -116,11 +122,38 @@ export class ContactsService {
return contact;
}
async findAll(tenantId: string, query: QueryContactsDto) {
async findAll(
tenantId: string,
query: QueryContactsDto,
user?: JwtPayload,
bearerToken?: string,
) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const where: Prisma.ContactWhereInput = { tenantId };
// Visibility-Filter aufbauen
let baseWhere: Record<string, unknown> = { tenantId };
if (user) {
const level = await this.visibilityService.getLevel(tenantId, 'CONTACT');
if (level !== VisibilityLevel.ALL) {
let teamMemberIds: string[] | undefined;
if (level === VisibilityLevel.TEAM && bearerToken) {
teamMemberIds = await this.teamResolver.getTeamMemberIds(
user.sub,
user.department,
bearerToken,
);
}
baseWhere = buildVisibilityFilter({
user,
level,
ownerRelation: 'owners',
teamMemberIds,
});
}
}
const where: Prisma.ContactWhereInput = baseWhere as Prisma.ContactWhereInput;
if (query.type) {
where.type = query.type;

View file

@ -7,11 +7,13 @@ import {
Body,
Param,
Query,
Req,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import {
ApiTags,
ApiOperation,
@ -62,8 +64,10 @@ export class DealsController {
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: QueryDealsDto,
@Req() req: Request,
) {
const result = await this.dealsService.findAll(user.tenantId!, query);
const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', '');
const result = await this.dealsService.findAll(user.tenantId!, query, user, bearerToken);
return paginatedResponse(
result.data,
result.total,

View file

@ -3,9 +3,10 @@ import { DealsController } from './deals.controller';
import { DealsService } from './deals.service';
import { OwnersModule } from '../owners/owners.module';
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
import { VisibilityModule } from '../visibility/visibility.module';
@Module({
imports: [OwnersModule, CustomFieldsModule],
imports: [OwnersModule, CustomFieldsModule, VisibilityModule],
controllers: [DealsController],
providers: [DealsService],
exports: [DealsService],

View file

@ -11,7 +11,11 @@ import { ForecastQueryDto, ForecastPeriod } from './dto/forecast-query.dto';
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
import { Prisma } from '.prisma/crm-client';
import { VisibilityService } from '../visibility/visibility.service';
import { TeamResolverService } from '../visibility/team-resolver.service';
import { buildVisibilityFilter } from '../common/utils/build-visibility-filter';
import { JwtPayload } from '../common/decorators/current-user.decorator';
import { Prisma, VisibilityLevel } from '.prisma/crm-client';
@Injectable()
export class DealsService {
@ -19,6 +23,8 @@ export class DealsService {
private readonly prisma: CrmPrismaService,
private readonly eventPublisher: CrmEventPublisher,
private readonly customFieldsService: CustomFieldsService,
private readonly visibilityService: VisibilityService,
private readonly teamResolver: TeamResolverService,
) {}
async create(tenantId: string, userId: string, dto: CreateDealDto) {
@ -125,11 +131,38 @@ export class DealsService {
return deal;
}
async findAll(tenantId: string, query: QueryDealsDto) {
async findAll(
tenantId: string,
query: QueryDealsDto,
user?: JwtPayload,
bearerToken?: string,
) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const where: Prisma.DealWhereInput = { tenantId };
// Visibility-Filter aufbauen
let baseWhere: Record<string, unknown> = { tenantId };
if (user) {
const level = await this.visibilityService.getLevel(tenantId, 'DEAL');
if (level !== VisibilityLevel.ALL) {
let teamMemberIds: string[] | undefined;
if (level === VisibilityLevel.TEAM && bearerToken) {
teamMemberIds = await this.teamResolver.getTeamMemberIds(
user.sub,
user.department,
bearerToken,
);
}
baseWhere = buildVisibilityFilter({
user,
level,
ownerRelation: 'owners',
teamMemberIds,
});
}
}
const where: Prisma.DealWhereInput = baseWhere as Prisma.DealWhereInput;
if (query.pipelineId) {
where.pipelineId = query.pipelineId;

View file

@ -0,0 +1,13 @@
import { IsEnum } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { VisibilityLevel } from '.prisma/crm-client';
export class SetVisibilityDto {
@ApiProperty({
enum: VisibilityLevel,
description: 'Sichtbarkeitslevel: OWN, TEAM oder ALL',
example: 'ALL',
})
@IsEnum(VisibilityLevel)
level!: VisibilityLevel;
}

View file

@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedisService } from '../redis/redis.service';
const CACHE_PREFIX = 'team_members';
const CACHE_TTL = 300; // 5 Minuten
/**
* Loest Team-Mitglieder (User-IDs im gleichen Department) auf.
* Ruft den Core-Service-Endpoint /api/v1/users/team-members auf
* und cached das Ergebnis in Redis.
*/
@Injectable()
export class TeamResolverService {
private readonly logger = new Logger(TeamResolverService.name);
private readonly coreServiceUrl: string;
constructor(
private readonly redis: RedisService,
private readonly config: ConfigService,
) {
this.coreServiceUrl = this.config.get<string>(
'CORE_SERVICE_URL',
'http://core:3000',
);
}
/**
* Team-Member-IDs fuer einen User abrufen (cached).
* Nutzt den Bearer-Token des anfragenden Users fuer den Core-API-Call.
*/
async getTeamMemberIds(
userId: string,
department: string | undefined,
bearerToken: string,
): Promise<string[]> {
if (!department) {
return [userId];
}
const cacheKey = `${CACHE_PREFIX}:${userId}`;
// Cache pruefen
const cached = await this.redis.get(cacheKey);
if (cached) {
try {
return JSON.parse(cached) as string[];
} catch {
// Cache korrupt, neu laden
}
}
// Core-Service aufrufen
try {
const response = await fetch(
`${this.coreServiceUrl}/api/v1/users/team-members`,
{
headers: {
Authorization: `Bearer ${bearerToken}`,
'Content-Type': 'application/json',
},
},
);
if (!response.ok) {
this.logger.warn(
`Core-Service team-members Aufruf fehlgeschlagen: ${response.status}`,
);
return [userId];
}
const body = (await response.json()) as {
data: { userIds: string[] };
};
const userIds = body.data.userIds;
// In Cache schreiben
await this.redis.set(cacheKey, JSON.stringify(userIds), CACHE_TTL);
return userIds;
} catch (error) {
this.logger.warn(
`Core-Service team-members Aufruf fehlgeschlagen: ${error}`,
);
return [userId];
}
}
}

View file

@ -0,0 +1,68 @@
import {
Controller,
Get,
Put,
Param,
Body,
UseGuards,
ForbiddenException,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { TenantGuard } from '../auth/guards/tenant.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { JwtPayload } from '../common/decorators/current-user.decorator';
import { VisibilityService } from './visibility.service';
import { SetVisibilityDto } from './dto/set-visibility.dto';
const VALID_ENTITIES = ['COMPANY', 'CONTACT', 'DEAL', 'ACTIVITY'];
@ApiTags('Visibility Settings')
@ApiBearerAuth()
@UseGuards(TenantGuard)
@Controller('visibility-settings')
export class VisibilityController {
constructor(private readonly visibilityService: VisibilityService) {}
/**
* Prueft ob der User Tenant-Admin ist (tenantRole oder Platform-Admin).
*/
private assertTenantAdmin(user: JwtPayload): void {
const isAdmin =
user.role === 'PLATFORM_ADMIN' ||
user.role === 'TENANT_ADMIN' ||
user.tenantRole === 'ADMIN';
if (!isAdmin) {
throw new ForbiddenException(
'Nur Tenant-Administratoren koennen Sichtbarkeitseinstellungen aendern.',
);
}
}
@Get()
@ApiOperation({ summary: 'Alle Sichtbarkeitseinstellungen abrufen' })
async getAll(@CurrentUser() user: JwtPayload) {
this.assertTenantAdmin(user);
const levels = await this.visibilityService.getAllLevels(user.tenantId!);
return { data: levels };
}
@Put(':entity')
@ApiOperation({ summary: 'Sichtbarkeitslevel fuer eine Entity setzen' })
async setLevel(
@CurrentUser() user: JwtPayload,
@Param('entity') entity: string,
@Body() dto: SetVisibilityDto,
) {
this.assertTenantAdmin(user);
const entityUpper = entity.toUpperCase();
if (!VALID_ENTITIES.includes(entityUpper)) {
throw new ForbiddenException(
`Ungueltige Entity: ${entity}. Erlaubt: ${VALID_ENTITIES.join(', ')}`,
);
}
await this.visibilityService.setLevel(user.tenantId!, entityUpper, dto.level);
return { data: { entity: entityUpper, level: dto.level } };
}
}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { VisibilityService } from './visibility.service';
import { VisibilityController } from './visibility.controller';
import { TeamResolverService } from './team-resolver.service';
@Module({
controllers: [VisibilityController],
providers: [VisibilityService, TeamResolverService],
exports: [VisibilityService, TeamResolverService],
})
export class VisibilityModule {}

View file

@ -0,0 +1,93 @@
import { Injectable, Logger } from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { RedisService } from '../redis/redis.service';
import { VisibilityLevel } from '.prisma/crm-client';
const CACHE_PREFIX = 'crm_visibility';
const CACHE_TTL = 300; // 5 Minuten
@Injectable()
export class VisibilityService {
private readonly logger = new Logger(VisibilityService.name);
constructor(
private readonly prisma: CrmPrismaService,
private readonly redis: RedisService,
) {}
/**
* Sichtbarkeitslevel fuer eine Entity abrufen (mit Redis-Cache).
* Default: ALL (bestehendes Verhalten bleibt erhalten).
*/
async getLevel(
tenantId: string,
entity: string,
): Promise<VisibilityLevel> {
const cacheKey = `${CACHE_PREFIX}:${tenantId}:${entity}`;
// Cache pruefen
const cached = await this.redis.get(cacheKey);
if (cached) {
return cached as VisibilityLevel;
}
// Aus DB laden
const setting = await this.prisma.crmVisibilitySetting.findUnique({
where: { tenantId_entity: { tenantId, entity } },
});
const level = setting?.level ?? VisibilityLevel.ALL;
// In Cache schreiben
await this.redis.set(cacheKey, level, CACHE_TTL);
return level;
}
/**
* Sichtbarkeitslevel fuer eine Entity setzen.
*/
async setLevel(
tenantId: string,
entity: string,
level: VisibilityLevel,
): Promise<void> {
await this.prisma.crmVisibilitySetting.upsert({
where: { tenantId_entity: { tenantId, entity } },
update: { level },
create: { tenantId, entity, level },
});
// Cache invalidieren
const cacheKey = `${CACHE_PREFIX}:${tenantId}:${entity}`;
await this.redis.del(cacheKey);
this.logger.log(
`Visibility fuer ${entity} auf ${level} gesetzt (Tenant: ${tenantId})`,
);
}
/**
* Alle Sichtbarkeitslevels fuer einen Tenant abrufen.
*/
async getAllLevels(
tenantId: string,
): Promise<Record<string, VisibilityLevel>> {
const settings = await this.prisma.crmVisibilitySetting.findMany({
where: { tenantId },
});
const result: Record<string, VisibilityLevel> = {
COMPANY: VisibilityLevel.ALL,
CONTACT: VisibilityLevel.ALL,
DEAL: VisibilityLevel.ALL,
ACTIVITY: VisibilityLevel.ALL,
};
for (const s of settings) {
result[s.entity] = s.level;
}
return result;
}
}

View file

@ -0,0 +1,180 @@
.container {
max-width: 800px;
}
.header {
margin-bottom: 24px;
}
.header h2 {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0 0 8px 0;
}
.subtitle {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
line-height: 1.5;
}
.card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table thead {
background: #f9fafb;
}
.table th {
padding: 12px 16px;
text-align: left;
font-size: 0.8125rem;
font-weight: 600;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.table td {
padding: 16px;
border-bottom: 1px solid #f3f4f6;
}
.table tr:last-child td {
border-bottom: none;
}
.entityCol {
width: 180px;
}
.levelCol {
width: auto;
}
.levelHeader {
display: flex;
flex-direction: column;
gap: 2px;
}
.levelDesc {
font-weight: 400;
font-size: 0.6875rem;
color: #9ca3af;
line-height: 1.3;
}
.entityLabel {
font-weight: 500;
color: #111827;
font-size: 0.875rem;
}
.radioCell {
text-align: center;
}
.radioLabel {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.radio {
width: 16px;
height: 16px;
accent-color: #1a56db;
cursor: pointer;
}
.radioText {
font-size: 0.8125rem;
color: #374151;
}
.info {
display: flex;
gap: 12px;
margin-top: 20px;
padding: 16px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
font-size: 0.8125rem;
color: #1e40af;
line-height: 1.5;
}
.info svg {
flex-shrink: 0;
margin-top: 2px;
color: #3b82f6;
}
.infoList {
margin: 6px 0 0 0;
padding-left: 18px;
}
.infoList li {
margin-bottom: 2px;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
}
.saveButton {
padding: 10px 24px;
background: #1a56db;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.saveButton:hover:not(:disabled) {
background: #1e429f;
}
.saveButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.successMsg {
font-size: 0.8125rem;
color: #059669;
font-weight: 500;
}
.errorMsg {
font-size: 0.8125rem;
color: #dc2626;
font-weight: 500;
}
.loading {
padding: 40px;
text-align: center;
color: #6b7280;
font-size: 0.875rem;
}

View file

@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
import styles from './AdminCrmSettingsPage.module.css';
type VisibilityLevel = 'OWN' | 'TEAM' | 'ALL';
interface VisibilitySettings {
COMPANY: VisibilityLevel;
CONTACT: VisibilityLevel;
DEAL: VisibilityLevel;
ACTIVITY: VisibilityLevel;
}
const ENTITIES = [
{ key: 'COMPANY' as const, label: 'Unternehmen' },
{ key: 'CONTACT' as const, label: 'Kontakte' },
{ key: 'DEAL' as const, label: 'Vorgänge' },
{ key: 'ACTIVITY' as const, label: 'Aktivitäten' },
];
const LEVELS: { value: VisibilityLevel; label: string; description: string }[] = [
{
value: 'ALL',
label: 'Alle',
description: 'Alle Mitarbeiter sehen alle Datensätze im Mandanten',
},
{
value: 'TEAM',
label: 'Team',
description: 'Mitarbeiter sehen nur Datensätze ihres Teams (gleiche Abteilung)',
},
{
value: 'OWN',
label: 'Eigene',
description: 'Mitarbeiter sehen nur Datensätze, bei denen sie als Besitzer eingetragen sind',
},
];
export function AdminCrmSettingsPage() {
const queryClient = useQueryClient();
const [settings, setSettings] = useState<VisibilitySettings>({
COMPANY: 'ALL',
CONTACT: 'ALL',
DEAL: 'ALL',
ACTIVITY: 'ALL',
});
const [hasChanges, setHasChanges] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const { data, isLoading } = useQuery<{ data: VisibilitySettings }>({
queryKey: ['crm', 'visibility-settings'],
queryFn: () => api.get('/crm/visibility-settings').then((r) => r.data),
});
useEffect(() => {
if (data?.data) {
setSettings(data.data);
}
}, [data]);
const saveMutation = useMutation({
mutationFn: async (newSettings: VisibilitySettings) => {
// Parallel alle Entities speichern
await Promise.all(
ENTITIES.map((e) =>
api.put(`/crm/visibility-settings/${e.key}`, {
level: newSettings[e.key],
}),
),
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crm', 'visibility-settings'] });
setHasChanges(false);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
},
});
const handleChange = (entity: keyof VisibilitySettings, level: VisibilityLevel) => {
setSettings((prev) => ({ ...prev, [entity]: level }));
setHasChanges(true);
setSaveSuccess(false);
};
const handleSave = () => {
saveMutation.mutate(settings);
};
if (isLoading) {
return <div className={styles.loading}>Lade Einstellungen...</div>;
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h2>CRM Sichtbarkeitseinstellungen</h2>
<p className={styles.subtitle}>
Legen Sie fest, welche CRM-Datensätze für Ihre Mitarbeiter sichtbar sind.
Administratoren sehen immer alle Daten unabhängig von dieser Einstellung.
</p>
</div>
<div className={styles.card}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.entityCol}>Bereich</th>
{LEVELS.map((l) => (
<th key={l.value} className={styles.levelCol}>
<div className={styles.levelHeader}>
{l.label}
<span className={styles.levelDesc}>{l.description}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{ENTITIES.map((entity) => (
<tr key={entity.key}>
<td className={styles.entityLabel}>{entity.label}</td>
{LEVELS.map((level) => (
<td key={level.value} className={styles.radioCell}>
<label className={styles.radioLabel}>
<input
type="radio"
name={entity.key}
value={level.value}
checked={settings[entity.key] === level.value}
onChange={() => handleChange(entity.key, level.value)}
className={styles.radio}
/>
<span className={styles.radioText}>{level.label}</span>
</label>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.info}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 7V11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="8" cy="5" r="0.75" fill="currentColor" />
</svg>
<div>
<strong>Hinweis:</strong> Rollen-basierte Ausnahmen:
<ul className={styles.infoList}>
<li><strong>Admin</strong> sieht immer alle Datensätze</li>
<li><strong>Team-Lead</strong> sieht mindestens Team-Sicht (auch wenn &quot;Eigene&quot; eingestellt ist)</li>
<li><strong>Nur-Lesen</strong> kann Daten ansehen, aber nicht bearbeiten</li>
</ul>
</div>
</div>
<div className={styles.actions}>
<button
className={styles.saveButton}
onClick={handleSave}
disabled={!hasChanges || saveMutation.isPending}
>
{saveMutation.isPending ? 'Speichere...' : 'Speichern'}
</button>
{saveSuccess && (
<span className={styles.successMsg}>Einstellungen gespeichert</span>
)}
{saveMutation.isError && (
<span className={styles.errorMsg}>
Fehler beim Speichern. Bitte versuchen Sie es erneut.
</span>
)}
</div>
</div>
);
}

View file

@ -10,6 +10,7 @@ const tabs = [
{ to: '/admin/events', label: 'Events' },
{ to: '/admin/ssl', label: 'SSL / Domain' },
{ to: '/admin/profile-access', label: 'Profilzugriff' },
{ to: '/admin/crm-settings', label: 'CRM Sichtbarkeit' },
];
export function AdminLayout() {

View file

@ -15,6 +15,7 @@ import { AdminSslPage } from '../admin/AdminSslPage';
import { AdminCompanyPage } from '../admin/AdminCompanyPage';
import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage';
import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage';
import { AdminCrmSettingsPage } from '../admin/AdminCrmSettingsPage';
import { ProfilePage } from '../profile/ProfilePage';
import { ContactsPage } from '../crm/contacts/ContactsPage';
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
@ -94,6 +95,7 @@ export function App() {
<Route path="events" element={<AdminEventsPage />} />
<Route path="ssl" element={<AdminSslPage />} />
<Route path="profile-access" element={<AdminProfileAccessPage />} />
<Route path="crm-settings" element={<AdminCrmSettingsPage />} />
</Route>
{/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */}
<Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} />