Compare commits

...

2 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
5 changed files with 121 additions and 31 deletions
+7 -6
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. [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." 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-08-31T19:55:00.000Z" last_updated: "2026-09-01T00:15:00.000Z"
last_activity: 2026-08-31 -- conversazioni: scrivere per primo, menzioni, mail sul tag. Committata, push bloccato da un 403 di gitea 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: progress:
total_phases: 4 total_phases: 4
completed_phases: 0 completed_phases: 0
@@ -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`) | | 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**; 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** | | 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 | | 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 |
@@ -70,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: 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] 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-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. - **[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.
@@ -82,7 +84,6 @@ 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.
@@ -98,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. 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`. **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 Resume file: None
@@ -290,7 +290,7 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
key={m.id} key={m.id}
message={m} message={m}
clientId={thread.clientId} clientId={thread.clientId}
mentionCandidates={thread.mentionCandidates} mentionCandidates={thread.mentionAliases}
/> />
)) ))
)} )}
@@ -302,7 +302,7 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
channelKey={activeChannel} channelKey={activeChannel}
channelLabel={activeLabel} channelLabel={activeLabel}
isGeneral={isGeneral} isGeneral={isGeneral}
mentionCandidates={thread.mentionCandidates} mentionTargets={thread.mentionTargets}
notifyEmailCount={thread.notifyEmailCount} notifyEmailCount={thread.notifyEmailCount}
/> />
</> </>
@@ -5,6 +5,7 @@ import { AtSign } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { normalizeForSearch, type MentionTarget } from "@/lib/mentions";
import { replyToConversation } from "@/app/admin/conversazioni/actions"; import { replyToConversation } from "@/app/admin/conversazioni/actions";
/** /**
@@ -20,15 +21,19 @@ export function ReplyComposer({
channelKey, channelKey,
channelLabel, channelLabel,
isGeneral, isGeneral,
mentionCandidates, mentionTargets,
notifyEmailCount, notifyEmailCount,
}: { }: {
clientId: string; clientId: string;
channelKey: string; channelKey: string;
channelLabel: string; channelLabel: string;
isGeneral: boolean; 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. */ /** Quante mail partirebbero davvero con un tag. Zero = si avvisa, non si tace. */
notifyEmailCount: number; notifyEmailCount: number;
}) { }) {
@@ -43,10 +48,14 @@ export function ReplyComposer({
const suggestions = useMemo(() => { const suggestions = useMemo(() => {
if (!trigger) return []; if (!trigger) return [];
const q = trigger.query.toLowerCase(); const q = normalizeForSearch(trigger.query);
// Il nome intero per primo: è quello che si vuole quasi sempre. // Si filtra sugli alias ma si tiene il TARGET: digitando «car» il cliente si
return mentionCandidates.filter((c) => c.toLowerCase().startsWith(q)).slice(0, 4); // trova passando dal brand, e resta comunque una riga sola col suo nome.
}, [trigger, mentionCandidates]); // 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; const open = suggestions.length > 0;
@@ -122,8 +131,8 @@ export function ReplyComposer({
aria-label="Tagga il cliente" 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" 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) => (
<li key={candidate}> <li key={target.label}>
<button <button
type="button" type="button"
role="option" role="option"
@@ -133,7 +142,7 @@ export function ReplyComposer({
// nel posto sbagliato. // nel posto sbagliato.
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
insert(candidate); insert(target.label);
}} }}
className={cn( className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted", "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" /> <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> </button>
</li> </li>
))} ))}
@@ -162,7 +171,7 @@ export function ReplyComposer({
onKeyDown={(e) => { onKeyDown={(e) => {
if (open && (e.key === "Enter" || e.key === "Tab")) { if (open && (e.key === "Enter" || e.key === "Tab")) {
e.preventDefault(); e.preventDefault();
insert(suggestions[0]); insert(suggestions[0].label);
return; return;
} }
if (e.key === "Escape" && open) { if (e.key === "Escape" && open) {
+18 -4
View File
@@ -4,7 +4,7 @@ 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 { mentionTargets, mentionAliases, type MentionTarget } from "@/lib/mentions";
import { getMentionRecipients } from "@/lib/client-notifications"; import { getMentionRecipients } from "@/lib/client-notifications";
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -58,8 +58,17 @@ 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[]; * **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 * 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 * 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); 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 messages: ThreadMessage[] = commentRows.map((c) => {
const info = entityMap.get(c.entity_id); const info = entityMap.get(c.entity_id);
return { return {
@@ -324,7 +337,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), mentionTargets: targets,
mentionAliases: mentionAliases(targets),
notifyEmailCount: (await getMentionRecipients(clientId)).length, notifyEmailCount: (await getMentionRecipients(clientId)).length,
}; };
} }
+73 -7
View File
@@ -40,19 +40,85 @@ function foldChar(ch: string): string {
} }
/** /**
* I nomi con cui questo cliente può essere taggato: il nome della persona, il * La stessa normalizzazione che usa il parser, esposta per chi deve confrontare
* suo solo nome di battesimo e il brand. Ordinati dal più lungo al più corto * testo digitato con un nome — oggi il filtro del menu nel compositore.
* perché «@Mario Rossi» deve vincere su «@Mario», altrimenti il chip si *
* fermerebbe a metà del nome. * 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: { export function mentionCandidates(client: {
name: string; name: string;
brand_name?: string | null; brand_name?: string | null;
}): string[] { }): 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 seen = new Set<string>();
const out: string[] = []; const out: string[] = [];
for (const value of raw) { for (const value of values) {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) continue; if (!trimmed) continue;
const key = trimmed.toLowerCase(); const key = trimmed.toLowerCase();
@@ -60,7 +126,7 @@ export function mentionCandidates(client: {
seen.add(key); seen.add(key);
out.push(trimmed); out.push(trimmed);
} }
return out.sort((a, b) => b.length - a.length); return out;
} }
export type MentionPart = export type MentionPart =