Files
clienthub/STATUS.md
T
simone 817a8cd5d1 chore(claude): architettura base .claude — skill preventivo e audit, hook di guardia, piani nel repo
La cartella aveva dentro solo rules/ e i settings: 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 duplica CLAUDE.md di progetto, che resta quello che comanda.

Due skill locali (le altre restano globali in ~/.claude/skills/):
- /preventivo — la catena agent.ts → schema.ts → assemble.ts → ProposalDeck e i tre
  modi di romperla, di cui uno solo fa rumore. Nessun prompt di generazione qui
  dentro: quello vive in agent.ts ed e' l'unico. Porta check-profilo.sh.
- /audit — guida scripts/audit-fonti.ts, nuovo, che mette in moto le cinque fonti di
  src/lib/audit/sources/, in prod dal 2026-08-19 ma mai chiamate da nessuno. Provate
  su giojello.com: 5 su 5, 42,7 s, PageSpeed mobile 58 / desktop 93.

Due hook, provati a mano (6 casi il primo, 5 il secondo):
- guardia-migration.sh BLOCCA l'SQL distruttivo sulle entita' protette — il vincolo
  Data Safety (LOCKED) fatto rispettare dalla macchina invece che dalla memoria.
- guardia-token.sh AVVISA sulle classi Tailwind grezze. Non blocca: con ~450
  occorrenze di debito, bloccare lo renderebbe un ostacolo da disattivare.

I tre piani di v2.5 entrano nel repo: stavano solo in ~/.claude/plans/ e STATE.md
avvertiva che senza quelli la milestone non era ricostruibile. Passati al setaccio
per credenziali prima di committarli.

Corretta in rules/memory-discipline.md la chiave della memoria persistente: e'
…-Vault-IAMCAVALLI-hub, non quella del workspace. Sedici file stavano nella prima,
la regola indicava la seconda.

Impeccable resta abilitato solo a livello globale: fuori da settings.json locale.

Nessun tocco al prodotto. Build e lint verdi, lint identico al baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:09:14 +02:00

659 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | 2730 | ⏸️ in pausa — Phase 27 ~50% |
| v2.4 Post-vendita | 13, 26 | ✅ chiusa 2026-08-08, entrambe in produzione |
| v2.3 Email & Accesso | 2325 | ✅ shipped 2026-07-29, verificata E2E |
| v2.2 Sales Loop | 1822 | ✅ shipped 2026-06-20 |
| v2.1 Offer Studio + CRM | 11, 12, 14 | ✅ chiusa per reset 2026-06-19 |
| v1.0 + v2.0 | 110 | ✅ 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-<dominio>.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 2730), 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/<id>` → "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 `<OtpGate/>` 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/<uuid>/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/` |