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:
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user