feat(chat): chat a canali nel portale cliente e inbox admin per canale
Il portale aveva una sola conversazione con un selettore di fase in un dropdown: il cliente non vedeva dove c'era del non letto, e una risposta admin poteva atterrare su un'entità diversa da quella della domanda. Ora i messaggi si organizzano in canali — "Generale" più uno per fase — derivati in un solo posto (src/lib/chat-channels.ts) così che le due sponde concordino sulla stessa chiave. Task e deliverable non sono più scrivibili ma lo storico non resta orfano: rientra nel canale della fase proprietaria conservando il nome dell'entità come badge. - migration 0021 (additiva, già applicata in prod): client_channel_reads per il letto/non-letto per canale lato cliente, più il primo indice mai esistito su comments (entity_id, created_at) - GET/POST /api/client/chat: polling dei messaggi e ricevuta di lettura - ChatPanel: tab per canale, pallino di non letto, modalità full-screen - inbox admin: tab per canale con targeting dell'entità corretta in risposta e snapshot di adminLastReadAt sul thread Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user