feat: pagina Conversazioni — inbox unificata messaggi clienti

Nuova pagina admin /admin/conversazioni: vista WhatsApp-style (lista
conversazioni a sinistra, thread + risposta a destra) che aggrega i
messaggi di tutti i clienti dalla tabella comments, cross-cliente.

- Migration additiva 0014: clients.admin_last_read_at per tracciare
  letto/non-letto (pallini + badge in sidebar). Applicata a prod.
- conversations-queries.ts: aggregazione entity_id → cliente
  (general/phase/task/deliverable), getConversations /
  getConversationThread / getUnreadConversationsCount.
- Risposte admin salvate come commento "general" (visibili anche nella
  chat cliente e nel CommentsTab della scheda).
- Voce di menu + badge non-letti (AdminSidebar/AdminShell/layout).
- Token semantici per dual light/dark; refresh manuale (no polling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:10:14 +02:00
parent a20a9de2d7
commit c1cc13a99a
10 changed files with 994 additions and 10 deletions
+59
View File
@@ -0,0 +1,59 @@
"use server";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { eq } from "drizzle-orm";
import { authOptions } from "@/lib/auth";
import { db } from "@/db";
import { clients, comments } from "@/db/schema";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
/**
* 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 and in the client detail CommentsTab.
*/
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 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");
await db.insert(comments).values({
entity_type: "general",
entity_id: clientId,
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");
}