Compare commits

..

2 Commits

Author SHA1 Message Date
simone d71fede4e4 docs(state): conversazioni scritte e committate, push bloccato da un 403 di gitea
Il 403 va scritto come bloccante e non come nota di passaggio: finche' dura,
NESSUN commit arriva in produzione -- Coolify deploya sul push a `main`.
La credenziale nel portachiavi legge ma non scrive, e la chiave SSH locale non
e' registrata su Gitea, quindi non c'e' via alternativa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:53:21 +02:00
simone 41530b556a 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>
2026-08-31 21:51:58 +02:00
15 changed files with 816 additions and 64 deletions
+5
View File
@@ -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
+6 -4
View File
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
milestone: v2.5 milestone: v2.5
milestone_name: Audit milestone_name: Audit
status: executing status: executing
stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1, rifiniture, chat a canali (0021), modifica messaggi + firma (0022), stati task e date pagamenti (0023) in prod; C2 (TidyCal) bloccato sulle credenziali API. Nulla del portale e' stato visto a schermo: .env.local non autentica piu', serve ?preview=1 in prod. Il riquadro 'Prossimo pagamento' non compare finche' nessuna rata ha una due_date." stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1, rifiniture, chat a canali (0021), modifica messaggi + firma (0022), stati task e date pagamenti (0023) in prod; C2 (TidyCal) bloccato sulle credenziali API. [BLOCCANTE] Conversazioni (scrittura per primo, menzioni, mail sul tag) e' COMMITTATA E FERMA IN LOCALE: git push su gitea risponde 403, quindi non e' in prod. Nulla del portale e' stato visto a schermo: .env.local non autentica piu', serve ?preview=1 in prod. Il riquadro 'Prossimo pagamento' non compare finche' nessuna rata ha una due_date."
last_updated: "2026-08-26T16:10:00.000Z" last_updated: "2026-08-31T19:55:00.000Z"
last_activity: 2026-08-26 -- architettura .claude di hub: skill /preventivo e /audit, due hook di guardia, piani portati nel repo last_activity: 2026-08-31 -- conversazioni: scrivere per primo, menzioni, mail sul tag. Committata, push bloccato da un 403 di gitea
progress: progress:
total_phases: 4 total_phases: 4
completed_phases: 0 completed_phases: 0
@@ -39,7 +39,8 @@ Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
| C3 — Alleggerire l'hub | ⏸️ senza perimetro | | C3 — Alleggerire l'hub | ⏸️ senza perimetro |
| Rifiniture — tassonomie, tab pagamenti, riordino task | ✅ in prod 2026-08-20 (`0019`) | | Rifiniture — tassonomie, tab pagamenti, riordino task | ✅ in prod 2026-08-20 (`0019`) |
| Portale cliente — stepper compatto/full-width, card offerta | ✅ in prod 2026-08-21 (`0020`); override provato su Caruso Speaker | | Portale cliente — stepper compatto/full-width, card offerta | ✅ in prod 2026-08-21 (`0020`); override provato su Caruso Speaker |
| Chat — canali, modifica messaggi, firma admin | ✅ in prod 2026-08-21 (`0021`, `0022`); **mai provata a mano**; menzioni rimandate, manca l'attribuzione | | Chat — canali, modifica messaggi, firma admin | ✅ in prod 2026-08-21 (`0021`, `0022`); **mai provata a mano**; manca l'attribuzione |
| Conversazioni — scrivere per primo, menzioni `@Nome`, mail sul tag | ⛔ **[BLOCCANTE]** scritta e committata (`41530b5`), **push 403**: non e' in prod. Nessuna migration. Build + lint puliti, parser provato su 9 casi; **mai vista a schermo** |
| Portale — stati task (forma, pill, legenda, «Cancellata») + date dei pagamenti | ✅ in prod 2026-08-22 (`2e9bd2a`, `8b54f48`, `fe76789`, migration `0023`); **mai visto a schermo**, nessuna `due_date` ancora inserita | | Portale — stati task (forma, pill, legenda, «Cancellata») + date dei pagamenti | ✅ in prod 2026-08-22 (`2e9bd2a`, `8b54f48`, `fe76789`, migration `0023`); **mai visto a schermo**, nessuna `due_date` ancora inserita |
| D — Whop → audit | ⏸️ dipende dal motore v2.5 | | D — Whop → audit | ⏸️ dipende dal motore v2.5 |
@@ -81,6 +82,7 @@ Log completo in `PROJECT.md`. Vive per il lavoro corrente:
### Blockers/Concerns ### Blockers/Concerns
- **[BLOCCANTE] TidyCal non ha webhook** (verificato 2026-08-19 sulla loro FAQ; la via suggerita è Zapier/Make). La REST API c'è, con Personal Access Token su tutti i piani, ma path, filtri e paginazione **stanno dietro il login**. Sblocca: l'utente apre `tidycal.com/integrations` → API Keys e passa token o documentazione. Non dedurre la forma dell'API dai docs. - **[BLOCCANTE] TidyCal non ha webhook** (verificato 2026-08-19 sulla loro FAQ; la via suggerita è Zapier/Make). La REST API c'è, con Personal Access Token su tutti i piani, ma path, filtri e paginazione **stanno dietro il login**. Sblocca: l'utente apre `tidycal.com/integrations` → API Keys e passa token o documentazione. Non dedurre la forma dell'API dai docs.
- **[BLOCCANTE] Il push su `gitea` risponde 403** (2026-08-31). La credenziale nel portachiavi macOS (utente `simone`) **legge ma non scrive**: `git fetch` passa, `git push` no. La chiave SSH locale non è registrata su Gitea, quindi non c'è nemmeno la via alternativa. Finché dura, **nessun commit arriva in produzione**: Coolify deploya sul push a `main`, e senza push non parte niente. Sblocca: l'utente rigenera un token Gitea con permesso di scrittura e aggiorna il portachiavi, oppure aggiunge la chiave pubblica su Gitea e si passa il remote a SSH. In coda al momento: `41530b5` (conversazioni).
- **[BLOCCANTE] `LEAD_WEBHOOK_SECRET` non è su Coolify**: finché manca, `/api/webhooks/lead` risponde 403 a tutti (fallimento chiuso voluto). Sblocca: l'utente la imposta. - **[BLOCCANTE] `LEAD_WEBHOOK_SECRET` non è su Coolify**: finché manca, `/api/webhooks/lead` risponde 403 a tutti (fallimento chiuso voluto). Sblocca: l'utente la imposta.
- **Il 100% dell'incassato è "Senza offerta"** — Caruso Speaker e Protocollo Estetico: 5.300 € senza offerte assegnate. Si sistema assegnandole dai rispettivi progetti. Il payload Elementor, intanto, non è ancora verificato sul campo: gestito in modo difensivo, serve un invio vero. - **Il 100% dell'incassato è "Senza offerta"** — Caruso Speaker e Protocollo Estetico: 5.300 € senza offerte assegnate. Si sistema assegnandole dai rispettivi progetti. Il payload Elementor, intanto, non è ancora verificato sul campo: gestito in modo difensivo, serve un invio vero.
- **Il copy del template v1 non ha fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi da recuperare prima di Phase 30. - **Il copy del template v1 non ha fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi da recuperare prima di Phase 30.
+16 -3
View File
@@ -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}`);
} }
+20 -2
View File
@@ -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>
);
}
+4 -1
View File
@@ -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">
+21 -3
View File
@@ -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>
+21 -5
View File
@@ -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)
+17 -2
View File
@@ -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) => {
+44
View File
@@ -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>
)
)}
</>
);
}
+160
View File
@@ -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;
}
+43 -13
View File
@@ -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,
}; };
} }
+61
View File
@@ -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 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 , 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
+140
View File
@@ -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");
}