From 41530b556aaa00e85ea4aa2723b819cb8ac3763d Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Mon, 31 Aug 2026 21:51:58 +0200 Subject: [PATCH] feat(conversazioni): scrivere per primo, taggare il cliente, notificarlo via mail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=`, 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 --- .env.example | 5 + src/app/admin/conversazioni/actions.ts | 19 +- src/app/client/[token]/page.tsx | 22 +- .../admin/conversazioni/ConversationsView.tsx | 79 ++++--- .../admin/conversazioni/ReplyComposer.tsx | 210 ++++++++++++++++++ src/components/admin/dashboard/InboxBand.tsx | 5 +- src/components/client-dashboard.tsx | 24 +- src/components/client/ChatPanel.tsx | 26 ++- src/components/client/ChatProvider.tsx | 19 +- src/components/ui/MentionText.tsx | 44 ++++ src/lib/client-notifications.ts | 160 +++++++++++++ src/lib/conversations-queries.ts | 56 +++-- src/lib/mailer.ts | 61 +++++ src/lib/mentions.ts | 140 ++++++++++++ 14 files changed, 810 insertions(+), 60 deletions(-) create mode 100644 src/components/admin/conversazioni/ReplyComposer.tsx create mode 100644 src/components/ui/MentionText.tsx create mode 100644 src/lib/client-notifications.ts create mode 100644 src/lib/mentions.ts diff --git a/.env.example b/.env.example index e753162..ed6c403 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ INTERNAL_SECRET=generate-with-openssl-rand-base64-32 RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx RESEND_FROM=Nome Mittente +# 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 diff --git a/src/app/admin/conversazioni/actions.ts b/src/app/admin/conversazioni/actions.ts index 567e4e9..6a9f2f7 100644 --- a/src/app/admin/conversazioni/actions.ts +++ b/src/app/admin/conversazioni/actions.ts @@ -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}`); } diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx index ec622dd..efa22c7 100644 --- a/src/app/client/[token]/page.tsx +++ b/src/app/client/[token]/page.tsx @@ -98,15 +98,31 @@ export async function generateMetadata({ }; } +/** + * Il canale chiesto con `?chat=`, 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)} /> ) : (

Progetto non disponibile.

diff --git a/src/components/admin/conversazioni/ConversationsView.tsx b/src/components/admin/conversazioni/ConversationsView.tsx index c5644ee..728de2f 100644 --- a/src/components/admin/conversazioni/ConversationsView.tsx +++ b/src/components/admin/conversazioni/ConversationsView.tsx @@ -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({ ) : (
-

Nessun messaggio dai clienti ancora.

+

Scegli un cliente per aprire la conversazione.

)} @@ -126,18 +126,28 @@ function ConversationListItem({ > {conv.name} - - {formatListTime(conv.lastMessageAt)} - + {conv.lastMessageAt && ( + + {formatListTime(conv.lastMessageAt)} + + )}

- {conv.lastMessageAuthor === "admin" && ( - Tu: + {conv.lastMessageAt ? ( + <> + {conv.lastMessageAuthor === "admin" && ( + Tu: + )} + {conv.lastMessage} + + ) : ( + + Nessun messaggio — scrivi tu per primo + )} - {conv.lastMessage}

- + {conv.lastMessageAt ? : } {conv.unread && ( {conv.unreadCount > 1 && ( @@ -272,34 +282,29 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
{visibleMessages.length === 0 ? (

- Nessun messaggio in questo canale. + Nessun messaggio in questo canale. Scrivi tu per primo.

) : ( visibleMessages.map((m) => ( - + )) )}
{/* Reply box — il canale viaggia col messaggio */} -
- - -