mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
c987ce87c0
commit
de4af77c5c
31 changed files with 1044 additions and 17 deletions
|
|
@ -7,6 +7,8 @@ export interface JwtPayload {
|
||||||
role: string;
|
role: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
|
tenantRole?: string; // ADMIN | MEMBER | VIEWER | TEAM_LEAD | READONLY
|
||||||
|
department?: string; // User-Abteilung (fuer TEAM-Visibility)
|
||||||
jti: string; // Token-ID fuer Revocation
|
jti: string; // Token-ID fuer Revocation
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,8 @@ export class AuthService {
|
||||||
role: user.role,
|
role: user.role,
|
||||||
tenantId: primaryMembership?.tenant.id,
|
tenantId: primaryMembership?.tenant.id,
|
||||||
tenantSlug: primaryMembership?.tenant.slug,
|
tenantSlug: primaryMembership?.tenant.slug,
|
||||||
|
tenantRole: primaryMembership?.tenantRole,
|
||||||
|
department: user.department ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Login erfolgreich: ${user.email}`);
|
this.logger.log(`Login erfolgreich: ${user.email}`);
|
||||||
|
|
@ -197,6 +199,8 @@ export class AuthService {
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
tenantId: payload.tenantId,
|
tenantId: payload.tenantId,
|
||||||
tenantSlug: payload.tenantSlug,
|
tenantSlug: payload.tenantSlug,
|
||||||
|
tenantRole: payload.tenantRole,
|
||||||
|
department: payload.department,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedException) throw error;
|
if (error instanceof UnauthorizedException) throw error;
|
||||||
|
|
@ -381,8 +385,10 @@ export class AuthService {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
department?: string | null;
|
||||||
twoFactorEnabled: boolean;
|
twoFactorEnabled: boolean;
|
||||||
tenantMemberships?: Array<{
|
tenantMemberships?: Array<{
|
||||||
|
tenantRole: string;
|
||||||
tenant: { id: string; slug: string };
|
tenant: { id: string; slug: string };
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
@ -473,6 +479,8 @@ export class AuthService {
|
||||||
role: user.role,
|
role: user.role,
|
||||||
tenantId: primaryMembership?.tenant.id,
|
tenantId: primaryMembership?.tenant.id,
|
||||||
tenantSlug: primaryMembership?.tenant.slug,
|
tenantSlug: primaryMembership?.tenant.slug,
|
||||||
|
tenantRole: primaryMembership?.tenantRole,
|
||||||
|
department: user.department ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,24 @@ export class UsersController {
|
||||||
return { message: 'Passwort erfolgreich geändert' };
|
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
|
* GET /api/v1/users
|
||||||
* Alle User auflisten (nur PLATFORM_ADMIN).
|
* Alle User auflisten (nur PLATFORM_ADMIN).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -890,3 +890,28 @@ model TradeEvent {
|
||||||
@@map("trade_events")
|
@@map("trade_events")
|
||||||
@@schema("app_crm")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -7,11 +7,13 @@ import {
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
|
|
@ -56,10 +58,14 @@ export class ActivitiesController {
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query() query: QueryActivitiesDto,
|
@Query() query: QueryActivitiesDto,
|
||||||
|
@Req() req: Request,
|
||||||
) {
|
) {
|
||||||
|
const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', '');
|
||||||
const result = await this.activitiesService.findAll(
|
const result = await this.activitiesService.findAll(
|
||||||
user.tenantId!,
|
user.tenantId!,
|
||||||
query,
|
query,
|
||||||
|
user,
|
||||||
|
bearerToken,
|
||||||
);
|
);
|
||||||
return paginatedResponse(
|
return paginatedResponse(
|
||||||
result.data,
|
result.data,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ActivitiesController } from './activities.controller';
|
import { ActivitiesController } from './activities.controller';
|
||||||
import { ActivitiesService } from './activities.service';
|
import { ActivitiesService } from './activities.service';
|
||||||
|
import { VisibilityModule } from '../visibility/visibility.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [VisibilityModule],
|
||||||
controllers: [ActivitiesController],
|
controllers: [ActivitiesController],
|
||||||
providers: [ActivitiesService],
|
providers: [ActivitiesService],
|
||||||
exports: [ActivitiesService],
|
exports: [ActivitiesService],
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,18 @@ import { CrmPrismaService } from '../prisma/crm-prisma.service';
|
||||||
import { CreateActivityDto } from './dto/create-activity.dto';
|
import { CreateActivityDto } from './dto/create-activity.dto';
|
||||||
import { UpdateActivityDto } from './dto/update-activity.dto';
|
import { UpdateActivityDto } from './dto/update-activity.dto';
|
||||||
import { QueryActivitiesDto } from './dto/query-activities.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()
|
@Injectable()
|
||||||
export class ActivitiesService {
|
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) {
|
async create(tenantId: string, userId: string, dto: CreateActivityDto) {
|
||||||
// Mindestens contactId oder companyId muss gesetzt sein
|
// 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 page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 25;
|
const pageSize = query.pageSize ?? 25;
|
||||||
|
|
||||||
const where: Prisma.ActivityWhereInput = { tenantId };
|
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
|
// Aggregierter Company-Feed: direkte + verknuepfte Kontakt-Aktivitaeten
|
||||||
if (query.companyId && query.includeContacts) {
|
if (query.companyId && query.includeContacts) {
|
||||||
const contactIds = await this.prisma.contact
|
const contactIds = await this.prisma.contact
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { CrmPrismaModule } from './prisma/crm-prisma.module';
|
||||||
import { RedisModule } from './redis/redis.module';
|
import { RedisModule } from './redis/redis.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
|
import { ReadonlyGuard } from './auth/guards/readonly.guard';
|
||||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { ContactsModule } from './contacts/contacts.module';
|
import { ContactsModule } from './contacts/contacts.module';
|
||||||
|
|
@ -27,6 +28,7 @@ import { EnrichmentModule } from './enrichment/enrichment.module';
|
||||||
import { ContractsModule } from './contracts/contracts.module';
|
import { ContractsModule } from './contracts/contracts.module';
|
||||||
import { GraphModule } from './graph/graph.module';
|
import { GraphModule } from './graph/graph.module';
|
||||||
import { DealTypesModule } from './deal-types/deal-types.module';
|
import { DealTypesModule } from './deal-types/deal-types.module';
|
||||||
|
import { VisibilityModule } from './visibility/visibility.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -57,12 +59,17 @@ import { DealTypesModule } from './deal-types/deal-types.module';
|
||||||
ContractsModule,
|
ContractsModule,
|
||||||
GraphModule,
|
GraphModule,
|
||||||
DealTypesModule,
|
DealTypesModule,
|
||||||
|
VisibilityModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: JwtAuthGuard,
|
useClass: JwtAuthGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ReadonlyGuard,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: APP_FILTER,
|
provide: APP_FILTER,
|
||||||
useClass: GlobalExceptionFilter,
|
useClass: GlobalExceptionFilter,
|
||||||
|
|
|
||||||
43
packages/crm-service/src/auth/guards/readonly.guard.ts
Normal file
43
packages/crm-service/src/auth/guards/readonly.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ export interface JwtPayload {
|
||||||
role: string;
|
role: string;
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
|
tenantRole?: string; // ADMIN | MEMBER | VIEWER | TEAM_LEAD | READONLY
|
||||||
|
department?: string; // User-Abteilung (fuer TEAM-Visibility)
|
||||||
jti: string;
|
jti: string;
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,13 @@ import {
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
|
|
@ -68,10 +70,14 @@ export class CompaniesController {
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query() query: QueryCompaniesDto,
|
@Query() query: QueryCompaniesDto,
|
||||||
|
@Req() req: Request,
|
||||||
) {
|
) {
|
||||||
|
const bearerToken = (req.headers.authorization ?? '').replace('Bearer ', '');
|
||||||
const result = await this.companiesService.findAll(
|
const result = await this.companiesService.findAll(
|
||||||
user.tenantId!,
|
user.tenantId!,
|
||||||
query,
|
query,
|
||||||
|
user,
|
||||||
|
bearerToken,
|
||||||
);
|
);
|
||||||
return paginatedResponse(
|
return paginatedResponse(
|
||||||
result.data,
|
result.data,
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ import { CrmPrismaModule } from '../prisma/crm-prisma.module';
|
||||||
import { LexwareModule } from '../lexware/lexware.module';
|
import { LexwareModule } from '../lexware/lexware.module';
|
||||||
import { OwnersModule } from '../owners/owners.module';
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||||
|
import { VisibilityModule } from '../visibility/visibility.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CrmPrismaModule, LexwareModule, OwnersModule, CustomFieldsModule],
|
imports: [CrmPrismaModule, LexwareModule, OwnersModule, CustomFieldsModule, VisibilityModule],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [CompaniesService],
|
providers: [CompaniesService],
|
||||||
exports: [CompaniesService],
|
exports: [CompaniesService],
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ import { QueryCompaniesDto } from './dto/query-companies.dto';
|
||||||
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||||
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
||||||
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
|
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';
|
import { EntityStatus } from '../common/dto/contact-info.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -17,6 +21,8 @@ export class CompaniesService {
|
||||||
private readonly prisma: CrmPrismaService,
|
private readonly prisma: CrmPrismaService,
|
||||||
private readonly lexwareContacts: LexwareContactsService,
|
private readonly lexwareContacts: LexwareContactsService,
|
||||||
private readonly customFieldsService: CustomFieldsService,
|
private readonly customFieldsService: CustomFieldsService,
|
||||||
|
private readonly visibilityService: VisibilityService,
|
||||||
|
private readonly teamResolver: TeamResolverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
|
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 page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 25;
|
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) {
|
if (query.industry) {
|
||||||
where.industry = { contains: query.industry, mode: 'insensitive' };
|
where.industry = { contains: query.industry, mode: 'insensitive' };
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,10 @@ export class ContactsController {
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query() query: QueryContactsDto,
|
@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(
|
return paginatedResponse(
|
||||||
result.data,
|
result.data,
|
||||||
result.total,
|
result.total,
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ import { LexwareModule } from '../lexware/lexware.module';
|
||||||
import { OwnersModule } from '../owners/owners.module';
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||||
import { GraphModule } from '../graph/graph.module';
|
import { GraphModule } from '../graph/graph.module';
|
||||||
|
import { VisibilityModule } from '../visibility/visibility.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [LexwareModule, OwnersModule, CustomFieldsModule, GraphModule],
|
imports: [LexwareModule, OwnersModule, CustomFieldsModule, GraphModule, VisibilityModule],
|
||||||
controllers: [ContactsController],
|
controllers: [ContactsController],
|
||||||
providers: [ContactsService],
|
providers: [ContactsService],
|
||||||
exports: [ContactsService],
|
exports: [ContactsService],
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import { LexwareContactsService } from '../lexware/lexware-contacts.service';
|
||||||
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||||
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
||||||
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
|
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';
|
import { EntityStatus } from '../common/dto/contact-info.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -19,6 +23,8 @@ export class ContactsService {
|
||||||
private readonly lexwareContacts: LexwareContactsService,
|
private readonly lexwareContacts: LexwareContactsService,
|
||||||
private readonly eventPublisher: CrmEventPublisher,
|
private readonly eventPublisher: CrmEventPublisher,
|
||||||
private readonly customFieldsService: CustomFieldsService,
|
private readonly customFieldsService: CustomFieldsService,
|
||||||
|
private readonly visibilityService: VisibilityService,
|
||||||
|
private readonly teamResolver: TeamResolverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
async create(tenantId: string, userId: string, dto: CreateContactDto) {
|
||||||
|
|
@ -116,11 +122,38 @@ export class ContactsService {
|
||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(tenantId: string, query: QueryContactsDto) {
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
query: QueryContactsDto,
|
||||||
|
user?: JwtPayload,
|
||||||
|
bearerToken?: string,
|
||||||
|
) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 25;
|
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) {
|
if (query.type) {
|
||||||
where.type = query.type;
|
where.type = query.type;
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ import {
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
|
|
@ -62,8 +64,10 @@ export class DealsController {
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query() query: QueryDealsDto,
|
@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(
|
return paginatedResponse(
|
||||||
result.data,
|
result.data,
|
||||||
result.total,
|
result.total,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import { DealsController } from './deals.controller';
|
||||||
import { DealsService } from './deals.service';
|
import { DealsService } from './deals.service';
|
||||||
import { OwnersModule } from '../owners/owners.module';
|
import { OwnersModule } from '../owners/owners.module';
|
||||||
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
import { CustomFieldsModule } from '../custom-fields/custom-fields.module';
|
||||||
|
import { VisibilityModule } from '../visibility/visibility.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OwnersModule, CustomFieldsModule],
|
imports: [OwnersModule, CustomFieldsModule, VisibilityModule],
|
||||||
controllers: [DealsController],
|
controllers: [DealsController],
|
||||||
providers: [DealsService],
|
providers: [DealsService],
|
||||||
exports: [DealsService],
|
exports: [DealsService],
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ import { ForecastQueryDto, ForecastPeriod } from './dto/forecast-query.dto';
|
||||||
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
|
||||||
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
|
||||||
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
|
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()
|
@Injectable()
|
||||||
export class DealsService {
|
export class DealsService {
|
||||||
|
|
@ -19,6 +23,8 @@ export class DealsService {
|
||||||
private readonly prisma: CrmPrismaService,
|
private readonly prisma: CrmPrismaService,
|
||||||
private readonly eventPublisher: CrmEventPublisher,
|
private readonly eventPublisher: CrmEventPublisher,
|
||||||
private readonly customFieldsService: CustomFieldsService,
|
private readonly customFieldsService: CustomFieldsService,
|
||||||
|
private readonly visibilityService: VisibilityService,
|
||||||
|
private readonly teamResolver: TeamResolverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(tenantId: string, userId: string, dto: CreateDealDto) {
|
async create(tenantId: string, userId: string, dto: CreateDealDto) {
|
||||||
|
|
@ -125,11 +131,38 @@ export class DealsService {
|
||||||
return deal;
|
return deal;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(tenantId: string, query: QueryDealsDto) {
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
query: QueryDealsDto,
|
||||||
|
user?: JwtPayload,
|
||||||
|
bearerToken?: string,
|
||||||
|
) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 25;
|
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) {
|
if (query.pipelineId) {
|
||||||
where.pipelineId = query.pipelineId;
|
where.pipelineId = query.pipelineId;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
88
packages/crm-service/src/visibility/team-resolver.service.ts
Normal file
88
packages/crm-service/src/visibility/team-resolver.service.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
packages/crm-service/src/visibility/visibility.controller.ts
Normal file
68
packages/crm-service/src/visibility/visibility.controller.ts
Normal 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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/crm-service/src/visibility/visibility.module.ts
Normal file
11
packages/crm-service/src/visibility/visibility.module.ts
Normal 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 {}
|
||||||
93
packages/crm-service/src/visibility/visibility.service.ts
Normal file
93
packages/crm-service/src/visibility/visibility.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
180
packages/frontend/src/admin/AdminCrmSettingsPage.module.css
Normal file
180
packages/frontend/src/admin/AdminCrmSettingsPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
180
packages/frontend/src/admin/AdminCrmSettingsPage.tsx
Normal file
180
packages/frontend/src/admin/AdminCrmSettingsPage.tsx
Normal 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 "Eigene" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ const tabs = [
|
||||||
{ to: '/admin/events', label: 'Events' },
|
{ to: '/admin/events', label: 'Events' },
|
||||||
{ to: '/admin/ssl', label: 'SSL / Domain' },
|
{ to: '/admin/ssl', label: 'SSL / Domain' },
|
||||||
{ to: '/admin/profile-access', label: 'Profilzugriff' },
|
{ to: '/admin/profile-access', label: 'Profilzugriff' },
|
||||||
|
{ to: '/admin/crm-settings', label: 'CRM Sichtbarkeit' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { AdminSslPage } from '../admin/AdminSslPage';
|
||||||
import { AdminCompanyPage } from '../admin/AdminCompanyPage';
|
import { AdminCompanyPage } from '../admin/AdminCompanyPage';
|
||||||
import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage';
|
import { AdminProfileAccessPage } from '../admin/AdminProfileAccessPage';
|
||||||
import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage';
|
import { AdminProfileDetailPage } from '../admin/AdminProfileDetailPage';
|
||||||
|
import { AdminCrmSettingsPage } from '../admin/AdminCrmSettingsPage';
|
||||||
import { ProfilePage } from '../profile/ProfilePage';
|
import { ProfilePage } from '../profile/ProfilePage';
|
||||||
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
import { ContactsPage } from '../crm/contacts/ContactsPage';
|
||||||
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
||||||
|
|
@ -94,6 +95,7 @@ export function App() {
|
||||||
<Route path="events" element={<AdminEventsPage />} />
|
<Route path="events" element={<AdminEventsPage />} />
|
||||||
<Route path="ssl" element={<AdminSslPage />} />
|
<Route path="ssl" element={<AdminSslPage />} />
|
||||||
<Route path="profile-access" element={<AdminProfileAccessPage />} />
|
<Route path="profile-access" element={<AdminProfileAccessPage />} />
|
||||||
|
<Route path="crm-settings" element={<AdminCrmSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */}
|
{/* Admin-Profildetail außerhalb des Admin-Layouts (volle Seite) */}
|
||||||
<Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} />
|
<Route path="admin/profiles/:userId" element={<AdminProfileDetailPage />} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue