diff --git a/apps/api/src/dashboard/dashboard.controller.ts b/apps/api/src/dashboard/dashboard.controller.ts index 40d362b..3ec21de 100644 --- a/apps/api/src/dashboard/dashboard.controller.ts +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -68,8 +68,9 @@ export class DashboardController { @Get('widgets') async getWidgets(@Req() req: Request) { - const { userId } = this.extractContext(req); - return this.dashboardService.getWidgets(userId); + const { userId, tenantId } = this.extractContext(req); + const role = (req as any).user?.role; + return this.dashboardService.getWidgets(userId, tenantId, role); } @Post('widgets') diff --git a/apps/api/src/dashboard/dashboard.module.ts b/apps/api/src/dashboard/dashboard.module.ts index 0caefab..dcee374 100644 --- a/apps/api/src/dashboard/dashboard.module.ts +++ b/apps/api/src/dashboard/dashboard.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; @@ -9,9 +10,13 @@ import { DashboardService } from './dashboard.service'; * - DashboardService: CRUD for per-user dashboard layouts and widget instances * - DashboardController: REST API for layout and widget operations * + * Imports ModuleRegistryModule so DashboardService can inject + * ModuleAccessService for the D-22 widget-module filter (Plan 15-05). + * * Exports DashboardService so downstream modules can access layout/widget data. */ @Module({ + imports: [ModuleRegistryModule], controllers: [DashboardController], providers: [DashboardService], exports: [DashboardService], diff --git a/apps/api/src/dashboard/dashboard.service.spec.ts b/apps/api/src/dashboard/dashboard.service.spec.ts index 322054d..2cfc7f4 100644 --- a/apps/api/src/dashboard/dashboard.service.spec.ts +++ b/apps/api/src/dashboard/dashboard.service.spec.ts @@ -5,9 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; * Objektreferenz (`mockMap`) gesteuert: `getModuleSlugForWidgetType` * liest `mockMap` erst zur Aufrufzeit, deshalb genügt eine Mutation vor * jedem Test statt eines dynamischen Imports mit `vi.resetModules()` je - * Testfall (vi.mock wird von Vitest ohnehin an den Dateianfang gehoben). + * Testfall. `vi.mock` selbst wird von Vitest an den Dateianfang gehoben — + * `mockMap` muss deshalb über `vi.hoisted` deklariert werden, sonst + * schlägt der Zugriff im Factory-Callback mit einem + * Initialisierungsfehler fehl. */ -const mockMap: Record = {}; +const mockMap = vi.hoisted(() => ({}) as Record); vi.mock('./widget-module-map', () => ({ WIDGET_MODULE_MAP: mockMap, @@ -125,7 +128,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { it('D-03: ein ADMIN sieht ein modulgebundenes Widget eines aktiven Moduls auch ohne eigenen Grant', async () => { mockMap['tender-radar'] = 'tender-radar'; - const widget = makeWidget({ id: 'w1', widgetType: 'tender-radar' }); + const widget = makeWidget({ id: 'w1', userId: 'admin-1', widgetType: 'tender-radar' }); const prisma = makeFakePrisma({ widgets: [widget], modules: [{ id: 'mod-1', slug: 'tender-radar' }], diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 28deb24..7af5480 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -2,12 +2,14 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Prisma, Role } from '@prisma/client'; +import { ModuleAccessService } from '../module-registry/module-access.service'; 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). @@ -54,7 +56,10 @@ const DEFAULT_SEARCH_PROVIDERS = [ */ @Injectable() export class DashboardService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly moduleAccessService: ModuleAccessService, + ) {} /** * Returns the user's saved layout, or a default empty layout @@ -89,13 +94,60 @@ export class DashboardService { } /** - * Returns all widget instances for a given user. + * 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) { - return this.prisma.widgetInstance.findMany({ + async getWidgets(userId: string, tenantId: string, role: Role) { + const widgets = await this.prisma.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; + } + + const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds( + tenantId, + userId, + role, + ); + + 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); + }); } /**