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"; // ── Types ───────────────────────────────────────────────────────────────────── export type ConversationSummary = { clientId: string; name: string; brand_name: string; token: string; slug: string | null; lastMessage: string; lastMessageAuthor: "client" | "admin"; lastMessageAt: Date; lastEntityLabel: string; unread: boolean; unreadCount: number; }; export type ThreadMessage = { id: string; author: "client" | "admin"; body: string; created_at: Date; entityLabel: string; entityType: string; }; export type ConversationThread = { clientId: string; name: string; brand_name: string; token: string; slug: string | null; messages: ThreadMessage[]; }; 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 }; // ── 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). async function buildEntityMap(): Promise<{ clientMeta: Map; entityMap: Map; allEntityIds: string[]; }> { const clientMeta = new Map(); const entityMap = 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" }); } const clientIds = clientRows.map((c) => c.id); if (clientIds.length === 0) { return { clientMeta, entityMap, allEntityIds: [] }; } 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)); 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" }); } 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" }); } 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", }); } } } } return { clientMeta, entityMap, allEntityIds: [...entityMap.keys()] }; } // 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 ────────────────────────────────────────────────────────────────── /** One entry per non-archived client that has at least one comment, newest first. */ 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 [clientId, msgs] of byClient) { const meta = clientMeta.get(clientId); if (!meta || msgs.length === 0) continue; 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, name: meta.name, brand_name: meta.brand_name, token: meta.token, slug: meta.slug, lastMessage: last.body, lastMessageAuthor: last.author as "client" | "admin", lastMessageAt: last.created_at, lastEntityLabel: entityMap.get(last.entity_id)?.label ?? "Generale", unread: clientMsgsAfterRead.length > 0, unreadCount: clientMsgsAfterRead.length, }); } summaries.sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime()); 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 } = 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, entityLabel: info?.label ?? "Generale", entityType: info?.type ?? "general", }; }); return { clientId: meta.id, name: meta.name, brand_name: meta.brand_name, token: meta.token, slug: meta.slug, messages, }; } /** 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; }