import { NextRequest, NextResponse } from "next/server"; import { and, eq, gt, inArray, asc } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/db"; import { comments, client_channel_reads } from "@/db/schema"; import { rateLimit } from "@/lib/rate-limit"; import { resolveClientByToken, assertClientOwnsEntity, getProjectChatScope, } from "@/lib/client-chat"; /** * Chat del portale cliente: poll dei messaggi nuovi (GET) e ricevuta di lettura * per canale (POST). * * Perché un endpoint dedicato invece di router.refresh(): il refresh RSC rifà * l'intera getProjectView — pagamenti, offerte, documenti, trascrizioni — per * portare a casa due righe di chat. A pannello aperto, ogni 20 secondi, sarebbe * sproporzionato. Qui si legge solo `comments`, filtrata su `since`. */ const readSchema = z.object({ token: z.string().min(1), channel_key: z.string().min(1), }); // ── GET: messaggi del progetto creati dopo `since` + stato di lettura ────────── export async function GET(request: NextRequest) { const ip = request.headers.get("x-forwarded-for") ?? "unknown"; // Una richiesta ogni 20s per pannello aperto: 30/min lascia margine a più // schede aperte dietro lo stesso IP senza aprire la porta a un abuso. if (!rateLimit(`chat-poll:${ip}`, 30, 60_000)) { return NextResponse.json({ error: "Troppe richieste" }, { status: 429 }); } try { const { searchParams } = new URL(request.url); const token = searchParams.get("token"); const projectId = searchParams.get("project_id"); const since = searchParams.get("since"); if (!token || !projectId) { return NextResponse.json({ error: "Parametri mancanti" }, { status: 400 }); } const clientId = await resolveClientByToken(token); if (!clientId) { return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } const entityIds = await getProjectChatScope(clientId, projectId); if (!entityIds) { return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } // Un `since` illeggibile deve degradare a "dammi tutto", non a una data del // 1970 né a un crash: nel peggiore dei casi il pannello rilegge lo storico. const sinceDate = since ? new Date(since) : null; const validSince = sinceDate && !Number.isNaN(sinceDate.getTime()) ? sinceDate : null; const scope = inArray(comments.entity_id, entityIds); const rows = await db .select() .from(comments) .where(validSince ? and(scope, gt(comments.created_at, validSince)) : scope) .orderBy(asc(comments.created_at)); const readRows = await db .select() .from(client_channel_reads) .where(eq(client_channel_reads.client_id, clientId)); return NextResponse.json({ comments: rows, reads: Object.fromEntries( readRows.map((r) => [r.channel_key, r.read_at.toISOString()]) ), }); } catch (err) { console.error("/api/client/chat GET error:", err); return NextResponse.json({ error: "Errore interno" }, { status: 500 }); } } // ── POST: il cliente ha letto un canale fino ad adesso ──────────────────────── export async function POST(request: NextRequest) { const ip = request.headers.get("x-forwarded-for") ?? "unknown"; if (!rateLimit(`chat-read:${ip}`, 60, 60_000)) { return NextResponse.json({ error: "Troppe richieste" }, { status: 429 }); } try { const parsed = readSchema.safeParse(await request.json()); if (!parsed.success) { return NextResponse.json({ error: "Dati non validi" }, { status: 400 }); } const { token, channel_key } = parsed.data; const clientId = await resolveClientByToken(token); if (!clientId) { return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } // channel_key è clients.id per "Generale", altrimenti dev'essere una fase // di questo cliente: senza il controllo si potrebbero seminare righe di // lettura su id arbitrari. const owns = channel_key === clientId ? true : await assertClientOwnsEntity(clientId, "phase", channel_key); if (!owns) { return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } await db .insert(client_channel_reads) .values({ client_id: clientId, channel_key, read_at: new Date() }) .onConflictDoUpdate({ target: [client_channel_reads.client_id, client_channel_reads.channel_key], set: { read_at: new Date() }, }); return NextResponse.json({ success: true }); } catch (err) { console.error("/api/client/chat POST error:", err); return NextResponse.json({ error: "Errore interno" }, { status: 500 }); } }