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:
@@ -16,6 +16,11 @@ INTERNAL_SECRET=generate-with-openssl-rand-base64-32
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
RESEND_FROM=Nome Mittente <no-reply@iamcavalli.net>
|
||||
|
||||
# Base pubblica dei link nelle email (pulsante "Apri la conversazione" della
|
||||
# notifica di tag). Opzionale: se manca si usa NEXTAUTH_URL, che in ogni
|
||||
# ambiente e' gia' l'origine giusta. Serve solo se le due devono divergere.
|
||||
# APP_BASE_URL=https://hub.iamcavalli.net
|
||||
|
||||
# Ingresso lead da fuori (form del sito, bridge Zapier/Make) su
|
||||
# POST /api/webhooks/lead, header x-webhook-secret.
|
||||
# A differenza di INTERNAL_SECRET questa route e' esposta a internet: se la
|
||||
|
||||
@@ -8,6 +8,8 @@ import { authOptions } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
import { clients, comments } from "@/db/schema";
|
||||
import { assertClientOwnsEntity } from "@/lib/client-chat";
|
||||
import { hasMention, mentionCandidates } from "@/lib/mentions";
|
||||
import { notifyClientMention } from "@/lib/client-notifications";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -43,12 +45,12 @@ export async function replyToConversation(clientId: string, formData: FormData)
|
||||
if (!clientId || !parsed.success) throw new Error("Dati mancanti");
|
||||
const { entity_type, entity_id, body } = parsed.data;
|
||||
|
||||
const rows = await db
|
||||
.select({ id: clients.id })
|
||||
const [client] = await db
|
||||
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
if (rows.length === 0) throw new Error("Cliente non trovato");
|
||||
if (!client) throw new Error("Cliente non trovato");
|
||||
|
||||
// Stessa verifica di appartenenza usata dalle route del portale: un entity_id
|
||||
// arbitrario nel form non deve poter scrivere nella chat di un altro cliente.
|
||||
@@ -68,6 +70,17 @@ export async function replyToConversation(clientId: string, formData: FormData)
|
||||
.set({ admin_last_read_at: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
|
||||
// Il tag è l'unico messaggio che esce dal portale. Si valuta sul testo appena
|
||||
// scritto, non su un campo nascosto del form: quello resterebbe indietro se
|
||||
// l'admin cancellasse la menzione prima di inviare.
|
||||
//
|
||||
// L'invio non è dentro una transazione con l'insert e non deve esserlo: se la
|
||||
// mail fallisce, il messaggio in chat resta comunque scritto — il contrario
|
||||
// sarebbe perdere il messaggio per colpa del provider di posta.
|
||||
if (hasMention(body, mentionCandidates(client))) {
|
||||
await notifyClientMention({ clientId, channelKey: entity_id, body });
|
||||
}
|
||||
|
||||
revalidatePath("/admin/conversazioni");
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
@@ -98,15 +98,31 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Il canale chiesto con `?chat=<key>`, se appartiene a QUESTO progetto.
|
||||
*
|
||||
* La validazione non e' cosmetica: senza, un id qualsiasi nella query aprirebbe
|
||||
* la chat su un canale vuoto — e con piu' progetti a tab, la fase di un progetto
|
||||
* spalancherebbe il pannello anche negli altri.
|
||||
*/
|
||||
function resolveChatChannel(
|
||||
requested: string | undefined,
|
||||
view: ProjectView
|
||||
): string | null {
|
||||
if (!requested) return null;
|
||||
if (requested === view.project.client_id) return requested;
|
||||
return view.phases.some((p) => p.id === requested) ? requested : null;
|
||||
}
|
||||
|
||||
export default async function ClientPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
searchParams: Promise<{ preview?: string }>;
|
||||
searchParams: Promise<{ preview?: string; chat?: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const { preview: previewParam } = await searchParams;
|
||||
const { preview: previewParam, chat: chatParam } = await searchParams;
|
||||
|
||||
// ⚠️ Il gate va PRIMA di ogni query sui dati del progetto: se si interroga il
|
||||
// DB e poi si decide di mostrare il form, i dati sono già nel payload RSC
|
||||
@@ -161,6 +177,7 @@ export default async function ClientPage({
|
||||
adminAvatarUrl: admin.avatarUrl,
|
||||
}}
|
||||
preview={preview}
|
||||
initialChatChannel={resolveChatChannel(chatParam, view)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -214,6 +231,7 @@ export default async function ClientPage({
|
||||
}}
|
||||
embedded
|
||||
preview={preview}
|
||||
initialChatChannel={resolveChatChannel(chatParam, view)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
|
||||
|
||||
@@ -5,13 +5,13 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
|
||||
import { SearchInput } from "@/components/ui/SearchInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MentionText } from "@/components/ui/MentionText";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReplyComposer } from "./ReplyComposer";
|
||||
import {
|
||||
editConversationMessage,
|
||||
markConversationRead,
|
||||
replyToConversation,
|
||||
} from "@/app/admin/conversazioni/actions";
|
||||
import type {
|
||||
ConversationSummary,
|
||||
@@ -93,7 +93,7 @@ export function ConversationsView({
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
|
||||
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">Nessun messaggio dai clienti ancora.</p>
|
||||
<p className="text-sm">Scegli un cliente per aprire la conversazione.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -126,18 +126,28 @@ function ConversationListItem({
|
||||
>
|
||||
{conv.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
||||
{formatListTime(conv.lastMessageAt)}
|
||||
</span>
|
||||
{conv.lastMessageAt && (
|
||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
||||
{formatListTime(conv.lastMessageAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||
{conv.lastMessageAuthor === "admin" && (
|
||||
<span className="text-muted-foreground/70">Tu: </span>
|
||||
{conv.lastMessageAt ? (
|
||||
<>
|
||||
{conv.lastMessageAuthor === "admin" && (
|
||||
<span className="text-muted-foreground/70">Tu: </span>
|
||||
)}
|
||||
{conv.lastMessage}
|
||||
</>
|
||||
) : (
|
||||
<span className="italic text-muted-foreground/70">
|
||||
Nessun messaggio — scrivi tu per primo
|
||||
</span>
|
||||
)}
|
||||
{conv.lastMessage}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<EntityBadge label={conv.lastEntityLabel} />
|
||||
{conv.lastMessageAt ? <EntityBadge label={conv.lastEntityLabel} /> : <span />}
|
||||
{conv.unread && (
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{conv.unreadCount > 1 && (
|
||||
@@ -272,34 +282,29 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
|
||||
{visibleMessages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
Nessun messaggio in questo canale.
|
||||
Nessun messaggio in questo canale. Scrivi tu per primo.
|
||||
</p>
|
||||
) : (
|
||||
visibleMessages.map((m) => (
|
||||
<MessageBubble key={m.id} message={m} clientId={thread.clientId} />
|
||||
<MessageBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
clientId={thread.clientId}
|
||||
mentionCandidates={thread.mentionCandidates}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply box — il canale viaggia col messaggio */}
|
||||
<form
|
||||
action={replyToConversation.bind(null, thread.clientId)}
|
||||
className="p-4 border-t border-border bg-card flex items-end gap-3"
|
||||
>
|
||||
<input type="hidden" name="entity_type" value={isGeneral ? "general" : "phase"} />
|
||||
<input type="hidden" name="entity_id" value={activeChannel} />
|
||||
<Textarea
|
||||
name="body"
|
||||
rows={2}
|
||||
required
|
||||
placeholder={`Rispondi in ${activeLabel}...`}
|
||||
aria-label={`Rispondi nel canale ${activeLabel}`}
|
||||
className="flex-1 resize-none"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="shrink-0">
|
||||
Invia
|
||||
</Button>
|
||||
</form>
|
||||
<ReplyComposer
|
||||
clientId={thread.clientId}
|
||||
channelKey={activeChannel}
|
||||
channelLabel={activeLabel}
|
||||
isGeneral={isGeneral}
|
||||
mentionCandidates={thread.mentionCandidates}
|
||||
notifyEmailCount={thread.notifyEmailCount}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -307,9 +312,11 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
||||
function MessageBubble({
|
||||
message,
|
||||
clientId,
|
||||
mentionCandidates,
|
||||
}: {
|
||||
message: ConversationThread["messages"][number];
|
||||
clientId: string;
|
||||
mentionCandidates: string[];
|
||||
}) {
|
||||
const isAdmin = message.author === "admin";
|
||||
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
|
||||
@@ -391,7 +398,17 @@ function MessageBubble({
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{message.body}
|
||||
<MentionText
|
||||
body={message.body}
|
||||
candidates={mentionCandidates}
|
||||
// Sulla bolla admin il fondo è `bg-primary`: un chip tenue ci
|
||||
// sparirebbe sopra, quindi si scava invece di colorare.
|
||||
mentionClassName={
|
||||
isAdmin
|
||||
? "rounded bg-primary-foreground/20 px-1 font-semibold"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState, useTransition } from "react";
|
||||
import { AtSign } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { replyToConversation } from "@/app/admin/conversazioni/actions";
|
||||
|
||||
/**
|
||||
* Casella di risposta dell'inbox, con il tag del cliente.
|
||||
*
|
||||
* È controllata — e non più un `<form action>` con una textarea libera — perché
|
||||
* il suggerimento del tag deve leggere il testo mentre lo scrivi e riscriverlo
|
||||
* al punto giusto. Il canale continua a viaggiare col messaggio: `entity_id` è
|
||||
* il canale aperto, `entity_type` si deriva da esso.
|
||||
*/
|
||||
export function ReplyComposer({
|
||||
clientId,
|
||||
channelKey,
|
||||
channelLabel,
|
||||
isGeneral,
|
||||
mentionCandidates,
|
||||
notifyEmailCount,
|
||||
}: {
|
||||
clientId: string;
|
||||
channelKey: string;
|
||||
channelLabel: string;
|
||||
isGeneral: boolean;
|
||||
/** I nomi con cui si può taggare questo cliente, dal più completo al più corto. */
|
||||
mentionCandidates: string[];
|
||||
/** Quante mail partirebbero davvero con un tag. Zero = si avvisa, non si tace. */
|
||||
notifyEmailCount: number;
|
||||
}) {
|
||||
const [body, setBody] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sending, startSending] = useTransition();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Posizione dell'ultima "@" ancora aperta e testo digitato dopo: null quando
|
||||
// non c'è nessun tag in corso di scrittura.
|
||||
const [trigger, setTrigger] = useState<{ at: number; query: string } | null>(null);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
if (!trigger) return [];
|
||||
const q = trigger.query.toLowerCase();
|
||||
// Il nome intero per primo: è quello che si vuole quasi sempre.
|
||||
return mentionCandidates.filter((c) => c.toLowerCase().startsWith(q)).slice(0, 4);
|
||||
}, [trigger, mentionCandidates]);
|
||||
|
||||
const open = suggestions.length > 0;
|
||||
|
||||
function syncTrigger(value: string, caret: number) {
|
||||
const before = value.slice(0, caret);
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at === -1) return setTrigger(null);
|
||||
// Una "@" vale solo a inizio parola: "email@dominio.it" non apre il menu.
|
||||
const prev = at > 0 ? before[at - 1] : " ";
|
||||
if (!/\s/.test(prev)) return setTrigger(null);
|
||||
const query = before.slice(at + 1);
|
||||
// Un tag non attraversa un a capo, e oltre il nome più lungo non c'è più
|
||||
// niente da suggerire.
|
||||
if (query.includes("\n")) return setTrigger(null);
|
||||
setTrigger({ at, query });
|
||||
}
|
||||
|
||||
function insert(candidate: string) {
|
||||
if (!trigger) return;
|
||||
const el = textareaRef.current;
|
||||
const caret = el?.selectionStart ?? body.length;
|
||||
const next = `${body.slice(0, trigger.at)}@${candidate} ${body.slice(caret)}`;
|
||||
const nextCaret = trigger.at + candidate.length + 2;
|
||||
setBody(next);
|
||||
setTrigger(null);
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(nextCaret, nextCaret);
|
||||
});
|
||||
}
|
||||
|
||||
/** Il pulsante @: inserisce la chiocciola e apre subito il menu. */
|
||||
function startMention() {
|
||||
const el = textareaRef.current;
|
||||
const caret = el?.selectionStart ?? body.length;
|
||||
const needsSpace = caret > 0 && !/\s/.test(body[caret - 1]);
|
||||
const prefix = needsSpace ? " @" : "@";
|
||||
const next = `${body.slice(0, caret)}${prefix}${body.slice(caret)}`;
|
||||
const nextCaret = caret + prefix.length;
|
||||
setBody(next);
|
||||
setTrigger({ at: nextCaret - 1, query: "" });
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(nextCaret, nextCaret);
|
||||
});
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = body.trim();
|
||||
if (!text || sending) return;
|
||||
const fd = new FormData();
|
||||
fd.set("entity_type", isGeneral ? "general" : "phase");
|
||||
fd.set("entity_id", channelKey);
|
||||
fd.set("body", text);
|
||||
setError(null);
|
||||
startSending(async () => {
|
||||
try {
|
||||
await replyToConversation(clientId, fd);
|
||||
setBody("");
|
||||
setTrigger(null);
|
||||
} catch {
|
||||
setError("Messaggio non inviato, riprova");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card p-4">
|
||||
<div className="relative flex items-end gap-3">
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label="Tagga il cliente"
|
||||
className="absolute bottom-full left-0 z-20 mb-2 w-64 overflow-hidden rounded-lg border border-border bg-card shadow-lg"
|
||||
>
|
||||
{suggestions.map((candidate, i) => (
|
||||
<li key={candidate}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === 0}
|
||||
// onMouseDown e non onClick: il click toglierebbe il fuoco alla
|
||||
// textarea prima di leggerne il caret, e l'inserimento finirebbe
|
||||
// nel posto sbagliato.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insert(candidate);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted",
|
||||
i === 0 && "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<AtSign className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-foreground">{candidate}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value);
|
||||
syncTrigger(e.target.value, e.target.selectionStart ?? e.target.value.length);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const el = e.currentTarget;
|
||||
syncTrigger(el.value, el.selectionStart ?? el.value.length);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (open && (e.key === "Enter" || e.key === "Tab")) {
|
||||
e.preventDefault();
|
||||
insert(suggestions[0]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && open) {
|
||||
e.preventDefault();
|
||||
setTrigger(null);
|
||||
return;
|
||||
}
|
||||
// Invio manda, Shift+Invio va a capo: il comportamento che il
|
||||
// pannello del cliente ha già.
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
placeholder={`Rispondi in ${channelLabel}...`}
|
||||
aria-label={`Rispondi nel canale ${channelLabel}`}
|
||||
className="flex-1 resize-none"
|
||||
/>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<Button type="button" size="sm" onClick={send} disabled={sending || !body.trim()}>
|
||||
{sending ? "Invio…" : "Invia"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startMention}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<AtSign className="h-3 w-3" /> Tagga
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{notifyEmailCount > 0
|
||||
? `Un tag gli manda una mail (${notifyEmailCount} ${notifyEmailCount === 1 ? "indirizzo" : "indirizzi"}).`
|
||||
: "Nessuna email a registro: il tag resta solo in chat."}
|
||||
</span>
|
||||
{error && <span className="text-[11px] text-destructive">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,10 @@ export async function InboxBand() {
|
||||
{conv.brand_name}
|
||||
</h3>
|
||||
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
|
||||
{relativeTime(conv.lastMessageAt)}
|
||||
{/* `unread` implica almeno un messaggio: il null non capita,
|
||||
ma il tipo lo ammette da quando la lista include anche i
|
||||
clienti con cui non si è ancora scritto. */}
|
||||
{conv.lastMessageAt ? relativeTime(conv.lastMessageAt) : ""}
|
||||
</span>
|
||||
{conv.unreadCount > 1 && (
|
||||
<span className="text-[10px] font-bold text-emerald-700 dark:text-emerald-400 shrink-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ClientView } from '@/lib/client-view';
|
||||
import { mentionCandidates } from '@/lib/mentions';
|
||||
import type { ChatData } from '@/lib/chat-channels';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { PhaseTimeline } from './phase-timeline';
|
||||
@@ -23,9 +24,18 @@ interface ClientDashboardProps {
|
||||
embedded?: boolean;
|
||||
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
|
||||
preview?: boolean;
|
||||
/** Canale da aprire all'arrivo (`?chat=`), gia' validato dalla pagina. */
|
||||
initialChatChannel?: string | null;
|
||||
}
|
||||
|
||||
export function ClientDashboard({ view, token, chat, embedded = false, preview = false }: ClientDashboardProps) {
|
||||
export function ClientDashboard({
|
||||
view,
|
||||
token,
|
||||
chat,
|
||||
embedded = false,
|
||||
preview = false,
|
||||
initialChatChannel = null,
|
||||
}: ClientDashboardProps) {
|
||||
// Determine payment display mode based on active offers.
|
||||
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
||||
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
||||
@@ -43,7 +53,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
|
||||
|
||||
return (
|
||||
<PreviewProvider preview={preview}>
|
||||
<ChatProvider phases={view.phases} clientId={view.client.id}>
|
||||
<ChatProvider
|
||||
phases={view.phases}
|
||||
clientId={view.client.id}
|
||||
initialChannel={initialChatChannel}
|
||||
>
|
||||
<div className={embedded ? "" : "min-h-screen bg-background"}>
|
||||
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
|
||||
{!embedded && (
|
||||
@@ -161,7 +175,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
|
||||
</footer>
|
||||
|
||||
{/* Floating chat panel — FAB + slide-in panel */}
|
||||
<ChatPanel token={token} chat={chat} />
|
||||
<ChatPanel
|
||||
token={token}
|
||||
chat={chat}
|
||||
mentionCandidates={mentionCandidates(view.client)}
|
||||
/>
|
||||
</div>
|
||||
</ChatProvider>
|
||||
</PreviewProvider>
|
||||
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
type ChatData,
|
||||
type ChatMessage,
|
||||
} from "@/lib/chat-channels";
|
||||
import { MentionText } from "@/components/ui/MentionText";
|
||||
import { useChatContext } from "./ChatProvider";
|
||||
import { usePreview } from "./PreviewProvider";
|
||||
|
||||
// TODO: la mail di notifica al cliente ("hai un messaggio non letto") è un giro a
|
||||
// sé: serve uno scheduler, che nel repo non esiste ancora. Hook point naturale:
|
||||
// una query su client_channel_reads incrociata con comments.author = 'admin'.
|
||||
// La notifica via mail al cliente esiste, ma solo per i messaggi in cui l'admin
|
||||
// lo tagga (@Nome): parte da replyToConversation, senza scheduler. Una notifica
|
||||
// per OGNI messaggio non letto sarebbe un'altra cosa e richiederebbe un job.
|
||||
|
||||
const POLL_MS = 20_000;
|
||||
/** Oltre questo scarto due messaggi dello stesso autore non si raggruppano più. */
|
||||
@@ -60,7 +61,16 @@ function monogram(label: string): string {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
||||
export function ChatPanel({
|
||||
token,
|
||||
chat,
|
||||
mentionCandidates,
|
||||
}: {
|
||||
token: string;
|
||||
chat: ChatData;
|
||||
/** I nomi con cui l'admin può taggare questo cliente — vedi lib/mentions.ts. */
|
||||
mentionCandidates: string[];
|
||||
}) {
|
||||
const {
|
||||
isOpen,
|
||||
expanded,
|
||||
@@ -495,6 +505,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
||||
clientId={clientId}
|
||||
adminName={adminName}
|
||||
adminAvatarUrl={adminAvatarUrl}
|
||||
mentionCandidates={mentionCandidates}
|
||||
canEdit={!preview}
|
||||
onEdit={applyEdit}
|
||||
/>
|
||||
@@ -605,6 +616,7 @@ function MessageList({
|
||||
clientId,
|
||||
adminName,
|
||||
adminAvatarUrl,
|
||||
mentionCandidates,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: {
|
||||
@@ -614,6 +626,7 @@ function MessageList({
|
||||
clientId: string;
|
||||
adminName: string;
|
||||
adminAvatarUrl: string | null;
|
||||
mentionCandidates: string[];
|
||||
/** Falso in anteprima admin: si guarda, non si scrive. */
|
||||
canEdit: boolean;
|
||||
onEdit: (id: string, body: string) => Promise<boolean>;
|
||||
@@ -647,6 +660,7 @@ function MessageList({
|
||||
grouped={grouped}
|
||||
adminName={adminName}
|
||||
adminAvatarUrl={adminAvatarUrl}
|
||||
mentionCandidates={mentionCandidates}
|
||||
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
|
||||
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
|
||||
entityLabel={
|
||||
@@ -676,6 +690,7 @@ function MessageRow({
|
||||
entityLabel,
|
||||
adminName,
|
||||
adminAvatarUrl,
|
||||
mentionCandidates,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: {
|
||||
@@ -686,6 +701,7 @@ function MessageRow({
|
||||
entityLabel?: string;
|
||||
adminName: string;
|
||||
adminAvatarUrl: string | null;
|
||||
mentionCandidates: string[];
|
||||
canEdit: boolean;
|
||||
onEdit: (id: string, body: string) => Promise<boolean>;
|
||||
}) {
|
||||
@@ -803,7 +819,7 @@ function MessageRow({
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
||||
{m.body}
|
||||
<MentionText body={m.body} candidates={mentionCandidates} />
|
||||
{m.edited_at && (
|
||||
<span className="ml-1.5 text-[10px] text-muted-foreground">
|
||||
(modificato)
|
||||
|
||||
@@ -36,16 +36,31 @@ export function ChatProvider({
|
||||
children,
|
||||
phases,
|
||||
clientId,
|
||||
initialChannel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
phases: ClientView["phases"];
|
||||
clientId: string;
|
||||
/**
|
||||
* Canale richiesto da fuori con `?chat=<key>` — è così che il pulsante della
|
||||
* mail di tag porta il cliente dentro la conversazione giusta invece che sulla
|
||||
* dashboard, dove la chat resterebbe chiusa e il messaggio non letto.
|
||||
*
|
||||
* Arriva dal server, già validato: leggerlo qui da `window.location` vorrebbe
|
||||
* dire aprire il pannello in un effetto, cioè renderizzarlo chiuso e riaprirlo
|
||||
* subito dopo l'idratazione.
|
||||
*/
|
||||
initialChannel?: string | null;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// Un canale richiesto vale anche come "apri il pannello": chi arriva da quel
|
||||
// link sta venendo a leggere un messaggio, non a guardare la timeline.
|
||||
const [isOpen, setIsOpen] = useState(!!initialChannel);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Il canale "Generale" è identificato dall'id del cliente: stessa convenzione
|
||||
// di comments.entity_id, così non serve tradurre nulla in scrittura.
|
||||
const [activeChannel, setActiveChannel] = useState<string>(clientId);
|
||||
const [activeChannel, setActiveChannel] = useState<string>(
|
||||
initialChannel ?? clientId
|
||||
);
|
||||
|
||||
const openChat = useCallback(
|
||||
(phaseId?: string) => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Fragment } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { splitMentions } from "@/lib/mentions";
|
||||
|
||||
/**
|
||||
* Corpo di un messaggio di chat con le menzioni evidenziate.
|
||||
*
|
||||
* Sostituisce `{m.body}` dentro il paragrafo: mantiene `whitespace-pre-wrap` sul
|
||||
* genitore, quindi gli a capo continuano a funzionare senza dover spezzare il
|
||||
* testo in righe qui dentro.
|
||||
*/
|
||||
export function MentionText({
|
||||
body,
|
||||
candidates,
|
||||
mentionClassName,
|
||||
}: {
|
||||
body: string;
|
||||
candidates: string[];
|
||||
/** Il contrasto cambia col fondo della bolla: lo decide chi la disegna. */
|
||||
mentionClassName?: string;
|
||||
}) {
|
||||
const parts = splitMentions(body, candidates);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.type === "mention" ? (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded px-1 font-semibold",
|
||||
mentionClassName ??
|
||||
"bg-primary/10 text-primary dark:bg-primary/20"
|
||||
)}
|
||||
>
|
||||
{part.value}
|
||||
</span>
|
||||
) : (
|
||||
<Fragment key={i}>{part.value}</Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { clients, client_emails, phases } from "@/db/schema";
|
||||
import { mentionEmailTemplate, sendEmail } from "@/lib/mailer";
|
||||
import { getAdminIdentity } from "@/lib/settings";
|
||||
import { GENERAL_LABEL } from "@/lib/chat-channels";
|
||||
|
||||
/**
|
||||
* Notifica al cliente che è stato taggato in chat.
|
||||
*
|
||||
* Il portale non manda una notifica per ogni messaggio, e non deve: il cliente
|
||||
* lo apre quando gli pare. Il tag è l'eccezione dichiarata — l'admin decide che
|
||||
* quel messaggio non può aspettare il prossimo accesso, e quella decisione vale
|
||||
* una mail. Nessuno scheduler, nessuna coda: l'invio parte dalla stessa azione
|
||||
* che scrive il messaggio.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gli indirizzi che ricevono la notifica: la whitelist OTP (le persone che
|
||||
* possono davvero entrare nel portale) più l'indirizzo di contatto sulla scheda.
|
||||
* Deduplicati senza distinzione di maiuscole, perché lo stesso indirizzo scritto
|
||||
* in due modi è una persona sola.
|
||||
*/
|
||||
export async function getMentionRecipients(clientId: string): Promise<string[]> {
|
||||
const [whitelisted, clientRows] = await Promise.all([
|
||||
db
|
||||
.select({ email: client_emails.email })
|
||||
.from(client_emails)
|
||||
.where(eq(client_emails.client_id, clientId)),
|
||||
db
|
||||
.select({ email: clients.email })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const value of [
|
||||
...whitelisted.map((r) => r.email),
|
||||
clientRows[0]?.email ?? null,
|
||||
]) {
|
||||
const email = value?.trim();
|
||||
if (!email) continue;
|
||||
const key = email.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(email);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base pubblica del portale. `NEXTAUTH_URL` è già l'origine giusta in ogni
|
||||
* ambiente; `APP_BASE_URL` esiste solo per il caso in cui le due debbano
|
||||
* divergere. Se manca tutto si torna null: meglio una mail senza link che una
|
||||
* mail con un link a localhost.
|
||||
*/
|
||||
function portalBaseUrl(): string | null {
|
||||
const raw = process.env.APP_BASE_URL ?? process.env.NEXTAUTH_URL;
|
||||
const trimmed = raw?.trim().replace(/\/+$/, "");
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
type MentionInput = {
|
||||
clientId: string;
|
||||
/** clients.id per "Generale", phases.id per una fase. */
|
||||
channelKey: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type MentionNotifyResult = {
|
||||
/** Quante mail sono partite davvero. */
|
||||
sent: number;
|
||||
recipients: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Non lancia mai: il messaggio in chat è già stato scritto e resta valido anche
|
||||
* se Resend è giù. Un errore d'invio si logga e si restituisce, non si propaga
|
||||
* fino a far sembrare fallita la risposta al cliente.
|
||||
*/
|
||||
export async function notifyClientMention({
|
||||
clientId,
|
||||
channelKey,
|
||||
body,
|
||||
}: MentionInput): Promise<MentionNotifyResult> {
|
||||
const result: MentionNotifyResult = { sent: 0, recipients: 0, errors: [] };
|
||||
|
||||
try {
|
||||
const [clientRow] = await db
|
||||
.select({
|
||||
name: clients.name,
|
||||
brand_name: clients.brand_name,
|
||||
token: clients.token,
|
||||
slug: clients.slug,
|
||||
})
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
if (!clientRow) return result;
|
||||
|
||||
const recipients = await getMentionRecipients(clientId);
|
||||
result.recipients = recipients.length;
|
||||
if (recipients.length === 0) return result;
|
||||
|
||||
const [admin, channelLabel] = await Promise.all([
|
||||
getAdminIdentity(),
|
||||
resolveChannelLabel(clientId, channelKey),
|
||||
]);
|
||||
|
||||
const base = portalBaseUrl();
|
||||
// Lo slug è il link "bello" quando c'è; il token è sempre valido. Entrambi
|
||||
// sono credenziali al portatore: finiscono solo nella mail del cliente.
|
||||
const url = base
|
||||
? `${base}/client/${clientRow.slug ?? clientRow.token}?chat=${encodeURIComponent(channelKey)}`
|
||||
: null;
|
||||
|
||||
const { subject, html } = mentionEmailTemplate({
|
||||
clientName: clientRow.name,
|
||||
adminName: admin.name,
|
||||
channelLabel,
|
||||
body,
|
||||
url,
|
||||
});
|
||||
|
||||
// In serie e non in parallelo: sono uno o due indirizzi, e un 429 di Resend
|
||||
// su un burst costerebbe più di quanto valga la concorrenza.
|
||||
for (const to of recipients) {
|
||||
const outcome = await sendEmail({ to, subject, html });
|
||||
if (outcome.ok) result.sent++;
|
||||
else result.errors.push(`${to}: ${outcome.error}`);
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.error("[notifyClientMention] invii falliti:", result.errors.join(" | "));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "errore sconosciuto";
|
||||
result.errors.push(message);
|
||||
console.error("[notifyClientMention] errore:", message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** "Generale" o il titolo della fase: è la stessa etichetta che il cliente vede sul tab. */
|
||||
async function resolveChannelLabel(
|
||||
clientId: string,
|
||||
channelKey: string
|
||||
): Promise<string> {
|
||||
if (channelKey === clientId) return GENERAL_LABEL;
|
||||
const [row] = await db
|
||||
.select({ title: phases.title })
|
||||
.from(phases)
|
||||
.where(eq(phases.id, channelKey))
|
||||
.limit(1);
|
||||
return row?.title?.trim() || GENERAL_LABEL;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,67 @@ export function otpEmailTemplate(code: string, brandName: string): { subject: st
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica «ti hanno taggato in chat».
|
||||
*
|
||||
* Il cliente apre il portale quando gli pare: senza questa mail un messaggio
|
||||
* urgente resta lì finché non gli viene in mente di guardare. Il testo dice una
|
||||
* cosa sola — c'è un messaggio per te, qui — e ci porta con un pulsante.
|
||||
*
|
||||
* L'estratto è volutamente corto: serve a far capire di cosa si parla, non a
|
||||
* sostituire la chat. La conversazione si legge nel portale, dove si risponde.
|
||||
*/
|
||||
export function mentionEmailTemplate({
|
||||
clientName,
|
||||
adminName,
|
||||
channelLabel,
|
||||
body,
|
||||
url,
|
||||
}: {
|
||||
clientName: string;
|
||||
adminName: string;
|
||||
channelLabel: string;
|
||||
body: string;
|
||||
/** null se la base pubblica non è configurata: la mail parte comunque, senza pulsante. */
|
||||
url: string | null;
|
||||
}): { subject: string; html: string } {
|
||||
const firstName = clientName.trim().split(/\s+/)[0] || clientName;
|
||||
const excerpt = truncate(body, 280);
|
||||
|
||||
const button = url
|
||||
? `<p style="margin:0 0 24px;"><a href="${escapeHtml(url)}" style="display:inline-block;background:#1a1a1a;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:14px 28px;border-radius:8px;">Apri la conversazione</a></p>`
|
||||
: "";
|
||||
|
||||
return {
|
||||
subject: `${adminName} ti ha taggato in chat`,
|
||||
html: `<!doctype html>
|
||||
<html lang="it">
|
||||
<body style="margin:0;padding:32px 16px;background:#f6f6f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#1a1a1a;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border-radius:12px;padding:32px;">
|
||||
<tr><td>
|
||||
<p style="margin:0 0 8px;font-size:14px;color:#71717a;">${escapeHtml(channelLabel)}</p>
|
||||
<h1 style="margin:0 0 24px;font-size:20px;font-weight:600;">${escapeHtml(firstName)}, ${escapeHtml(adminName)} ti ha taggato in chat</h1>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;margin:0 0 24px;background:#f6f6f4;border-radius:8px;">
|
||||
<tr><td style="padding:16px 18px;font-size:15px;line-height:1.6;color:#1a1a1a;">${escapeHtml(excerpt).replace(/\n/g, "<br>")}</td></tr>
|
||||
</table>
|
||||
${button}
|
||||
<p style="margin:0;font-size:14px;color:#71717a;line-height:1.6;">Rispondi dal portale: la conversazione resta lì, insieme al progetto.</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Taglia sull'ultimo spazio prima del limite, per non spezzare una parola a metà. */
|
||||
function truncate(text: string, max: number): string {
|
||||
const clean = text.trim();
|
||||
if (clean.length <= max) return clean;
|
||||
const cut = clean.slice(0, max);
|
||||
const lastSpace = cut.lastIndexOf(" ");
|
||||
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** Il brand name arriva dal DB ed è admin-controlled, ma finisce in HTML: si sanifica comunque. */
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Menzioni nella chat — «@Nome» dentro il corpo di un messaggio.
|
||||
*
|
||||
* Il modello è deliberatamente senza schema: nessuna tabella `mentions`, nessun
|
||||
* marcatore nel testo. Il body resta esattamente quello che l'admin ha scritto,
|
||||
* e la menzione si riconosce a posteriori confrontando il testo con i nomi noti
|
||||
* del cliente di quella conversazione.
|
||||
*
|
||||
* Perché così e non con un token strutturato tipo `@[Nome](client:id)`:
|
||||
* 1. il testo resta leggibile ovunque finisca — email di notifica, anteprima
|
||||
* nella lista, log — senza bisogno di un renderer che lo traduca;
|
||||
* 2. sopravvive alla modifica di un messaggio, che qui riscrive il body e non
|
||||
* saprebbe rigenerare un id nascosto;
|
||||
* 3. non introduce una migration per una funzione che riguarda una sola entità
|
||||
* (la chat è 1:1, admin ↔ cliente: non esiste "chi" da disambiguare).
|
||||
*
|
||||
* Il prezzo: se il cliente cambia nome, le vecchie menzioni smettono di essere
|
||||
* evidenziate. È un difetto cosmetico e si auto-ripara scrivendo il nome nuovo.
|
||||
*
|
||||
* Funzioni pure: nessun accesso al DB, usabili sia sul server sia nel browser.
|
||||
*/
|
||||
|
||||
/** Diacritici Unicode combinanti — via da qui, così «Nicolò» matcha «nicolo». */
|
||||
const COMBINING = /[\u0300-\u036f]/g;
|
||||
/**
|
||||
* Una menzione è delimitata da parola su entrambi i lati:
|
||||
* - a destra, «@Mario» non deve matchare dentro «@Mariotti»;
|
||||
* - a sinistra, la chiocciola deve aprire una parola. Senza questo controllo
|
||||
* «scrivimi a mario@teckell.it» conterrebbe un tag «@Teckell» e farebbe
|
||||
* partire una mail di notifica che nessuno ha chiesto.
|
||||
*/
|
||||
const WORD_CHAR = /[\p{L}\p{N}_]/u;
|
||||
|
||||
function foldChar(ch: string): string {
|
||||
const folded = ch.normalize("NFD").replace(COMBINING, "").toLowerCase();
|
||||
// Un carattere può decomporsi in più code point: si tiene il primo, così la
|
||||
// stringa ripiegata resta allineata all'originale posizione per posizione —
|
||||
// che è quello che permette di ritagliare il testo originale dagli indici.
|
||||
return folded.length > 0 ? folded[0] : ch.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* I nomi con cui questo cliente può essere taggato: il nome della persona, il
|
||||
* suo solo nome di battesimo e il brand. Ordinati dal più lungo al più corto
|
||||
* perché «@Mario Rossi» deve vincere su «@Mario», altrimenti il chip si
|
||||
* fermerebbe a metà del nome.
|
||||
*/
|
||||
export function mentionCandidates(client: {
|
||||
name: string;
|
||||
brand_name?: string | null;
|
||||
}): string[] {
|
||||
const raw = [client.name, client.brand_name ?? "", client.name.split(/\s+/)[0] ?? ""];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const value of raw) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
export type MentionPart =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "mention"; value: string };
|
||||
|
||||
/**
|
||||
* Spezza il corpo del messaggio in testo semplice e menzioni, pronte per essere
|
||||
* rese con uno stile diverso. Il valore di una menzione include la chiocciola:
|
||||
* chi lo disegna non deve rimetterla e non deve sapere come è stata trovata.
|
||||
*/
|
||||
export function splitMentions(body: string, candidates: string[]): MentionPart[] {
|
||||
if (!body) return [];
|
||||
if (candidates.length === 0) return [{ type: "text", value: body }];
|
||||
|
||||
// Si lavora su array di code point, non sulla stringa: gli indici restano
|
||||
// validi anche con emoji o caratteri fuori dal piano base.
|
||||
const chars = Array.from(body);
|
||||
const folded = chars.map(foldChar);
|
||||
const foldedCandidates = candidates.map((c) => Array.from(c).map(foldChar));
|
||||
|
||||
const parts: MentionPart[] = [];
|
||||
let plainFrom = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < chars.length) {
|
||||
if (chars[i] !== "@") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = i > 0 ? chars[i - 1] : undefined;
|
||||
if (before !== undefined && WORD_CHAR.test(before)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const matched = foldedCandidates.find((cand) =>
|
||||
matchesAt(folded, i + 1, cand)
|
||||
);
|
||||
if (!matched) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const end = i + 1 + matched.length;
|
||||
if (plainFrom < i) {
|
||||
parts.push({ type: "text", value: chars.slice(plainFrom, i).join("") });
|
||||
}
|
||||
parts.push({ type: "mention", value: chars.slice(i, end).join("") });
|
||||
plainFrom = end;
|
||||
i = end;
|
||||
}
|
||||
|
||||
if (plainFrom < chars.length) {
|
||||
parts.push({ type: "text", value: chars.slice(plainFrom).join("") });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function matchesAt(
|
||||
folded: string[],
|
||||
start: number,
|
||||
candidate: string[]
|
||||
): boolean {
|
||||
if (start + candidate.length > folded.length) return false;
|
||||
for (let k = 0; k < candidate.length; k++) {
|
||||
if (folded[start + k] !== candidate[k]) return false;
|
||||
}
|
||||
const after = folded[start + candidate.length];
|
||||
return after === undefined || !WORD_CHAR.test(after);
|
||||
}
|
||||
|
||||
/** Questo messaggio tagga il cliente? È il solo interruttore della notifica. */
|
||||
export function hasMention(body: string, candidates: string[]): boolean {
|
||||
return splitMentions(body, candidates).some((p) => p.type === "mention");
|
||||
}
|
||||
Reference in New Issue
Block a user