import { cache } from "react"; import { db } from "@/db"; import { clients, projects, phases, tasks, deliverables, comments } from "@/db/schema"; import { eq, inArray, asc } from "drizzle-orm"; import type { Comment } from "@/db/schema"; import { buildChannels, type ChatChannel } from "@/lib/chat-channels"; import { mentionCandidates } from "@/lib/mentions"; import { getMentionRecipients } from "@/lib/client-notifications"; // ── Types ───────────────────────────────────────────────────────────────────── export type ConversationSummary = { clientId: string; name: string; brand_name: string; token: string; slug: string | null; /** Stringa vuota per un cliente con cui non si e' ancora scambiato nulla. */ lastMessage: string; lastMessageAuthor: "client" | "admin" | null; /** null = nessun messaggio: la conversazione esiste ma e' ancora da aprire. */ lastMessageAt: Date | null; lastEntityLabel: string; unread: boolean; unreadCount: number; }; export type ThreadMessage = { id: string; author: "client" | "admin"; body: string; created_at: Date; /** Valorizzato solo se il messaggio è stato modificato dopo l'invio. */ edited_at: Date | null; entityLabel: string; entityType: string; /** * Canale in cui il messaggio va letto: clientId per "Generale", phases.id per * una fase. Task e deliverable rientrano nella fase proprietaria — la regola * sta in chat-channels.ts, condivisa col portale, perché le due sponde devono * mettere lo stesso messaggio nello stesso tab. */ channelKey: string; }; export type ConversationThread = { clientId: string; name: string; brand_name: string; token: string; slug: string | null; messages: ThreadMessage[]; /** Generale + tutte le fasi del cliente (di tutti i suoi progetti). */ channels: ChatChannel[]; /** * Serve alla vista per capire QUALI canali avevano novità al momento * dell'apertura: aprire la conversazione la segna letta subito, quindi dopo il * refresh il dato non sarebbe più ricostruibile. */ adminLastReadAt: Date | null; /** I nomi con cui questo cliente si tagga in chat — vedi lib/mentions.ts. */ mentionCandidates: string[]; /** * Quanti indirizzi riceverebbero la notifica di un tag. Zero significa che il * tag resta un'evidenziazione e basta: il compositore lo dice prima, invece di * lasciar credere che sia partita una mail che non partirà. */ notifyEmailCount: number; }; type ClientMeta = { id: string; name: string; brand_name: string; token: string; slug: string | null; admin_last_read_at: Date | null; }; type EntityInfo = { clientId: string; label: string; type: string; channelKey: string }; // ── Shared aggregation ────────────────────────────────────────────────────────── // Builds the entity_id → owning client map by walking // clients → projects → phases → tasks → deliverables, then groups every comment // under its owning client. Same pattern as admin-queries.getClientFullDetail, // but across ALL non-archived clients at once (cross-client inbox). // cache(): senza, questa funzione gira quattro volte per render della pagina // admin — getConversations, getConversationThread, il badge nel layout e // InboxBand — e ogni giro sono cinque scansioni su tutto il DB. const buildEntityMap = cache(async function buildEntityMap(): Promise<{ clientMeta: Map; entityMap: Map; allEntityIds: string[]; phasesByClient: Map; }> { const clientMeta = new Map(); const entityMap = new Map(); const phasesByClient = new Map(); const clientRows = await db .select() .from(clients) .where(eq(clients.archived, false)); for (const c of clientRows) { clientMeta.set(c.id, { id: c.id, name: c.name, brand_name: c.brand_name, token: c.token, slug: c.slug, admin_last_read_at: c.admin_last_read_at, }); // A client's own id is the entity_id for "general" messages. entityMap.set(c.id, { clientId: c.id, label: "Generale", type: "general", channelKey: c.id, }); } const clientIds = clientRows.map((c) => c.id); if (clientIds.length === 0) { return { clientMeta, entityMap, allEntityIds: [], phasesByClient }; } const projectRows = await db .select({ id: projects.id, client_id: projects.client_id }) .from(projects) .where(inArray(projects.client_id, clientIds)); const projectToClient = new Map(projectRows.map((p) => [p.id, p.client_id])); const projectIds = projectRows.map((p) => p.id); if (projectIds.length > 0) { const phaseRows = await db .select({ id: phases.id, project_id: phases.project_id, title: phases.title }) .from(phases) .where(inArray(phases.project_id, projectIds)) // I tab dei canali devono uscire nello stesso ordine in cui il cliente // vede le fasi nella sua timeline. .orderBy(asc(phases.project_id), asc(phases.sort_order)); const phaseToClient = new Map(); for (const ph of phaseRows) { const clientId = projectToClient.get(ph.project_id); if (!clientId) continue; phaseToClient.set(ph.id, clientId); entityMap.set(ph.id, { clientId, label: `Fase: ${ph.title}`, type: "phase", 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); if (phaseIds.length > 0) { const taskRows = await db .select({ id: tasks.id, phase_id: tasks.phase_id, title: tasks.title }) .from(tasks) .where(inArray(tasks.phase_id, phaseIds)); const taskToClient = new Map(); for (const t of taskRows) { const clientId = phaseToClient.get(t.phase_id); if (!clientId) continue; taskToClient.set(t.id, clientId); entityMap.set(t.id, { clientId, label: `Task: ${t.title}`, type: "task", // Un task non è un canale: i suoi messaggi si leggono nella sua fase. channelKey: t.phase_id, }); } const taskIds = taskRows.map((t) => t.id); if (taskIds.length > 0) { const delivRows = await db .select({ id: deliverables.id, task_id: deliverables.task_id, title: deliverables.title }) .from(deliverables) .where(inArray(deliverables.task_id, taskIds)); for (const d of delivRows) { const clientId = taskToClient.get(d.task_id); if (!clientId) continue; entityMap.set(d.id, { clientId, label: `Deliverable: ${d.title}`, type: "deliverable", // Come per i task: il canale è la fase del task proprietario. channelKey: entityMap.get(d.task_id)?.channelKey ?? clientId, }); } } } } return { clientMeta, entityMap, allEntityIds: [...entityMap.keys()], phasesByClient, }; }); // Groups comments (already sorted asc) by owning client id. function groupCommentsByClient( commentRows: Comment[], entityMap: Map ): Map { const byClient = new Map(); for (const c of commentRows) { const info = entityMap.get(c.entity_id); if (!info) continue; // orphaned comment (deleted entity) — skip const arr = byClient.get(info.clientId) ?? []; arr.push(c); byClient.set(info.clientId, arr); } return byClient; } async function fetchComments(allEntityIds: string[]): Promise { if (allEntityIds.length === 0) return []; return db .select() .from(comments) .where(inArray(comments.entity_id, allEntityIds)) .orderBy(asc(comments.created_at)); } // ── Public API ────────────────────────────────────────────────────────────────── /** * Una voce per OGNI cliente non archiviato, anche senza un solo messaggio. * * Prima l'elenco nasceva dai commenti, quindi un cliente compariva solo dopo * aver scritto per primo: dall'inbox era letteralmente impossibile aprire una * conversazione. Il centro messaggi è la rubrica dei clienti, non l'archivio di * chi ha già parlato — la lista parte da `clients` e i commenti la arricchiscono. * * Ordine: prima chi ha scritto, dal più recente; in coda chi non ha ancora * niente, in ordine alfabetico. Così l'inbox resta un inbox e i clienti muti * non spingono giù le conversazioni vive. */ export async function getConversations(): Promise { const { clientMeta, entityMap, allEntityIds } = await buildEntityMap(); const commentRows = await fetchComments(allEntityIds); const byClient = groupCommentsByClient(commentRows, entityMap); const summaries: ConversationSummary[] = []; for (const meta of clientMeta.values()) { const msgs = byClient.get(meta.id) ?? []; const last = msgs[msgs.length - 1]; const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0; const clientMsgsAfterRead = msgs.filter( (m) => m.author === "client" && m.created_at.getTime() > lastReadAt ); summaries.push({ clientId: meta.id, name: meta.name, brand_name: meta.brand_name, token: meta.token, slug: meta.slug, lastMessage: last?.body ?? "", lastMessageAuthor: (last?.author as "client" | "admin") ?? null, lastMessageAt: last?.created_at ?? null, lastEntityLabel: last ? entityMap.get(last.entity_id)?.label ?? "Generale" : "Generale", unread: clientMsgsAfterRead.length > 0, unreadCount: clientMsgsAfterRead.length, }); } summaries.sort((a, b) => { if (a.lastMessageAt && b.lastMessageAt) { return b.lastMessageAt.getTime() - a.lastMessageAt.getTime(); } if (a.lastMessageAt) return -1; if (b.lastMessageAt) return 1; return a.name.localeCompare(b.name, "it"); }); return summaries; } /** Full ordered thread for a single client, with per-message entity labels. */ export async function getConversationThread( clientId: string ): Promise { const { clientMeta, entityMap, allEntityIds, phasesByClient } = await buildEntityMap(); const meta = clientMeta.get(clientId); if (!meta) return null; // Restrict to entity ids owned by this client. const ownEntityIds = allEntityIds.filter( (id) => entityMap.get(id)?.clientId === clientId ); const commentRows = await fetchComments(ownEntityIds); const messages: ThreadMessage[] = commentRows.map((c) => { const info = entityMap.get(c.entity_id); return { id: c.id, author: c.author as "client" | "admin", body: c.body, created_at: c.created_at, edited_at: c.edited_at, entityLabel: info?.label ?? "Generale", entityType: info?.type ?? "general", // Entità sparita (fase cancellata): il messaggio riemerge in Generale // invece di restare senza tab e sparire dalla vista senza errore. channelKey: info?.channelKey ?? clientId, }; }); return { clientId: meta.id, name: meta.name, brand_name: meta.brand_name, token: meta.token, slug: meta.slug, messages, channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []), adminLastReadAt: meta.admin_last_read_at, mentionCandidates: mentionCandidates(meta), notifyEmailCount: (await getMentionRecipients(clientId)).length, }; } /** Number of clients whose conversation has unread client messages (sidebar badge). */ export async function getUnreadConversationsCount(): Promise { const { clientMeta, entityMap, allEntityIds } = await buildEntityMap(); const commentRows = await fetchComments(allEntityIds); const byClient = groupCommentsByClient(commentRows, entityMap); let count = 0; for (const [clientId, msgs] of byClient) { const meta = clientMeta.get(clientId); if (!meta) continue; const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0; const hasUnread = msgs.some( (m) => m.author === "client" && m.created_at.getTime() > lastReadAt ); if (hasUnread) count++; } return count; }