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_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
RESEND_FROM=Nome Mittente <no-reply@iamcavalli.net>
|
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
|
# Ingresso lead da fuori (form del sito, bridge Zapier/Make) su
|
||||||
# POST /api/webhooks/lead, header x-webhook-secret.
|
# POST /api/webhooks/lead, header x-webhook-secret.
|
||||||
# A differenza di INTERNAL_SECRET questa route e' esposta a internet: se la
|
# 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 { db } from "@/db";
|
||||||
import { clients, comments } from "@/db/schema";
|
import { clients, comments } from "@/db/schema";
|
||||||
import { assertClientOwnsEntity } from "@/lib/client-chat";
|
import { assertClientOwnsEntity } from "@/lib/client-chat";
|
||||||
|
import { hasMention, mentionCandidates } from "@/lib/mentions";
|
||||||
|
import { notifyClientMention } from "@/lib/client-notifications";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const session = await getServerSession(authOptions);
|
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");
|
if (!clientId || !parsed.success) throw new Error("Dati mancanti");
|
||||||
const { entity_type, entity_id, body } = parsed.data;
|
const { entity_type, entity_id, body } = parsed.data;
|
||||||
|
|
||||||
const rows = await db
|
const [client] = await db
|
||||||
.select({ id: clients.id })
|
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.id, clientId))
|
.where(eq(clients.id, clientId))
|
||||||
.limit(1);
|
.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
|
// 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.
|
// 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() })
|
.set({ admin_last_read_at: new Date() })
|
||||||
.where(eq(clients.id, clientId));
|
.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/conversazioni");
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
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({
|
export default async function ClientPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ token: string }>;
|
params: Promise<{ token: string }>;
|
||||||
searchParams: Promise<{ preview?: string }>;
|
searchParams: Promise<{ preview?: string; chat?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { token } = await params;
|
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
|
// ⚠️ 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
|
// 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,
|
adminAvatarUrl: admin.avatarUrl,
|
||||||
}}
|
}}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
|
initialChatChannel={resolveChatChannel(chatParam, view)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -214,6 +231,7 @@ export default async function ClientPage({
|
|||||||
}}
|
}}
|
||||||
embedded
|
embedded
|
||||||
preview={preview}
|
preview={preview}
|
||||||
|
initialChatChannel={resolveChatChannel(chatParam, view)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
|
<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 { useRouter } from "next/navigation";
|
||||||
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
|
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
|
||||||
import { SearchInput } from "@/components/ui/SearchInput";
|
import { SearchInput } from "@/components/ui/SearchInput";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { MentionText } from "@/components/ui/MentionText";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ReplyComposer } from "./ReplyComposer";
|
||||||
import {
|
import {
|
||||||
editConversationMessage,
|
editConversationMessage,
|
||||||
markConversationRead,
|
markConversationRead,
|
||||||
replyToConversation,
|
|
||||||
} from "@/app/admin/conversazioni/actions";
|
} from "@/app/admin/conversazioni/actions";
|
||||||
import type {
|
import type {
|
||||||
ConversationSummary,
|
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">
|
<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} />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -126,18 +126,28 @@ function ConversationListItem({
|
|||||||
>
|
>
|
||||||
{conv.name}
|
{conv.name}
|
||||||
</span>
|
</span>
|
||||||
|
{conv.lastMessageAt && (
|
||||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
||||||
{formatListTime(conv.lastMessageAt)}
|
{formatListTime(conv.lastMessageAt)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||||
|
{conv.lastMessageAt ? (
|
||||||
|
<>
|
||||||
{conv.lastMessageAuthor === "admin" && (
|
{conv.lastMessageAuthor === "admin" && (
|
||||||
<span className="text-muted-foreground/70">Tu: </span>
|
<span className="text-muted-foreground/70">Tu: </span>
|
||||||
)}
|
)}
|
||||||
{conv.lastMessage}
|
{conv.lastMessage}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="italic text-muted-foreground/70">
|
||||||
|
Nessun messaggio — scrivi tu per primo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
<EntityBadge label={conv.lastEntityLabel} />
|
{conv.lastMessageAt ? <EntityBadge label={conv.lastEntityLabel} /> : <span />}
|
||||||
{conv.unread && (
|
{conv.unread && (
|
||||||
<span className="flex items-center gap-1.5 shrink-0">
|
<span className="flex items-center gap-1.5 shrink-0">
|
||||||
{conv.unreadCount > 1 && (
|
{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">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
|
||||||
{visibleMessages.length === 0 ? (
|
{visibleMessages.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-10">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
visibleMessages.map((m) => (
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Reply box — il canale viaggia col messaggio */}
|
{/* Reply box — il canale viaggia col messaggio */}
|
||||||
<form
|
<ReplyComposer
|
||||||
action={replyToConversation.bind(null, thread.clientId)}
|
clientId={thread.clientId}
|
||||||
className="p-4 border-t border-border bg-card flex items-end gap-3"
|
channelKey={activeChannel}
|
||||||
>
|
channelLabel={activeLabel}
|
||||||
<input type="hidden" name="entity_type" value={isGeneral ? "general" : "phase"} />
|
isGeneral={isGeneral}
|
||||||
<input type="hidden" name="entity_id" value={activeChannel} />
|
mentionCandidates={thread.mentionCandidates}
|
||||||
<Textarea
|
notifyEmailCount={thread.notifyEmailCount}
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -307,9 +312,11 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
|||||||
function MessageBubble({
|
function MessageBubble({
|
||||||
message,
|
message,
|
||||||
clientId,
|
clientId,
|
||||||
|
mentionCandidates,
|
||||||
}: {
|
}: {
|
||||||
message: ConversationThread["messages"][number];
|
message: ConversationThread["messages"][number];
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
mentionCandidates: string[];
|
||||||
}) {
|
}) {
|
||||||
const isAdmin = message.author === "admin";
|
const isAdmin = message.author === "admin";
|
||||||
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
|
// 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">
|
<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>
|
</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}
|
{conv.brand_name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
|
<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>
|
</span>
|
||||||
{conv.unreadCount > 1 && (
|
{conv.unreadCount > 1 && (
|
||||||
<span className="text-[10px] font-bold text-emerald-700 dark:text-emerald-400 shrink-0">
|
<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 type { ClientView } from '@/lib/client-view';
|
||||||
|
import { mentionCandidates } from '@/lib/mentions';
|
||||||
import type { ChatData } from '@/lib/chat-channels';
|
import type { ChatData } from '@/lib/chat-channels';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { PhaseTimeline } from './phase-timeline';
|
import { PhaseTimeline } from './phase-timeline';
|
||||||
@@ -23,9 +24,18 @@ interface ClientDashboardProps {
|
|||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
|
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
|
||||||
preview?: boolean;
|
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.
|
// Determine payment display mode based on active offers.
|
||||||
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
||||||
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
||||||
@@ -43,7 +53,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PreviewProvider preview={preview}>
|
<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"}>
|
<div className={embedded ? "" : "min-h-screen bg-background"}>
|
||||||
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
|
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
@@ -161,7 +175,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* Floating chat panel — FAB + slide-in panel */}
|
{/* Floating chat panel — FAB + slide-in panel */}
|
||||||
<ChatPanel token={token} chat={chat} />
|
<ChatPanel
|
||||||
|
token={token}
|
||||||
|
chat={chat}
|
||||||
|
mentionCandidates={mentionCandidates(view.client)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
</PreviewProvider>
|
</PreviewProvider>
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import {
|
|||||||
type ChatData,
|
type ChatData,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "@/lib/chat-channels";
|
} from "@/lib/chat-channels";
|
||||||
|
import { MentionText } from "@/components/ui/MentionText";
|
||||||
import { useChatContext } from "./ChatProvider";
|
import { useChatContext } from "./ChatProvider";
|
||||||
import { usePreview } from "./PreviewProvider";
|
import { usePreview } from "./PreviewProvider";
|
||||||
|
|
||||||
// TODO: la mail di notifica al cliente ("hai un messaggio non letto") è un giro a
|
// La notifica via mail al cliente esiste, ma solo per i messaggi in cui l'admin
|
||||||
// sé: serve uno scheduler, che nel repo non esiste ancora. Hook point naturale:
|
// lo tagga (@Nome): parte da replyToConversation, senza scheduler. Una notifica
|
||||||
// una query su client_channel_reads incrociata con comments.author = 'admin'.
|
// per OGNI messaggio non letto sarebbe un'altra cosa e richiederebbe un job.
|
||||||
|
|
||||||
const POLL_MS = 20_000;
|
const POLL_MS = 20_000;
|
||||||
/** Oltre questo scarto due messaggi dello stesso autore non si raggruppano più. */
|
/** 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();
|
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 {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
expanded,
|
expanded,
|
||||||
@@ -495,6 +505,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
|||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
adminName={adminName}
|
adminName={adminName}
|
||||||
adminAvatarUrl={adminAvatarUrl}
|
adminAvatarUrl={adminAvatarUrl}
|
||||||
|
mentionCandidates={mentionCandidates}
|
||||||
canEdit={!preview}
|
canEdit={!preview}
|
||||||
onEdit={applyEdit}
|
onEdit={applyEdit}
|
||||||
/>
|
/>
|
||||||
@@ -605,6 +616,7 @@ function MessageList({
|
|||||||
clientId,
|
clientId,
|
||||||
adminName,
|
adminName,
|
||||||
adminAvatarUrl,
|
adminAvatarUrl,
|
||||||
|
mentionCandidates,
|
||||||
canEdit,
|
canEdit,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
@@ -614,6 +626,7 @@ function MessageList({
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
adminName: string;
|
adminName: string;
|
||||||
adminAvatarUrl: string | null;
|
adminAvatarUrl: string | null;
|
||||||
|
mentionCandidates: string[];
|
||||||
/** Falso in anteprima admin: si guarda, non si scrive. */
|
/** Falso in anteprima admin: si guarda, non si scrive. */
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onEdit: (id: string, body: string) => Promise<boolean>;
|
onEdit: (id: string, body: string) => Promise<boolean>;
|
||||||
@@ -647,6 +660,7 @@ function MessageList({
|
|||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
adminName={adminName}
|
adminName={adminName}
|
||||||
adminAvatarUrl={adminAvatarUrl}
|
adminAvatarUrl={adminAvatarUrl}
|
||||||
|
mentionCandidates={mentionCandidates}
|
||||||
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
|
// 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.
|
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
|
||||||
entityLabel={
|
entityLabel={
|
||||||
@@ -676,6 +690,7 @@ function MessageRow({
|
|||||||
entityLabel,
|
entityLabel,
|
||||||
adminName,
|
adminName,
|
||||||
adminAvatarUrl,
|
adminAvatarUrl,
|
||||||
|
mentionCandidates,
|
||||||
canEdit,
|
canEdit,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
@@ -686,6 +701,7 @@ function MessageRow({
|
|||||||
entityLabel?: string;
|
entityLabel?: string;
|
||||||
adminName: string;
|
adminName: string;
|
||||||
adminAvatarUrl: string | null;
|
adminAvatarUrl: string | null;
|
||||||
|
mentionCandidates: string[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onEdit: (id: string, body: string) => Promise<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">
|
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
||||||
{m.body}
|
<MentionText body={m.body} candidates={mentionCandidates} />
|
||||||
{m.edited_at && (
|
{m.edited_at && (
|
||||||
<span className="ml-1.5 text-[10px] text-muted-foreground">
|
<span className="ml-1.5 text-[10px] text-muted-foreground">
|
||||||
(modificato)
|
(modificato)
|
||||||
|
|||||||
@@ -36,16 +36,31 @@ export function ChatProvider({
|
|||||||
children,
|
children,
|
||||||
phases,
|
phases,
|
||||||
clientId,
|
clientId,
|
||||||
|
initialChannel,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
phases: ClientView["phases"];
|
phases: ClientView["phases"];
|
||||||
clientId: string;
|
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);
|
const [expanded, setExpanded] = useState(false);
|
||||||
// Il canale "Generale" è identificato dall'id del cliente: stessa convenzione
|
// Il canale "Generale" è identificato dall'id del cliente: stessa convenzione
|
||||||
// di comments.entity_id, così non serve tradurre nulla in scrittura.
|
// 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(
|
const openChat = useCallback(
|
||||||
(phaseId?: string) => {
|
(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 { eq, inArray, asc } from "drizzle-orm";
|
||||||
import type { Comment } from "@/db/schema";
|
import type { Comment } from "@/db/schema";
|
||||||
import { buildChannels, type ChatChannel } from "@/lib/chat-channels";
|
import { buildChannels, type ChatChannel } from "@/lib/chat-channels";
|
||||||
|
import { mentionCandidates } from "@/lib/mentions";
|
||||||
|
import { getMentionRecipients } from "@/lib/client-notifications";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -13,9 +15,11 @@ export type ConversationSummary = {
|
|||||||
brand_name: string;
|
brand_name: string;
|
||||||
token: string;
|
token: string;
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
|
/** Stringa vuota per un cliente con cui non si e' ancora scambiato nulla. */
|
||||||
lastMessage: string;
|
lastMessage: string;
|
||||||
lastMessageAuthor: "client" | "admin";
|
lastMessageAuthor: "client" | "admin" | null;
|
||||||
lastMessageAt: Date;
|
/** null = nessun messaggio: la conversazione esiste ma e' ancora da aprire. */
|
||||||
|
lastMessageAt: Date | null;
|
||||||
lastEntityLabel: string;
|
lastEntityLabel: string;
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
@@ -54,6 +58,14 @@ export type ConversationThread = {
|
|||||||
* refresh il dato non sarebbe più ricostruibile.
|
* refresh il dato non sarebbe più ricostruibile.
|
||||||
*/
|
*/
|
||||||
adminLastReadAt: Date | null;
|
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 = {
|
type ClientMeta = {
|
||||||
@@ -221,17 +233,26 @@ async function fetchComments(allEntityIds: string[]): Promise<Comment[]> {
|
|||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────────────────────────────
|
// ── 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[]> {
|
export async function getConversations(): Promise<ConversationSummary[]> {
|
||||||
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
|
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
|
||||||
const commentRows = await fetchComments(allEntityIds);
|
const commentRows = await fetchComments(allEntityIds);
|
||||||
const byClient = groupCommentsByClient(commentRows, entityMap);
|
const byClient = groupCommentsByClient(commentRows, entityMap);
|
||||||
|
|
||||||
const summaries: ConversationSummary[] = [];
|
const summaries: ConversationSummary[] = [];
|
||||||
for (const [clientId, msgs] of byClient) {
|
for (const meta of clientMeta.values()) {
|
||||||
const meta = clientMeta.get(clientId);
|
const msgs = byClient.get(meta.id) ?? [];
|
||||||
if (!meta || msgs.length === 0) continue;
|
|
||||||
|
|
||||||
const last = msgs[msgs.length - 1];
|
const last = msgs[msgs.length - 1];
|
||||||
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
|
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
|
||||||
const clientMsgsAfterRead = msgs.filter(
|
const clientMsgsAfterRead = msgs.filter(
|
||||||
@@ -239,21 +260,28 @@ export async function getConversations(): Promise<ConversationSummary[]> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
summaries.push({
|
summaries.push({
|
||||||
clientId,
|
clientId: meta.id,
|
||||||
name: meta.name,
|
name: meta.name,
|
||||||
brand_name: meta.brand_name,
|
brand_name: meta.brand_name,
|
||||||
token: meta.token,
|
token: meta.token,
|
||||||
slug: meta.slug,
|
slug: meta.slug,
|
||||||
lastMessage: last.body,
|
lastMessage: last?.body ?? "",
|
||||||
lastMessageAuthor: last.author as "client" | "admin",
|
lastMessageAuthor: (last?.author as "client" | "admin") ?? null,
|
||||||
lastMessageAt: last.created_at,
|
lastMessageAt: last?.created_at ?? null,
|
||||||
lastEntityLabel: entityMap.get(last.entity_id)?.label ?? "Generale",
|
lastEntityLabel: last ? entityMap.get(last.entity_id)?.label ?? "Generale" : "Generale",
|
||||||
unread: clientMsgsAfterRead.length > 0,
|
unread: clientMsgsAfterRead.length > 0,
|
||||||
unreadCount: clientMsgsAfterRead.length,
|
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;
|
return summaries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +324,8 @@ export async function getConversationThread(
|
|||||||
messages,
|
messages,
|
||||||
channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []),
|
channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []),
|
||||||
adminLastReadAt: meta.admin_last_read_at,
|
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. */
|
/** Il brand name arriva dal DB ed è admin-controlled, ma finisce in HTML: si sanifica comunque. */
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s
|
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