import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; import { CreateFavoriteDto } from './dto/create-favorite.dto'; import { ReorderFavoritesDto } from './dto/reorder-favorites.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto'; import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service'; /** * Service for managing per-user, per-widget favorite links. * * Mandantengebunden (260911-gwh): jede Methode nimmt `tenantId` als ERSTEN * Parameter und laeuft ueber GENAU EINEN Klienten `tenantPrisma` — der * Mandant kommt aus `extractContext` im Controller, DERSELBEN Quelle wie * `dashboard.controller.ts` (nicht dem auth-Praezedenzfall/Claim): der Link * haengt ueber `widgetId` an `WidgetInstance`, und `WidgetInstance` ist * unter der dashboard-Mandantenquelle gebunden. Eine abweichende Quelle * wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines * SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen. * * Die Regel auf `FavoriteLink` trug bei der Messung 260911-gwh (Aufgabe 1, * Pruefung 4) KEINE Benutzerdimension — dieselbe Lehre wie `CalendarSource`/ * `DashboardLayout`/`WidgetInstance`. Nachtrag (260911-nke, Etappe 3b): seit * Migration 20260911120000 traegt die Regel auf `FavoriteLink` die * Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`) * — jeder `forTenant()`-Aufruf unten reicht `userId` als drittes Argument * durch. Die `userId`-Filter unten bleiben trotzdem UNVERAENDERT bestehen: * zweites Netz, kein Ersatz — ein Aufrufer, der `userId` vergisst, saehe * ohne sie den ganzen Mandanten (siehe .planning/WINDOWS.md). * * Access control (T-08-06 / Pitfall 3): * - Every query is scoped by userId (prevents cross-user access). * - list() additionally scopes by widgetId so each widget instance has its own set. * - update() and remove() verify userId ownership before mutating. * - reorder() runs as one withTenantTransaction() (T-JDD-03) and scopes * every updateMany by userId AND widgetId (see reorder() doc below). * * `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert * (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der * Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes * PostgreSQL-Verhalten, gemessen in Aufgabe 1, Pruefung 7) — ohne den * Riegel waere der Unterschied zwischen "Widget existiert nicht" (500) und * "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber * Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle * ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden * Mandanten") mit derselben `NotFoundException('Widget not found')`. */ @Injectable() export class FavoritesService { constructor( private readonly prisma: PrismaService, private readonly iconDiscovery: IconDiscoveryService, ) {} /** * Returns all favorites for a user's widget instance, ordered by position asc. * Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links). */ async list(tenantId: string, userId: string, widgetId: string) { if (!widgetId) throw new BadRequestException('widgetId is required'); const tenantPrisma = forTenant(this.prisma, tenantId, userId); return tenantPrisma.favoriteLink.findMany({ where: { userId, widgetId }, orderBy: [{ position: 'asc' }, { title: 'asc' }], }); } /** * Creates a new favorite link. * Verifies the target widget belongs to the caller BEFORE any icon * discovery network call (T-GWH-05). * If iconUrl is not provided, triggers server-side icon discovery with SSRF protection. */ async create(tenantId: string, userId: string, dto: CreateFavoriteDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); // T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von // WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel // wuerde ein gebundenes create mit einer fremdmandantigen widgetId // gelingen. Eine Antwort fuer alle drei Faelle: existiert nicht, // gehoert einem Kollegen, liegt bei einem fremden Mandanten. const widget = await tenantPrisma.widgetInstance.findUnique({ where: { id: dto.widgetId }, select: { userId: true }, }); if (!widget || widget.userId !== userId) { throw new NotFoundException('Widget not found'); } // Normalize so a scheme-less entry like "ctl.de" is stored (and discovered) // as "https://ctl.de" — otherwise the link and icon discovery both break. const url = normalizeUrl(dto.url); let iconUrl = dto.iconUrl ?? null; // Server-side icon discovery (D-05) — only when caller did not supply an icon if (!iconUrl) { iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url); } return tenantPrisma.favoriteLink.create({ data: { userId, tenantId, widgetId: dto.widgetId, title: dto.title, url, iconUrl, position: dto.position ?? 0, }, }); } /** * Updates an existing favorite. * Verifies userId ownership before applying changes (T-08-06). * Accepts null as an explicit value for iconUrl (clears stored icon). */ async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId) { throw new NotFoundException('FavoriteLink not found'); } const data: Record = {}; if (dto.title !== undefined) data.title = dto.title; const normalizedUrl = dto.url !== undefined ? normalizeUrl(dto.url) : undefined; if (normalizedUrl !== undefined) data.url = normalizedUrl; if (dto.position !== undefined) data.position = dto.position; if ('iconUrl' in dto) { if (dto.iconUrl) { // Explicit icon URL supplied — respect it as-is. data.iconUrl = dto.iconUrl; } else { // Icon cleared (empty/null) — re-run discovery against the effective // (new or existing) url so editing a broken favorite repairs its icon. const effectiveUrl = normalizedUrl ?? link.url; data.iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(effectiveUrl); } } return tenantPrisma.favoriteLink.update({ where: { id }, data, }); } /** * Deletes a favorite link. * Verifies userId ownership before deleting (T-08-06). */ async remove(tenantId: string, id: string, userId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId) { throw new NotFoundException('FavoriteLink not found'); } await tenantPrisma.favoriteLink.delete({ where: { id } }); } /** * Persists the display order of a user's favorites for one widget * instance (260917-jdd, PUT /favorites/order). * * Laeuft als EINE Transaktion ueber `withTenantTransaction()` — die * einzige gemessene atomare Form fuer einen Mehrschritt-Zugriff * (prisma-tenant.extension.ts Z. 33-49/104-109); die Array-Form von * `$transaction` auf einem mit `forTenant()` gebundenen Klienten ist * gemessen NICHT atomar, die interaktive Form auf dem gebundenen Klienten * faellt unter Last aus (siehe dortige Messung). `withTenantTransaction()` * setzt KEINE Benutzerdimension in der Sitzung (nur `app.current_tenant`) * — die Regel auf `FavoriteLink` faellt deshalb in ihren `IS NULL`-Zweig * und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung unten * `userId` UND `widgetId` selbst (zweites Netz, wie der Kopfkommentar * dieser Klasse es fuer alle Methoden vorsieht). * * `updateMany` statt `update({ where: { id } })`, weil `update` nur nach * `id` filtern koennte — der Ownership-Check muesste dann als separater * Lese-Schritt VOR dem Schreiben stehen, mit derselben TOCTOU-Luecke wie * ein fehlendes zweites Netz. `updateMany` traegt die Bedingung direkt in * der Schreiboperation und liefert `count`, das sofort geprueft wird. * * Existenzorakel-Vermeidung (T-JDD-06): EINE BadRequestException mit * DERSELBEN Meldung fuer fremde id, unbekannte id, Teilmenge sowie * fremdes/unbekanntes Widget oder Mandant — kein Fall verraet, welcher * Grund zutraf (Muster T-GWH-05). * * Altbestand: alle Zeilen mit `position = 0` (vor diesem Plan gab es * keine Sortierung) normalisiert sich beim ERSTEN Aufruf zu `0..n-1` — * kein Migrations- oder Sonderpfad noetig. */ async reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto) { if (new Set(dto.ids).size !== dto.ids.length) { throw new BadRequestException('ids must match the favorites of this widget exactly'); } return withTenantTransaction(this.prisma, tenantId, async (tx) => { const existing = await tx.favoriteLink.findMany({ where: { userId, widgetId: dto.widgetId }, 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 favorites of this widget exactly'); } for (const [index, id] of dto.ids.entries()) { const { count } = await tx.favoriteLink.updateMany({ where: { id, userId, widgetId: dto.widgetId }, data: { position: index }, }); if (count !== 1) { throw new BadRequestException('ids must match the favorites of this widget exactly'); } } return tx.favoriteLink.findMany({ where: { userId, widgetId: dto.widgetId }, orderBy: [{ position: 'asc' }, { title: 'asc' }], }); }); } /** * Fetches the raw bytes of a favorite's stored icon, scoped to the * requesting user (T-08-06 — same ownership check as update/remove). * Never accepts a client-supplied URL — only the stored iconUrl on a * row the caller owns is fetched (T-QFIP-01). * * Throws NotFoundException (404) if the row doesn't exist, isn't owned * by the caller, or has no icon on record. Throws a 502 HttpException * if the upstream fetch fails (unreachable, timeout, non-image, or * SSRF-blocked) -- never returns a placeholder image. */ async getIconBytes( tenantId: string, id: string, userId: string, ): Promise<{ contentType: string; body: Buffer }> { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } }); if (!link || link.userId !== userId || !link.iconUrl) { throw new NotFoundException('FavoriteLink not found'); } try { return await this.iconDiscovery.fetchIconBytes(link.iconUrl); } catch { throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY); } } }