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 ( + + ); + })} +
+ )} + {/* 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 */}
+ +