41530b556a
Tre cose che mancavano all'inbox admin, tutte senza migration. 1. Dall'inbox era impossibile aprire una conversazione: getConversations() costruiva la lista dai commenti, quindi un cliente compariva solo dopo aver scritto lui. Ora la lista parte da `clients` e i commenti la arricchiscono. Ordine: prima chi ha scritto (per recenza), in coda i clienti muti in alfabetico, cosi' l'inbox resta un inbox. 2. Menzioni «@Nome», rinviate dalla chat a canali. Modello senza schema: il tag si riconosce confrontando il testo con i nomi noti del cliente (nome intero, nome di battesimo, brand), insensibile ad accenti e maiuscole. Il body resta quello che l'admin ha scritto, quindi la menzione sopravvive alla modifica di un messaggio e resta leggibile ovunque finisca, mail compresa. Confini di parola su ENTRAMBI i lati: senza quello a sinistra, «scrivimi a mario@teckell.it» conteneva un tag «@Teckell». 3. Un tag manda una mail. E' l'unico messaggio che esce dal portale: per il resto il cliente entra quando gli pare, ma il tag e' la dichiarazione che quel messaggio non puo' aspettare il prossimo accesso. Nessuno scheduler -- parte dalla stessa azione che scrive il messaggio, fuori transazione: se Resend e' giu' il messaggio in chat resta comunque scritto. Destinatari: whitelist OTP + email della scheda, deduplicati. Con zero indirizzi il compositore lo dice PRIMA, invece di lasciar credere che sia partita una mail che non partira'. Il pulsante della mail punta a `?chat=<canale>`, validato lato server e passato come prop: leggerlo nel browser vorrebbe dire renderizzare il pannello chiuso e riaprirlo dopo l'idratazione. La casella di risposta diventa controllata (ReplyComposer): il suggerimento del tag deve leggere il testo mentre lo scrivi e reinserirlo al caret giusto. Invio manda, Shift+Invio va a capo -- come nel pannello del cliente. Verificato: `npm run build` e `eslint` puliti, parser delle menzioni provato su 9 casi. NON verificato a schermo ne' contro il DB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
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<string, ClientMeta>;
|
|
entityMap: Map<string, EntityInfo>;
|
|
allEntityIds: string[];
|
|
phasesByClient: Map<string, { id: string; title: string }[]>;
|
|
}> {
|
|
const clientMeta = new Map<string, ClientMeta>();
|
|
const entityMap = new Map<string, EntityInfo>();
|
|
const phasesByClient = new Map<string, { id: string; title: string }[]>();
|
|
|
|
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<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",
|
|
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<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",
|
|
// 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<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 ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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<ConversationSummary[]> {
|
|
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<ConversationThread | null> {
|
|
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<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;
|
|
}
|