diff --git a/src/app/admin/conversazioni/actions.ts b/src/app/admin/conversazioni/actions.ts index a5984fe..eee0c0c 100644 --- a/src/app/admin/conversazioni/actions.ts +++ b/src/app/admin/conversazioni/actions.ts @@ -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, }); diff --git a/src/app/api/client/chat/route.ts b/src/app/api/client/chat/route.ts new file mode 100644 index 0000000..b763329 --- /dev/null +++ b/src/app/api/client/chat/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, eq, gt, inArray, asc } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/db"; +import { comments, client_channel_reads } from "@/db/schema"; +import { rateLimit } from "@/lib/rate-limit"; +import { + resolveClientByToken, + assertClientOwnsEntity, + getProjectChatScope, +} from "@/lib/client-chat"; + +/** + * Chat del portale cliente: poll dei messaggi nuovi (GET) e ricevuta di lettura + * per canale (POST). + * + * Perché un endpoint dedicato invece di router.refresh(): il refresh RSC rifà + * l'intera getProjectView — pagamenti, offerte, documenti, trascrizioni — per + * portare a casa due righe di chat. A pannello aperto, ogni 20 secondi, sarebbe + * sproporzionato. Qui si legge solo `comments`, filtrata su `since`. + */ + +const readSchema = z.object({ + token: z.string().min(1), + channel_key: z.string().min(1), +}); + +// ── GET: messaggi del progetto creati dopo `since` + stato di lettura ────────── + +export async function GET(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for") ?? "unknown"; + // Una richiesta ogni 20s per pannello aperto: 30/min lascia margine a più + // schede aperte dietro lo stesso IP senza aprire la porta a un abuso. + if (!rateLimit(`chat-poll:${ip}`, 30, 60_000)) { + return NextResponse.json({ error: "Troppe richieste" }, { status: 429 }); + } + + try { + const { searchParams } = new URL(request.url); + const token = searchParams.get("token"); + const projectId = searchParams.get("project_id"); + const since = searchParams.get("since"); + + if (!token || !projectId) { + return NextResponse.json({ error: "Parametri mancanti" }, { status: 400 }); + } + + const clientId = await resolveClientByToken(token); + if (!clientId) { + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); + } + + const entityIds = await getProjectChatScope(clientId, projectId); + if (!entityIds) { + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); + } + + // Un `since` illeggibile deve degradare a "dammi tutto", non a una data del + // 1970 né a un crash: nel peggiore dei casi il pannello rilegge lo storico. + const sinceDate = since ? new Date(since) : null; + const validSince = + sinceDate && !Number.isNaN(sinceDate.getTime()) ? sinceDate : null; + + const scope = inArray(comments.entity_id, entityIds); + const rows = await db + .select() + .from(comments) + .where(validSince ? and(scope, gt(comments.created_at, validSince)) : scope) + .orderBy(asc(comments.created_at)); + + const readRows = await db + .select() + .from(client_channel_reads) + .where(eq(client_channel_reads.client_id, clientId)); + + return NextResponse.json({ + comments: rows, + reads: Object.fromEntries( + readRows.map((r) => [r.channel_key, r.read_at.toISOString()]) + ), + }); + } catch (err) { + console.error("/api/client/chat GET error:", err); + return NextResponse.json({ error: "Errore interno" }, { status: 500 }); + } +} + +// ── POST: il cliente ha letto un canale fino ad adesso ──────────────────────── + +export async function POST(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for") ?? "unknown"; + if (!rateLimit(`chat-read:${ip}`, 60, 60_000)) { + return NextResponse.json({ error: "Troppe richieste" }, { status: 429 }); + } + + try { + const parsed = readSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: "Dati non validi" }, { status: 400 }); + } + const { token, channel_key } = parsed.data; + + const clientId = await resolveClientByToken(token); + if (!clientId) { + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); + } + + // channel_key è clients.id per "Generale", altrimenti dev'essere una fase + // di questo cliente: senza il controllo si potrebbero seminare righe di + // lettura su id arbitrari. + const owns = + channel_key === clientId + ? true + : await assertClientOwnsEntity(clientId, "phase", channel_key); + if (!owns) { + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); + } + + await db + .insert(client_channel_reads) + .values({ client_id: clientId, channel_key, read_at: new Date() }) + .onConflictDoUpdate({ + target: [client_channel_reads.client_id, client_channel_reads.channel_key], + set: { read_at: new Date() }, + }); + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("/api/client/chat POST error:", err); + return NextResponse.json({ error: "Errore interno" }, { status: 500 }); + } +} diff --git a/src/app/api/client/comment/route.ts b/src/app/api/client/comment/route.ts index 1da9984..8ed19c1 100644 --- a/src/app/api/client/comment/route.ts +++ b/src/app/api/client/comment/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; -import { eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/db"; -import { clients, comments, tasks, phases, deliverables, projects } from "@/db/schema"; +import { comments } from "@/db/schema"; import { rateLimit } from "@/lib/rate-limit"; +import { resolveClientByToken, assertClientOwnsEntity } from "@/lib/client-chat"; const commentSchema = z.object({ token: z.string().min(1), @@ -31,86 +31,31 @@ export async function POST(request: NextRequest) { const { token, entity_type, entity_id, body: commentBody } = parsed.data; - // Validate token - const clientRows = await db - .select({ id: clients.id }) - .from(clients) - .where(eq(clients.token, token)) - .limit(1); - - if (clientRows.length === 0) { + const clientId = await resolveClientByToken(token); + if (!clientId) { return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } - const clientId = clientRows[0].id; - - if (entity_type === "general") { - // General messages: entity_id must be the client's own id - if (entity_id !== clientId) { - return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); - } - } else { - // Scope phases through projects → client - const clientProjects = await db - .select({ id: projects.id }) - .from(projects) - .where(eq(projects.client_id, clientId)); - const projectIds = clientProjects.map((p) => p.id); - - if (projectIds.length === 0) { - return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); - } - - const phasesForClient = await db - .select({ id: phases.id }) - .from(phases) - .where(inArray(phases.project_id, projectIds)); - const phaseIds = phasesForClient.map((p) => p.id); - - if (phaseIds.length === 0) { - return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); - } - - const taskRows = await db - .select({ id: tasks.id }) - .from(tasks) - .where(inArray(tasks.phase_id, phaseIds)); - - if (entity_type === "phase") { - // Phase: entity_id must be one of the client's phases - if (!phasesForClient.find((p) => p.id === entity_id)) { - return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); - } - } else if (entity_type === "task") { - if (!taskRows.find((r) => r.id === entity_id)) { - return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); - } - } else { - // deliverable - const taskIds = taskRows.map((r) => r.id); - if (taskIds.length === 0) { - return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); - } - const delivRows = await db - .select({ id: deliverables.id }) - .from(deliverables) - .where(inArray(deliverables.task_id, taskIds)); - if (!delivRows.find((r) => r.id === entity_id)) { - return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); - } - } + const owns = await assertClientOwnsEntity(clientId, entity_type, entity_id); + if (!owns) { + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } - await db.insert(comments).values({ - entity_type, - entity_id, - author: "client", - body: commentBody, - }); + // Il messaggio torna indietro: il pannello lo usa per sostituire la copia + // optimistic con la riga vera (stesso id, stesso created_at del server). + const [created] = await db + .insert(comments) + .values({ + entity_type, + entity_id, + author: "client", + body: commentBody, + }) + .returning(); - return NextResponse.json({ success: true }, { status: 201 }); + return NextResponse.json({ success: true, comment: created }, { status: 201 }); } catch (err) { console.error("/api/client/comment error:", err); return NextResponse.json({ error: "Errore interno" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx index c280ce1..2805a8b 100644 --- a/src/app/client/[token]/page.tsx +++ b/src/app/client/[token]/page.tsx @@ -12,7 +12,6 @@ import { ClientDashboard } from "@/components/client-dashboard"; import { OtpGate } from "@/components/client/OtpGate"; import { PreviewBanner } from "@/components/client/PreviewBanner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { Comment } from "@/db/schema"; import { normalizeTaskStatus } from "@/lib/task-status"; export const revalidate = 0; @@ -147,7 +146,11 @@ export default async function ClientPage({ > @@ -193,7 +196,11 @@ export default async function ClientPage({ diff --git a/src/components/admin/conversazioni/ConversationsView.tsx b/src/components/admin/conversazioni/ConversationsView.tsx index f819d30..e3f6e6f 100644 --- a/src/components/admin/conversazioni/ConversationsView.tsx +++ b/src/components/admin/conversazioni/ConversationsView.tsx @@ -85,7 +85,10 @@ export function ConversationsView({ {/* ── Right: active thread ────────────────────────────────── */} {activeThread ? ( - + // key sul cliente: al refresh (che segna letta la conversazione) il + // componente NON rimonta e tiene i pallini calcolati all'apertura; + // cambiando cliente rimonta e li ricalcola sui dati freschi. + ) : ( @@ -153,11 +156,53 @@ function ActiveThread({ thread }: { thread: ConversationThread }) { const scrollRef = useRef(null); const portalHref = `/client/${thread.slug ?? thread.token}`; + // Canali con messaggi del cliente non ancora letti, fotografati al montaggio. + // Non è un useMemo: dopo markConversationRead + refresh il calcolo darebbe + // sempre insieme vuoto e i pallini sparirebbero prima di essere visti. + const [unreadAtOpen] = useState(() => { + const lastReadAt = thread.adminLastReadAt?.getTime() ?? 0; + const set = new Set(); + for (const m of thread.messages) { + if (m.author === "client" && new Date(m.created_at).getTime() > lastReadAt) { + set.add(m.channelKey); + } + } + return set; + }); + const [visited, setVisited] = useState>(new Set()); + + // Si apre dove c'è qualcosa da leggere; in mancanza, sul canale dell'ultimo + // messaggio. Aprire sempre su "Generale" costringerebbe a cercare a mano il + // tab da cui è arrivata la domanda. + const [activeChannel, setActiveChannel] = useState(() => { + const firstUnread = thread.messages.find( + (m) => m.author === "client" && unreadAtOpen.has(m.channelKey) + ); + if (firstUnread) return firstUnread.channelKey; + const last = thread.messages[thread.messages.length - 1]; + return last?.channelKey ?? thread.clientId; + }); + + function selectChannel(key: string) { + setActiveChannel(key); + setVisited((prev) => new Set(prev).add(key)); + } + + const visibleMessages = useMemo( + () => thread.messages.filter((m) => m.channelKey === activeChannel), + [thread.messages, activeChannel] + ); + + const activeLabel = + thread.channels.find((c) => c.key === activeChannel)?.label ?? "Generale"; + const isGeneral = activeChannel === thread.clientId; + const showTabs = thread.channels.length > 1; + // Keep the thread pinned to the latest message. useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; - }, [thread.clientId, thread.messages.length]); + }, [activeChannel, visibleMessages.length]); return ( <> @@ -184,27 +229,68 @@ function ActiveThread({ thread }: { thread: ConversationThread }) { + {/* Canali — stessi tab che vede il cliente nel portale */} + {showTabs && ( + + {thread.channels.map((channel) => { + const active = channel.key === activeChannel; + const unread = unreadAtOpen.has(channel.key) && !visited.has(channel.key); + return ( + selectChannel(channel.key)} + title={channel.label} + className={cn( + "flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors", + active + ? "bg-muted font-semibold text-foreground" + : "font-medium text-muted-foreground hover:text-foreground" + )} + > + {channel.label} + {unread && !active && ( + <> + + non letti + > + )} + + ); + })} + + )} + {/* Messages */} - {thread.messages.length === 0 ? ( + {visibleMessages.length === 0 ? ( - Nessun messaggio in questa conversazione. + Nessun messaggio in questo canale. ) : ( - thread.messages.map((m) => ) + visibleMessages.map((m) => ) )} - {/* Reply box */} + {/* Reply box — il canale viaggia col messaggio */} + + @@ -221,7 +307,9 @@ function MessageBubble({ message: ConversationThread["messages"][number]; }) { const isAdmin = message.author === "admin"; - const showEntity = message.entityType !== "general"; + // Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo. + const showEntity = + message.entityType === "task" || message.entityType === "deliverable"; return ( {/* Floating chat panel — FAB + slide-in panel */} - + diff --git a/src/components/client/ChatPanel.tsx b/src/components/client/ChatPanel.tsx index c376893..d76ba1b 100644 --- a/src/components/client/ChatPanel.tsx +++ b/src/components/client/ChatPanel.tsx @@ -1,277 +1,637 @@ "use client"; -import { useState, useTransition, useRef, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import type { Comment } from "@/db/schema"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Maximize2, Minimize2, MessageCircle, Send, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + buildChannels, + buildChannelIndex, + channelOfComment, + type ChatData, + type ChatMessage, +} from "@/lib/chat-channels"; import { useChatContext } from "./ChatProvider"; import { usePreview } from "./PreviewProvider"; -// TODO: Email-on-tag (admin→client notification when admin posts on a phase/task) is OUT OF SCOPE -// Hook point: after db.insert(comments) in /src/app/api/client/comment/route.ts and -// postAdminComment in /src/app/admin/clients/[id]/actions.ts — send Resend email here. +// TODO: la mail di notifica al cliente ("hai un messaggio non letto") è un giro a +// sé: serve uno scheduler, che nel repo non esiste ancora. Hook point naturale: +// una query su client_channel_reads incrociata con comments.author = 'admin'. -function formatTime(ts: Date | string): string { - const d = typeof ts === "string" ? new Date(ts) : ts; - return d.toLocaleString("it-IT", { - day: "2-digit", - month: "short", +const POLL_MS = 20_000; +/** Oltre questo scarto due messaggi dello stesso autore non si raggruppano più. */ +const GROUP_WINDOW_MS = 5 * 60_000; + +type PendingMessage = { + tempId: string; + channelKey: string; + body: string; + failed: boolean; +}; + +function toDate(value: Date | string): Date { + return value instanceof Date ? value : new Date(value); +} + +function formatTime(value: Date | string): string { + return toDate(value).toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit", }); } -interface ChatPanelProps { - token: string; - comments: Comment[]; +function formatDayDivider(value: Date | string): string { + const d = toDate(value); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + if (d.toDateString() === today.toDateString()) return "Oggi"; + if (d.toDateString() === yesterday.toDateString()) return "Ieri"; + return d.toLocaleDateString("it-IT", { + weekday: "long", + day: "numeric", + month: "long", + }); } -export function ChatPanel({ token, comments }: ChatPanelProps) { - const { isOpen, selectedPhaseId, phases, clientId, openChat, closeChat } = useChatContext(); +function monogram(label: string): string { + const parts = label.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); +} + +export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) { + const { + isOpen, + expanded, + activeChannel, + setActiveChannel, + phases, + clientId, + openChat, + closeChat, + toggleExpanded, + } = useChatContext(); const preview = usePreview(); + + const channels = useMemo(() => buildChannels(clientId, phases), [clientId, phases]); + const index = useMemo(() => buildChannelIndex(clientId, phases), [clientId, phases]); + + // `chat.messages` è la base che arriva dal server a ogni render RSC; `polled` + // raccoglie ciò che il poll e gli invii hanno imparato dopo. Si fondono per id, + // così quando il server recupera il ritardo i doppioni collassano da soli + // invece di comparire due volte. + const [polled, setPolled] = useState([]); + const [reads, setReads] = useState>(chat.reads); + const [pending, setPending] = useState([]); const [body, setBody] = useState(""); const [error, setError] = useState(null); - const [, startTransition] = useTransition(); - const router = useRouter(); + const bottomRef = useRef(null); + const fabRef = useRef(null); + const textareaRef = useRef(null); - // Build entity list: Generale + phases - type EntityOption = { id: string; type: "general" | "phase"; label: string }; - const entityOptions: EntityOption[] = [ - { id: clientId, type: "general", label: "Generale" }, - ...phases.map((p) => ({ id: p.id, type: "phase" as const, label: p.title })), - ]; + const messages = useMemo(() => { + const byId = new Map(); + for (const m of chat.messages) byId.set(m.id, m); + for (const m of polled) byId.set(m.id, m); + return [...byId.values()].sort( + (a, b) => toDate(a.created_at).getTime() - toDate(b.created_at).getTime() + ); + }, [chat.messages, polled]); - // Build label map for displaying tags on existing messages - const labelMap = new Map(); - labelMap.set(clientId, "Generale"); - for (const p of phases) { - labelMap.set(p.id, p.title); - for (const t of p.tasks) { - labelMap.set(t.id, `${p.title} — ${t.title}`); - for (const d of t.deliverables) { - labelMap.set(d.id, `${t.title} — ${d.title}`); + // ── Non letti per canale ──────────────────────────────────────────────────── + // Un canale ha novità se l'ultimo messaggio dell'admin è più recente di quando + // il cliente l'ha guardato. Senza ricevuta di lettura vale come mai letto. + const unreadChannels = useMemo(() => { + const lastAdminAt = new Map(); + for (const m of messages) { + if (m.author !== "admin") continue; + const key = channelOfComment(m, index, clientId); + const at = toDate(m.created_at).getTime(); + if (at > (lastAdminAt.get(key) ?? 0)) lastAdminAt.set(key, at); + } + const unread = new Set(); + for (const [key, at] of lastAdminAt) { + const readAt = reads[key]; + if (!readAt || at > new Date(readAt).getTime()) unread.add(key); + } + return unread; + }, [messages, reads, index, clientId]); + + const visibleMessages = useMemo( + () => messages.filter((m) => channelOfComment(m, index, clientId) === activeChannel), + [messages, index, clientId, activeChannel] + ); + + const visiblePending = useMemo( + () => pending.filter((p) => p.channelKey === activeChannel), + [pending, activeChannel] + ); + + const activeLabel = + channels.find((c) => c.key === activeChannel)?.label ?? channels[0]?.label ?? ""; + + // ── Polling ──────────────────────────────────────────────────────────────── + // `since` in una ref: se fosse una dipendenza dell'effetto, ogni messaggio + // nuovo smonterebbe e rimonterebbe l'intervallo, rimettendo il timer a zero. + const sinceRef = useRef(null); + useEffect(() => { + const latest = messages[messages.length - 1]; + sinceRef.current = latest ? toDate(latest.created_at).toISOString() : null; + }, [messages]); + + useEffect(() => { + // In anteprima admin non si scrive e non serve inseguire le novità. + if (!isOpen || preview) return; + + let aborted = false; + async function poll() { + if (document.visibilityState !== "visible") return; + const params = new URLSearchParams({ token, project_id: chat.projectId }); + if (sinceRef.current) params.set("since", sinceRef.current); + try { + const res = await fetch(`/api/client/chat?${params}`); + if (!res.ok || aborted) return; + const data = (await res.json()) as { + comments: ChatMessage[]; + reads: Record; + }; + if (aborted) return; + if (data.comments?.length) { + setPolled((prev) => [...prev, ...data.comments]); + } + if (data.reads) setReads((prev) => ({ ...prev, ...data.reads })); + } catch { + // Rete assente o richiesta caduta: si riprova al giro dopo, in silenzio. + // Un errore a schermo ogni 20 secondi sarebbe peggio del ritardo. } } - } - // Current selection: sync with selectedPhaseId from context - const [currentEntityId, setCurrentEntityId] = useState(clientId); - useEffect(() => { - setCurrentEntityId(selectedPhaseId ?? clientId); - }, [selectedPhaseId, clientId]); + const id = setInterval(poll, POLL_MS); + // Tornando sulla scheda si recupera subito, senza aspettare il tick. + const onVisible = () => { + if (document.visibilityState === "visible") poll(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + aborted = true; + clearInterval(id); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [isOpen, preview, token, chat.projectId]); - // Sort all comments chronologically - const sorted = [...comments].sort( - (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + // ── Ricevuta di lettura ──────────────────────────────────────────────────── + const markRead = useCallback( + async (channelKey: string) => { + // Ottimistico: il pallino sparisce appena guardi il tab, non dopo il POST. + setReads((prev) => ({ ...prev, [channelKey]: new Date().toISOString() })); + try { + await fetch("/api/client/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, channel_key: channelKey }), + }); + } catch { + // Se non passa, il pallino torna al prossimo poll. Non vale un errore. + } + }, + [token] ); useEffect(() => { - if (isOpen) { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + if (!isOpen || preview) return; + if (!unreadChannels.has(activeChannel)) return; + const id = setTimeout(() => markRead(activeChannel), 400); + return () => clearTimeout(id); + }, [isOpen, preview, activeChannel, unreadChannels, markRead]); + + // ── Scroll, focus, tastiera ──────────────────────────────────────────────── + useEffect(() => { + if (isOpen) bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [isOpen, activeChannel, visibleMessages.length, visiblePending.length]); + + useEffect(() => { + if (isOpen && !preview) textareaRef.current?.focus(); + }, [isOpen, preview]); + + useEffect(() => { + if (!isOpen) return; + function onKey(e: KeyboardEvent) { + if (e.key !== "Escape") return; + // Esc scala di un livello per volta: prima esce dal tutto schermo, poi chiude. + if (expanded) toggleExpanded(); + else { + closeChat(); + fabRef.current?.focus(); + } } - }, [isOpen, comments.length]); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [isOpen, expanded, toggleExpanded, closeChat]); - function handleSend(e: React.FormEvent) { - e.preventDefault(); - const trimmed = body.trim(); - if (!trimmed) return; + function handleClose() { + closeChat(); + fabRef.current?.focus(); + } - const selectedEntity = entityOptions.find((en) => en.id === currentEntityId); - if (!selectedEntity) return; - - setError(null); - startTransition(async () => { + // ── Invio ────────────────────────────────────────────────────────────────── + const send = useCallback( + async (text: string, channelKey: string, tempId: string) => { + const isGeneral = channelKey === clientId; try { const res = await fetch("/api/client/comment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, - entity_type: selectedEntity.type, - entity_id: selectedEntity.id, - body: trimmed, + entity_type: isGeneral ? "general" : "phase", + entity_id: channelKey, + body: text, }), }); if (!res.ok) { - const data = await res.json(); + const data = await res.json().catch(() => ({})); setError(data.error ?? "Errore nell'invio"); + setPending((prev) => + prev.map((p) => (p.tempId === tempId ? { ...p, failed: true } : p)) + ); return; } - setBody(""); - router.refresh(); + const data = (await res.json()) as { comment?: ChatMessage }; + // La riga vera prende il posto della copia locale: stesso id del server, + // così il prossimo render RSC non la duplica. + if (data.comment) setPolled((prev) => [...prev, data.comment!]); + setPending((prev) => prev.filter((p) => p.tempId !== tempId)); } catch { setError("Errore di rete"); + setPending((prev) => + prev.map((p) => (p.tempId === tempId ? { ...p, failed: true } : p)) + ); } - }); + }, + [token, clientId] + ); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = body.trim(); + if (!trimmed) return; + const tempId = `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`; + setError(null); + setPending((prev) => [ + ...prev, + { tempId, channelKey: activeChannel, body: trimmed, failed: false }, + ]); + setBody(""); + void send(trimmed, activeChannel, tempId); } + function retry(item: PendingMessage) { + setError(null); + setPending((prev) => + prev.map((p) => (p.tempId === item.tempId ? { ...p, failed: false } : p)) + ); + void send(item.body, item.channelKey, item.tempId); + } + + function discard(tempId: string) { + setPending((prev) => prev.filter((p) => p.tempId !== tempId)); + setError(null); + } + + const hasAnyUnread = unreadChannels.size > 0; + // Un solo canale (cliente a retainer, senza fasi): la striscia di tab non + // aggiungerebbe nulla e ruberebbe altezza al feed. + const showTabs = channels.length > 1; + return ( <> - {/* FAB — floating action button bottom-right */} + {/* FAB */} (isOpen ? closeChat() : openChat())} - aria-label="Apri messaggi" - className="fixed bottom-6 right-6 z-40 w-14 h-14 rounded-full bg-[#1A463C] text-white shadow-lg hover:bg-[#163a31] transition-colors flex items-center justify-center" + onClick={() => (isOpen ? handleClose() : openChat(activeChannel))} + aria-label={isOpen ? "Chiudi messaggi" : "Apri messaggi"} + aria-expanded={isOpen} + className="fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" > - {isOpen ? ( - /* X close icon */ - - - - ) : ( - /* Chat bubble + plus SVG */ - - - + {isOpen ? : } + {!isOpen && hasAnyUnread && ( + )} + {!isOpen && hasAnyUnread && Ci sono messaggi non letti} - {/* Overlay — subtle backdrop on mobile */} + {/* Backdrop: solo dove il pannello copre davvero la pagina */} {isOpen && ( )} - {/* Slide-in panel — z-[60] so it sits above the sticky page header (z-50) */} + {/* Pannello. Larghezza animata invece che inset: `left` da auto a 0 non + transiziona, e il salto si vedrebbe a ogni espansione. */} - {/* Vertical "Chiudi" tab — notebook-divider style, protrudes from the left edge */} - - - - - - Chiudi - - - - {/* Panel header */} - - Messaggi & Revisioni - - - {/* Chat feed */} - - {sorted.length === 0 && ( - - Nessun messaggio ancora. Scrivi qui sotto per iniziare. - - )} - {sorted.map((c) => { - const isClient = c.author === "client"; - const entityLabel = labelMap.get(c.entity_id); - const showTag = c.entity_id !== clientId && entityLabel; - - return ( - - {/* Author + tag */} - - - {isClient ? "Tu" : "iamcavalli"} - - {showTag && ( - - {entityLabel} - - )} - - - {/* Bubble */} - - {c.body} - - - {/* Timestamp */} - {formatTime(c.created_at)} - - ); - })} - - - - {/* Divider */} - - - {/* Composer — in anteprima admin lo storico resta visibile (serve proprio - a vedere cosa legge il cliente) ma non si può scrivere al posto suo. - Colori in hex come nel resto del pannello: questo componente è - un'isola forzata a bg-white, i token semantici qui renderebbero - testo chiaro su fondo chiaro in dark mode. */} - {preview ? ( - - Composer disattivato in anteprima + {/* Header */} + + + + + {activeLabel} + - ) : ( - - {/* Tag selector: Generale + phases */} - setCurrentEntityId(e.target.value)} - className="w-full text-xs border border-[#e5e7eb] rounded-lg px-3 py-1.5 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30" - > - {entityOptions.map((en) => ( - - {en.label} - - ))} - - - - setBody(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(e as unknown as React.FormEvent); - } - }} - placeholder="Scrivi un messaggio… (Invio per inviare)" - rows={2} - className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30" - /> + + {/* Su mobile il pannello è già a piena larghezza: il tasto non avrebbe effetto. */} - Invia + {expanded ? : } + + + - {error && {error}} - + + + {/* Tab dei canali */} + {showTabs && ( + + + {channels.map((channel) => { + const active = channel.key === activeChannel; + const unread = unreadChannels.has(channel.key); + return ( + setActiveChannel(channel.key)} + title={channel.label} + className={cn( + "flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring", + active + ? "bg-muted font-semibold text-foreground" + : "font-medium text-muted-foreground hover:text-foreground" + )} + > + {channel.label} + {unread && !active && ( + <> + + non letti + > + )} + + ); + })} + + )} + + {/* Feed */} + + {/* A tutto schermo si allarga il contenitore, non la misura del testo: + una riga larga 2000px non si legge. */} + + {visibleMessages.length === 0 && visiblePending.length === 0 ? ( + + Nessun messaggio in questo canale. Scrivi qui sotto per iniziare. + + ) : ( + + )} + + {visiblePending.map((item) => ( + + + + + Tu + + {item.failed ? "non inviato" : "invio…"} + + + + {item.body} + + {item.failed && ( + + retry(item)} + className="text-[11px] font-semibold text-primary hover:underline" + > + Riprova + + discard(item.tempId)} + className="text-[11px] font-medium text-muted-foreground hover:underline" + > + Elimina + + + )} + + + ))} + + + + + {/* Composer */} + + + {preview ? ( + + Composer disattivato in anteprima + + ) : ( + + {/* Nessun selettore: il canale è il tab attivo. Prima andava + scelto due volte, qui e nella timeline. */} + + setBody(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }} + rows={2} + placeholder={`Scrivi in ${activeLabel}… (Invio per inviare)`} + aria-label={`Scrivi un messaggio in ${activeLabel}`} + className="w-full resize-none bg-transparent px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" + /> + + + Shift + Invio per andare a capo + + + Invia + + + + {error && {error}} + + )} + + > ); } + +// ── Feed ───────────────────────────────────────────────────────────────────── + +function MessageList({ + messages, + index, + activeChannel, + clientId, +}: { + messages: ChatMessage[]; + index: ReturnType; + activeChannel: string; + clientId: string; +}) { + // Divider di giornata e raggruppamento si calcolano in un passaggio solo, + // prima del JSX: dentro una .map() servirebbero variabili riassegnate a ogni + // giro, che il compilatore React (giustamente) rifiuta. + const items = messages.map((m, i) => { + const at = toDate(m.created_at); + const prev = i > 0 ? messages[i - 1] : null; + const prevAt = prev ? toDate(prev.created_at) : null; + const showDivider = !prevAt || at.toDateString() !== prevAt.toDateString(); + // Messaggi consecutivi dello stesso autore a breve distanza: si ripete solo + // il testo, non nome e avatar. Meno rumore, stessa informazione. + const grouped = + !showDivider && + !!prev && + m.author === prev.author && + at.getTime() - (prevAt as Date).getTime() < GROUP_WINDOW_MS; + return { message: m, at, showDivider, grouped }; + }); + + return ( + <> + {items.map(({ message: m, at, showDivider, grouped }) => { + const isClient = m.author === "client"; + const name = isClient ? "Tu" : "iamcavalli"; + // Dentro un canale-fase, i messaggi storici su un task o un deliverable + // restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano. + const entityLabel = + activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined; + + return ( + + {showDivider && ( + + + + {formatDayDivider(at)} + + + + )} + + {grouped ? ( + + ) : ( + + )} + + {!grouped && ( + + {name} + {entityLabel && ( + + {entityLabel} + + )} + {formatTime(at)} + + )} + + {m.body} + + + + + ); + })} + > + ); +} + +function Avatar({ label, tone }: { label: string; tone: "client" | "admin" }) { + return ( + + {monogram(label)} + + ); +} diff --git a/src/components/client/ChatProvider.tsx b/src/components/client/ChatProvider.tsx index 935da31..137844a 100644 --- a/src/components/client/ChatProvider.tsx +++ b/src/components/client/ChatProvider.tsx @@ -1,15 +1,27 @@ "use client"; -import { createContext, useContext, useState, useCallback } from "react"; +import { createContext, useContext, useState, useCallback, useMemo } from "react"; import type { ClientView } from "@/lib/client-view"; +/** + * Stato di sola UI della chat: quale canale è aperto, se il pannello è visibile, + * se è a tutto schermo. I messaggi NON stanno qui — vivono in ChatPanel, che è + * l'unico a leggerli, a fare polling e a tenerne la copia optimistic. + * + * Il canale sta invece nel context perché lo decide anche chi è fuori dal + * pannello: la bolla su una PhaseCard apre la chat già sulla fase giusta. + */ interface ChatContextValue { isOpen: boolean; - selectedPhaseId: string | null; // null = "Generale" + expanded: boolean; + /** Chiave del canale attivo: clientId per "Generale", phases.id per una fase. */ + activeChannel: string; phases: ClientView["phases"]; clientId: string; openChat: (phaseId?: string) => void; closeChat: () => void; + toggleExpanded: () => void; + setActiveChannel: (key: string) => void; } const ChatContext = createContext(null); @@ -30,20 +42,36 @@ export function ChatProvider({ clientId: string; }) { const [isOpen, setIsOpen] = useState(false); - const [selectedPhaseId, setSelectedPhaseId] = useState(null); + const [expanded, setExpanded] = useState(false); + // Il canale "Generale" è identificato dall'id del cliente: stessa convenzione + // di comments.entity_id, così non serve tradurre nulla in scrittura. + const [activeChannel, setActiveChannel] = useState(clientId); - const openChat = useCallback((phaseId?: string) => { - setSelectedPhaseId(phaseId ?? null); - setIsOpen(true); - }, []); - - const closeChat = useCallback(() => { - setIsOpen(false); - }, []); - - return ( - - {children} - + const openChat = useCallback( + (phaseId?: string) => { + setActiveChannel(phaseId ?? clientId); + setIsOpen(true); + }, + [clientId] ); + + const closeChat = useCallback(() => setIsOpen(false), []); + const toggleExpanded = useCallback(() => setExpanded((v) => !v), []); + + const value = useMemo( + () => ({ + isOpen, + expanded, + activeChannel, + phases, + clientId, + openChat, + closeChat, + toggleExpanded, + setActiveChannel, + }), + [isOpen, expanded, activeChannel, phases, clientId, openChat, closeChat, toggleExpanded] + ); + + return {children}; } diff --git a/src/db/migrations/0021_chat_channels.sql b/src/db/migrations/0021_chat_channels.sql new file mode 100644 index 0000000..8cc63a2 --- /dev/null +++ b/src/db/migrations/0021_chat_channels.sql @@ -0,0 +1,36 @@ +-- Additive: chat a canali nel portale cliente (Generale + una per fase). +-- +-- Il modello resta quello polimorfico di `comments` (entity_type + entity_id): +-- nessuna colonna nuova lì, il "canale" è derivato — general => clients.id, +-- phase => phases.id, mentre task e deliverable rientrano nel canale della fase +-- proprietaria. Qui si aggiunge solo ciò che il modello non sa esprimere: fino a +-- dove il CLIENTE ha letto ciascun canale. +-- +-- Perché una tabella e non una colonna su clients: un singolo +-- client_last_read_at segnerebbe letti TUTTI i canali all'apertura della chat, +-- e il pallino sul singolo tab — l'unica cosa che dice al cliente dove +-- guardare — perderebbe senso. L'admin resta invece su clients.admin_last_read_at +-- (vedi 0014): lì la lettura è già a livello di conversazione e il pallino per +-- canale si deriva da quel timestamp, quindi non serve nulla di nuovo. +-- +-- channel_key non ha FK: vale clients.id per "Generale" e phases.id per le fasi. +-- Una fase cancellata lascia una riga orfana, innocua e ignorata in lettura. +-- +-- Apply to prod via SSH+docker exec BEFORE pushing schema-dependent code. +-- No drops, no truncates, no data loss. + +CREATE TABLE IF NOT EXISTS client_channel_reads ( + id text PRIMARY KEY, + client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + channel_key text NOT NULL, + read_at timestamptz NOT NULL DEFAULT now() +); + +-- Upsert target: ON CONFLICT (client_id, channel_key) DO UPDATE SET read_at = now() +CREATE UNIQUE INDEX IF NOT EXISTS client_channel_reads_client_channel_idx + ON client_channel_reads (client_id, channel_key); + +-- Il feed ora si legge filtrato per entità e ordinato nel tempo: comments non ha +-- mai avuto un indice (nemmeno su entity_id) dal giorno 0. +CREATE INDEX IF NOT EXISTS comments_entity_created_idx + ON comments (entity_id, created_at); diff --git a/src/db/schema.ts b/src/db/schema.ts index 9a3ac4b..01f3567 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -165,7 +165,11 @@ export const comments = pgTable("comments", { id: text("id") .primaryKey() .$defaultFn(() => nanoid()), - entity_type: text("entity_type").notNull(), // task | deliverable + // general | phase | task | deliverable. Il canale della chat si deriva da qui: + // general => entity_id è clients.id, phase => phases.id. Task e deliverable non + // sono più scrivibili da nessuna UI, ma lo storico si legge ancora e rientra nel + // canale della fase proprietaria — vedi src/lib/chat-channels.ts. + entity_type: text("entity_type").notNull(), entity_id: text("entity_id").notNull(), author: text("author").notNull(), // client | admin body: text("body").notNull(), @@ -174,6 +178,32 @@ export const comments = pgTable("comments", { .defaultNow(), }); +// Fin dove il CLIENTE ha letto ciascun canale della chat. Una riga per +// (client_id, channel_key); channel_key è clients.id per "Generale" e phases.id +// per le fasi — nessuna FK, è polimorfico come comments. +// +// L'admin NON usa questa tabella: la sua lettura resta clients.admin_last_read_at +// (a livello di conversazione), e il pallino per canale si deriva da lì. +export const client_channel_reads = pgTable( + "client_channel_reads", + { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + channel_key: text("channel_key").notNull(), + read_at: timestamp("read_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("client_channel_reads_client_channel_idx").on( + table.client_id, + table.channel_key + ), + ] +); + // ============ TAGS (polymorphic — services now, leads in Phase 14) ============ // entity_type scopes the tag pool (D-06): "services" tags and "leads" tags are // separate pools even though they share this table. No `color` column — badge @@ -1001,6 +1031,16 @@ export const commentsRelations = relations(comments, (_) => ({ // Polymorphic: no direct FK relation — entity_type + entity_id used at query time })); +export const clientChannelReadsRelations = relations( + client_channel_reads, + ({ one }) => ({ + client: one(clients, { + fields: [client_channel_reads.client_id], + references: [clients.id], + }), + }) +); + export const tagsRelations = relations(tags, (_) => ({ // Polymorphic: no direct FK relation — entity_type + entity_id used at query time })); @@ -1151,6 +1191,8 @@ export type Deliverable = typeof deliverables.$inferSelect; export type NewDeliverable = typeof deliverables.$inferInsert; export type Comment = typeof comments.$inferSelect; export type NewComment = typeof comments.$inferInsert; +export type ClientChannelRead = typeof client_channel_reads.$inferSelect; +export type NewClientChannelRead = typeof client_channel_reads.$inferInsert; export type Tag = typeof tags.$inferSelect; export type NewTag = typeof tags.$inferInsert; export type Payment = typeof payments.$inferSelect; diff --git a/src/lib/chat-channels.ts b/src/lib/chat-channels.ts new file mode 100644 index 0000000..59b1ef4 --- /dev/null +++ b/src/lib/chat-channels.ts @@ -0,0 +1,131 @@ +/** + * Canali della chat — logica condivisa tra portale cliente e inbox admin. + * + * `comments` è polimorfica (entity_type + entity_id) e non conosce il concetto di + * "canale": lo deriviamo qui, in un solo posto, perché le due sponde devono + * concordare al carattere. Se cliente e admin calcolassero il canale ognuno per + * conto suo, una risposta finirebbe in un tab diverso da quello in cui è stata + * scritta la domanda — che è esattamente il bug che questa riorganizzazione chiude. + * + * Convenzione delle chiavi: + * "Generale" -> clients.id + * una fase -> phases.id + * task -> la chiave della fase proprietaria (tasks.phase_id) + * deliverable -> la chiave della fase del task proprietario + * + * Task e deliverable non sono più scrivibili da nessuna UI, ma lo storico esiste: + * rientra nel canale della fase invece di restare orfano, conservando il nome + * dell'entità come badge sul messaggio. + * + * Funzioni pure: nessun accesso al DB, nessun import di server-only. + */ + +export const GENERAL_LABEL = "Generale"; + +export type ChatChannel = { + /** clients.id per "Generale", phases.id per le fasi. */ + key: string; + type: "general" | "phase"; + label: string; +}; + +type PhaseLike = { + id: string; + title: string; + tasks?: ReadonlyArray; +}; + +type TaskLike = { + id: string; + title?: string; + deliverables?: ReadonlyArray<{ id: string; title?: string }>; +}; + +export type ChannelIndex = { + /** entity_id -> chiave del canale. */ + channelOf: ReadonlyMap; + /** + * entity_id -> nome dell'entità, popolato SOLO per task e deliverable: dentro + * un canale-fase serve a distinguere "su cosa" era il messaggio storico. + * Vuoto per general e phase, dove l'etichetta è già il nome del tab. + */ + entityLabel: ReadonlyMap; +}; + +/** L'elenco dei tab: Generale in testa, poi le fasi nell'ordine ricevuto. */ +export function buildChannels( + clientId: string, + phases: ReadonlyArray<{ id: string; title: string }> +): ChatChannel[] { + return [ + { key: clientId, type: "general", label: GENERAL_LABEL }, + ...phases.map((p) => ({ + key: p.id, + type: "phase" as const, + // Una fase senza titolo romperebbe il tab: meglio un'etichetta muta che vuota. + label: p.title?.trim() || "Fase senza titolo", + })), + ]; +} + +/** Mappa ogni entità del progetto al canale in cui i suoi messaggi vanno letti. */ +export function buildChannelIndex( + clientId: string, + phases: ReadonlyArray +): ChannelIndex { + const channelOf = new Map(); + const entityLabel = new Map(); + + channelOf.set(clientId, clientId); + + for (const phase of phases) { + channelOf.set(phase.id, phase.id); + for (const task of phase.tasks ?? []) { + channelOf.set(task.id, phase.id); + if (task.title) entityLabel.set(task.id, task.title); + for (const deliverable of task.deliverables ?? []) { + channelOf.set(deliverable.id, phase.id); + if (deliverable.title) entityLabel.set(deliverable.id, deliverable.title); + } + } + } + + return { channelOf, entityLabel }; +} + +/** + * Canale di un messaggio. Il fallback su "Generale" non è cosmetico: se una fase + * viene cancellata, i suoi messaggi resterebbero senza tab e sparirebbero dalla + * vista senza alcun errore. Meglio farli riemergere in Generale. + */ +export function channelOfComment( + comment: { entity_id: string }, + index: ChannelIndex, + clientId: string +): string { + return index.channelOf.get(comment.entity_id) ?? clientId; +} + +/** + * Forma strutturale di un messaggio, compatibile sia con la riga Drizzle + * (`created_at: Date`) sia con la stessa riga passata via JSON dal poll + * (`created_at: string`). Evita il cast `as unknown as Comment[]` che serviva + * per far entrare i comment nel tipo legacy ClientView. + */ +export type ChatMessage = { + id: string; + entity_type: string; + entity_id: string; + author: string; + body: string; + created_at: Date | string; +}; + +/** Tutto ciò che il pannello riceve dal server al primo render. */ +export type ChatData = { + /** Serve al poll: la chat è per progetto, come getProjectView. */ + projectId: string; + messages: ChatMessage[]; + /** channel_key -> ISO dell'ultima lettura del cliente. */ + reads: Record; +}; diff --git a/src/lib/client-chat.ts b/src/lib/client-chat.ts new file mode 100644 index 0000000..9a17f0a --- /dev/null +++ b/src/lib/client-chat.ts @@ -0,0 +1,127 @@ +import { eq, and, inArray } from "drizzle-orm"; +import { db } from "@/db"; +import { clients, projects, phases, tasks, deliverables } from "@/db/schema"; + +/** + * Autorizzazione della chat lato cliente, in un solo posto. + * + * Prima queste stesse regole vivevano inline dentro POST /api/client/comment; + * ora le route sono tre (scrittura, poll, ricevuta di lettura) e duplicarle + * sarebbe il modo più rapido per farle divergere. + * + * Nota sul modello di autenticazione: qui si valida SOLO il token del portale, + * non il cookie di sessione OTP. È il comportamento pre-esistente, condiviso con + * /api/client/approve — chi ha il link scrive. Allineare tutte le route client al + * gate OTP è un lavoro a sé: queste funzioni non lo peggiorano né lo risolvono. + */ + +/** clients.id dal token del portale, o null se il token non esiste. */ +export async function resolveClientByToken(token: string): Promise { + const rows = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.token, token)) + .limit(1); + return rows[0]?.id ?? null; +} + +/** + * L'entità appartiene davvero a questo cliente? + * + * Una join mirata per tipo invece delle quattro scansioni "prendi tutti i + * progetti, tutte le fasi, tutti i task" che faceva la route: stesso esito, una + * query sola, e non cresce col numero di task del cliente. + */ +export async function assertClientOwnsEntity( + clientId: string, + entityType: "general" | "phase" | "task" | "deliverable", + entityId: string +): Promise { + if (entityType === "general") { + // I messaggi generali sono ancorati al cliente stesso. + return entityId === clientId; + } + + if (entityType === "phase") { + const rows = await db + .select({ id: phases.id }) + .from(phases) + .innerJoin(projects, eq(phases.project_id, projects.id)) + .where(and(eq(phases.id, entityId), eq(projects.client_id, clientId))) + .limit(1); + return rows.length > 0; + } + + if (entityType === "task") { + const rows = await db + .select({ id: tasks.id }) + .from(tasks) + .innerJoin(phases, eq(tasks.phase_id, phases.id)) + .innerJoin(projects, eq(phases.project_id, projects.id)) + .where(and(eq(tasks.id, entityId), eq(projects.client_id, clientId))) + .limit(1); + return rows.length > 0; + } + + const rows = await db + .select({ id: deliverables.id }) + .from(deliverables) + .innerJoin(tasks, eq(deliverables.task_id, tasks.id)) + .innerJoin(phases, eq(tasks.phase_id, phases.id)) + .innerJoin(projects, eq(phases.project_id, projects.id)) + .where(and(eq(deliverables.id, entityId), eq(projects.client_id, clientId))) + .limit(1); + return rows.length > 0; +} + +/** + * Gli entity_id di cui la chat di UN progetto deve leggere i messaggi: + * il cliente stesso (canale Generale) più fasi, task e deliverable del progetto. + * + * Deve restare allineato allo scope di getProjectView (client-view.ts): se il + * poll leggesse più entità del primo render, comparirebbero messaggi che nessun + * tab sa dove mettere; se ne leggesse meno, le risposte non arriverebbero mai. + * + * Ritorna null se il progetto non è di questo cliente. + */ +export async function getProjectChatScope( + clientId: string, + projectId: string +): Promise { + const projectRows = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.client_id, clientId))) + .limit(1); + if (projectRows.length === 0) return null; + + const phaseRows = await db + .select({ id: phases.id }) + .from(phases) + .where(eq(phases.project_id, projectId)); + const phaseIds = phaseRows.map((p) => p.id); + + const taskRows = + phaseIds.length === 0 + ? [] + : await db + .select({ id: tasks.id }) + .from(tasks) + .where(inArray(tasks.phase_id, phaseIds)); + const taskIds = taskRows.map((t) => t.id); + + const deliverableRows = + taskIds.length === 0 + ? [] + : await db + .select({ id: deliverables.id }) + .from(deliverables) + .where(inArray(deliverables.task_id, taskIds)); + + return [ + clientId, + ...phaseIds, + ...taskIds, + ...deliverableRows.map((d) => d.id), + ]; +} diff --git a/src/lib/client-view.ts b/src/lib/client-view.ts index c021514..9975041 100644 --- a/src/lib/client-view.ts +++ b/src/lib/client-view.ts @@ -1,6 +1,6 @@ import { eq, ne, and, inArray, asc, desc } from "drizzle-orm"; import { db } from "@/db"; -import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_macros, clientTranscripts } from "@/db/schema"; +import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, client_channel_reads, project_offers, offer_micros, offer_macros, clientTranscripts } from "@/db/schema"; import type { TaskStatus } from "@/lib/task-status"; import { getComputedOfferValues } from "@/lib/offer-value"; @@ -128,6 +128,12 @@ export interface ProjectView { body: string; created_at: Date; }>; + /** + * Fin dove il cliente ha letto ciascun canale della chat: channel_key -> ISO. + * Serve al primo paint — senza, i pallini di "non letto" comparirebbero solo + * dopo il primo poll, cioè fino a 20 secondi dopo l'apertura della pagina. + */ + channel_reads: Record; global_progress_pct: number; activeOffers?: Array<{ id: string; @@ -425,6 +431,11 @@ export async function getProjectView(projectId: string): Promise { const phaseTasks = tasksRows .filter((t) => t.phase_id === phase.id) @@ -456,6 +467,9 @@ export async function getProjectView(projectId: string): Promise [r.channel_key, r.read_at.toISOString()]) + ), global_progress_pct, activeOffers: activeOffers.length > 0 ? activeOffers : undefined, transcripts: transcriptsRows, diff --git a/src/lib/conversations-queries.ts b/src/lib/conversations-queries.ts index 801776c..07c9fb4 100644 --- a/src/lib/conversations-queries.ts +++ b/src/lib/conversations-queries.ts @@ -1,7 +1,9 @@ +import { cache } from "react"; import { db } from "@/db"; import { clients, projects, phases, tasks, deliverables, comments } from "@/db/schema"; import { eq, inArray, asc } from "drizzle-orm"; import type { Comment } from "@/db/schema"; +import { buildChannels, type ChatChannel } from "@/lib/chat-channels"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -26,6 +28,13 @@ export type ThreadMessage = { created_at: Date; entityLabel: string; entityType: string; + /** + * Canale in cui il messaggio va letto: clientId per "Generale", phases.id per + * una fase. Task e deliverable rientrano nella fase proprietaria — la regola + * sta in chat-channels.ts, condivisa col portale, perché le due sponde devono + * mettere lo stesso messaggio nello stesso tab. + */ + channelKey: string; }; export type ConversationThread = { @@ -35,6 +44,14 @@ export type ConversationThread = { token: string; slug: string | null; messages: ThreadMessage[]; + /** Generale + tutte le fasi del cliente (di tutti i suoi progetti). */ + channels: ChatChannel[]; + /** + * Serve alla vista per capire QUALI canali avevano novità al momento + * dell'apertura: aprire la conversazione la segna letta subito, quindi dopo il + * refresh il dato non sarebbe più ricostruibile. + */ + adminLastReadAt: Date | null; }; type ClientMeta = { @@ -46,7 +63,7 @@ type ClientMeta = { admin_last_read_at: Date | null; }; -type EntityInfo = { clientId: string; label: string; type: string }; +type EntityInfo = { clientId: string; label: string; type: string; channelKey: string }; // ── Shared aggregation ────────────────────────────────────────────────────────── // Builds the entity_id → owning client map by walking @@ -54,13 +71,18 @@ type EntityInfo = { clientId: string; label: string; type: string }; // under its owning client. Same pattern as admin-queries.getClientFullDetail, // but across ALL non-archived clients at once (cross-client inbox). -async function buildEntityMap(): Promise<{ +// cache(): senza, questa funzione gira quattro volte per render della pagina +// admin — getConversations, getConversationThread, il badge nel layout e +// InboxBand — e ogni giro sono cinque scansioni su tutto il DB. +const buildEntityMap = cache(async function buildEntityMap(): Promise<{ clientMeta: Map; entityMap: Map; allEntityIds: string[]; + phasesByClient: Map; }> { const clientMeta = new Map(); const entityMap = new Map(); + const phasesByClient = new Map(); const clientRows = await db .select() @@ -77,12 +99,17 @@ async function buildEntityMap(): Promise<{ admin_last_read_at: c.admin_last_read_at, }); // A client's own id is the entity_id for "general" messages. - entityMap.set(c.id, { clientId: c.id, label: "Generale", type: "general" }); + entityMap.set(c.id, { + clientId: c.id, + label: "Generale", + type: "general", + channelKey: c.id, + }); } const clientIds = clientRows.map((c) => c.id); if (clientIds.length === 0) { - return { clientMeta, entityMap, allEntityIds: [] }; + return { clientMeta, entityMap, allEntityIds: [], phasesByClient }; } const projectRows = await db @@ -96,13 +123,24 @@ async function buildEntityMap(): Promise<{ const phaseRows = await db .select({ id: phases.id, project_id: phases.project_id, title: phases.title }) .from(phases) - .where(inArray(phases.project_id, projectIds)); + .where(inArray(phases.project_id, projectIds)) + // I tab dei canali devono uscire nello stesso ordine in cui il cliente + // vede le fasi nella sua timeline. + .orderBy(asc(phases.project_id), asc(phases.sort_order)); const phaseToClient = new Map(); for (const ph of phaseRows) { const clientId = projectToClient.get(ph.project_id); if (!clientId) continue; phaseToClient.set(ph.id, clientId); - entityMap.set(ph.id, { clientId, label: `Fase: ${ph.title}`, type: "phase" }); + entityMap.set(ph.id, { + clientId, + label: `Fase: ${ph.title}`, + type: "phase", + channelKey: ph.id, + }); + const list = phasesByClient.get(clientId) ?? []; + list.push({ id: ph.id, title: ph.title }); + phasesByClient.set(clientId, list); } const phaseIds = phaseRows.map((p) => p.id); @@ -116,7 +154,13 @@ async function buildEntityMap(): Promise<{ const clientId = phaseToClient.get(t.phase_id); if (!clientId) continue; taskToClient.set(t.id, clientId); - entityMap.set(t.id, { clientId, label: `Task: ${t.title}`, type: "task" }); + entityMap.set(t.id, { + clientId, + label: `Task: ${t.title}`, + type: "task", + // Un task non è un canale: i suoi messaggi si leggono nella sua fase. + channelKey: t.phase_id, + }); } const taskIds = taskRows.map((t) => t.id); @@ -132,14 +176,21 @@ async function buildEntityMap(): Promise<{ clientId, label: `Deliverable: ${d.title}`, type: "deliverable", + // Come per i task: il canale è la fase del task proprietario. + channelKey: entityMap.get(d.task_id)?.channelKey ?? clientId, }); } } } } - return { clientMeta, entityMap, allEntityIds: [...entityMap.keys()] }; -} + return { + clientMeta, + entityMap, + allEntityIds: [...entityMap.keys()], + phasesByClient, + }; +}); // Groups comments (already sorted asc) by owning client id. function groupCommentsByClient( @@ -208,7 +259,7 @@ export async function getConversations(): Promise { export async function getConversationThread( clientId: string ): Promise { - const { clientMeta, entityMap, allEntityIds } = await buildEntityMap(); + const { clientMeta, entityMap, allEntityIds, phasesByClient } = await buildEntityMap(); const meta = clientMeta.get(clientId); if (!meta) return null; @@ -227,6 +278,9 @@ export async function getConversationThread( created_at: c.created_at, entityLabel: info?.label ?? "Generale", entityType: info?.type ?? "general", + // Entità sparita (fase cancellata): il messaggio riemerge in Generale + // invece di restare senza tab e sparire dalla vista senza errore. + channelKey: info?.channelKey ?? clientId, }; }); @@ -237,6 +291,8 @@ export async function getConversationThread( token: meta.token, slug: meta.slug, messages, + channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []), + adminLastReadAt: meta.admin_last_read_at, }; }
- Nessun messaggio in questa conversazione. + Nessun messaggio in questo canale.
- Nessun messaggio ancora. Scrivi qui sotto per iniziare. -
{error}
+ Nessun messaggio in questo canale. Scrivi qui sotto per iniziare. +
+ {item.body} +
+ Composer disattivato in anteprima +
+ {m.body} +