Compare commits

...

4 Commits

Author SHA1 Message Date
simone 9ce6f02ee4 fix(conversazioni): il menu dei tag mostra una riga per persona, non per alias
Con un cliente «Gian Luca Caruso / Caruso Speaker» il menu proponeva tre voci
per un destinatario solo. Non era un difetto di resa: al menu era stata passata
la stessa lista che il parser usa per RICONOSCERE un tag rileggendo il testo.

Sono due domande diverse, e il codice le aveva confuse:
  - come lo si puo' scrivere -> tutti gli alias, invisibili, solo in lettura
  - chi si puo' scegliere    -> una riga per destinatario

`MentionTarget` le separa: `label` per il menu, `aliases` per la rilettura.
Scrivere «@Gian» a mano continua a valere come tag e a far partire la mail --
cambia solo cosa viene offerto, non cosa viene accettato.

Il filtro del menu ora passa da `normalizeForSearch`, la stessa
normalizzazione del parser: con un toLowerCase() a parte, digitare «nicolo»
non avrebbe trovato «Nicolò» nel menu mentre nel messaggio sarebbe stato
riconosciuto -- due regole per la stessa domanda, e la seconda si scopre solo
quando la mail non parte.

Il difetto scalava peggio del valore: con tre contatti per cliente sarebbero
diventate nove righe. `mentionTargets()` ritorna gia' un array per questo, ma
il tag per singola persona resta impossibile finche' `client_emails` ha gli
indirizzi e non i nomi: serve una migration, ed e' un lavoro a se'.

Verificato: build e lint puliti, 23 test su parser e menu. Mai visto a schermo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:22:38 +02:00
simone 614eb00040 docs(state): il push e' passato, il 403 non c'e' piu'
Il bloccante sul push va tolto, non lasciato: una memoria sbagliata e' peggio
di una assente, e "push 403" avrebbe fatto ripartire la prossima sessione da
un problema gia' risolto.

Resta scritto COME si e' risolto -- l'accesso a Gitea si recupera dalla CLI
admin dentro il container, non dal web -- perche' quello sara' ancora vero la
prossima volta che il portachiavi scade.

