Files
clienthub/src/app/admin/conversazioni/actions.ts
T
simone ac74a81a72 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>
2026-08-21 17:50:44 +02:00

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");
}