From 0d7186ba030d8ec007aa662402b6d74ffc960785 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Fri, 21 Aug 2026 17:53:33 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20chat=20a=20canali=20=E2=80=94=20design?= =?UTF-8?q?=20system,=20STATUS,=20STATE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il pattern che vale la pena rileggere: la chiave del canale è derivata, non è una colonna, e sta in un modulo condiviso perché le due sponde devono concordare al carattere. Più il perché del letto/non-letto asimmetrico (tabella per il cliente, timestamp per l'admin). Chiude anche il caveat in STATUS.md sulla risposta admin che finiva sempre sul thread generale. Co-Authored-By: Claude Opus 5 --- .planning/STATE.md | 15 ++++----- STATUS.md | 40 +++++++++++++++++++++-- design-reference/DESIGN-SYSTEM.md | 54 +++++++++++++++++++++++++------ 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/.planning/STATE.md b/.planning/STATE.md index 7becf75..3aa2830 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 e le due rifiniture del 2026-08-20 in prod; C2 (TidyCal) bloccato sulle credenziali API." -last_updated: "2026-08-21T00:20:00.000Z" -last_activity: 2026-08-21 -- portale cliente: stepper compatto/full-width, card offerta con override (0020) +stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1, rifiniture e chat a canali (0021) in prod; C2 (TidyCal) bloccato sulle credenziali API. La chat non e' stata provata a mano: .env.local non autentica piu'." +last_updated: "2026-08-21T16:10:00.000Z" +last_activity: 2026-08-21 -- chat a canali (portale + inbox admin), migration 0021 progress: total_phases: 4 completed_phases: 0 @@ -39,6 +39,7 @@ Phase 27 resta a metà — schema e fonti in prod, resto da scrivere. | C3 — Alleggerire l'hub | ⏸️ senza perimetro | | Rifiniture — tassonomie, "In revisione", tab pagamenti, riordino task | ✅ in prod 2026-08-20 (migration `0019`) | | Portale cliente — stepper compatto/full-width, card offerta | ✅ in prod 2026-08-21 (`0020`); override provato su Caruso Speaker | +| Chat a canali — portale + inbox admin per canale | ✅ in prod 2026-08-21 (`0021`); build verde, **mai provata a mano** | | D — Whop → audit | ⏸️ dipende dal motore v2.5 | Progress: [███░░░░░░░] 25% (v2.5) @@ -63,10 +64,6 @@ sintetizzatore che **incrocia** le osservazioni in massimo 10 finding. Vincolo c regge tutto: **un numero entra solo se misurato**, rintracciabile in `audit_runs.raw`. Passo per passo in `STATUS.md` e in `…radiant-valley.md`. -## Performance Metrics - -**Velocity:** 21 plans (v2.1–v2.4). Phase 27: spike ~1h, schema ~1h, fonti ~2h. Modifiche hub: A+B+C1 in una sessione. - ## Accumulated Context ### Decisions @@ -95,6 +92,6 @@ Log completo in `PROJECT.md`. Vive per il lavoro corrente: ## Session Continuity Last session: 2026-08-21 -Stopped at: portale cliente — stepper compatto e a tutta larghezza, card offerta senza accordion, "Valore dell'offerta" con override admin (migration `0020` applicata prima del push, colonna riletta a conferma). Build e lint verdi, deploy atterrato (immagine `44be190` = HEAD). **Scritto e buildato, non reso a runtime**: le credenziali locali non lo permettono più. -Next: (1) guardare il portale in prod e cliccare l'override in `/admin/projects/` → Offerte; (2) sbloccare TidyCal con token o documentazione; (3) `LEAD_WEBHOOK_SECRET` su Coolify — finché manca la route risponde 403 a tutti; (4) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`. +Stopped at: **chat a canali** — "Generale" + una per fase, chiave derivata in `src/lib/chat-channels.ts` e condivisa fra portale e inbox admin (se le due sponde la calcolassero da sole, la risposta finirebbe in un altro tab). Migration `0021` applicata a prod prima del push, tabella riletta a conferma: `client_channel_reads` per il non-letto per canale lato cliente (l'admin resta su `admin_last_read_at`), più il primo indice mai esistito su `comments`. Build e lint verdi. **Scritto e buildato, non provato a mano**: le credenziali locali non lo permettono più. +Next: (1) aprire il portale in prod e provare la chat — mandare un messaggio su una fase e verificare che la risposta admin torni nello stesso tab; (2) sbloccare TidyCal con token o documentazione; (3) `LEAD_WEBHOOK_SECRET` su Coolify — finché manca la route risponde 403 a tutti; (4) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`. Resume file: None diff --git a/STATUS.md b/STATUS.md index 70da891..d08c391 100644 --- a/STATUS.md +++ b/STATUS.md @@ -145,9 +145,43 @@ Cosa manca, e perché: usato, ora che dashboard e progetti sono in produzione. - **Whop → audit.** Dipende dal motore: oggi sarebbe un innesco che non innesca nulla. -⚠️ Il tab Commenti permetteva di rispondere sulla **singola entità**; -`replyToConversation` salva sempre sul thread generale. Non è una regressione introdotta -ora — era già una scelta di prodotto — ma da oggi è l'unica via. +✅ ~~Il tab Commenti permetteva di rispondere sulla singola entità; +`replyToConversation` salva sempre sul thread generale.~~ **Chiuso dalla chat a canali** +(sotto): la risposta va sull'entità del canale attivo. + +### Chat a canali (2026-08-21, in produzione) + +Il portale aveva **una** conversazione con un selettore di fase in un dropdown. Due +problemi veri: il cliente non aveva modo di vedere *dove* c'era del non letto, e una +risposta dell'admin poteva atterrare su un'entità diversa da quella della domanda. + +Ora i messaggi si organizzano in **canali** — "Generale" più uno per fase. Il canale non +è una colonna: `comments` resta polimorfica (`entity_type` + `entity_id`) e la chiave si +*deriva*. La derivazione sta in un posto solo, `src/lib/chat-channels.ts`, funzioni pure +importate da entrambe le sponde — **perché se cliente e admin la calcolassero ognuno per +conto suo, una risposta finirebbe in un tab diverso da quello della domanda, che è +esattamente il bug che questo giro chiude.** Task e deliverable non sono più scrivibili, +ma lo storico non resta orfano: rientra nel canale della fase proprietaria, conservando +il nome dell'entità come badge sul messaggio. + +**Il letto/non-letto è asimmetrico fra le due sponde, di proposito.** Il cliente ha una +tabella nuova, `client_channel_reads`, una riga per `(client_id, channel_key)`: un +singolo `client_last_read_at` segnerebbe letti *tutti* i canali all'apertura della chat, +e il pallino sul singolo tab — l'unica cosa che dice al cliente dove guardare — perderebbe +senso. L'admin invece resta su `clients.admin_last_read_at` (già esistente dalla `0014`): +lì la lettura è per conversazione e il pallino per canale si deriva da quel timestamp, +quindi nessuna tabella nuova. Il thread admin porta `adminLastReadAt` come **snapshot** +congelato al caricamento: senza, l'auto-mark-read all'apertura cancellerebbe i pallini +sotto gli occhi di chi sta leggendo. + +Migration `0021`, additiva, applicata a prod **prima** del push e tabella riletta a +conferma. Dentro anche un indice su `comments (entity_id, created_at)`: la tabella non ne +ha mai avuto uno, **nemmeno su `entity_id`**, dal giorno 0 — e il feed si legge esattamente +così. Deploy atterrato, immagine taggata `ac74a81` = HEAD. + +⚠️ **Buildata, non provata a mano** — stesso motivo di sopra (`.env.local` scaduto). +**Da fare:** aprire il portale di un cliente, scrivere su una fase, rispondere da +`/admin/conversazioni` e verificare che la risposta torni **in quel tab**. ### v2.5 — Audit (Phases 27–30), in pausa diff --git a/design-reference/DESIGN-SYSTEM.md b/design-reference/DESIGN-SYSTEM.md index 0de56a8..baa5cd7 100644 --- a/design-reference/DESIGN-SYSTEM.md +++ b/design-reference/DESIGN-SYSTEM.md @@ -150,7 +150,9 @@ across all future admin pages. | `LeadDetail` | `src/components/admin/leads/LeadDetail.tsx` | Lead detail page (`/admin/pipeline/[id]`): bespoke header (name + inline `StatusBadge` + action bar), asymmetric `lg:grid-cols-3` layout (1/3 profile card w/ icon-chip rows + tags, 2/3 Note/Follow-up + unified timeline). | | `ClientProfitability` | `src/components/admin/dashboard/ClientProfitability.tsx` | Dashboard widget: valore orario reale per cliente (`getClientProfitability` → contrattualizzato ÷ ore tracciate) con badge margine (Ottimo `bg-primary/10 text-primary` / In Linea `bg-muted`). | | `YearSelector` | `src/components/admin/YearSelector.tsx` | Selettore anno restilizzato a pill (`rounded-full border-border bg-card shadow-card`), frecce ←/→, valore `font-mono`. Guida i dati year-scoped della dashboard via `?year=`. | -| `ConversationsView` | `src/components/admin/conversazioni/ConversationsView.tsx` | Inbox messaggi clienti (`/admin/conversazioni`) a due pannelli: lista conversazioni filtrabile (search su nome/brand/ultimo messaggio, pallino unread) + thread attivo con bolle messaggio (admin a destra `bg-primary`, cliente a sinistra), badge entità per messaggio (Generale/Fase/Task/Deliverable) e form di risposta. Auto-mark-read all'apertura. | +| `ConversationsView` | `src/components/admin/conversazioni/ConversationsView.tsx` | Inbox messaggi clienti (`/admin/conversazioni`) a due pannelli: lista conversazioni filtrabile (search su nome/brand/ultimo messaggio, pallino unread) + thread attivo con **tab per canale** (Generale + una per fase, pallino accent sul tab non letto) e bolle messaggio (admin a destra `bg-primary`, cliente a sinistra). Badge entità solo sui messaggi storici di task/deliverable. La risposta va sull'entità del canale attivo. Auto-mark-read all'apertura, su snapshot di `adminLastReadAt`. | +| `ChatPanel` | `src/components/client/ChatPanel.tsx` | Chat del portale cliente: FAB `h-14 w-14` fisso in basso a destra con pallino unread, drawer laterale `fixed inset-y-0 right-0 bg-card shadow-2xl` che in `expanded` diventa full-screen (`Maximize2`/`Minimize2`, `Esc` chiude prima l'espansione poi il pannello). Tab canale scrollabili orizzontalmente (`no-scrollbar`), invio ottimistico, polling `GET /api/client/chat` ogni 20s. | +| `ChatProvider` | `src/components/client/ChatProvider.tsx` | Context del solo stato UI (aperto/espanso/canale attivo) — i messaggi restano dentro `ChatPanel`, unico a fare polling e a tenere la copia optimistic. Il canale sta nel context perché lo decide anche chi è fuori dal pannello: `openChat(phaseId)` dalla bolla di una `PhaseCard` apre la chat già sulla fase giusta. | | `MessagesWidget` | `src/components/admin/dashboard/MessagesWidget.tsx` | Widget dashboard "Messaggi Clienti": chat clienti in attesa di risposta (`getConversations()` filtrato su `unread`), pill emerald "N Nuovi" + pallino pulsante, righe con anteprima e link `Rispondi →` deep-link a `/admin/conversazioni?c=`. Empty state "Nessun messaggio in attesa ✓". | | `CopyLinkButton` | `src/components/admin/CopyLinkButton.tsx` | Icon button che copia un URL assoluto (`window.location.origin + path`) negli appunti; swap icona → check emerald per 1.5s con tooltip "Copiato!". Usato nella lista Clienti per il link profilo pubblico. | | `ClientRow` | `src/components/admin/ClientRow.tsx` | Riga tabella Clienti restilizzata Quiet Luxury: nome link → dettaglio + sottotitolo brand/progetti, colonne numeriche `font-mono` right-aligned (LTV, incassato come pill emerald, da saldare), cella "Link profilo" con pill mono troncata (apre portale in new tab) + `CopyLinkButton`. | @@ -183,25 +185,57 @@ layout: no in-`
` `PageHeader` (sidebar + shell header carry the chrome). - Widget rimossi dal legacy: chart incassi mensili, card "Da incassare"/"Clienti acquisiti", barre "Ore per cliente" (sostituite dalla redditività oraria). -### Conversazioni (inbox) notes +### Chat a canali (portale + inbox) notes -`/admin/conversazioni` è l'inbox unificata dei messaggi clienti (replica -`design-reference/pagina-conversazioni.html`). Ogni conversazione è **per cliente**: -raccoglie tutti i suoi commenti — `general` + quelli su fasi/task/deliverable — -in un unico thread ordinato, con badge entità per messaggio. +I messaggi vivono in **canali**: "Generale" più uno per fase. Il canale non è una +colonna — `comments` resta polimorfica (`entity_type` + `entity_id`) — ma una +chiave *derivata*, e la derivazione sta in un posto solo: +`src/lib/chat-channels.ts`, funzioni pure senza accesso al DB, importate da +entrambe le sponde. + + "Generale" -> clients.id + una fase -> phases.id + task -> la chiave della fase proprietaria (tasks.phase_id) + deliverable -> la chiave della fase del task proprietario + +**Perché in un modulo condiviso**: se cliente e admin calcolassero il canale +ognuno per conto suo, una risposta atterrerebbe in un tab diverso da quello in +cui è stata scritta la domanda. Task e deliverable non sono più scrivibili da +nessuna UI, ma lo storico esiste e rientra nel canale della fase, conservando il +nome dell'entità come badge sul messaggio. + +**Il letto/non-letto è asimmetrico fra le due sponde, di proposito:** + +- **Cliente** → tabella `client_channel_reads` (migration 0021), una riga per + `(client_id, channel_key)` con upsert `ON CONFLICT … DO UPDATE`. Serve una + riga per canale perché un singolo `client_last_read_at` segnerebbe letti + *tutti* i canali all'apertura della chat, e il pallino sul singolo tab — + l'unica cosa che dice al cliente dove guardare — perderebbe senso. +- **Admin** → resta su `clients.admin_last_read_at` (0014): lì la lettura è già + a livello di conversazione, e il pallino per canale si deriva da quel + timestamp. Nessuna tabella nuova. + +`/admin/conversazioni` (replica `design-reference/pagina-conversazioni.html`) +resta a due pannelli, con i canali come tab dentro il thread attivo. - **Fonte unread**: `clients.admin_last_read_at` confrontato con l'ultimo messaggio autore `client`. Una conversazione è non letta se esiste un messaggio cliente dopo `admin_last_read_at` (o se è `NULL`). Query in `src/lib/conversations-queries.ts` (`getConversations`, `getConversationThread`, - `getUnreadConversationsCount`). + `getUnreadConversationsCount`). Il thread porta con sé `adminLastReadAt` come + **snapshot**: congelato al caricamento, così l'auto-mark-read all'apertura non + cancella i pallini per canale sotto gli occhi di chi sta leggendo. - **Badge sidebar**: voce "Conversazioni" (`AdminSidebar`) con pill emerald del numero di clienti con non letti (pallino verde se collapsed), alimentato da `getUnreadConversationsCount()` in `src/app/admin/layout.tsx`. Stessa fonte di verità del `MessagesWidget` in dashboard. -- **Reply**: salvata come commento `general` sul cliente e aggiorna - `admin_last_read_at` (mark-read). L'apertura di una conversazione fa - auto-mark-read. Deep-link a un thread specifico via `?c=`. +- **Reply**: scritta sull'entità del canale attivo (cliente per "Generale", + fase per gli altri) — non più sempre `general` — e aggiorna + `admin_last_read_at`. Deep-link a un thread via `?c=`. + +**Indice**: `comments` non ha mai avuto un indice, nemmeno su `entity_id`, dal +giorno 0. La 0021 aggiunge `(entity_id, created_at)`, che è esattamente il modo +in cui il feed si legge. ### Clienti (lista) notes