In cima ai Next: rigenerare il token, che e' stato incollato in chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:36:45 +02:00
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 907 additions and 65 deletions
+5
View File
@@ -16,6 +16,11 @@ INTERNAL_SECRET=generate-with-openssl-rand-base64-32
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
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
# POST /api/webhooks/lead, header x-webhook-secret.
# A differenza di INTERNAL_SECRET questa route e' esposta a internet: se la
+8 -5
View File
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
milestone: v2.5
milestone_name: Audit
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."
last_updated: "2026-08-26T16:10:00.000Z"
last_activity: 2026-08-26 -- architettura .claude di hub: skill /preventivo e /audit, due hook di guardia, piani portati nel repo
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. Conversazioni (scrittura per primo, menzioni, mail sul tag) pushata il 2026-09-01, nessuna migration, MAI provata a mano. 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-09-01T00:15:00.000Z"
last_activity: 2026-09-01 -- conversazioni: scrivere per primo, menzioni, mail sul tag. Pushata dopo aver recuperato l'accesso a gitea dalla CLI admin
progress:
total_phases: 4
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 |
| 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 |
| 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 | ✅ pushata 2026-09-01 (`41530b5`), nessuna migration. Menu dei tag corretto lo stesso giorno: mostrava **una riga per alias** invece che per persona. Build + lint puliti, 23 test sul parser e sul menu; **nessuna mail di tag mai partita davvero** |
| 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 |
@@ -69,6 +70,8 @@ Passo per passo in `STATUS.md` e in `.claude/plans/v2.5-audit-motore.md`.
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
- **[2026-09-01] Una lista che serve a leggere non è una lista da offrire in scrittura** — il menu dei tag mostrava tre voci (nome, brand, nome di battesimo) per una persona sola, perché gli era stata passata la stessa lista che il parser usa per **riconoscere** un tag nel testo. Sono due domande diverse: *come lo si può scrivere* (tutti gli alias, invisibili) e *chi si può scegliere* (una riga per destinatario). Ora `MentionTarget` le tiene separate — `label` per il menu, `aliases` per la rilettura — e il filtro passa da `normalizeForSearch`, la stessa normalizzazione del parser: con un `toLowerCase()` a parte, digitare «nicolo» non troverebbe «Nicolò» nel menu mentre nel messaggio verrebbe riconosciuto. Il difetto scalava peggio del valore: con tre contatti sarebbero state nove righe.
- **[2026-09-01] L'accesso a Gitea si recupera dalla sua CLI, non dal web** — il push rispondeva 403 (la credenziale nel portachiavi leggeva ma non scriveva) e l'account web non era piu' accessibile, quindi la via del browser era chiusa in partenza. Si rientra da dentro il container: `docker exec -u git gitea-tgw04ws48sogkso84oogwckk gitea admin user change-password` per la password e `... generate-access-token --scopes write:repository --raw` per il token del push. Il token va messo nel portachiavi con `git credential approve`, **non** nell'URL del remote: li' finirebbe in chiaro dentro `.git/config`. Da ricordare perche' senza push non esiste deploy — Coolify parte da `main` e basta.
- **[2026-08-22] Un task cancellato esce dai denominatori, non dalla lista** — resta visibile barrato (il cliente ha letto quella voce e deve capire che fine ha fatto) ma non conta, via un solo `countsTowardProgress()` in `task-status.ts`: contarlo terrebbe la fase sotto il 100% per sempre, contarlo come fatto racconterebbe una consegna mai avvenuta. Se in una fase restano solo cancellati torna «Da iniziare»: degenere, ma «Completata» mentirebbe.
- **[2026-08-22] Le date dei pagamenti sì, gli importi per riga no** — LOCKED #2 parla di cifre, non di date. Salvate a **mezzogiorno UTC** (a mezzanotte il giorno civile a Roma è già quello dopo) e contate sui giorni civili a Roma in `src/lib/payment-dates.ts`, lo stesso modulo del futuro promemoria email: mail e portale non devono contraddirsi su quanti giorni mancano.
- **[2026-08-20] Gli importi scritti a mano non si ricalcolano** — `amount_locked` esclude la riga da `rescalePayments`, e lo scarto fra somma rate e totale si dichiara invece di aggiustarlo. Il backfill dell'ordine rate va per `percent DESC`, non per `ctid`: 3 progetti su 5 erano già scombinati e l'ordine fisico avrebbe fissato l'errore.
@@ -96,5 +99,5 @@ Last session: 2026-08-26
Stopped at: **stato task «Cancellata» + date dei pagamenti nel portale** (`fe76789`, migration `0023` applicata a prod prima del push). Un task tolto dal lavoro ora ha dove stare: **X nel cerchio, titolo barrato**, pill «Cancellata» — l'unico stato chiuso con la pill, perché «Fatto» e «Cancellata» sono entrambi barrati e scambiarli significa credere consegnato ciò che non esiste. Esce da **tutti** i denominatori via `countsTowardProgress()`. Nel Kanban cliente la colonna compare solo se piena (mai nascosta se ha dentro qualcosa); nell'admin c'è sempre, ed è così che si cancella un task. Lato pagamenti: `payments.due_date` (nullable, più indice parziale per il futuro promemoria email), riquadro «Prossimo pagamento» con conto alla rovescia in parole e rosso se scaduto, «Scade il…» / «Pagato il…» su ogni riga, **zero importi**. Admin: campo Scadenza per rata, «Incassato nel mese» → «Incassato il» (giorno; le analytics raggruppano per mese e non se ne accorgono). Build, typecheck e lint verdi.
**2026-08-26 — architettura `.claude/`**: skill `/preventivo` e `/audit`, due hook di guardia, piani nel repo. Nessun tocco al prodotto. Le due cose trovate e non risolte sul preventivo → `STATUS.md`.
Next: (1) verificare in prod con `?preview=1` **entrambe** le cose: stati task su **Caruso Speaker, fase «3 - Esecuzione»** (l'unica con «In corso» e «In revisione» insieme, 4 + 2 su 11) e box pagamenti — ma prima **inserire una scadenza** dal tab Pagamenti, altrimenti il riquadro non compare per definizione; (2) due `paid_at` storici valgono il primo del mese (2026-03-01, 2026-01-01, scritti dal vecchio selettore a mese) e il cliente ora li legge come «Pagato il 1 mar 2026»: correggerli se il giorno vero era un altro; (3) provare la chat in prod: modificare un messaggio admin e vederlo cambiare da solo entro ~20s senza duplicarsi; (4) sbloccare TidyCal con token o documentazione; (5) `LEAD_WEBHOOK_SECRET` su Coolify, senza cui `/api/webhooks/lead` risponde 403 a tutti; (6) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`.
Next: (0) **[SICUREZZA] rigenerare il token Gitea creato il 2026-09-01**: e' stato incollato in chat, quindi va considerato esposto. Si revoca da Settings -> Applications e si rifa'; (1) verificare in prod con `?preview=1` **entrambe** le cose: stati task su **Caruso Speaker, fase «3 - Esecuzione»** (l'unica con «In corso» e «In revisione» insieme, 4 + 2 su 11) e box pagamenti — ma prima **inserire una scadenza** dal tab Pagamenti, altrimenti il riquadro non compare per definizione; (2) due `paid_at` storici valgono il primo del mese (2026-03-01, 2026-01-01, scritti dal vecchio selettore a mese) e il cliente ora li legge come «Pagato il 1 mar 2026»: correggerli se il giorno vero era un altro; (3) provare la chat in prod: modificare un messaggio admin e vederlo cambiare da solo entro ~20s senza duplicarsi; (4) sbloccare TidyCal con token o documentazione; (5) `LEAD_WEBHOOK_SECRET` su Coolify, senza cui `/api/webhooks/lead` risponde 403 a tutti; (6) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`.
Resume file: None
+16 -3
View File
@@ -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}`);
}
+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({
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)}
/>
) : (
<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 { 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({
) : (
<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} />
<p className="text-sm">Nessun messaggio dai clienti ancora.</p>
<p className="text-sm">Scegli un cliente per aprire la conversazione.</p>
</div>
)}
</section>
@@ -126,18 +126,28 @@ function ConversationListItem({
>
{conv.name}
</span>
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
{formatListTime(conv.lastMessageAt)}
</span>
{conv.lastMessageAt && (
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
{formatListTime(conv.lastMessageAt)}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground truncate">
{conv.lastMessageAuthor === "admin" && (
<span className="text-muted-foreground/70">Tu: </span>
{conv.lastMessageAt ? (
<>
{conv.lastMessageAuthor === "admin" && (
<span className="text-muted-foreground/70">Tu: </span>
)}
{conv.lastMessage}
</>
) : (
<span className="italic text-muted-foreground/70">
Nessun messaggio scrivi tu per primo
</span>
)}
{conv.lastMessage}
</p>
<div className="mt-2 flex items-center justify-between gap-2">
<EntityBadge label={conv.lastEntityLabel} />
{conv.lastMessageAt ? <EntityBadge label={conv.lastEntityLabel} /> : <span />}
{conv.unread && (
<span className="flex items-center gap-1.5 shrink-0">
{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">
{visibleMessages.length === 0 ? (
<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>
) : (
visibleMessages.map((m) => (
<MessageBubble key={m.id} message={m} clientId={thread.clientId} />
<MessageBubble
key={m.id}
message={m}
clientId={thread.clientId}
mentionCandidates={thread.mentionAliases}
/>
))
)}
</div>
{/* Reply box — il canale viaggia col messaggio */}
<form
action={replyToConversation.bind(null, thread.clientId)}
className="p-4 border-t border-border bg-card flex items-end gap-3"
>
<input type="hidden" name="entity_type" value={isGeneral ? "general" : "phase"} />
<input type="hidden" name="entity_id" value={activeChannel} />
<Textarea
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>
<ReplyComposer
clientId={thread.clientId}
channelKey={activeChannel}
channelLabel={activeLabel}
isGeneral={isGeneral}
mentionTargets={thread.mentionTargets}
notifyEmailCount={thread.notifyEmailCount}
/>
</>
);
}
@@ -307,9 +312,11 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
function MessageBubble({
message,
clientId,
mentionCandidates,
}: {
message: ConversationThread["messages"][number];
clientId: string;
mentionCandidates: string[];
}) {
const isAdmin = message.author === "admin";
// 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">
{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>
)}
@@ -0,0 +1,219 @@
"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 { normalizeForSearch, type MentionTarget } from "@/lib/mentions";
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,
mentionTargets,
notifyEmailCount,
}: {
clientId: string;
channelKey: string;
channelLabel: string;
isGeneral: boolean;
/**
* Chi si può taggare: **una voce per destinatario**, non una per modo di
* scriverlo. Prima qui arrivava la lista piatta degli alias e il menu mostrava
* tre righe — nome, brand, nome di battesimo — per una persona sola.
*/
mentionTargets: MentionTarget[];
/** 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 = normalizeForSearch(trigger.query);
// Si filtra sugli alias ma si tiene il TARGET: digitando «car» il cliente si
// trova passando dal brand, e resta comunque una riga sola col suo nome.
// La normalizzazione è quella del parser, così «nicolo» trova «Nicolò».
return mentionTargets
.filter((t) => t.aliases.some((a) => normalizeForSearch(a).startsWith(q)))
.slice(0, 6);
}, [trigger, mentionTargets]);
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((target, i) => (
<li key={target.label}>
<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(target.label);
}}
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">{target.label}</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].label);
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}
</h3>
<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>
{conv.unreadCount > 1 && (
<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 { mentionCandidates } from '@/lib/mentions';
import type { ChatData } from '@/lib/chat-channels';
import { RefreshCw } from 'lucide-react';
import { PhaseTimeline } from './phase-timeline';
@@ -23,9 +24,18 @@ interface ClientDashboardProps {
embedded?: boolean;
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
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.
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
@@ -43,7 +53,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
return (
<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"}>
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
{!embedded && (
@@ -161,7 +175,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
</footer>
{/* Floating chat panel — FAB + slide-in panel */}
<ChatPanel token={token} chat={chat} />
<ChatPanel
token={token}
chat={chat}
mentionCandidates={mentionCandidates(view.client)}
/>
</div>
</ChatProvider>
</PreviewProvider>
+21 -5
View File
@@ -10,12 +10,13 @@ import {
type ChatData,
type ChatMessage,
} from "@/lib/chat-channels";
import { MentionText } from "@/components/ui/MentionText";
import { useChatContext } from "./ChatProvider";
import { usePreview } from "./PreviewProvider";
// TODO: la mail di notifica al cliente ("hai un messaggio non letto") è un giro a
// sé: serve uno scheduler, che nel repo non esiste ancora. Hook point naturale:
// una query su client_channel_reads incrociata con comments.author = 'admin'.
// La notifica via mail al cliente esiste, ma solo per i messaggi in cui l'admin
// lo tagga (@Nome): parte da replyToConversation, senza scheduler. Una notifica
// per OGNI messaggio non letto sarebbe un'altra cosa e richiederebbe un job.
const POLL_MS = 20_000;
/** 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();
}
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 {
isOpen,
expanded,
@@ -495,6 +505,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
clientId={clientId}
adminName={adminName}
adminAvatarUrl={adminAvatarUrl}
mentionCandidates={mentionCandidates}
canEdit={!preview}
onEdit={applyEdit}
/>
@@ -605,6 +616,7 @@ function MessageList({
clientId,
adminName,
adminAvatarUrl,
mentionCandidates,
canEdit,
onEdit,
}: {
@@ -614,6 +626,7 @@ function MessageList({
clientId: string;
adminName: string;
adminAvatarUrl: string | null;
mentionCandidates: string[];
/** Falso in anteprima admin: si guarda, non si scrive. */
canEdit: boolean;
onEdit: (id: string, body: string) => Promise<boolean>;
@@ -647,6 +660,7 @@ function MessageList({
grouped={grouped}
adminName={adminName}
adminAvatarUrl={adminAvatarUrl}
mentionCandidates={mentionCandidates}
// 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.
entityLabel={
@@ -676,6 +690,7 @@ function MessageRow({
entityLabel,
adminName,
adminAvatarUrl,
mentionCandidates,
canEdit,
onEdit,
}: {
@@ -686,6 +701,7 @@ function MessageRow({
entityLabel?: string;
adminName: string;
adminAvatarUrl: string | null;
mentionCandidates: string[];
canEdit: 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">
{m.body}
<MentionText body={m.body} candidates={mentionCandidates} />
{m.edited_at && (
<span className="ml-1.5 text-[10px] text-muted-foreground">
(modificato)
+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) => {
+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;
}
+57 -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 type { Comment } from "@/db/schema";
import { buildChannels, type ChatChannel } from "@/lib/chat-channels";
import { mentionTargets, mentionAliases, type MentionTarget } from "@/lib/mentions";
import { getMentionRecipients } from "@/lib/client-notifications";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -13,9 +15,11 @@ export type ConversationSummary = {
brand_name: string;
token: string;
slug: string | null;
/** Stringa vuota per un cliente con cui non si e' ancora scambiato nulla. */
lastMessage: string;
lastMessageAuthor: "client" | "admin";
lastMessageAt: Date;
lastMessageAuthor: "client" | "admin" | null;
/** null = nessun messaggio: la conversazione esiste ma e' ancora da aprire. */
lastMessageAt: Date | null;
lastEntityLabel: string;
unread: boolean;
unreadCount: number;
@@ -54,6 +58,23 @@ export type ConversationThread = {
* refresh il dato non sarebbe più ricostruibile.
*/
adminLastReadAt: Date | null;
/**
* **Chi** si può taggare: una voce per destinatario, per il menu del
* compositore. Oggi ne esce sempre una — la chat è 1:1.
*/
mentionTargets: MentionTarget[];
/**
* **Come** un tag viene riconosciuto rileggendo il testo: tutti gli alias di
* tutti i destinatari. Serve solo a evidenziare, mai a offrire una scelta —
* confondere le due liste è ciò che faceva comparire tre voci per una persona.
*/
mentionAliases: 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 = {
@@ -221,17 +242,26 @@ async function fetchComments(allEntityIds: string[]): Promise<Comment[]> {
// ── 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[]> {
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
const commentRows = await fetchComments(allEntityIds);
const byClient = groupCommentsByClient(commentRows, entityMap);
const summaries: ConversationSummary[] = [];
for (const [clientId, msgs] of byClient) {
const meta = clientMeta.get(clientId);
if (!meta || msgs.length === 0) continue;
for (const meta of clientMeta.values()) {
const msgs = byClient.get(meta.id) ?? [];
const last = msgs[msgs.length - 1];
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
const clientMsgsAfterRead = msgs.filter(
@@ -239,21 +269,28 @@ export async function getConversations(): Promise<ConversationSummary[]> {
);
summaries.push({
clientId,
clientId: meta.id,
name: meta.name,
brand_name: meta.brand_name,
token: meta.token,
slug: meta.slug,
lastMessage: last.body,
lastMessageAuthor: last.author as "client" | "admin",
lastMessageAt: last.created_at,
lastEntityLabel: entityMap.get(last.entity_id)?.label ?? "Generale",
lastMessage: last?.body ?? "",
lastMessageAuthor: (last?.author as "client" | "admin") ?? null,
lastMessageAt: last?.created_at ?? null,
lastEntityLabel: last ? entityMap.get(last.entity_id)?.label ?? "Generale" : "Generale",
unread: clientMsgsAfterRead.length > 0,
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;
}
@@ -271,6 +308,10 @@ export async function getConversationThread(
);
const commentRows = await fetchComments(ownEntityIds);
// Calcolati una volta: gli alias derivano dai target, e ricavarli due volte
// aprirebbe la porta a un menu e a un'evidenziazione che non concordano.
const targets = mentionTargets(meta);
const messages: ThreadMessage[] = commentRows.map((c) => {
const info = entityMap.get(c.entity_id);
return {
@@ -296,6 +337,9 @@ export async function getConversationThread(
messages,
channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []),
adminLastReadAt: meta.admin_last_read_at,
mentionTargets: targets,
mentionAliases: mentionAliases(targets),
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. */
function escapeHtml(s: string): string {
return s
+206
View File
@@ -0,0 +1,206 @@
/**
* 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();
}
/**
* La stessa normalizzazione che usa il parser, esposta per chi deve confrontare
* testo digitato con un nome oggi il filtro del menu nel compositore.
*
* Va condivisa, non riscritta: con un `toLowerCase()` fatto in casa, digitare
* «@nicolo» non troverebbe «Nicolò» nel menu mentre il parser lo riconoscerebbe
* benissimo nel messaggio. Due regole diverse per la stessa domanda, e la
* seconda si scopre solo quando la mail non parte.
*/
export function normalizeForSearch(value: string): string {
return Array.from(value).map(foldChar).join("");
}
/**
* Chi si può taggare, e in quanti modi lo si può scrivere.
*
* La distinzione è il punto di tutto il modulo, ed è quella che il menu del
* compositore aveva sbagliato mostrando tre voci per una persona sola:
*
* - `label` = **chi**. Una riga per destinatario, ed è il testo che finisce nel
* messaggio scegliendolo dal menu.
* - `aliases` = **come**. Tutti i modi in cui quel destinatario viene
* riconosciuto rileggendo il testo, perché «@Gian» scritto a mano deve valere
* quanto «@Gian Luca Caruso» scelto dall'elenco.
*
* Tenendole separate, il giorno in cui i contatti per cliente saranno tre il
* menu avrà tre righe e non nove.
*/
export type MentionTarget = {
/** Il nome che si scrive scegliendolo dal menu. Uno per destinatario. */
label: string;
/** Ogni forma accettata in lettura, dalla più lunga alla più corta. */
aliases: string[];
};
/**
* I destinatari taggabili in questa conversazione.
*
* Oggi ne esce sempre uno: la chat è 1:1 e `clients` conosce una persona sola.
* La forma è già al plurale perché il giorno in cui i contatti diventano righe
* a (oggi `client_emails` ha gli indirizzi ma non i nomi) qui cambia solo la
* query che li carica, non il menu il parser.
*/
export function mentionTargets(client: {
name: string;
brand_name?: string | null;
}): MentionTarget[] {
const label = client.name.trim();
if (!label) return [];
return [
{
label,
// Il brand è un alias della persona, non un secondo destinatario: taggare
// «@Caruso Speaker» scrive comunque a chi legge quella conversazione.
aliases: dedupe([label, client.brand_name ?? "", label.split(/\s+/)[0] ?? ""]),
},
];
}
/**
* Tutti gli alias di tutti i destinatari, dal più lungo al più corto: è la lista
* che serve al parser. L'ordine conta «@Mario Rossi» deve vincere su «@Mario»,
* altrimenti il chip si fermerebbe a metà del nome.
*/
export function mentionAliases(targets: ReadonlyArray<MentionTarget>): string[] {
return dedupe(targets.flatMap((t) => t.aliases)).sort((a, b) => b.length - a.length);
}
/** Scorciatoia per chi deve solo rileggere del testo, senza offrire un menu. */
export function mentionCandidates(client: {
name: string;
brand_name?: string | null;
}): string[] {
return mentionAliases(mentionTargets(client));
}
function dedupe(values: ReadonlyArray<string>): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
const trimmed = value.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(trimmed);
}
return out;
}
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");
}