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:
2026-08-31 21:51:58 +02:00
parent 817a8cd5d1
commit 41530b556a
14 changed files with 810 additions and 60 deletions
+17 -2
View File
@@ -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) => {