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:
2026-08-21 17:50:44 +02:00
parent 53f1758f52
commit ac74a81a72
14 changed files with 1320 additions and 332 deletions
+32 -11
View File
@@ -3,30 +3,46 @@
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { authOptions } from "@/lib/auth";
import { db } from "@/db";
import { clients, comments } from "@/db/schema";
import { assertClientOwnsEntity } from "@/lib/client-chat";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
const replySchema = z.object({
// "general" ancora il messaggio al cliente, "phase" a una sua fase. Task e
// deliverable restano fuori: nessuna UI li scrive più, da nessuna delle due parti.
entity_type: z.enum(["general", "phase"]),
entity_id: z.string().min(1),
body: z.string().trim().min(1, "Il messaggio non può essere vuoto").max(2000),
});
/**
* Admin reply from the Conversazioni inbox. Per project decision, replies are
* saved as a "general" comment on the client (entity_id = clientId), so they
* surface in the client's general chat.
* Risposta dell'admin dall'inbox Conversazioni, sul canale da cui si sta
* scrivendo.
*
* Questa è l'UNICA via di risposta dell'admin da quando il tab Commenti del
* progetto è stato rimosso: i messaggi su fase/task/deliverable si leggono qui
* con la loro etichetta, ma la risposta torna sempre sul thread generale.
* Fino alla chat a canali l'admin poteva rispondere SOLO sul thread generale:
* i messaggi su fase/task/deliverable si leggevano con la loro etichetta ma la
* risposta tornava sempre in Generale. Con i tab quella asimmetria diventava un
* bug visibile — domanda in "Fase 2", risposta in un altro tab — quindi ora il
* canale viaggia insieme al messaggio.
*/
export async function replyToConversation(clientId: string, formData: FormData) {
await requireAdmin();
const body = (formData.get("body") as string)?.trim();
if (!clientId || !body) throw new Error("Dati mancanti");
// Validate the client exists (entity_id integrity).
const parsed = replySchema.safeParse({
entity_type: formData.get("entity_type"),
entity_id: formData.get("entity_id"),
body: formData.get("body"),
});
if (!clientId || !parsed.success) throw new Error("Dati mancanti");
const { entity_type, entity_id, body } = parsed.data;
const rows = await db
.select({ id: clients.id })
.from(clients)
@@ -34,9 +50,14 @@ export async function replyToConversation(clientId: string, formData: FormData)
.limit(1);
if (rows.length === 0) throw new Error("Cliente non trovato");
// Stessa verifica di appartenenza usata dalle route del portale: un entity_id
// arbitrario nel form non deve poter scrivere nella chat di un altro cliente.
const owns = await assertClientOwnsEntity(clientId, entity_type, entity_id);
if (!owns) throw new Error("Canale non valido per questo cliente");
await db.insert(comments).values({
entity_type: "general",
entity_id: clientId,
entity_type,
entity_id,
author: "admin",
body,
});