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>
This commit is contained in:
@@ -3,30 +3,46 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, comments } from "@/db/schema";
|
import { clients, comments } from "@/db/schema";
|
||||||
|
import { assertClientOwnsEntity } from "@/lib/client-chat";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) throw new Error("Non autorizzato");
|
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
|
* Risposta dell'admin dall'inbox Conversazioni, sul canale da cui si sta
|
||||||
* saved as a "general" comment on the client (entity_id = clientId), so they
|
* scrivendo.
|
||||||
* surface in the client's general chat.
|
|
||||||
*
|
*
|
||||||
* Questa è l'UNICA via di risposta dell'admin da quando il tab Commenti del
|
* Fino alla chat a canali l'admin poteva rispondere SOLO sul thread generale:
|
||||||
* progetto è stato rimosso: i messaggi su fase/task/deliverable si leggono qui
|
* i messaggi su fase/task/deliverable si leggevano con la loro etichetta ma la
|
||||||
* con la loro etichetta, ma la risposta torna sempre sul thread generale.
|
* 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) {
|
export async function replyToConversation(clientId: string, formData: FormData) {
|
||||||
await requireAdmin();
|
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
|
const rows = await db
|
||||||
.select({ id: clients.id })
|
.select({ id: clients.id })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -34,9 +50,14 @@ export async function replyToConversation(clientId: string, formData: FormData)
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (rows.length === 0) throw new Error("Cliente non trovato");
|
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({
|
await db.insert(comments).values({
|
||||||
entity_type: "general",
|
entity_type,
|
||||||
entity_id: clientId,
|
entity_id,
|
||||||
author: "admin",
|
author: "admin",
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/db";
|
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 { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { resolveClientByToken, assertClientOwnsEntity } from "@/lib/client-chat";
|
||||||
|
|
||||||
const commentSchema = z.object({
|
const commentSchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
@@ -31,84 +31,29 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const { token, entity_type, entity_id, body: commentBody } = parsed.data;
|
const { token, entity_type, entity_id, body: commentBody } = parsed.data;
|
||||||
|
|
||||||
// Validate token
|
const clientId = await resolveClientByToken(token);
|
||||||
const clientRows = await db
|
if (!clientId) {
|
||||||
.select({ id: clients.id })
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.token, token))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (clientRows.length === 0) {
|
|
||||||
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = clientRows[0].id;
|
const owns = await assertClientOwnsEntity(clientId, entity_type, entity_id);
|
||||||
|
if (!owns) {
|
||||||
if (entity_type === "general") {
|
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(comments).values({
|
// Il messaggio torna indietro: il pannello lo usa per sostituire la copia
|
||||||
entity_type,
|
// optimistic con la riga vera (stesso id, stesso created_at del server).
|
||||||
entity_id,
|
const [created] = await db
|
||||||
author: "client",
|
.insert(comments)
|
||||||
body: commentBody,
|
.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) {
|
} catch (err) {
|
||||||
console.error("/api/client/comment error:", err);
|
console.error("/api/client/comment error:", err);
|
||||||
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
|
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { ClientDashboard } from "@/components/client-dashboard";
|
|||||||
import { OtpGate } from "@/components/client/OtpGate";
|
import { OtpGate } from "@/components/client/OtpGate";
|
||||||
import { PreviewBanner } from "@/components/client/PreviewBanner";
|
import { PreviewBanner } from "@/components/client/PreviewBanner";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type { Comment } from "@/db/schema";
|
|
||||||
import { normalizeTaskStatus } from "@/lib/task-status";
|
import { normalizeTaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
@@ -147,7 +146,11 @@ export default async function ClientPage({
|
|||||||
<ClientDashboard
|
<ClientDashboard
|
||||||
view={projectViewToClientView(client, view)}
|
view={projectViewToClientView(client, view)}
|
||||||
token={client.token}
|
token={client.token}
|
||||||
comments={view.comments as unknown as Comment[]}
|
chat={{
|
||||||
|
projectId: view.project.id,
|
||||||
|
messages: view.comments,
|
||||||
|
reads: view.channel_reads,
|
||||||
|
}}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -193,7 +196,11 @@ export default async function ClientPage({
|
|||||||
<ClientDashboard
|
<ClientDashboard
|
||||||
view={projectViewToClientView(client, view)}
|
view={projectViewToClientView(client, view)}
|
||||||
token={client.token}
|
token={client.token}
|
||||||
comments={view.comments as unknown as Comment[]}
|
chat={{
|
||||||
|
projectId: view.project.id,
|
||||||
|
messages: view.comments,
|
||||||
|
reads: view.channel_reads,
|
||||||
|
}}
|
||||||
embedded
|
embedded
|
||||||
preview={preview}
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -85,7 +85,10 @@ export function ConversationsView({
|
|||||||
{/* ── Right: active thread ────────────────────────────────── */}
|
{/* ── Right: active thread ────────────────────────────────── */}
|
||||||
<section className="flex-1 min-w-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
|
<section className="flex-1 min-w-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
|
||||||
{activeThread ? (
|
{activeThread ? (
|
||||||
<ActiveThread 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.
|
||||||
|
<ActiveThread key={activeThread.clientId} thread={activeThread} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
|
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
|
||||||
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
|
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
|
||||||
@@ -153,11 +156,53 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const portalHref = `/client/${thread.slug ?? thread.token}`;
|
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<string>();
|
||||||
|
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<Set<string>>(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.
|
// Keep the thread pinned to the latest message.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
}, [thread.clientId, thread.messages.length]);
|
}, [activeChannel, visibleMessages.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -184,27 +229,68 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Canali — stessi tab che vede il cliente nel portale */}
|
||||||
|
{showTabs && (
|
||||||
|
<div
|
||||||
|
className="no-scrollbar flex gap-1 overflow-x-auto border-b border-border px-4 py-2"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Canali della conversazione"
|
||||||
|
>
|
||||||
|
{thread.channels.map((channel) => {
|
||||||
|
const active = channel.key === activeChannel;
|
||||||
|
const unread = unreadAtOpen.has(channel.key) && !visited.has(channel.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => 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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="max-w-[120px] truncate">{channel.label}</span>
|
||||||
|
{unread && !active && (
|
||||||
|
<>
|
||||||
|
<span className="w-1.5 h-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||||
|
<span className="sr-only">non letti</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
|
||||||
{thread.messages.length === 0 ? (
|
{visibleMessages.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-10">
|
<p className="text-sm text-muted-foreground text-center py-10">
|
||||||
Nessun messaggio in questa conversazione.
|
Nessun messaggio in questo canale.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
thread.messages.map((m) => <MessageBubble key={m.id} message={m} />)
|
visibleMessages.map((m) => <MessageBubble key={m.id} message={m} />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reply box */}
|
{/* Reply box — il canale viaggia col messaggio */}
|
||||||
<form
|
<form
|
||||||
action={replyToConversation.bind(null, thread.clientId)}
|
action={replyToConversation.bind(null, thread.clientId)}
|
||||||
className="p-4 border-t border-border bg-card flex items-end gap-3"
|
className="p-4 border-t border-border bg-card flex items-end gap-3"
|
||||||
>
|
>
|
||||||
|
<input type="hidden" name="entity_type" value={isGeneral ? "general" : "phase"} />
|
||||||
|
<input type="hidden" name="entity_id" value={activeChannel} />
|
||||||
<Textarea
|
<Textarea
|
||||||
name="body"
|
name="body"
|
||||||
rows={2}
|
rows={2}
|
||||||
required
|
required
|
||||||
placeholder="Scrivi una risposta..."
|
placeholder={`Rispondi in ${activeLabel}...`}
|
||||||
|
aria-label={`Rispondi nel canale ${activeLabel}`}
|
||||||
className="flex-1 resize-none"
|
className="flex-1 resize-none"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm" className="shrink-0">
|
<Button type="submit" size="sm" className="shrink-0">
|
||||||
@@ -221,7 +307,9 @@ function MessageBubble({
|
|||||||
message: ConversationThread["messages"][number];
|
message: ConversationThread["messages"][number];
|
||||||
}) {
|
}) {
|
||||||
const isAdmin = message.author === "admin";
|
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 (
|
return (
|
||||||
<div className={cn("flex", isAdmin && "justify-end")}>
|
<div className={cn("flex", isAdmin && "justify-end")}>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ClientView } from '@/lib/client-view';
|
import type { ClientView } from '@/lib/client-view';
|
||||||
import type { Comment } from '@/db/schema';
|
import type { ChatData } from '@/lib/chat-channels';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { PhaseTimeline } from './phase-timeline';
|
import { PhaseTimeline } from './phase-timeline';
|
||||||
import { PaymentStatus } from './payment-status';
|
import { PaymentStatus } from './payment-status';
|
||||||
@@ -16,7 +16,8 @@ import { PreviewProvider } from './client/PreviewProvider';
|
|||||||
interface ClientDashboardProps {
|
interface ClientDashboardProps {
|
||||||
view: ClientView;
|
view: ClientView;
|
||||||
token: string;
|
token: string;
|
||||||
comments: Comment[];
|
/** Messaggi + ricevute di lettura della chat, già scoped a questo progetto. */
|
||||||
|
chat: ChatData;
|
||||||
/** When rendered inside the multi-project tabs wrapper, the page already
|
/** When rendered inside the multi-project tabs wrapper, the page already
|
||||||
* provides the portal header + footer — skip them here to avoid duplicates. */
|
* provides the portal header + footer — skip them here to avoid duplicates. */
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
@@ -24,7 +25,7 @@ interface ClientDashboardProps {
|
|||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientDashboard({ view, token, comments, embedded = false, preview = false }: ClientDashboardProps) {
|
export function ClientDashboard({ view, token, chat, embedded = false, preview = false }: ClientDashboardProps) {
|
||||||
// Determine payment display mode based on active offers.
|
// Determine payment display mode based on active offers.
|
||||||
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
||||||
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
||||||
@@ -160,7 +161,7 @@ export function ClientDashboard({ view, token, comments, embedded = false, previ
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* Floating chat panel — FAB + slide-in panel */}
|
{/* Floating chat panel — FAB + slide-in panel */}
|
||||||
<ChatPanel token={token} comments={comments} />
|
<ChatPanel token={token} chat={chat} />
|
||||||
</div>
|
</div>
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
</PreviewProvider>
|
</PreviewProvider>
|
||||||
|
|||||||
+563
-203
@@ -1,277 +1,637 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition, useRef, useEffect } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { Maximize2, Minimize2, MessageCircle, Send, X } from "lucide-react";
|
||||||
import type { Comment } from "@/db/schema";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
buildChannels,
|
||||||
|
buildChannelIndex,
|
||||||
|
channelOfComment,
|
||||||
|
type ChatData,
|
||||||
|
type ChatMessage,
|
||||||
|
} from "@/lib/chat-channels";
|
||||||
import { useChatContext } from "./ChatProvider";
|
import { useChatContext } from "./ChatProvider";
|
||||||
import { usePreview } from "./PreviewProvider";
|
import { usePreview } from "./PreviewProvider";
|
||||||
|
|
||||||
// TODO: Email-on-tag (admin→client notification when admin posts on a phase/task) is OUT OF SCOPE
|
// TODO: la mail di notifica al cliente ("hai un messaggio non letto") è un giro a
|
||||||
// Hook point: after db.insert(comments) in /src/app/api/client/comment/route.ts and
|
// sé: serve uno scheduler, che nel repo non esiste ancora. Hook point naturale:
|
||||||
// postAdminComment in /src/app/admin/clients/[id]/actions.ts — send Resend email here.
|
// una query su client_channel_reads incrociata con comments.author = 'admin'.
|
||||||
|
|
||||||
function formatTime(ts: Date | string): string {
|
const POLL_MS = 20_000;
|
||||||
const d = typeof ts === "string" ? new Date(ts) : ts;
|
/** Oltre questo scarto due messaggi dello stesso autore non si raggruppano più. */
|
||||||
return d.toLocaleString("it-IT", {
|
const GROUP_WINDOW_MS = 5 * 60_000;
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
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",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatPanelProps {
|
function formatDayDivider(value: Date | string): string {
|
||||||
token: string;
|
const d = toDate(value);
|
||||||
comments: Comment[];
|
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) {
|
function monogram(label: string): string {
|
||||||
const { isOpen, selectedPhaseId, phases, clientId, openChat, closeChat } = useChatContext();
|
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 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<ChatMessage[]>([]);
|
||||||
|
const [reads, setReads] = useState<Record<string, string>>(chat.reads);
|
||||||
|
const [pending, setPending] = useState<PendingMessage[]>([]);
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
const router = useRouter();
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fabRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// Build entity list: Generale + phases
|
const messages = useMemo(() => {
|
||||||
type EntityOption = { id: string; type: "general" | "phase"; label: string };
|
const byId = new Map<string, ChatMessage>();
|
||||||
const entityOptions: EntityOption[] = [
|
for (const m of chat.messages) byId.set(m.id, m);
|
||||||
{ id: clientId, type: "general", label: "Generale" },
|
for (const m of polled) byId.set(m.id, m);
|
||||||
...phases.map((p) => ({ id: p.id, type: "phase" as const, label: p.title })),
|
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
|
// ── Non letti per canale ────────────────────────────────────────────────────
|
||||||
const labelMap = new Map<string, string>();
|
// Un canale ha novità se l'ultimo messaggio dell'admin è più recente di quando
|
||||||
labelMap.set(clientId, "Generale");
|
// il cliente l'ha guardato. Senza ricevuta di lettura vale come mai letto.
|
||||||
for (const p of phases) {
|
const unreadChannels = useMemo(() => {
|
||||||
labelMap.set(p.id, p.title);
|
const lastAdminAt = new Map<string, number>();
|
||||||
for (const t of p.tasks) {
|
for (const m of messages) {
|
||||||
labelMap.set(t.id, `${p.title} — ${t.title}`);
|
if (m.author !== "admin") continue;
|
||||||
for (const d of t.deliverables) {
|
const key = channelOfComment(m, index, clientId);
|
||||||
labelMap.set(d.id, `${t.title} — ${d.title}`);
|
const at = toDate(m.created_at).getTime();
|
||||||
|
if (at > (lastAdminAt.get(key) ?? 0)) lastAdminAt.set(key, at);
|
||||||
|
}
|
||||||
|
const unread = new Set<string>();
|
||||||
|
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<string | null>(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<string, string>;
|
||||||
|
};
|
||||||
|
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 id = setInterval(poll, POLL_MS);
|
||||||
const [currentEntityId, setCurrentEntityId] = useState<string>(clientId);
|
// Tornando sulla scheda si recupera subito, senza aspettare il tick.
|
||||||
useEffect(() => {
|
const onVisible = () => {
|
||||||
setCurrentEntityId(selectedPhaseId ?? clientId);
|
if (document.visibilityState === "visible") poll();
|
||||||
}, [selectedPhaseId, clientId]);
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisible);
|
||||||
|
return () => {
|
||||||
|
aborted = true;
|
||||||
|
clearInterval(id);
|
||||||
|
document.removeEventListener("visibilitychange", onVisible);
|
||||||
|
};
|
||||||
|
}, [isOpen, preview, token, chat.projectId]);
|
||||||
|
|
||||||
// Sort all comments chronologically
|
// ── Ricevuta di lettura ────────────────────────────────────────────────────
|
||||||
const sorted = [...comments].sort(
|
const markRead = useCallback(
|
||||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (!isOpen || preview) return;
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
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) {
|
function handleClose() {
|
||||||
e.preventDefault();
|
closeChat();
|
||||||
const trimmed = body.trim();
|
fabRef.current?.focus();
|
||||||
if (!trimmed) return;
|
}
|
||||||
|
|
||||||
const selectedEntity = entityOptions.find((en) => en.id === currentEntityId);
|
// ── Invio ──────────────────────────────────────────────────────────────────
|
||||||
if (!selectedEntity) return;
|
const send = useCallback(
|
||||||
|
async (text: string, channelKey: string, tempId: string) => {
|
||||||
setError(null);
|
const isGeneral = channelKey === clientId;
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/client/comment", {
|
const res = await fetch("/api/client/comment", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token,
|
token,
|
||||||
entity_type: selectedEntity.type,
|
entity_type: isGeneral ? "general" : "phase",
|
||||||
entity_id: selectedEntity.id,
|
entity_id: channelKey,
|
||||||
body: trimmed,
|
body: text,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
setError(data.error ?? "Errore nell'invio");
|
setError(data.error ?? "Errore nell'invio");
|
||||||
|
setPending((prev) =>
|
||||||
|
prev.map((p) => (p.tempId === tempId ? { ...p, failed: true } : p))
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBody("");
|
const data = (await res.json()) as { comment?: ChatMessage };
|
||||||
router.refresh();
|
// 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 {
|
} catch {
|
||||||
setError("Errore di rete");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* FAB — floating action button bottom-right */}
|
{/* FAB */}
|
||||||
<button
|
<button
|
||||||
|
ref={fabRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (isOpen ? closeChat() : openChat())}
|
onClick={() => (isOpen ? handleClose() : openChat(activeChannel))}
|
||||||
aria-label="Apri messaggi"
|
aria-label={isOpen ? "Chiudi messaggi" : "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"
|
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 ? (
|
{isOpen ? <X className="h-6 w-6" /> : <MessageCircle className="h-6 w-6" />}
|
||||||
/* X close icon */
|
{!isOpen && hasAnyUnread && (
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
<span
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
className="absolute right-1 top-1 h-3.5 w-3.5 rounded-full border-2 border-background bg-accent"
|
||||||
</svg>
|
aria-hidden="true"
|
||||||
) : (
|
/>
|
||||||
/* Chat bubble + plus SVG */
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.83L3 20l1.09-3.27C3.39 15.56 3 13.83 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
|
{!isOpen && hasAnyUnread && <span className="sr-only">Ci sono messaggi non letti</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Overlay — subtle backdrop on mobile */}
|
{/* Backdrop: solo dove il pannello copre davvero la pagina */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 bg-black/10 lg:hidden"
|
className={cn("fixed inset-0 z-50 bg-foreground/10", !expanded && "lg:hidden")}
|
||||||
onClick={closeChat}
|
onClick={handleClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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. */}
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 right-0 h-full z-[60] w-full sm:w-[420px] bg-white shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${
|
className={cn(
|
||||||
|
"fixed inset-y-0 right-0 z-[60] flex flex-col bg-card shadow-2xl",
|
||||||
|
"transition-[transform,width] duration-300 ease-in-out",
|
||||||
|
expanded ? "w-full" : "w-full sm:w-[460px]",
|
||||||
isOpen ? "translate-x-0" : "translate-x-full"
|
isOpen ? "translate-x-0" : "translate-x-full"
|
||||||
}`}
|
)}
|
||||||
aria-label="Messaggi & Revisioni"
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-label="Messaggi & Revisioni"
|
||||||
|
aria-modal={expanded ? true : undefined}
|
||||||
|
// Chiuso resta nel DOM per animare: senza `inert` Tab e screen reader ci
|
||||||
|
// finirebbero comunque dentro.
|
||||||
|
inert={!isOpen}
|
||||||
>
|
>
|
||||||
{/* Vertical "Chiudi" tab — notebook-divider style, protrudes from the left edge */}
|
{/* Header */}
|
||||||
<button
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3.5">
|
||||||
type="button"
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
onClick={closeChat}
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" aria-hidden="true" />
|
||||||
aria-label="Chiudi"
|
<h2 className="truncate text-sm font-bold text-foreground" title={activeLabel}>
|
||||||
className="absolute left-0 top-1/2 flex -translate-x-full -translate-y-1/2 flex-col items-center gap-2 rounded-l-xl bg-[#1A463C] px-2 py-4 text-white shadow-lg transition-colors hover:bg-[#163a31]"
|
{activeLabel}
|
||||||
>
|
</h2>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] [writing-mode:vertical-rl]">
|
|
||||||
Chiudi
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Panel header */}
|
|
||||||
<div className="flex items-center px-5 py-4 border-b border-[#e5e7eb] shrink-0">
|
|
||||||
<h2 className="text-base font-bold text-[#1a1a1a]">Messaggi & Revisioni</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat feed */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
||||||
{sorted.length === 0 && (
|
|
||||||
<p className="text-sm text-[#71717a] italic text-center py-10">
|
|
||||||
Nessun messaggio ancora. Scrivi qui sotto per iniziare.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{sorted.map((c) => {
|
|
||||||
const isClient = c.author === "client";
|
|
||||||
const entityLabel = labelMap.get(c.entity_id);
|
|
||||||
const showTag = c.entity_id !== clientId && entityLabel;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={c.id}
|
|
||||||
className={`flex flex-col gap-1 ${isClient ? "items-end" : "items-start"}`}
|
|
||||||
>
|
|
||||||
{/* Author + tag */}
|
|
||||||
<div className={`flex items-center gap-2 ${isClient ? "flex-row-reverse" : ""}`}>
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${
|
|
||||||
isClient
|
|
||||||
? "bg-[#DEF168] text-[#1A463C]"
|
|
||||||
: "bg-[#1A463C] text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isClient ? "Tu" : "iamcavalli"}
|
|
||||||
</span>
|
|
||||||
{showTag && (
|
|
||||||
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-2 py-0.5 rounded-full truncate max-w-[160px]">
|
|
||||||
{entityLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bubble */}
|
|
||||||
<div
|
|
||||||
className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
|
||||||
isClient
|
|
||||||
? "bg-[#1A463C] text-white rounded-tr-sm"
|
|
||||||
: "bg-[#f4f4f5] text-[#1a1a1a] rounded-tl-sm"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{c.body}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
<span className="text-[10px] text-[#71717a]">{formatTime(c.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div ref={bottomRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t border-[#e5e7eb]" />
|
|
||||||
|
|
||||||
{/* 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 ? (
|
|
||||||
<div className="p-3 bg-[#fafafa] shrink-0 text-center text-xs text-[#71717a]">
|
|
||||||
Composer disattivato in anteprima
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa] shrink-0">
|
{/* Su mobile il pannello è già a piena larghezza: il tasto non avrebbe effetto. */}
|
||||||
{/* Tag selector: Generale + phases */}
|
|
||||||
<select
|
|
||||||
value={currentEntityId}
|
|
||||||
onChange={(e) => 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) => (
|
|
||||||
<option key={en.id} value={en.id}>
|
|
||||||
{en.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<textarea
|
|
||||||
value={body}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={!body.trim()}
|
onClick={toggleExpanded}
|
||||||
className="self-end px-4 py-2 rounded-lg bg-[#1A463C] text-white text-sm font-semibold disabled:opacity-40 hover:bg-[#1A463C]/90 transition-colors"
|
aria-label={expanded ? "Riduci la chat" : "Espandi a tutto schermo"}
|
||||||
|
className="hidden rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring sm:inline-flex"
|
||||||
>
|
>
|
||||||
Invia
|
{expanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Chiudi"
|
||||||
|
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
</div>
|
||||||
</form>
|
|
||||||
|
{/* Tab dei canali */}
|
||||||
|
{showTabs && (
|
||||||
|
<div className="shrink-0 border-b border-border bg-card">
|
||||||
|
<div
|
||||||
|
className="no-scrollbar mx-auto flex w-full gap-1 overflow-x-auto px-4 py-2"
|
||||||
|
style={expanded ? { maxWidth: "48rem" } : undefined}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Canali della conversazione"
|
||||||
|
>
|
||||||
|
{channels.map((channel) => {
|
||||||
|
const active = channel.key === activeChannel;
|
||||||
|
const unread = unreadChannels.has(channel.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => 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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="max-w-[110px] truncate">{channel.label}</span>
|
||||||
|
{unread && !active && (
|
||||||
|
<>
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" aria-hidden="true" />
|
||||||
|
<span className="sr-only">non letti</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Feed */}
|
||||||
|
<div className="thin-scrollbar flex-1 overflow-y-auto">
|
||||||
|
{/* A tutto schermo si allarga il contenitore, non la misura del testo:
|
||||||
|
una riga larga 2000px non si legge. */}
|
||||||
|
<div
|
||||||
|
className="mx-auto w-full px-5 py-5"
|
||||||
|
style={expanded ? { maxWidth: "48rem" } : undefined}
|
||||||
|
>
|
||||||
|
{visibleMessages.length === 0 && visiblePending.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-sm italic text-muted-foreground">
|
||||||
|
Nessun messaggio in questo canale. Scrivi qui sotto per iniziare.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<MessageList
|
||||||
|
messages={visibleMessages}
|
||||||
|
index={index}
|
||||||
|
activeChannel={activeChannel}
|
||||||
|
clientId={clientId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visiblePending.map((item) => (
|
||||||
|
<div key={item.tempId} className="mt-5 flex gap-3">
|
||||||
|
<Avatar label="Tu" tone="client" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-foreground">Tu</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{item.failed ? "non inviato" : "invio…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 whitespace-pre-wrap text-sm leading-relaxed",
|
||||||
|
item.failed ? "text-destructive" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.body}
|
||||||
|
</p>
|
||||||
|
{item.failed && (
|
||||||
|
<div className="mt-1 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => retry(item)}
|
||||||
|
className="text-[11px] font-semibold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Riprova
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => discard(item.tempId)}
|
||||||
|
className="text-[11px] font-medium text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div className="shrink-0 border-t border-border bg-card">
|
||||||
|
<div
|
||||||
|
className="mx-auto w-full px-4 py-3"
|
||||||
|
style={expanded ? { maxWidth: "48rem" } : undefined}
|
||||||
|
>
|
||||||
|
{preview ? (
|
||||||
|
<p className="py-2 text-center text-xs text-muted-foreground">
|
||||||
|
Composer disattivato in anteprima
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Nessun selettore: il canale è il tab attivo. Prima andava
|
||||||
|
scelto due volte, qui e nella timeline. */}
|
||||||
|
<div className="rounded-xl border border-border bg-background transition-colors focus-within:border-primary">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between border-t border-border-light px-3 py-2">
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
Shift + Invio per andare a capo
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!body.trim()}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3.5 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<Send className="h-3.5 w-3.5" /> Invia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Feed ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MessageList({
|
||||||
|
messages,
|
||||||
|
index,
|
||||||
|
activeChannel,
|
||||||
|
clientId,
|
||||||
|
}: {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
index: ReturnType<typeof buildChannelIndex>;
|
||||||
|
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 (
|
||||||
|
<div key={m.id}>
|
||||||
|
{showDivider && (
|
||||||
|
<div className="my-4 flex items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{formatDayDivider(at)}
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn("flex gap-3", grouped ? "mt-1" : "mt-5")}>
|
||||||
|
{grouped ? (
|
||||||
|
<div className="w-9 shrink-0" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Avatar label={name} tone={isClient ? "client" : "admin"} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{!grouped && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-foreground">{name}</span>
|
||||||
|
{entityLabel && (
|
||||||
|
<span className="max-w-[160px] truncate rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{entityLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-muted-foreground">{formatTime(at)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
||||||
|
{m.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({ label, tone }: { label: string; tone: "client" | "admin" }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[11px] font-bold",
|
||||||
|
tone === "client"
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "bg-primary text-primary-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{monogram(label)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback } from "react";
|
import { createContext, useContext, useState, useCallback, useMemo } from "react";
|
||||||
import type { ClientView } from "@/lib/client-view";
|
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 {
|
interface ChatContextValue {
|
||||||
isOpen: boolean;
|
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"];
|
phases: ClientView["phases"];
|
||||||
clientId: string;
|
clientId: string;
|
||||||
openChat: (phaseId?: string) => void;
|
openChat: (phaseId?: string) => void;
|
||||||
closeChat: () => void;
|
closeChat: () => void;
|
||||||
|
toggleExpanded: () => void;
|
||||||
|
setActiveChannel: (key: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatContext = createContext<ChatContextValue | null>(null);
|
const ChatContext = createContext<ChatContextValue | null>(null);
|
||||||
@@ -30,20 +42,36 @@ export function ChatProvider({
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(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<string>(clientId);
|
||||||
|
|
||||||
const openChat = useCallback((phaseId?: string) => {
|
const openChat = useCallback(
|
||||||
setSelectedPhaseId(phaseId ?? null);
|
(phaseId?: string) => {
|
||||||
setIsOpen(true);
|
setActiveChannel(phaseId ?? clientId);
|
||||||
}, []);
|
setIsOpen(true);
|
||||||
|
},
|
||||||
const closeChat = useCallback(() => {
|
[clientId]
|
||||||
setIsOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatContext.Provider value={{ isOpen, selectedPhaseId, phases, clientId, openChat, closeChat }}>
|
|
||||||
{children}
|
|
||||||
</ChatContext.Provider>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
+43
-1
@@ -165,7 +165,11 @@ export const comments = pgTable("comments", {
|
|||||||
id: text("id")
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$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(),
|
entity_id: text("entity_id").notNull(),
|
||||||
author: text("author").notNull(), // client | admin
|
author: text("author").notNull(), // client | admin
|
||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
@@ -174,6 +178,32 @@ export const comments = pgTable("comments", {
|
|||||||
.defaultNow(),
|
.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) ============
|
// ============ TAGS (polymorphic — services now, leads in Phase 14) ============
|
||||||
// entity_type scopes the tag pool (D-06): "services" tags and "leads" tags are
|
// 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
|
// 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
|
// 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, (_) => ({
|
export const tagsRelations = relations(tags, (_) => ({
|
||||||
// Polymorphic: no direct FK relation — entity_type + entity_id used at query time
|
// 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 NewDeliverable = typeof deliverables.$inferInsert;
|
||||||
export type Comment = typeof comments.$inferSelect;
|
export type Comment = typeof comments.$inferSelect;
|
||||||
export type NewComment = typeof comments.$inferInsert;
|
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 Tag = typeof tags.$inferSelect;
|
||||||
export type NewTag = typeof tags.$inferInsert;
|
export type NewTag = typeof tags.$inferInsert;
|
||||||
export type Payment = typeof payments.$inferSelect;
|
export type Payment = typeof payments.$inferSelect;
|
||||||
|
|||||||
@@ -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<TaskLike>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskLike = {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
deliverables?: ReadonlyArray<{ id: string; title?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelIndex = {
|
||||||
|
/** entity_id -> chiave del canale. */
|
||||||
|
channelOf: ReadonlyMap<string, string>;
|
||||||
|
/**
|
||||||
|
* 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<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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<PhaseLike>
|
||||||
|
): ChannelIndex {
|
||||||
|
const channelOf = new Map<string, string>();
|
||||||
|
const entityLabel = new Map<string, string>();
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
};
|
||||||
@@ -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<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string[] | null> {
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
+15
-1
@@ -1,6 +1,6 @@
|
|||||||
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
|
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
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 type { TaskStatus } from "@/lib/task-status";
|
||||||
import { getComputedOfferValues } from "@/lib/offer-value";
|
import { getComputedOfferValues } from "@/lib/offer-value";
|
||||||
|
|
||||||
@@ -128,6 +128,12 @@ export interface ProjectView {
|
|||||||
body: string;
|
body: string;
|
||||||
created_at: Date;
|
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<string, string>;
|
||||||
global_progress_pct: number;
|
global_progress_pct: number;
|
||||||
activeOffers?: Array<{
|
activeOffers?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -425,6 +431,11 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
.where(inArray(comments.entity_id, allEntityIds))
|
.where(inArray(comments.entity_id, allEntityIds))
|
||||||
.orderBy(asc(comments.created_at));
|
.orderBy(asc(comments.created_at));
|
||||||
|
|
||||||
|
const channelReadRows = await db
|
||||||
|
.select()
|
||||||
|
.from(client_channel_reads)
|
||||||
|
.where(eq(client_channel_reads.client_id, project.client_id));
|
||||||
|
|
||||||
const phasesWithTasks = phasesRows.map((phase) => {
|
const phasesWithTasks = phasesRows.map((phase) => {
|
||||||
const phaseTasks = tasksRows
|
const phaseTasks = tasksRows
|
||||||
.filter((t) => t.phase_id === phase.id)
|
.filter((t) => t.phase_id === phase.id)
|
||||||
@@ -456,6 +467,9 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
documents: documentsRows,
|
documents: documentsRows,
|
||||||
notes: notesRows,
|
notes: notesRows,
|
||||||
comments: commentsRows,
|
comments: commentsRows,
|
||||||
|
channel_reads: Object.fromEntries(
|
||||||
|
channelReadRows.map((r) => [r.channel_key, r.read_at.toISOString()])
|
||||||
|
),
|
||||||
global_progress_pct,
|
global_progress_pct,
|
||||||
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
|
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
|
||||||
transcripts: transcriptsRows,
|
transcripts: transcriptsRows,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { cache } from "react";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, projects, phases, tasks, deliverables, comments } from "@/db/schema";
|
import { clients, projects, phases, tasks, deliverables, comments } from "@/db/schema";
|
||||||
import { eq, inArray, asc } from "drizzle-orm";
|
import { eq, inArray, asc } from "drizzle-orm";
|
||||||
import type { Comment } from "@/db/schema";
|
import type { Comment } from "@/db/schema";
|
||||||
|
import { buildChannels, type ChatChannel } from "@/lib/chat-channels";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -26,6 +28,13 @@ export type ThreadMessage = {
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
entityLabel: string;
|
entityLabel: string;
|
||||||
entityType: 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 = {
|
export type ConversationThread = {
|
||||||
@@ -35,6 +44,14 @@ export type ConversationThread = {
|
|||||||
token: string;
|
token: string;
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
messages: ThreadMessage[];
|
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 = {
|
type ClientMeta = {
|
||||||
@@ -46,7 +63,7 @@ type ClientMeta = {
|
|||||||
admin_last_read_at: Date | null;
|
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 ──────────────────────────────────────────────────────────
|
// ── Shared aggregation ──────────────────────────────────────────────────────────
|
||||||
// Builds the entity_id → owning client map by walking
|
// 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,
|
// under its owning client. Same pattern as admin-queries.getClientFullDetail,
|
||||||
// but across ALL non-archived clients at once (cross-client inbox).
|
// 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<string, ClientMeta>;
|
clientMeta: Map<string, ClientMeta>;
|
||||||
entityMap: Map<string, EntityInfo>;
|
entityMap: Map<string, EntityInfo>;
|
||||||
allEntityIds: string[];
|
allEntityIds: string[];
|
||||||
|
phasesByClient: Map<string, { id: string; title: string }[]>;
|
||||||
}> {
|
}> {
|
||||||
const clientMeta = new Map<string, ClientMeta>();
|
const clientMeta = new Map<string, ClientMeta>();
|
||||||
const entityMap = new Map<string, EntityInfo>();
|
const entityMap = new Map<string, EntityInfo>();
|
||||||
|
const phasesByClient = new Map<string, { id: string; title: string }[]>();
|
||||||
|
|
||||||
const clientRows = await db
|
const clientRows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -77,12 +99,17 @@ async function buildEntityMap(): Promise<{
|
|||||||
admin_last_read_at: c.admin_last_read_at,
|
admin_last_read_at: c.admin_last_read_at,
|
||||||
});
|
});
|
||||||
// A client's own id is the entity_id for "general" messages.
|
// 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);
|
const clientIds = clientRows.map((c) => c.id);
|
||||||
if (clientIds.length === 0) {
|
if (clientIds.length === 0) {
|
||||||
return { clientMeta, entityMap, allEntityIds: [] };
|
return { clientMeta, entityMap, allEntityIds: [], phasesByClient };
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectRows = await db
|
const projectRows = await db
|
||||||
@@ -96,13 +123,24 @@ async function buildEntityMap(): Promise<{
|
|||||||
const phaseRows = await db
|
const phaseRows = await db
|
||||||
.select({ id: phases.id, project_id: phases.project_id, title: phases.title })
|
.select({ id: phases.id, project_id: phases.project_id, title: phases.title })
|
||||||
.from(phases)
|
.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<string, string>();
|
const phaseToClient = new Map<string, string>();
|
||||||
for (const ph of phaseRows) {
|
for (const ph of phaseRows) {
|
||||||
const clientId = projectToClient.get(ph.project_id);
|
const clientId = projectToClient.get(ph.project_id);
|
||||||
if (!clientId) continue;
|
if (!clientId) continue;
|
||||||
phaseToClient.set(ph.id, clientId);
|
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);
|
const phaseIds = phaseRows.map((p) => p.id);
|
||||||
@@ -116,7 +154,13 @@ async function buildEntityMap(): Promise<{
|
|||||||
const clientId = phaseToClient.get(t.phase_id);
|
const clientId = phaseToClient.get(t.phase_id);
|
||||||
if (!clientId) continue;
|
if (!clientId) continue;
|
||||||
taskToClient.set(t.id, clientId);
|
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);
|
const taskIds = taskRows.map((t) => t.id);
|
||||||
@@ -132,14 +176,21 @@ async function buildEntityMap(): Promise<{
|
|||||||
clientId,
|
clientId,
|
||||||
label: `Deliverable: ${d.title}`,
|
label: `Deliverable: ${d.title}`,
|
||||||
type: "deliverable",
|
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.
|
// Groups comments (already sorted asc) by owning client id.
|
||||||
function groupCommentsByClient(
|
function groupCommentsByClient(
|
||||||
@@ -208,7 +259,7 @@ export async function getConversations(): Promise<ConversationSummary[]> {
|
|||||||
export async function getConversationThread(
|
export async function getConversationThread(
|
||||||
clientId: string
|
clientId: string
|
||||||
): Promise<ConversationThread | null> {
|
): Promise<ConversationThread | null> {
|
||||||
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
|
const { clientMeta, entityMap, allEntityIds, phasesByClient } = await buildEntityMap();
|
||||||
const meta = clientMeta.get(clientId);
|
const meta = clientMeta.get(clientId);
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
|
|
||||||
@@ -227,6 +278,9 @@ export async function getConversationThread(
|
|||||||
created_at: c.created_at,
|
created_at: c.created_at,
|
||||||
entityLabel: info?.label ?? "Generale",
|
entityLabel: info?.label ?? "Generale",
|
||||||
entityType: info?.type ?? "general",
|
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,
|
token: meta.token,
|
||||||
slug: meta.slug,
|
slug: meta.slug,
|
||||||
messages,
|
messages,
|
||||||
|
channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []),
|
||||||
|
adminLastReadAt: meta.admin_last_read_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user