diff --git a/.planning/STATE.md b/.planning/STATE.md index 732db63..42764b1 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -40,7 +40,7 @@ Phase 27 resta a metà — schema e fonti in prod, resto da scrivere. | 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**; manca l'attribuzione | -| Conversazioni — scrivere per primo, menzioni `@Nome`, mail sul tag | ✅ pushata 2026-09-01 (`41530b5`), nessuna migration. Build + lint puliti, parser provato su 9 casi; **mai vista a schermo, nessuna mail di tag mai partita davvero** | +| 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 | @@ -70,6 +70,7 @@ 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. diff --git a/src/components/admin/conversazioni/ConversationsView.tsx b/src/components/admin/conversazioni/ConversationsView.tsx index 728de2f..0e07a42 100644 --- a/src/components/admin/conversazioni/ConversationsView.tsx +++ b/src/components/admin/conversazioni/ConversationsView.tsx @@ -290,7 +290,7 @@ function ActiveThread({ thread }: { thread: ConversationThread }) { key={m.id} message={m} clientId={thread.clientId} - mentionCandidates={thread.mentionCandidates} + mentionCandidates={thread.mentionAliases} /> )) )} @@ -302,7 +302,7 @@ function ActiveThread({ thread }: { thread: ConversationThread }) { channelKey={activeChannel} channelLabel={activeLabel} isGeneral={isGeneral} - mentionCandidates={thread.mentionCandidates} + mentionTargets={thread.mentionTargets} notifyEmailCount={thread.notifyEmailCount} /> diff --git a/src/components/admin/conversazioni/ReplyComposer.tsx b/src/components/admin/conversazioni/ReplyComposer.tsx index 0e35a82..609cf88 100644 --- a/src/components/admin/conversazioni/ReplyComposer.tsx +++ b/src/components/admin/conversazioni/ReplyComposer.tsx @@ -5,6 +5,7 @@ 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"; /** @@ -20,15 +21,19 @@ export function ReplyComposer({ channelKey, channelLabel, isGeneral, - mentionCandidates, + mentionTargets, 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[]; + /** + * 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; }) { @@ -43,10 +48,14 @@ export function ReplyComposer({ 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 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; @@ -122,8 +131,8 @@ export function ReplyComposer({ 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) => ( -
  • + {suggestions.map((target, i) => ( +
  • ))} @@ -162,7 +171,7 @@ export function ReplyComposer({ onKeyDown={(e) => { if (open && (e.key === "Enter" || e.key === "Tab")) { e.preventDefault(); - insert(suggestions[0]); + insert(suggestions[0].label); return; } if (e.key === "Escape" && open) { diff --git a/src/lib/conversations-queries.ts b/src/lib/conversations-queries.ts index eb57463..3d3d661 100644 --- a/src/lib/conversations-queries.ts +++ b/src/lib/conversations-queries.ts @@ -4,7 +4,7 @@ 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 { mentionCandidates } from "@/lib/mentions"; +import { mentionTargets, mentionAliases, type MentionTarget } from "@/lib/mentions"; import { getMentionRecipients } from "@/lib/client-notifications"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -58,8 +58,17 @@ export type ConversationThread = { * refresh il dato non sarebbe più ricostruibile. */ adminLastReadAt: Date | null; - /** I nomi con cui questo cliente si tagga in chat — vedi lib/mentions.ts. */ - mentionCandidates: string[]; + /** + * **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 @@ -299,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 { @@ -324,7 +337,8 @@ export async function getConversationThread( messages, channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []), adminLastReadAt: meta.admin_last_read_at, - mentionCandidates: mentionCandidates(meta), + mentionTargets: targets, + mentionAliases: mentionAliases(targets), notifyEmailCount: (await getMentionRecipients(clientId)).length, }; } diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts index d1f82e2..bbab939 100644 --- a/src/lib/mentions.ts +++ b/src/lib/mentions.ts @@ -40,19 +40,85 @@ function foldChar(ch: string): string { } /** - * 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. + * 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 sé (oggi `client_emails` ha gli indirizzi ma non i nomi) qui cambia solo la + * query che li carica, non il menu né 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): 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[] { - const raw = [client.name, client.brand_name ?? "", client.name.split(/\s+/)[0] ?? ""]; + return mentionAliases(mentionTargets(client)); +} + +function dedupe(values: ReadonlyArray): string[] { const seen = new Set(); const out: string[] = []; - for (const value of raw) { + for (const value of values) { const trimmed = value.trim(); if (!trimmed) continue; const key = trimmed.toLowerCase(); @@ -60,7 +126,7 @@ export function mentionCandidates(client: { seen.add(key); out.push(trimmed); } - return out.sort((a, b) => b.length - a.length); + return out; } export type MentionPart =