Compare commits
2 Commits
d71fede4e4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ce6f02ee4 | |||
| 614eb00040 |
+7
-6
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user