import { ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma, Role } from '@prisma/client'; import { ModuleAccessService } from '../module-registry/module-access.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateWidgetDto } from './dto/create-widget.dto'; import { SaveLayoutDto } from './dto/save-layout.dto'; import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; import { getModuleSlugForWidgetType } from './widget-module-map'; /** * Default search providers (D-15). * Returned as part of getSearchProviders even when no DB rows exist. * userId null = global defaults — cannot be deleted by users. */ const DEFAULT_SEARCH_PROVIDERS = [ { id: 'google', userId: null, tenantId: null, name: 'Google', urlTemplate: 'https://www.google.com/search?q={query}', isDefault: true, createdAt: new Date('2024-01-01'), }, { id: 'bing', userId: null, tenantId: null, name: 'Bing', urlTemplate: 'https://www.bing.com/search?q={query}', isDefault: true, createdAt: new Date('2024-01-01'), }, { id: 'ddg', userId: null, tenantId: null, name: 'DuckDuckGo', urlTemplate: 'https://duckduckgo.com/?q={query}', isDefault: true, createdAt: new Date('2024-01-01'), }, ]; /** * Service managing per-user dashboard layouts and widget instances. * * Layout (position/size) and widget config are stored in separate models * to avoid unnecessary saves when only one changes (RESEARCH anti-pattern). * * All operations are scoped by userId for security (T-05-01, T-05-02) — the * three ownership checks in this file (`updateWidgetConfig`, `removeWidget`, * `removeSearchProvider`) compare against the user id from the session proof * and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance` * and `SearchProvider` knew only the tenant dimension, not the user dimension, * when measured 260910-krx, Aufgabe 1, Befund G — until the switch is flipped * (WINDOWS #18) they remain the only actually effective protection against * cross-reading/cross-deleting between two users of the SAME tenant, and the * `forTenant()` binding below ADDS a tenant boundary on top of them, it never * replaces them. * * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 tragen die * Regeln auf `DashboardLayout`, `WidgetInstance` und `SearchProvider` die * Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`, * fuer `SearchProvider` zusaetzlich als vier befehlsgetrennte Regeln) — jeder * `forTenant()`-Aufruf unten reicht `userId` als drittes Argument durch. Die * drei anwendungsseitigen Besitzpruefungen bleiben UNVERAENDERT: zweites Netz, * kein Ersatz. Ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen * Mandanten (siehe .planning/WINDOWS.md). Beobachtung fuer die Kritikschrift: * `removeWidget`/`updateWidgetConfig`/`removeSearchProvider` holen die Zeile * per `findUnique({ where: { id } })` und vergleichen danach `userId` — nach * dem Scharfschalten liefert `findUnique` fuer die Zeile eines Kollegen * bereits `null` (die Regel blendet sie aus), die Anwendung meldet dann * NotFoundException statt der heutigen Forbidden-Form — beides eine * Abweisung, nur die Fehlerart aendert sich. */ @Injectable() export class DashboardService { constructor( private readonly prisma: PrismaService, private readonly moduleAccessService: ModuleAccessService, ) {} /** * Returns the user's saved layout, or a default empty layout * with all breakpoint arrays initialized. */ async getLayout(userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const record = await tenantPrisma.dashboardLayout.findUnique({ where: { userId }, }); if (!record) { return { lg: [], md: [], sm: [], xs: [], xxs: [] }; } return record.layouts; } /** * Upserts the user's dashboard layout. * Creates a new record if none exists, updates if it does. * * `userId` is platform-wide `@unique` (no tenant component) — a tenant * whose user id was, by hand, moved off its actually-visible row could hit * an `upsert` conflict on a row it cannot see under RLS. Measured * (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row * throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known * error that the `tenders` area's translation pattern catches — this is a * different Prisma error class, `.code`/`.meta` are `undefined`, the only * signal is the raw `.message` text). Translated below into an * understandable German message instead of a raw 500, same intent as * `tender-notification-pref.service.ts`, different detection. Not * reachable via any application path today (a user's tenant id never * changes after creation) — the honest fix is a schema change and is * deferred as a product decision to Etappe 3, same as WINDOWS #22. */ async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); try { return await tenantPrisma.dashboardLayout.upsert({ where: { userId }, update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue }, create: { userId, tenantId, layouts: dto.layouts as unknown as Prisma.InputJsonValue, }, }); } catch (error) { if (error instanceof Prisma.PrismaClientUnknownRequestError) { throw new ConflictException( 'Die Dashboard-Anordnung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.', ); } throw error; } } /** * Returns all widget instances for a given user, gefiltert um Widgets * eines für den Benutzer gesperrten Moduls (D-22, PERM-07). * * Die bestehende Query bleibt unverändert die erste Aktion. Steht unter * den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — der * Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die * Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei * mindestens einem modulgebundenen Widget wird die Zugriffsauflösung * aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und * Sidebar, keine zweite Implementierung). Lässt sich ein eingetragener * Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das * betroffene Widget entfernt (Fail-Closed). */ async getWidgets(userId: string, tenantId: string, role: Role) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const widgets = await tenantPrisma.widgetInstance.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, }); const boundSlugs = [ ...new Set( widgets .map((w) => getModuleSlugForWidgetType(w.widgetType)) .filter((slug): slug is string => slug !== undefined), ), ]; if (boundSlugs.length === 0) { return widgets; } // getAccessibleModuleIds() already binds internally (260910-exd, // module-access.service.ts) — do NOT wrap it a second time here. const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds( tenantId, userId, role, ); // Module catalogue: deliberately left UNBOUND — see the reasoning at // the bottom of this file (260910-krx, Aufgabe 3). const modules = await this.prisma.module.findMany({ where: { slug: { in: boundSlugs } }, select: { id: true, slug: true }, }); const slugToModuleId = new Map(modules.map((m) => [m.slug, m.id])); return widgets.filter((w) => { const slug = getModuleSlugForWidgetType(w.widgetType); if (slug === undefined) { return true; } const moduleId = slugToModuleId.get(slug); if (moduleId === undefined) { return false; } return accessibleModuleIds.has(moduleId); }); } /** * Creates a new widget instance for the user. */ async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); return tenantPrisma.widgetInstance.create({ data: { userId, tenantId, widgetType: dto.widgetType, config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue, }, }); } /** * Updates the config of a widget instance. * Verifies ownership by userId before updating (T-05-01) — REAL, not * decorative (unlike the `ldap`/`dkv` findUnique-then-write shape that * produced this effort's first two vulnerabilities): `widget.userId !== * userId` genuinely compares against the session-sourced user id and * subsumes the tenant dimension. Both queries below run over the SAME * bound client and the same tenant id — reading and writing are never * split across the binding, or the check could pass on a row the write no * longer sees, or vice versa (260910-krx, Aufgabe 1, Befund D). */ async updateWidgetConfig( id: string, userId: string, tenantId: string, dto: UpdateWidgetConfigDto, ) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id }, }); if (!widget || widget.userId !== userId) { throw new NotFoundException( `Widget with id '${id}' not found`, ); } // Merge existing config with new config const mergedConfig = { ...(widget.config as Record), ...dto.config, }; return tenantPrisma.widgetInstance.update({ where: { id }, data: { config: mergedConfig as unknown as Prisma.InputJsonValue }, }); } /** * Removes a widget instance. * Verifies ownership by userId before deleting (T-05-01) — same real * ownership check as `updateWidgetConfig` above, same reasoning: both * queries run over the SAME bound client and tenant id. */ async removeWidget(id: string, userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id }, }); if (!widget || widget.userId !== userId) { throw new NotFoundException( `Widget with id '${id}' not found`, ); } return tenantPrisma.widgetInstance.delete({ where: { id }, }); } // --- Search Providers (05-02, D-15) --- /** * Returns the three default providers merged with any user-custom providers. * Defaults are always returned even with an empty DB (no seed migration needed). * The three defaults come from the TypeScript constant above (decision * 05-02), never from the database — they are unaffected by the binding * below and are always prepended unchanged. */ async getSearchProviders(userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const custom = await tenantPrisma.searchProvider.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, }); return [...DEFAULT_SEARCH_PROVIDERS, ...custom]; } /** * Creates a user-custom search provider. `tenantId` stays a required * parameter of this method — the only write path this model has (260910-krx, * Aufgabe 1, Befund F, WINDOWS #19): no application path exists that * creates a tenant-less row, which is why the RLS rule on `SearchProvider` * was deliberately left unchanged/strict in migration 20260910120000. */ async addSearchProvider( userId: string, tenantId: string, dto: CreateSearchProviderDto, ) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); return tenantPrisma.searchProvider.create({ data: { userId, tenantId, name: dto.name, urlTemplate: dto.urlTemplate, isDefault: false, }, }); } /** * Removes a user-custom search provider. * Verifies ownership — default providers (userId null) cannot be deleted * (T-05-07) — REAL, same reasoning as `updateWidgetConfig`/`removeWidget` * above: both queries run over the SAME bound client and tenant id. */ async removeSearchProvider(id: string, userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); // Default providers have hardcoded IDs that won't exist in DB const provider = await tenantPrisma.searchProvider.findUnique({ where: { id }, }); if (!provider || provider.userId !== userId) { throw new NotFoundException( `Search provider with id '${id}' not found`, ); } return tenantPrisma.searchProvider.delete({ where: { id }, }); } } // --- Modulkatalog: bewusst ungebunden (260910-krx, Aufgabe 3) -------------- // // Der eine verbleibende ungebundene Modellzugriff dieser Datei (das // `module`-Modell in `getWidgets`, ueber den ungebundenen Basisclient) // betrifft den plattformweiten Modulkatalog (`Module`). // MESSUNG (rls-scratch-check.mjs, Pruefung `module-tabelle-traegt-keinen- // zeilenschutz`, uebernommen aus dem Bereich `module-registry`, 260910-exd // Befund E): die Tabelle traegt heute KEINEN Zeilenschutz — `pg_class. // relrowsecurity` ist `false`, eine Bindung waere heute WIRKUNGSLOS, nicht // katastrophal. BEDINGUNG: sie wuerde katastrophal, WENN Etappe 3 dieser // Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer jeden // Mandanten. Die Katalogaufloesung, die dieser Dienst fuer den Widget- // Modulfilter aufruft (`ModuleAccessService.getAccessibleModuleIds`), bindet // bereits seit 260910-exd in ihrem eigenen Dienst — dieser Zugriff wird hier // NICHT ein zweites Mal gebunden.