ac74a81a72
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>
85 lines
2.9 KiB
TypeScript
85 lines
2.9 KiB
TypeScript
"use server";
|
|
|
|
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),
|
|
});
|
|
|
|
/**
|
|
* Risposta dell'admin dall'inbox Conversazioni, sul canale da cui si sta
|
|
* scrivendo.
|
|
*
|
|
* 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 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)
|
|
.where(eq(clients.id, clientId))
|
|
.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,
|
|
entity_id,
|
|
author: "admin",
|
|
body,
|
|
});
|
|
|
|
// Replying implies the admin has read the incoming messages.
|
|
await db
|
|
.update(clients)
|
|
.set({ admin_last_read_at: new Date() })
|
|
.where(eq(clients.id, clientId));
|
|
|
|
revalidatePath("/admin/conversazioni");
|
|
revalidatePath(`/admin/clients/${clientId}`);
|
|
}
|
|
|
|
/** Mark a client's conversation as read up to now (clears unread dot/badge). */
|
|
export async function markConversationRead(clientId: string) {
|
|
await requireAdmin();
|
|
if (!clientId) return;
|
|
await db
|
|
.update(clients)
|
|
.set({ admin_last_read_at: new Date() })
|
|
.where(eq(clients.id, clientId));
|
|
revalidatePath("/admin/conversazioni");
|
|
}
|