# ClientHub (IAMCAVALLI) — Status _Ultimo aggiornamento: 2026-08-22_ Questo è **l'unico documento narrativo** del progetto: a che punto siamo, cosa manca, cosa abbiamo imparato. `.planning/STATE.md` è il digest che leggono i comandi `/gsd-*` — non raddoppia questo file, ci rimanda. ## Stato attuale In produzione su `hub.iamcavalli.net` (Coolify/Hetzner, deploy automatico su push a `main` via Gitea). Build verde, `npm audit` pulito. Milestone **v2.5 "Audit"** in **pausa** a Phase 27 a metà: in produzione c'è solo lo schema dell'audit, nessuna pagina. La pausa è una scelta del 2026-08-19 — prima le **modifiche all'hub** chieste il 2026-08-18, poi il motore. | Milestone | Fasi | Stato | |---|---|---| | Modifiche hub | — | 🔨 in corso — dashboard e progetti in produzione, pipeline a metà | | v2.5 Audit | 27–30 | ⏸️ in pausa — Phase 27 ~50% | | v2.4 Post-vendita | 13, 26 | ✅ chiusa 2026-08-08, entrambe in produzione | | v2.3 Email & Accesso | 23–25 | ✅ shipped 2026-07-29, verificata E2E | | v2.2 Sales Loop | 18–22 | ✅ shipped 2026-06-20 | | v2.1 Offer Studio + CRM | 11, 12, 14 | ✅ chiusa per reset 2026-06-19 | | v1.0 + v2.0 | 1–10 | ✅ shipped giugno 2026 | ## In corso ### Modifiche hub (richieste 2026-08-18) Tre aree: dashboard, progetti, pipeline. Piano in [`.claude/plans/v2.5-modifiche-hub.md`](.claude/plans/v2.5-modifiche-hub.md). **In produzione dal 2026-08-19** (commit `4b135ce` → `19ed377`): - **Progetti.** Via il tab Commenti (duplicava `/admin/conversazioni`, che è un sovrainsieme stretto) e il timer dalla lista. Riepilogo soldi + avanzamento in testa al progetto. **Timer per fase e task** — migration `0018`, `ON DELETE SET NULL` perché cancellare un task non deve cancellare le ore lavorate. - **Dashboard.** Inbox promossa in cima a piena larghezza, con da-quanto-aspetta e su cosa è stato scritto. **Analytics per linea di prodotto** (Entry/Signature/Retainer, letti dalla tassonomia). **Timeline delle consegne** con semaforo ritardo/anticipo, scadenza dedotta da offerta + durata con override manuale `projects.due_date`. - **Pipeline.** `POST /api/webhooks/lead`, un endpoint solo per form del sito e bridge; regge payload piatto, `fields` annidati di Elementor e urlencoded, e non duplica chi compila due volte. Provato contro il DB di produzione via tunnel, poi ripulito. **In produzione dal 2026-08-20** (`3fcb10d`, `5547e55`) — due modifiche uscite dall'uso reale del pannello, nessuna migration (verificato: `tasks.status` è `text` senza `CHECK`): - **Rinomina di un valore di tassonomia** da `/admin/impostazioni`. Prima si poteva solo aggiungere o eliminare: per cambiare nome a una fase bisognava cancellarla — il che la strappava via da ogni servizio — e ricrearla a mano. `renamePoolValue` c'era già e propagava ovunque tranne in un punto: `importOfferIntoProject` copia `services.fase` dentro `phases.title` e poi ritrova la fase **confrontando i titoli**, perché `phases.offer_phase_id` esiste in schema ma non viene mai popolata. Un rename fermo al catalogo lasciava le fasi dei progetti col vecchio nome e al re-import ne nasceva una duplicata. Ora propaga anche lì, con lo stesso match `trim`+`lowercase` del merge, ed è l'unico rename che chiede conferma — dicendo quante fasi e quanti progetti tocca. - **Stato task "In revisione"**, fra "In corso" e "Fatto", visibile anche al cliente. Il lavoro vero non era il nuovo stato ma i tre letterali ricopiati a mano in otto file: ora tutto deriva da `TASK_STATUSES` in `src/lib/task-status.ts`. **In produzione dal 2026-08-20**, secondo giro (`b49d4bf`, `1fa8e1a`, migration `0019`): - **Tab pagamenti.** Mettere una rata su "saldato" la faceva saltare in fondo: `payments` non aveva **nessuna** colonna d'ordine e nessuna delle 13 query che la leggono aveva un `ORDER BY`, quindi Postgres restituiva l'ordine fisico e una `UPDATE` in MVCC riscrive la tupla in coda. In produzione **3 progetti su 5 erano già scombinati**. Per questo il backfill della `0019` **non** ordina per `ctid` (avrebbe fotografato lo scombinamento) ma per `percent DESC` con tie su `label`. Ora le rate si rinominano, gli importi si sovrascrivono a mano (`amount_locked` li esclude dal ricalcolo) e lo scarto fra somma rate e totale viene **dichiarato**, non aggiustato di nascosto. Schema a 3 rate: 50/25/25. - **Riordino dei task** dentro la fase, trascinando. `PhasesTab` è un Server Component con closure `"use server"` inline, quindi non è stato convertito: le righe restano server-renderizzate e `SortableTaskList` ci monta attorno solo la maniglia. Due bug chiusi per strada, entrambi vivi in produzione: `rescalePayments` azzerava le righe con `percent` NULL appena una riga del progetto ne aveva uno (ed è proprio quello che produce `splitPayment`), e il selettore di schema cancellava `status`/`paid_at` senza chiedere, con due rate già saldate a rischio. ⚠️ **Provato a runtime, non ancora cliccato.** Build di produzione contro il DB vero via tunnel SSH, in sola lettura: pagina progetto 200, 27 maniglie di trascinamento (esattamente i task delle fasi con più di un task), rate nell'ordine giusto. Restano da esercitare a mano le tre scritture nuove — `reorderTasks`, `updatePaymentField`, `clearPaymentOverride` — e il drag vero e proprio. ⚠️ **Il giro precedente resta deployato ma non provato a mano.** Build e lint verdi, immagine `9a57e45` viva, `/admin/login` risponde 200. Restano da esercitare in UI le due interazioni: la matita di rinomina con il dialog di conferma, e il drag di un task in "In revisione" con il controllo che la fase risulti *attiva*. Non sono state automatizzate di proposito: **l'unico DB raggiungibile in locale è la produzione**, e una prova di rinomina riscriverebbe titoli di fasi veri. **In produzione dal 2026-08-21** (migration `0020`) — portale cliente: - **Barra di avanzamento compatta e a tutta larghezza.** Rubava ~147px di altezza sopra la piega per dire una cosa sola. Ora "Step N" e lo stato stanno sulla stessa riga, il titolo della fase sotto — due righe invece di tre — e il padding scende da `py-8` a `py-3`: ~88px. Il mock sta in `design-reference/Client-Portal-Progress-Bar`, scritto in `slate-*` grezzi come tutti i mock: tradotto a token, non copiato. - **Card offerta senza accordion.** Via "Cosa è compreso": la lista dei servizi non serve al cliente e non viene più nemmeno **proiettata** — `client-view.ts` ora legge dai servizi i soli prezzi. `OffersSection` ha perso il suo unico `useState` e con esso il `"use client"`. - **"Valore incluso" → "Valore dell'offerta", con override.** Era sempre la somma dei prezzi di catalogo dei servizi del tier: un artefatto che si muove quando si muove il listino. In produzione produceva **€20.250 su offerte vendute a 7.000 e 5.500** — al cliente sembrava uno sconto del 70%. Ora `project_offers.offer_value_override` vince sulla somma; NULL torna al calcolo, `0` nasconde la riga. Si gestisce dal tab Offerte del progetto, che mostra accanto la cifra calcolata per far vedere cosa si sta sostituendo. La somma vive in `src/lib/offer-value.ts`, letta **sia** dal portale sia dall'admin: duplicarla avrebbe fatto divergere le due viste. - **"Prezzo finale" → "Investimento finale"** (solo una tantum; il ricorrente resta "Canone mensile"). ✅ **Verificato quel che si poteva verificare.** Build verde, lint senza nuovi warning, migration `0020` applicata a prod **prima** del push e colonna riletta dal DB a conferma; deploy atterrato (l'immagine in esecuzione è taggata `44be190`, cioè HEAD). **La scrittura dell'override è provata sul campo**: il 2026-08-21 in prod `Caruso Speaker / B` ha `offer_value_override = 7500.00` a fronte di un calcolo di 20.250 — la `0020` aggiunge la colonna e basta, non fa backfill, quindi quel numero è stato messo a mano dal tab Offerte. ⚠️ **Non reso a runtime, e va detto.** Le pagine non sono state aperte contro i dati veri: `.env.local` è scaduto **su tre fronti** — `ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la password del DB** — e l'host che dichiara (`178.104.27.55:5432`) è chiuso dal firewall. Estrarre la password viva dal container è stato **bloccato dal classifier dei permessi**, e non è stato aggirato. Quindi: *scritto e buildato*, non *provato*. **Da fare a mano:** aprire il portale di un cliente e guardare barra e card offerta. **Resta un override da mettere**: `Rossi Inc / B` è venduta a 7.000 e il portale le mostra ancora i 20.250 del calcolo. (`Teckell / A`: calcolo e venduto coincidono a 200, non serve toccarla.) Perché la verifica locale torni possibile serve riallineare `.env.local` alle variabili di Coolify (vedi § Note tecniche). Cosa manca, e perché: - **[BLOCCANTE] TidyCal.** Non ha webhook — è scritto nella loro FAQ, la strada suggerita è Zapier/Make. Ha una REST API con Personal Access Token su tutti i piani, ma **path, filtri per data e paginazione stanno dietro il login** e non sono indicizzati. Serve che l'utente apra `tidycal.com/integrations` → API Keys e passi token o documentazione: dedurre la forma dell'API violerebbe la regola che il progetto si è già dato dopo il caso PageSpeed. - **Alleggerire l'hub.** Senza perimetro. Da affrontare guardando cosa è davvero poco 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.~~ **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**. ### Modifica dei messaggi + firma in chat (2026-08-21, in produzione) Due mancanze emerse **provando la chat in produzione** — che è esattamente il tipo di cosa che né il build né il lint possono dire. **Modifica dei messaggi**, modello Slack/Discord: si corregge un proprio messaggio **senza limite di tempo**, il testo precedente **non si conserva**, accanto all'ora compare «modificato». > ⚠️ **Scelta deliberata, non una svista.** Illimitato + nessuno storico significa che > un messaggio scritto mesi fa resta riscrivibile, e l'unica difesa dell'altra parte è > quell'etichetta. Il rischio è stato posto e accettato il 2026-08-21. Se un domani > servisse dimostrare cosa era stato scritto, la strada è una tabella `comment_edits`: > additiva, si aggiunge senza toccare niente di quanto c'è ora. **Il punto difficile non era scrivere la modifica, era propagarla.** Il poll chiede i messaggi con `created_at > since`, e una modifica **non cambia `created_at`** — quindi l'altra parte avrebbe continuato a vedere il testo vecchio fino a un ricaricamento completo. Il filtro ora guarda **anche `edited_at`**, e il watermark del client è il **massimo fra i due su tutti i messaggi**: se restasse il `created_at` dell'ultimo, il server rispedirebbe lo stesso messaggio modificato a ogni giro, per sempre. Le due cose vanno insieme — una sola delle due e o la modifica non arriva, o arriva all'infinito. Il merge per id era già in piedi, quindi nessun duplicato da temere. **Il non-letto resta ancorato a `created_at`, di proposito**: correggere un refuso non deve riaccendere il pallino di un canale già letto. Annotato nel codice, altrimenti al prossimo refactor sembra una svista da "sistemare". Autorizzazione: **si modifica solo ciò di cui si è autori** — il controllo è su `author`, non solo sulla proprietà dell'entità, e lo rifà il server (il pulsante nascosto non è una difesa). Migration `0022`, additiva, applicata a prod prima del push e riletta a conferma: 11 messaggi, 0 marcati come modificati. **Firma in chat.** Il nome era la stringa `iamcavalli` cablata nel pannello: il cliente leggeva il marchio dove si aspetta una persona, e il monogramma diceva `IA`. Ora nome e foto arrivano da `settings` — **nessuna migration**, la tabella è già un key/value con i suoi helper — e si impostano da `/admin/impostazioni`. La foto è un **URL esterno**, e non è un ripiego: `/api/uploads/[...path]` **non esiste**. La deroga al vincolo LOCKED #5 per le immagini dell'audit è scritta in CLAUDE.md ma il codice non è mai stato costruito (Phase 27 è ferma allo schema). Un upload vero avrebbe richiesto di modificare un vincolo LOCKED *e* costruire da zero volume persistente, Server Action, rotta di lettura, whitelist MIME e limite di dimensione. Avatar assente o rotto ricade sul monogramma, così un link che muore non lascia un buco. Nell'inbox admin il nome **non** cambia: lì i propri messaggi dicono «Tu (Admin)», che è già corretto. Il problema era solo come il cliente vede l'altra parte. ⚠️ **Buildata, non provata a mano** (`.env.local` scaduto). **Da fare, in quest'ordine:** (1) con la chat del cliente **aperta**, modificare da `/admin/conversazioni` un messaggio dell'admin in quel canale — entro ~20s il testo deve cambiare da solo, senza duplicarsi; (2) verificare che il pallino **non** si riaccenda; (3) impostare nome e foto in `/admin/impostazioni` e ricontrollare il portale; (4) da `?preview=1` il pulsante «Modifica» non deve esserci. **Rimandato, con la ragione scritta:** le **menzioni** con più persone invitate in un portale. L'ostacolo non sono le notifiche ma l'attribuzione — `comments.author` contiene solo `client`/`admin`, non *quale* persona, quindi oggi tre invitati sono tre «Tu» identici. L'identità però esiste già: la sessione OTP porta l'email (`ClientSession = { clientId, email, iat }`) e `client_emails` è la lista degli invitati. Manca che le scritture della chat leggano la sessione invece del solo token — il lavoro già annotato in `client-chat.ts` come «un lavoro a sé». Ordine giusto: **attribuzione**, poi notifiche (Resend c'è già), poi eventualmente menzioni. Molto probabilmente vedere *chi* ha scritto risolve gran parte del problema da solo. ### Stati dei task, resi leggibili nel portale (2026-08-22) Nella Timeline — la vista di default — lo stato di un task era **solo un cerchietto colorato**: ambra col puntino da 1.5px per «In corso», violetto col puntino da 2px per «In revisione». A 20px sono lo stesso oggetto. Solo «In revisione» aveva un `title`; gli altri tre stati non avevano né testo, né tooltip, né `aria-label`. Il design system chiede l'opposto, ed è la sua regola UX 3: **colore + testo, mai colore da solo**. **«In revisione» non significa quello che dice il commit che l'ha introdotto.** `5547e55` lo descriveva come controllo qualità interno («finito, ma da controllare prima di consegnarlo»). Nel flusso reale è **«consegnato, aspetta l'OK del cliente»**: la palla è al cliente. Un cerchietto muto per uno stato che *richiede un'azione* era il problema vero, più della somiglianza fra i due colori. Cosa è cambiato: - **Le quattro icone si distinguono per forma**, non solo per colore, quindi reggono anche in bianco e nero e con un daltonismo: vuoto → punto → **spunta vuota** → spunta piena. La spunta compare quando il lavoro è finito e si riempie quando è confermato. - **Pill di testo solo su «In corso» e «In revisione».** «Da fare» è un cerchio vuoto e «Fatto» ha il titolo barrato: etichettarli aggiungeva rumore senza aggiungere informazione. - **Il conteggio dei task in attesa sta nell'header della fase**, accanto a «4 di 8 task», quindi si legge anche a card chiusa. Serve davvero: l'admin può forzare una fase su «Completata» dalla select, e in quel caso la card parte collassata con dentro la richiesta. - **Legenda + micro-copy** sopra entrambe le viste, che dice chi revisiona e che **ogni pagina include un giro di revisione** — l'informazione commerciale che il cliente non aveva da nessuna parte. Rimanda alla chat della fase, che esiste già. - **Colonne del Kanban cliente colorate**, con la stessa tinta dell'icona in Timeline: prima erano quattro colonne grigie identiche. - **Lo screen reader legge lo stato su tutti e quattro** (icona `aria-hidden` + `sr-only`), non solo sui due che portano la pill. **La barra di avanzamento non è stata toccata.** Un task in revisione conta come uno in corso — cioè zero. Cambia il *tipo* di lavoro, non l'avanzamento: gonfiare la percentuale avrebbe fatto dire alla barra una cosa che il contatore «x di y task» smentiva una riga sotto. Visual e copy stanno in un solo file, `src/components/client/TaskStatusIndicator.tsx`, e la legenda si genera da `TASK_STATUSES`: aggiungere un quinto stato non lascia indietro una lista scritta a mano. È la stessa disciplina di `5547e55`, nata proprio perché due liste fisse avevano fatto sparire dei task dal portale **senza un errore**. **In produzione dal 2026-08-22** (`2e9bd2a`, più `8b54f48` che toglie la doppia lettura della pill allo screen reader). Nessuna migration: è solo UI. Build e typecheck verdi, lint invariato — 12 errori, tutti preesistenti e nessuno nei file toccati. Verificato che il bundle servito in produzione contenga davvero il codice nuovo: legenda, micro-copy e il conteggio «in attesa di riscontro» sono nei chunk statici del container vivo. ⚠️ **Non ancora visto a schermo, e non posso vederlo io.** Il portale sta dietro il gate OTP e l'anteprima admin vuole una sessione. La verifica che conta va fatta a mano: `?preview=1` da admin loggato su **Caruso Speaker**, fase «3 - Esecuzione» — l'unica dove «In corso» (4) e «In revisione» (2) convivono, su 11 task, quindi l'unica dove si vede se ora si distinguono davvero. Da guardare anche col tema scuro: è lì che l'icona «In corso» prima non aveva varianti. ### Task cancellati e date dei pagamenti (2026-08-22) Due buchi che si vedevano solo aprendo il portale dalla parte del cliente. **Un task tolto dal lavoro non aveva dove stare.** L'admin poteva solo cancellarlo dal DB — e allora il cliente non capiva perché una voce che aveva letto la settimana prima non c'era più — oppure lasciarlo lì a fingersi «Da fare» per sempre. Ora `cancelled` è il quinto stato: **X dentro il cerchio, titolo barrato**, e la pill «Cancellata». È l'unico stato *chiuso* che porta la pill, e non è un'incoerenza: «Fatto» e «Cancellata» sono **entrambi barrati**, e scambiarli significa credere consegnato qualcosa che non esiste. La forma da sola non basta a coprire quel rischio. Il lavoro vero, di nuovo, non era il nuovo stato ma i denominatori. Un task cancellato esce da **tutti**: percentuale di fase, percentuale globale, board delle consegne, riepilogo admin — attraverso un solo `countsTowardProgress()` in `src/lib/task-status.ts`, non cinque `!== "cancelled"` sparsi in giro. Contarlo terrebbe la fase sotto il 100% per un lavoro che nessuno farà mai; contarlo come fatto racconterebbe una consegna mai avvenuta. `recomputePhaseStatus` lo ignora allo stesso modo: senza, cancellare l'ultima voce di una fase la lasciava «In corso» per sempre. Se restano **solo** cancellati la fase torna «Da iniziare» — è degenere, ma «Completata» al cliente racconterebbe una consegna che non c'è stata. Nel Kanban cliente la colonna «Cancellate» compare **solo se ha dentro qualcosa**, così le quattro che raccontano il lavoro si tengono la larghezza — ma mai se è piena, quindi nessun task sparisce dalla board: era esattamente il bug che aveva fatto nascere `TASK_STATUSES`. Nell'admin la colonna c'è sempre, ed è così che si cancella un task: trascinandocelo. **Il box pagamenti non sapeva dire quando si paga.** `payments` aveva `paid_at` — quando una rata è stata incassata — e nient'altro: nessun campo per la scadenza. Il portale non poteva rispondere alla domanda più ovvia del cliente, e non c'era niente su cui agganciare il promemoria via email. **Migration `0023`**, additiva: `payments.due_date`, nullable senza default, più un indice parziale su cui girerà la query del promemoria («le non saldate in scadenza entro N giorni», che l'indice `(project_id, sort_order)` non copre). Nullable è una scelta: una rata senza data concordata è normale, e un default inventerebbe una scadenza che nessuno ha pattuito. **Il portale mostra la data solo se c'è.** Cosa vede il cliente: - In cima al box, **«Prossimo pagamento»**: data lunga e conto alla rovescia in parole — «tra 12 giorni», «domani», «oggi». Se è passata, il riquadro diventa rosso e dice «Pagamento scaduto — scaduto da 3 giorni». Un arretrato batte sempre la rata del mese prossimo: se c'è, *quello* è il prossimo pagamento. - Su ogni riga, la sua data: **«Scade il…»** se aperta, **«Pagato il…»** se saldata. - **Nessun importo per riga.** LOCKED #2 resta dov'era: le date non sono cifre. I giorni si contano in `src/lib/payment-dates.ts`, sui **giorni civili a Roma** e non sugli istanti: il container gira a UTC, e «manca una settimana» non deve cambiare risposta a seconda del fuso di chi renderizza. È lo stesso modulo che userà il promemoria email — scritto ora proprio perché la mail e il portale non si contraddicano su quanti giorni mancano. Le date si salvano a **mezzogiorno UTC**, non a mezzanotte: a mezzanotte UTC il giorno civile a Roma è già quello dopo, e la data tornerebbe indietro di un giorno appena riletta. Lato admin ogni rata ha ora il campo **Scadenza**, e «Incassato nel mese» è diventato **«Incassato il»** — precisione al giorno, che le analytics non notano perché raggruppano con `extract(month from paid_at)`. Il warning sul cambio schema rate conta anche le scadenze: una rata «da saldare» con una data è già sotto gli occhi del cliente, e sparirebbe in silenzio. **In produzione dal 2026-08-22** (`fe76789`). Migration `0023` applicata a prod **prima** del push, come da procedura: `ALTER TABLE` + `CREATE INDEX`, 12 righe in tabella, nessuna toccata. Build, typecheck e lint verdi — lint invariato, nessun problema nuovo nei file toccati. ⚠️ **Due cose da fare a mano.** (1) Il portale non l'ho visto a schermo: vale la stessa verifica `?preview=1` della sezione precedente, e finché nessuna rata ha una `due_date` il riquadro «Prossimo pagamento» **non compare per definizione** — va messa una scadenza dal tab Pagamenti per vederlo. (2) Due `paid_at` storici valgono il **primo del mese** (2026-03-01 e 2026-01-01): li ha scritti così il vecchio selettore a mese, e ora il cliente li legge come «Pagato il 1 mar 2026». Se il giorno vero era un altro, si corregge dal nuovo campo. ### Architettura `.claude/` (2026-08-26) Configurazione, non prodotto: l'unico file di codice che nasce qui è `scripts/audit-fonti.ts`. `hub/.claude/` aveva tre cose dentro — `rules/`, `settings.json`, `settings.local.json` — e nessun posto dove mettere una skill, un hook o un piano. Ora ha lo scheletro completo e un **`.claude/CLAUDE.md`** che spiega cosa va dove. Non è «il CLAUDE.md che mancava»: quello di progetto sta alla radice ed è lui a comandare. Questo è la guida della cartella di configurazione, e si carica quando ci lavori dentro. **Due skill locali** (le altre restano globali in `~/.claude/skills/`, e copiarle qui significherebbe averne due destinate a divergere): - **`/preventivo`** — non contiene un prompt di generazione, e non deve averlo: quello vive in `agent.ts` ed è l'unico. Contiene la mappa della catena `agent.ts → schema.ts → assemble.ts → ProposalDeck + sections/`, con la tabella dei tre modi di romperla — e solo uno fa rumore. Campo nello schema e non nel prompt: **ogni** generazione muore con «Contenuto AI non valido». Campo nel prompt e non nello schema: Zod lo scarta in silenzio. Campo nello schema senza sezione che lo legge: generazione verde, sezione assente. Porta anche `check-profilo.sh`, il preflight sui dati finti. - **`/audit`** — guida `scripts/audit-fonti.ts`, nuovo, che mette in moto le cinque fonti di `src/lib/audit/sources/`, in produzione dal 2026-08-19 ma **mai chiamate da nessuno**. Provate su giojello.com il 2026-08-26: cinque su cinque, **42,7 s** (PageSpeed 34,2), tre pagine raccolte, CrUX disponibile a livello URL, PageSpeed mobile 58 / desktop 93. Il grezzo finisce in `audit-fonti-.json`, gitignorato: è lì che ogni numero del documento dovrà essere rintracciabile. Distinto da `spike-audit.ts`, che è un altro attrezzo e risponde a un'altra domanda. **Due hook di guardia**, provati a mano prima di fidarsene (6 casi il primo, 5 il secondo): - `guardia-migration.sh` **blocca** — è il vincolo Data Safety (LOCKED) fatto rispettare dalla macchina invece che dalla memoria. Toglie i commenti e spezza per `;` prima di guardare, così «non fare mai DROP TABLE payments» dentro un commento non fa scattare nulla, e `ALTER TABLE clients DROP COLUMN token` sì. Fuori da `src/db/migrations/` non ha voce. - `guardia-token.sh` **avvisa e basta**. Bloccare, con ~450 occorrenze di debito già in casa, lo renderebbe un ostacolo da disattivare invece di un promemoria da leggere. Whitelist per le eccezioni sanzionate: `StatusBadge`, `AdminShell`, `mailer`, `globals.css`, `design-reference/`. **I tre piani della milestone sono entrati nel repo.** Stavano solo in `~/.claude/plans/` con nomi generati a caso, e STATE.md avvertiva che senza quelli v2.5 non era ricostruibile: un piano che vive sul portatile di chi l'ha scritto non è documentazione, è un ricordo. Passati al setaccio per credenziali prima di committarli — dentro ci sono nomi di variabili d'ambiente, mai i loro valori. Corretta anche una riga di `rules/memory-discipline.md` che mandava nel posto sbagliato: la memoria persistente di hub sta sotto la chiave `…-Vault-IAMCAVALLI-hub`, non quella del workspace senza suffisso. Sedici file erano nella prima, la regola indicava la seconda. #### Due cose trovate sul preventivo, e non risolte Nessuna delle due è stata toccata: sono decisioni, non refusi. **`profile.ts` spedisce dati placeholder in ogni preventivo generato.** Tre testimonianze sono letteralmente `{ name: "Cliente 1", quote: "→ Aggiungere testimonianza reale." }`; accanto, email `simone@iamcavalli.com` e sito `iamcavalli.com` quando il dominio è **iamcavalli.net**, e titolo «Strategist · Brand & Business» quando il posizionamento è **Full Stack Designer**. Il file è uno **snapshot**: finisce in `proposals.content` al momento della generazione e ci resta anche correggendo il file dopo. Non è riempibile a occhio — `brand/prove.md` è ancora `stato: scheletro-intervista`, con i numeri marcati «da verificare». Quindi la mossa non è inventare: è **non renderizzare il blocco** finché il dato non c'è, allineare i campi già verificati, e portare la domanda a chi ha le testimonianze vere. `check-profilo.sh` oggi fallisce con 19 occorrenze, ed è il comportamento giusto. **`unitPrice` viaggia nel payload RSC di `/preventivo/[slug]`.** `PricingSection` renderizza solo `publicPrice ?? servicesTotal` e il nome dei servizi — corretto. Ma `assembleProposal` mette in `content.offer.tiers[].services[].unitPrice` anche il prezzo unitario, e `ProposalDeck` è `"use client"`: riceve l'intero `proposal` come prop da un server component, quindi tutto l'oggetto viene serializzato nella pagina, renderizzato o no. È la stessa trappola del gate OTP: **sparire a schermo non è sparire**. Verificato leggendo il codice, **non ancora a schermo** — serve `curl` su un preventivo pubblicato e `LC_ALL=C grep -c unitPrice`. Se conferma, la correzione è una proiezione client-safe come già fa `client-view.ts` per il portale, non nascondere una sezione. Terza, minore: `agent.ts` chiama il modello con `max_tokens: 8192` per uno schema che chiede fino a 5 problemi + 5 soluzioni + matrice. Se la risposta viene troncata l'errore non dice «troncata», dice «L'AI ha prodotto JSON non valido» — e manda a cercare nel posto sbagliato. Il controllo di `stop_reason` costa tre righe. ### v2.5 — Audit (Phases 27–30), in pausa Il servizio di analisi sito (tre livelli: **Radiografia / Prima-Dopo / Rotta**) diventa un documento privato su `/audit/[slug]`, generato da un motore multi-agente e rifinito a mano prima della consegna. I tre livelli sono **configurazioni di un unico documento**: i blocchi non pertinenti non esistono nel DOM. Fatto finora (Phase 27, ~50%): - **[prod 2026-08-18] Schema.** Migration `0017_audits.sql`, 7 tabelle additive, più `checklist_items` seminata con 264 voci. Nessuna UI le legge ancora. - **[prod 2026-08-19, inerte] Le fonti del motore.** `src/lib/audit/sources/` — PageSpeed, CrUX, Wayback, RDAP, robots/sitemap/JSON-LD, header. Provate sul campo su giojello.com: giro completo in 73 s, tutte e cinque hanno risposto. Deployate, ma **nessuna route le chiama**. Manca: agent + sintetizzatore + pipeline, storage immagini, editor admin, pagina pubblica. Dettaglio in [`.planning/ROADMAP.md`](.planning/ROADMAP.md) e [`.planning/REQUIREMENTS.md`](.planning/REQUIREMENTS.md). I due piani della milestone sono **nel repo dal 2026-08-26**: [`.claude/plans/v2.5-audit-documento.md`](.claude/plans/v2.5-audit-documento.md) per il documento, [`.claude/plans/v2.5-audit-motore.md`](.claude/plans/v2.5-audit-motore.md) per il motore. Prima stavano solo in `~/.claude/plans/`, e senza quei file la milestone non era ricostruibile: un piano che vive sul portatile di chi l'ha scritto non è documentazione. ## Fatto ### v2.4 — Post-vendita - **[Phase 13, prod 2026-08-01] Ciclo di vita dei servizi ricorrenti.** `project_offers.status` (attivo/sospeso/cessato) + `end_date` (migr. 0016). Prima un retainer non poteva finire e il forecast lo sommava a ogni mese in eterno. Comandi Sospendi/Riattiva/Cessa nella tab Offerte; il cliente vede stato, "attivo dal" e canone mensile. Dettaglio: [`.planning/phases/13-…/13-SUMMARY.md`](.planning/phases/13-ciclo-vita-servizi-ricorrenti/13-SUMMARY.md) - **[Phase 26, prod 2026-08-08] Anteprima admin del portale + toggle password.** `?preview=1` più una sessione Auth.js valida apre il portale di un cliente in **sola lettura**, senza passare dal gate OTP. Deviazione consapevole dal vincolo LOCKED #4, annotata in `CLAUDE.md`. Dettaglio: [`.planning/phases/26-…/26-SUMMARY.md`](.planning/phases/26-anteprima-admin-e-login/26-SUMMARY.md) ### v2.3 — Email & Accesso - **Gate OTP sul portale cliente**: whitelist email per cliente (`client_emails`), codice a 6 cifre via Resend, sessione firmata 90 giorni, revoca in blocco dall'admin (`clients.sessions_valid_from`). Migr. 0015. Verificato E2E in produzione. Archivio: [`v2.3-ROADMAP.md`](.planning/milestones/v2.3-ROADMAP.md) ### Prima di v2.3 - **[2026-07] Audit di sicurezza chiuso**: 4 vulnerabilità risolte e deployate, slug clienti ruotati a 12 char CSPRNG, `INTERNAL_SECRET` e `ADMIN_PASSWORD` configurati su Coolify. Report in [`.planning/security/`](.planning/security/). - **Design system "Quiet Luxury"**: dashboard, liste (Clienti/Offerte/Catalogo/ Preventivi/Progetti), Conversazioni, Impostazioni, Pipeline+Kanban, dettaglio Lead e portale cliente base sono a token semantici e dual-theme. - **Tassonomie centralizzate** in Impostazioni (modello Notion, pool persistenti `src/lib/taxonomy.ts`). - **Lead → Cliente**: `clients.email/phone` + `leads.archived` (migr. 0011). `convertLeadToClient` riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won". - **Offerta → Fasi/Task**: `importOfferIntoProject` crea fasi raggruppando i servizi del tier per `services.fase`. - **Offerte (modello + UI)**: `offer_macros.offer_type` ('una_tantum'|'retainer') + toggle "Modalità" nell'editor (migr. 0012). Tab Offerte a 2 step. ## Da fare - [ ] **Whitelist portale**: dei 4 clienti solo uno ha email autorizzate. Chi ha la whitelist vuota **non entra nel proprio portale** — si popola da `/admin/clients/` → "Accessi al portale", poi va reinviato il link. - [ ] **Fasi/Task dall'offerta** funzionano solo se i servizi hanno il campo **Fase** valorizzato nel Catalogo (altrimenti finiscono in "Generale"). - [ ] **Debito design (DEBT-01)**: **~40 file, ~450 occorrenze** di palette Tailwind raw e hex literal invece dei token semantici. I cluster: `/admin/projects/[id]` e i suoi tab (~182), `/admin/offers/[id]/edit` (~79), `/admin/clients/[id]` (~59), tutto `/quote/[token]` (~48, ed è rivolto al cliente), `ChatPanel` (37), più `ui/dialog.tsx` che propaga il look vecchio a ogni modale. *Esclusi perché legittimi:* `AdminSidebar` (eccezione brand documentata), `src/lib/mailer.ts` (HTML email), i colori di stato di `StatusBadge` (sanzionati dal design system, hanno già le varianti `dark:`). - [ ] Micro legacy "Mantenimento" senza tier: valutare se rimuoverlo/normalizzarlo. - [ ] **Backlog**: canoni mensili tracciabili (RET-06 — serve una tabella nuova), PROP-03 (Stripe sul deck), PROP-04 (auto-provisioning al "Vinto"), SEND-01/02 (invio preventivo via email — il mailer è già pronto). Elenco completo in [`.planning/REQUIREMENTS.md`](.planning/REQUIREMENTS.md). ## Lezioni operative Cose imparate a caro prezzo. Non sono documentazione di feature: sono trappole in cui si ricasca. - **Il gate OTP non va nel layout.** Prima implementazione: gate in `client/[token]/layout.tsx` che rendeva `` al posto di `{children}`. **Non protegge nulla.** Nell'App Router il segmento `page` è renderizzato in parallelo al layout: la dashboard spariva a schermo ma fasi, task e pagamenti restavano leggibili nel payload RSC dell'HTML (46.907 byte → 17.594 dopo il fix). Il gate sta in cima alla `page`, prima di ogni query, via `getClientGate()`. **Ogni nuova route sotto `/client/[token]/` deve fare lo stesso.** - **Ricreare il dominio su Resend rigenera la chiave DKIM.** Se il dominio torna `failed`, non fidarsi di valori DKIM annotati in passato: rileggerli da `GET /domains` e confrontarli con `dig +short TXT resend._domainkey.iamcavalli.net @8.8.8.8`. - **`.env.local` non è allineato a produzione, e la deriva è peggiorata.** Il 2026-07-28 erano stati ruotati **solo su Coolify** `ADMIN_PASSWORD` e `NEXTAUTH_SECRET`; il 2026-08-21 si è scoperto stale anche **la password del DB** (Postgres risponde `password authentication failed`) e chiuso dal firewall l'host che il file dichiara, `178.104.27.55:5432` — il DB vero è su `127.0.0.1:54321` dietro tunnel, quindi vanno riscritti host **e** porta. Recuperare la password viva dal container è **bloccato dal classifier**: non si aggira, si chiede all'utente di riallineare il file. Conseguenza operativa: finché resta così, **una modifica al portale si verifica solo guardandola in produzione** — e va detto, invece di far passare "buildato" per "funziona". Le migration non ne soffrono (`docker exec` non usa quelle credenziali). - **L'API Coolify rifiuta `is_build_time`** con 422 sul POST a `/api/v1/applications//envs`: mandare solo `key`, `value`, `is_preview`. - **Una lista di valori validi dimentica in silenzio, una negazione no.** `recomputePhaseStatus` decideva "fase iniziata" con `status === "in_progress" || status === "done"`. Aggiungendo "In revisione", una fase con tutti i task in revisione non rientrava né in `allDone` né in `anyActive` e **retrocedeva a "upcoming"**: si leggeva "non iniziata" quando era quasi finita. Stesso schema in `ClientKanban`, dove i task erano ripartiti da un oggetto a tre chiavi fisse invece che dalle colonne: un task fuori da quelle spariva da ogni colonna e da ogni contatore, e il cliente ne vedeva meno di quanti ce n'erano, **senza errore**. La causa a monte di entrambi era lo stesso `as` al confine del portale, che TypeScript non verifica. Quando un insieme di stati può crescere: derivare le colonne dalla costante, scrivere il predicato come negazione, e normalizzare al confine invece di castare. - **Playwright non funziona contro `npm run dev`**: la CSP blocca `eval` e i client component non si idratano. Serve il build di produzione. - **Le API di Google cambiano forma sotto i piedi, e in silenzio.** Scrivendo `src/lib/audit/sources/` due estrazioni dedotte dalla documentazione hanno restituito valori vuoti *senza errore*: `largest-contentful-paint-element` non esiste più (ora è `lcp-breakdown-insight`, con `subpart`/`duration` e senza percentuali) e `configSettings.screenEmulation` non esiste affatto nelle risposte pubbliche. Trovate solo perché le fonti sono state fatte girare su un sito vero prima di costruirci sopra. **Ogni estrazione da un'API di terzi va vista funzionare, non dedotta dai docs.** - **Laboratorio e campo misurano cose diverse, e la differenza *è* il risultato.** Su giojello.com Lighthouse dà `server-response-time` **7 ms** e CrUX dà TTFB p75 **3.553 ms con l'1% di visite nel verde**: il server risponde in fretta al datacenter Google e lento a tutti gli altri. Due numeri con lo stesso nome verrebbero fusi in uno — da qui `risposta_server_ms` invece di `ttfb_ms`. - **I punteggi PageSpeed ballano fra un giro e l'altro**: performance mobile 52 e poi 64 sullo stesso sito a 30 minuti di distanza. Nei documenti consegnati un punteggio va sempre con la sua data, mai presentato come una costante del sito. ## Note tecniche - **Non esiste un database di sviluppo.** Quello puntato da `.env.local` **è** la produzione: qualunque cosa si esegua in locale scrive su dati reali — verificare i conteggi delle tabelle protette prima e dopo ogni prova. ⚠️ Dal 2026-08-21 quelle credenziali **non autenticano più** e l'host che il file dichiara è chiuso: per raggiungere il DB servono il tunnel su `127.0.0.1:54321` **e** una password viva presa da Coolify. Fino ad allora `docker exec` via SSH è l'unica via. - **Controllare che un deploy sia atterrato**: `docker ps` sul server mostra l'immagine, e **il tag È lo SHA del commit** — si confronta con `git rev-parse --short HEAD`. Coolify ci mette ~2 minuti e cambia il suffisso del container a ogni giro: un controllo immediato fa credere a torto che il webhook sia rotto. - **Migrazioni**: SQL scritto a mano in `src/db/migrations/` (`drizzle-kit generate` è rotto — vanno tenuti in sync `schema.ts` e l'SQL). Si applicano **da locale via SSH + docker exec**, senza tunnel — procedura completa in `CLAUDE.md`. Il tunnel `ssh -f -N -L 54321:localhost:54321` serve solo per puntare il tooling locale (es. `npx tsx`) al DB di prod. - **Ordine di deploy con schema**: applicare la migrazione a prod **prima** del push (il deploy fa girare subito il codice nuovo). - **Coolify API**: credenziali in `~/.coolify.env` (formato `export VAR=…`, va sorgentato). App uuid `xsksow44g4kcoo8wocsgkscc`. - **`overrides` in package.json** forzano `postcss >= 8.5.18` e `sharp >= 0.35.0`: le versioni che Next si porta dietro hanno CVE high e non c'è fix upstream. Se un aggiornamento di Next rompe qualcosa, è il primo posto dove guardare. - `offer_micros` non ha `created_at` (no "tier più vecchio" affidabile). - **Debito tecnico non bloccante**: tabelle legacy `service_catalog` / `offer_services` / `offer_micro_services` come deadweight; `createService` / `serviceSchema` dead code in `src/app/admin/catalog/actions.ts`. ## File chiave | File | Scopo | |---|---| | src/proxy.ts | Middleware (Next 16 lo chiama `proxy`, non `middleware`) | | src/lib/client-gate.ts | Gate OTP — va chiamato in cima a ogni page sotto `/client/[token]/` | | src/lib/otp.ts, client-session.ts | Codici OTP e cookie di sessione 90gg | | src/lib/mailer.ts | Unico punto di invio email (Resend) | | src/lib/forecast-queries.ts | Forecast 12 mesi — rispetta stato e `end_date` dei retainer | | src/lib/taxonomy.ts | Pool tassonomie (Impostazioni) | | src/app/admin/projects/project-actions.ts | `importOfferIntoProject`, `setProjectOfferLifecycle`, piani pagamento | | src/components/admin/tabs/OffersTab.tsx | Tab Offerte + comandi ciclo di vita | | src/lib/admin-queries.ts / client-view.ts | I due layer separati: admin vs proiezioni client-safe | | src/lib/audit/sources/ | Le 5 fonti del motore audit (nessun LLM) — `fetch` espone anche gli helper di rete condivisi | | src/db/migrations/ | 0011 (email/phone), 0012 (offer_type), 0015 (OTP), 0016 (ciclo di vita), 0017 (audit) | ## Dove sta il resto | Cosa | Dove | |---|---| | Regole per Claude, procedure deploy/DB, vincoli LOCKED | `CLAUDE.md` | | Digest di stato per i comandi `/gsd-*` | `.planning/STATE.md` | | Requisiti e backlog della milestone corrente | `.planning/REQUIREMENTS.md` | | Roadmap e storico milestone | `.planning/ROADMAP.md`, `.planning/MILESTONES.md` | | Archivi delle milestone chiuse | `.planning/milestones/` | | Fasi della milestone in corso | `.planning/phases/` | | Report dell'audit di sicurezza (chiuso) | `.planning/security/` | | Design system e mock per pagina | `design-reference/` |