import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { Role } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto'; /** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */ const CUSTOM_MODULE_SELECT = { id: true, name: true, url: true, category: true, ownerUserId: true, createdAt: true, updatedAt: true, }; /** Der Aufrufer, wie ihn der Controller aus dem Anmelde-Token liest. */ export interface CustomModuleCaller { id: string; role: Role; } function isAdmin(caller: CustomModuleCaller): boolean { return caller.role === Role.ADMIN || caller.role === Role.SUPER_ADMIN; } /** Aus der Zeile die Antwort machen: `personal` statt der Besitzer-Kennung. */ function toResponse(row: T) { const { ownerUserId, ...rest } = row; return { ...rest, personal: ownerUserId !== null }; } /** * Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu): Seitenleisten- * Eintraege mit externer https-Adresse. Zwei Arten: * * - gemeinsam (`ownerUserId` null): vom Administrator angelegt, fuer alle * Benutzer des Mandanten sichtbar; Schreiben nur fuer Administratoren. * - persoenlich (`ownerUserId` = Benutzer): nur der Besitzer sieht, aendert und * loescht ihn. Ein anderer Benutzer bekommt fuer die id immer 404 — nie einen * Hinweis, dass es sie gibt. * * `tenantId` kommt ausschliesslich als Argument (aus `req.tenantId`), nie aus * dem DTO. Je Methode ein eigener `forTenant`-Klient. * * RLS-BINDUNG (Muster SearchProvider, siehe Migration 20260929130000): Lesen * und Schreiben PERSOENLICHER Eintraege laeuft mit dem Benutzer als drittem * Argument (`forTenant(prisma, tenantId, user.id)`); die Regel laesst dann nur * gemeinsame und eigene Zeilen zu. Schreiben GEMEINSAMER Eintraege laeuft * bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), weil die Regel einem * Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — die * Rollenpruefung (Administrator) sitzt vorher im Dienst. Zusaetzlich pruefen * alle Methoden `row.tenantId` und `row.ownerUserId` in der Anwendung, solange * der RLS-Schalter aus ist. */ @Injectable() export class CustomModulesService { constructor(private readonly prisma: PrismaService) {} /** Gemeinsame Eintraege plus die eigenen des Aufrufers. */ async list(tenantId: string, caller: CustomModuleCaller) { const tenantPrisma = forTenant(this.prisma, tenantId, caller.id); const rows = await tenantPrisma.customModule.findMany({ where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] }, orderBy: { name: 'asc' }, select: CUSTOM_MODULE_SELECT, }); return rows.map(toResponse); } async getOne(tenantId: string, caller: CustomModuleCaller, id: string) { const tenantPrisma = forTenant(this.prisma, tenantId, caller.id); const row = await this.loadVisible(tenantPrisma, tenantId, caller, id); const { tenantId: _omit, ...result } = row; return toResponse(result); } async create(tenantId: string, caller: CustomModuleCaller, dto: CreateCustomModuleDto) { const shared = dto.shared === true; if (shared && !isAdmin(caller)) { throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen'); } const data = { tenantId, name: dto.name, url: dto.url, category: dto.category, ownerUserId: shared ? null : caller.id, }; if (shared) { // Gemeinsam: ohne Benutzerkontext (die Regel verwehrt ihn dort). const tenantPrisma = forTenant(this.prisma, tenantId); const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT }); return toResponse(row); } const tenantPrisma = forTenant(this.prisma, tenantId, caller.id); const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT }); return toResponse(row); } async update( tenantId: string, caller: CustomModuleCaller, id: string, dto: UpdateCustomModuleDto, ) { const tenantPrisma = await this.writableClient(tenantId, caller, id); const data: { name?: string; url?: string; category?: string } = {}; if (dto.name !== undefined) data.name = dto.name; if (dto.url !== undefined) data.url = dto.url; if (dto.category !== undefined) data.category = dto.category; // Besitz und Gemeinsamkeit stehen nie in `data` — sie aendern sich nicht. const row = await tenantPrisma.customModule.update({ where: { id }, data, select: CUSTOM_MODULE_SELECT, }); return toResponse(row); } async remove(tenantId: string, caller: CustomModuleCaller, id: string) { const tenantPrisma = await this.writableClient(tenantId, caller, id); await tenantPrisma.customModule.delete({ where: { id } }); return { deleted: true }; } /** * Zeile laden, die der Aufrufer sehen darf: gleicher Mandant UND (gemeinsam * ODER eigene). Alles andere — unbekannt, fremder Mandant, fremder * persoenlicher Eintrag — ist ununterscheidbar 404. */ private async loadVisible( tenantPrisma: ReturnType, tenantId: string, caller: CustomModuleCaller, id: string, ) { const row = await tenantPrisma.customModule.findUnique({ where: { id }, select: { ...CUSTOM_MODULE_SELECT, tenantId: true }, }); if (!row || row.tenantId !== tenantId) { throw new NotFoundException('Eigenes Modul nicht gefunden'); } if (row.ownerUserId !== null && row.ownerUserId !== caller.id) { throw new NotFoundException('Eigenes Modul nicht gefunden'); } return row; } /** * Klient fuer Aendern/Loeschen: persoenlicher Eintrag -> mit Benutzer (nur der * Besitzer kommt bis hierher); gemeinsamer Eintrag -> nur Administrator (403 * sonst, der Eintrag ist fuer alle sichtbar, sein Bestehen ist kein * Geheimnis), dann ohne Benutzerkontext. */ private async writableClient(tenantId: string, caller: CustomModuleCaller, id: string) { const userClient = forTenant(this.prisma, tenantId, caller.id); const row = await this.loadVisible(userClient, tenantId, caller, id); if (row.ownerUserId === caller.id) { return userClient; } if (!isAdmin(caller)) { throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren ändern'); } const tenantPrisma = forTenant(this.prisma, tenantId); return tenantPrisma; } }