7704372c3c
- FavoriteLink: neue Spalten uploadedIconMime/iconVersion (Migration 20260923160000) - favorite-icon-files.ts: Erkennung PNG/JPEG/GIF/WebP/ICO/SVG, Pfadbildung ohne Byte aus der Anfrage im Pfad (T-LRR-01), best-effort Dateientfernung - FavoritesService: uploadIcon/removeUploadedIcon, Vorrang der hochgeladenen Datei in getIconBytes, Abrufprobe fuer eine neue iconUrl (422 statt stiller Speicherung), iconVersion-Erhoehung bei jeder Aenderung der Symbolquelle - FavoritesController: POST/DELETE /favorites/:id/icon, Cache-Control private - T-LRR-07 (Restrisiko aus dem Plan-Threat-Model geschlossen, ueber den Plan hinaus): DashboardService.removeWidget/deleteDashboard raeumen jetzt die Symboldateien der per Datenbank-Kaskade mitgeloeschten Favoriten auf (best effort, nie blockierend) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
676 lines
26 KiB
TypeScript
676 lines
26 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { Prisma, Role } from '@prisma/client';
|
||
import { removeFavoriteIconFileBestEffort } from '../favorites/favorite-icon-files';
|
||
import { ModuleAccessService } from '../module-registry/module-access.service';
|
||
import { forTenant, withTenantTransaction } 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 { RenameDashboardDto } from './dto/rename-dashboard.dto';
|
||
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
|
||
import { SaveLayoutDto } from './dto/save-layout.dto';
|
||
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||
import { getModuleSlugForWidgetType } from './widget-module-map';
|
||
|
||
/**
|
||
* T-AD9-06 — Riegel gegen Massenanfragen: hoechstens 20 Reiter je Benutzer
|
||
* (quick-260923-ad9, Task 2).
|
||
*/
|
||
const DASHBOARD_MAX_COUNT = 20;
|
||
|
||
/**
|
||
* 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 {
|
||
private readonly logger = new Logger(DashboardService.name);
|
||
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly moduleAccessService: ModuleAccessService,
|
||
) {}
|
||
|
||
/**
|
||
* T-LRR-07 (quick-260923-lrr, Restrisiko aus dem Favoriten-Plan
|
||
* geschlossen): loescht ein Widget seine `FavoriteLink`-Zeilen ueber die
|
||
* Datenbank-Kaskade (`onDelete: Cascade` auf `FavoriteLink.widgetId`),
|
||
* OHNE `FavoritesService` zu durchlaufen — dessen Datei-Aufraeumung in
|
||
* `remove()` greift hier also nicht. Diese Hilfsfunktion entfernt die
|
||
* Symboldateien der betroffenen Favoriten NACHTRAEGLICH, best effort
|
||
* (Muster T-HK4-04): ein Dateifehler wird protokolliert und geschluckt,
|
||
* er darf das Loeschen des Widgets/Reiters nie verhindern oder
|
||
* zuruecknehmen — deshalb laeuft dieser Aufruf immer NACH der
|
||
* erfolgreichen Datenbankoperation, nie innerhalb ihrer Transaktion.
|
||
*/
|
||
private async cleanUpFavoriteIconFiles(
|
||
userId: string,
|
||
rows: Array<{ id: string; uploadedIconMime: string | null }>,
|
||
): Promise<void> {
|
||
for (const row of rows) {
|
||
if (row.uploadedIconMime === null) continue;
|
||
const removed = await removeFavoriteIconFileBestEffort(userId, row.id, row.uploadedIconMime);
|
||
if (!removed) {
|
||
this.logger.warn(
|
||
`Symboldatei des kaskadiert geloeschten Favoriten ${row.id} konnte nicht entfernt werden (T-LRR-07)`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
|
||
* Benutzers, nach `position` aufsteigend — Position 0 ist der Standard
|
||
* und wird beim Öffnen geladen. Ist die Liste leer (erster Aufruf des
|
||
* Benutzers ueberhaupt), wird genau EIN Reiter „Dashboard“ angelegt.
|
||
*
|
||
* Das Anlegen laeuft in einer `withTenantTransaction`, deren ERSTE
|
||
* Anweisung eine Transaktionssperre auf die Benutzerkennung nimmt
|
||
* (`pg_advisory_xact_lock`, `hashtext` ueber die Benutzerkennung als
|
||
* ersten Schluessel, 0 als zweiten — beides eingebaute Postgres-
|
||
* Funktionen). Zwei gleichzeitige erste Aufrufe desselben Benutzers
|
||
* warten dadurch aufeinander statt beide "kein Reiter vorhanden" zu
|
||
* sehen; die erneute Zaehlung INNERHALB der Sperre verhindert die
|
||
* doppelte Anlage (T-AD9-07). `withTenantTransaction` setzt keine
|
||
* Benutzerdimension in der Sitzung — die Bedingung traegt `userId` UND
|
||
* `tenantId` deshalb selbst, als zweites Netz.
|
||
*/
|
||
async listDashboards(userId: string, tenantId: string) {
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
let dashboards = await tenantPrisma.dashboard.findMany({
|
||
where: { userId },
|
||
orderBy: { position: 'asc' },
|
||
});
|
||
|
||
if (dashboards.length === 0) {
|
||
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${userId}), 0)`;
|
||
const existing = await tx.dashboard.count({
|
||
where: { userId, tenantId },
|
||
});
|
||
if (existing === 0) {
|
||
await tx.dashboard.create({
|
||
data: { userId, tenantId, name: 'Dashboard', position: 0 },
|
||
});
|
||
}
|
||
});
|
||
|
||
dashboards = await tenantPrisma.dashboard.findMany({
|
||
where: { userId },
|
||
orderBy: { position: 'asc' },
|
||
});
|
||
}
|
||
|
||
return dashboards;
|
||
}
|
||
|
||
/**
|
||
* Riegel gegen fremde Reiter (T-AD9-01/02/03, Muster `FavoritesService.
|
||
* create`/T-GWH-05): liest den Reiter ueber den BEREITS gebundenen
|
||
* Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft
|
||
* fuer drei ununterscheidbare Faelle dieselbe `NotFoundException` — "gibt
|
||
* es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden
|
||
* Mandanten" (die Mandantengrenze zieht bereits der gebundene Klient).
|
||
* Niemals eine abweichende Antwort, aus der sich die Existenz eines
|
||
* fremden Reiters ablesen liesse.
|
||
*/
|
||
private async assertOwnedDashboard(
|
||
tenantPrisma: ReturnType<typeof forTenant>,
|
||
dashboardId: string,
|
||
userId: string,
|
||
): Promise<void> {
|
||
const dashboard = await tenantPrisma.dashboard.findUnique({
|
||
where: { id: dashboardId },
|
||
});
|
||
|
||
if (!dashboard || dashboard.userId !== userId) {
|
||
throw new NotFoundException(`Dashboard with id '${dashboardId}' not found`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Legt einen neuen, leeren Reiter an (quick-260923-ad9, Task 2, D-08).
|
||
* Name automatisch: "Dashboard 2", "Dashboard 3", … — die kleinste noch
|
||
* freie Zahl ab 2 (füllt eine Lücke, wenn z. B. "Dashboard 2" gelöscht
|
||
* wurde). Dieser Name ist ein gespeicherter Datenwert, keine
|
||
* Oberflächenbeschriftung — deshalb ein TypeScript-Text hier statt eines
|
||
* Übersetzungsschlüssels, genau wie der Name "Dashboard", den die
|
||
* Migration/`listDashboards` vergeben. Hängt ans Ende (höchste
|
||
* vorhandene Position plus eins) und liefert den neuen Reiter mit
|
||
* leerer Kachelliste (es existiert noch keine `WidgetInstance`-Zeile
|
||
* dafür).
|
||
*/
|
||
async createDashboard(userId: string, tenantId: string) {
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
const existing = await tenantPrisma.dashboard.findMany({ where: { userId } });
|
||
|
||
if (existing.length >= DASHBOARD_MAX_COUNT) {
|
||
throw new BadRequestException(
|
||
`Es sind bereits ${DASHBOARD_MAX_COUNT} Dashboards vorhanden — mehr sind nicht möglich.`,
|
||
);
|
||
}
|
||
|
||
const existingNames = new Set(existing.map((d) => d.name));
|
||
let n = 2;
|
||
while (existingNames.has(`Dashboard ${n}`)) n++;
|
||
|
||
const nextPosition = existing.reduce((max, d) => Math.max(max, d.position), -1) + 1;
|
||
|
||
return tenantPrisma.dashboard.create({
|
||
data: { userId, tenantId, name: `Dashboard ${n}`, position: nextPosition },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Benennt einen Reiter um (quick-260923-ad9, Task 2). `assertOwnedDashboard`
|
||
* läuft zuerst, über denselben gebundenen Klienten — eine fremde Kennung
|
||
* liefert die Nicht-gefunden-Antwort (T-AD9-03). Beschneiden und
|
||
* Längenprüfung (1–40 Zeichen) liegen bereits im DTO.
|
||
*/
|
||
async renameDashboard(
|
||
id: string,
|
||
userId: string,
|
||
tenantId: string,
|
||
dto: RenameDashboardDto,
|
||
) {
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
await this.assertOwnedDashboard(tenantPrisma, id, userId);
|
||
|
||
return tenantPrisma.dashboard.update({
|
||
where: { id },
|
||
data: { name: dto.name },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Löscht einen Reiter mit seinen Kacheln und seiner Anordnung
|
||
* (quick-260923-ad9, Task 2). `assertOwnedDashboard` läuft zuerst; danach
|
||
* wird geprüft, ob es der letzte verbleibende Reiter ist (D-10) — der
|
||
* Server weist das ab, die Oberfläche bietet den Knopf dafür gar nicht
|
||
* erst an. Löschen, Anordnung-/Kachel-Entfernen und das lückenlose
|
||
* Neuschreiben der verbleibenden Positionen laufen als EINE
|
||
* `withTenantTransaction` (mehrschrittig, muss atomar sein — dieselbe
|
||
* Begründung wie `FavoritesService.reorder`). Die Löschweitergabe in der
|
||
* Datenbank (`onDelete: Cascade`) bleibt als zweites Netz bestehen; der
|
||
* geschriebene Weg unten ist der gebundene. `withTenantTransaction`
|
||
* setzt keine Benutzerdimension in der Sitzung — jede Bedingung trägt
|
||
* `userId` deshalb selbst.
|
||
*/
|
||
async deleteDashboard(id: string, userId: string, tenantId: string) {
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
await this.assertOwnedDashboard(tenantPrisma, id, userId);
|
||
|
||
const count = await tenantPrisma.dashboard.count({ where: { userId, tenantId } });
|
||
if (count <= 1) {
|
||
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
|
||
}
|
||
|
||
// T-LRR-07: VOR der Kaskade merken, welche Favoriten dieses Reiters ein
|
||
// eigenes hochgeladenes Symbol tragen — siehe `cleanUpFavoriteIconFiles`.
|
||
// Nur ein Lesezugriff, kein Schreiben; laeuft ausserhalb der Transaktion
|
||
// unten, weil die Dateiraeumung selbst NICHT transaktional sein muss
|
||
// (und best effort niemals einen Rollback ausloesen darf).
|
||
const widgetsOnTab = await tenantPrisma.widgetInstance.findMany({
|
||
where: { dashboardId: id, userId },
|
||
select: { id: true },
|
||
});
|
||
const widgetIds = widgetsOnTab.map((w: { id: string }) => w.id);
|
||
const iconRows =
|
||
widgetIds.length === 0
|
||
? []
|
||
: await tenantPrisma.favoriteLink.findMany({
|
||
where: { widgetId: { in: widgetIds }, userId, uploadedIconMime: { not: null } },
|
||
select: { id: true, uploadedIconMime: true },
|
||
});
|
||
|
||
const result = await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
|
||
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
||
await tx.dashboard.deleteMany({ where: { id, userId } });
|
||
|
||
const remaining = await tx.dashboard.findMany({
|
||
where: { userId },
|
||
orderBy: { position: 'asc' },
|
||
});
|
||
|
||
for (const [index, dashboard] of remaining.entries()) {
|
||
await tx.dashboard.updateMany({
|
||
where: { id: dashboard.id, userId },
|
||
data: { position: index },
|
||
});
|
||
}
|
||
|
||
return { id };
|
||
});
|
||
|
||
await this.cleanUpFavoriteIconFiles(userId, iconRows);
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Persistiert die Reihenfolge der Reiter des Benutzers
|
||
* (quick-260923-ad9, Task 2). Wörtlich nach dem Muster
|
||
* `FavoritesService.reorder` (260917-jdd): EINE `withTenantTransaction`,
|
||
* darin erst die vorhandenen Kennungen lesen, auf exakte Übereinstimmung
|
||
* mit der gesendeten Liste prüfen (sonst Abweisung, KEIN Teilschreiben —
|
||
* die Prüfung läuft VOR jedem `updateMany`), dann je Eintrag ein
|
||
* `updateMany` mit `id` UND `userId` in der Bedingung und einer Prüfung
|
||
* auf genau eine getroffene Zeile (T-AD9-04). Existenzorakel-Vermeidung:
|
||
* EINE `BadRequestException` mit DERSELBEN Meldung für unvollständige,
|
||
* unbekannte und fremde Kennungen — kein Fall verrät, welcher Grund
|
||
* zutraf (Muster T-GWH-05/T-JDD-06).
|
||
*/
|
||
async reorderDashboards(userId: string, tenantId: string, dto: ReorderDashboardsDto) {
|
||
if (new Set(dto.ids).size !== dto.ids.length) {
|
||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||
}
|
||
|
||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||
const existing = await tx.dashboard.findMany({
|
||
where: { userId },
|
||
select: { id: true },
|
||
});
|
||
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
|
||
|
||
if (existing.length !== dto.ids.length || dto.ids.some((id) => !existingIds.has(id))) {
|
||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||
}
|
||
|
||
for (const [index, id] of dto.ids.entries()) {
|
||
const { count } = await tx.dashboard.updateMany({
|
||
where: { id, userId },
|
||
data: { position: index },
|
||
});
|
||
|
||
if (count !== 1) {
|
||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||
}
|
||
}
|
||
|
||
return tx.dashboard.findMany({
|
||
where: { userId },
|
||
orderBy: { position: 'asc' },
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Returns the saved layout of one dashboard tab, or a default empty
|
||
* layout with all breakpoint arrays initialized.
|
||
*
|
||
* quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
|
||
* `assertOwnedDashboard` runs first, over the SAME bound client.
|
||
*/
|
||
async getLayout(userId: string, tenantId: string, dashboardId: string) {
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
|
||
|
||
const record = await tenantPrisma.dashboardLayout.findUnique({
|
||
where: { dashboardId },
|
||
});
|
||
|
||
if (!record) {
|
||
return { lg: [], md: [], sm: [], xs: [], xxs: [] };
|
||
}
|
||
|
||
return record.layouts;
|
||
}
|
||
|
||
/**
|
||
* Upserts the layout of one dashboard tab.
|
||
* Creates a new record if none exists, updates if it does.
|
||
*
|
||
* quick-260923-ad9 (D-02): scoped by `dto.dashboardId` instead of
|
||
* `userId` — `assertOwnedDashboard` runs first, over the SAME bound
|
||
* client. `dashboardId` is now the `@unique` column on `DashboardLayout`
|
||
* (was `userId` before this plan).
|
||
*
|
||
* A bound conflicting upsert against a row invisible under RLS 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) — measured 260910-krx, Aufgabe 1,
|
||
* translation kept unchanged from before this plan.
|
||
*/
|
||
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
|
||
|
||
try {
|
||
return await tenantPrisma.dashboardLayout.upsert({
|
||
where: { dashboardId: dto.dashboardId },
|
||
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
|
||
create: {
|
||
userId,
|
||
tenantId,
|
||
dashboardId: dto.dashboardId,
|
||
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 of one dashboard tab, gefiltert um Widgets
|
||
* eines für den Benutzer gesperrten Moduls (D-22, PERM-07).
|
||
*
|
||
* quick-260923-ad9 (D-02): scoped by `dashboardId` instead of `userId` —
|
||
* `assertOwnedDashboard` runs first, over the SAME bound client. 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, dashboardId: string) {
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
await this.assertOwnedDashboard(tenantPrisma, dashboardId, userId);
|
||
|
||
const widgets = await tenantPrisma.widgetInstance.findMany({
|
||
where: { dashboardId },
|
||
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 on one dashboard tab.
|
||
* quick-260923-ad9 (D-02): `assertOwnedDashboard` runs first, over the
|
||
* SAME bound client — a widget can only be created on a tab the caller
|
||
* owns.
|
||
*/
|
||
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
|
||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||
await this.assertOwnedDashboard(tenantPrisma, dto.dashboardId, userId);
|
||
|
||
return tenantPrisma.widgetInstance.create({
|
||
data: {
|
||
userId,
|
||
tenantId,
|
||
dashboardId: dto.dashboardId,
|
||
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<string, unknown>),
|
||
...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.
|
||
*
|
||
* T-LRR-07 (quick-260923-lrr): dieselbe Kaskade wie in `deleteDashboard`
|
||
* trifft hier ein einzelnes Widget — vor dem Loeschen werden dessen
|
||
* Favoriten mit hochgeladenem Symbol gemerkt, danach werden ihre Dateien
|
||
* best effort entfernt (siehe `cleanUpFavoriteIconFiles`).
|
||
*/
|
||
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`,
|
||
);
|
||
}
|
||
|
||
const iconRows = await tenantPrisma.favoriteLink.findMany({
|
||
where: { widgetId: id, userId, uploadedIconMime: { not: null } },
|
||
select: { id: true, uploadedIconMime: true },
|
||
});
|
||
|
||
const result = await tenantPrisma.widgetInstance.delete({
|
||
where: { id },
|
||
});
|
||
|
||
await this.cleanUpFavoriteIconFiles(userId, iconRows);
|
||
|
||
return result;
|
||
}
|
||
|
||
// --- 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.
|