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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user