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 */} -
- - -