feat: pagina Conversazioni — inbox unificata messaggi clienti
Nuova pagina admin /admin/conversazioni: vista WhatsApp-style (lista conversazioni a sinistra, thread + risposta a destra) che aggrega i messaggi di tutti i clienti dalla tabella comments, cross-cliente. - Migration additiva 0014: clients.admin_last_read_at per tracciare letto/non-letto (pallini + badge in sidebar). Applicata a prod. - conversations-queries.ts: aggregazione entity_id → cliente (general/phase/task/deliverable), getConversations / getConversationThread / getUnreadConversationsCount. - Risposte admin salvate come commento "general" (visibili anche nella chat cliente e nel CommentsTab della scheda). - Voce di menu + badge non-letti (AdminSidebar/AdminShell/layout). - Token semantici per dual light/dark; refresh manuale (no polling). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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<string, ClientMeta>;
|
||||
entityMap: Map<string, EntityInfo>;
|
||||
allEntityIds: string[];
|
||||
}> {
|
||||
const clientMeta = new Map<string, ClientMeta>();
|
||||
const entityMap = new Map<string, EntityInfo>();
|
||||
|
||||
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<string, string>();
|
||||
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<string, string>();
|
||||
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<string, EntityInfo>
|
||||
): Map<string, Comment[]> {
|
||||
const byClient = new Map<string, Comment[]>();
|
||||
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<Comment[]> {
|
||||
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<ConversationSummary[]> {
|
||||
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<ConversationThread | null> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user