import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { CreateFavoriteDto } from './dto/create-favorite.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. * * `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) as any; 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) as any; // 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) as any; 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) as any; 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 } }); } /** * 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) as any; 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); } } }