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>
This commit is contained in:
2026-09-23 16:04:29 +02:00
parent bf4384ad73
commit 7704372c3c
11 changed files with 1454 additions and 21 deletions
+69 -2
View File
@@ -2,9 +2,11 @@ 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';
@@ -91,11 +93,40 @@ const DEFAULT_SEARCH_PROVIDERS = [
*/
@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
@@ -243,7 +274,25 @@ export class DashboardService {
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
}
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
// 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 } });
@@ -262,6 +311,10 @@ export class DashboardService {
return { id };
});
await this.cleanUpFavoriteIconFiles(userId, iconRows);
return result;
}
/**
@@ -505,6 +558,11 @@ export class DashboardService {
* 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);
@@ -518,9 +576,18 @@ export class DashboardService {
);
}
return tenantPrisma.widgetInstance.delete({
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) ---