feat(conversazioni): scrivere per primo, taggare il cliente, notificarlo via mail
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>
This commit is contained in:
@@ -4,6 +4,8 @@ import { clients, projects, phases, tasks, deliverables, comments } from "@/db/s
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,9 +15,11 @@ export type ConversationSummary = {
|
||||
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";
|
||||
lastMessageAt: Date;
|
||||
lastMessageAuthor: "client" | "admin" | null;
|
||||
/** null = nessun messaggio: la conversazione esiste ma e' ancora da aprire. */
|
||||
lastMessageAt: Date | null;
|
||||
lastEntityLabel: string;
|
||||
unread: boolean;
|
||||
unreadCount: number;
|
||||
@@ -54,6 +58,14 @@ export type ConversationThread = {
|
||||
* 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 = {
|
||||
@@ -221,17 +233,26 @@ async function fetchComments(allEntityIds: string[]): Promise<Comment[]> {
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One entry per non-archived client that has at least one comment, newest first. */
|
||||
/**
|
||||
* 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 [clientId, msgs] of byClient) {
|
||||
const meta = clientMeta.get(clientId);
|
||||
if (!meta || msgs.length === 0) continue;
|
||||
|
||||
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(
|
||||
@@ -239,21 +260,28 @@ export async function getConversations(): Promise<ConversationSummary[]> {
|
||||
);
|
||||
|
||||
summaries.push({
|
||||
clientId,
|
||||
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",
|
||||
lastMessageAt: last.created_at,
|
||||
lastEntityLabel: entityMap.get(last.entity_id)?.label ?? "Generale",
|
||||
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) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime());
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -296,6 +324,8 @@ export async function getConversationThread(
|
||||
messages,
|
||||
channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []),
|
||||
adminLastReadAt: meta.admin_last_read_at,
|
||||
mentionCandidates: mentionCandidates(meta),
|
||||
notifyEmailCount: (await getMentionRecipients(clientId)).length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user