Files
tessera-ctl/apps/api/src/dashboard/dashboard.service.ts
T
schalli 7704372c3c feat(260923-lrr): API — Favoriten-Symbol hochladen, Vorrang, Versionszaehler, Abrufprobe
- 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>
2026-09-23 16:04:29 +02:00

676 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.