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>
This commit is contained in:
+2
-1
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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) => (
|
||||
<li key={candidate}>
|
||||
{suggestions.map((target, i) => (
|
||||
<li key={target.label}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
@@ -133,7 +142,7 @@ export function ReplyComposer({
|
||||
// nel posto sbagliato.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insert(candidate);
|
||||
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",
|
||||
@@ -141,7 +150,7 @@ export function ReplyComposer({
|
||||
)}
|
||||
>
|
||||
<AtSign className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-foreground">{candidate}</span>
|
||||
<span className="truncate font-medium text-foreground">{target.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+73
-7
@@ -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<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[] {
|
||||
const raw = [client.name, client.brand_name ?? "", client.name.split(/\s+/)[0] ?? ""];
|
||||
return mentionAliases(mentionTargets(client));
|
||||
}
|
||||
|
||||
function dedupe(values: ReadonlyArray<string>): string[] {
|
||||
const seen = new Set<string>();
|
||||
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 =
|
||||
|
||||
Reference in New Issue
Block a user