Files
clienthub/STATUS.md
T
simone 4403b39e63 docs: task cancellati e date dei pagamenti — STATUS, STATE, design system
STATUS.md: perché la pill sta su "Cancellata" e non su "Fatto" (sono entrambi
barrati, e scambiarli significa credere consegnato ciò che non esiste), perché
`due_date` è nullable, e le due verifiche che restano a mano — il riquadro
"Prossimo pagamento" non compare finché nessuna rata ha una scadenza, e due
`paid_at` storici valgono il primo del mese perché li ha scritti il vecchio
selettore a mese.

DESIGN-SYSTEM.md: la quinta forma, la regola della colonna Kanban che si
nasconde solo da vuota, e la spec del box pagamenti.

STATE.md: due decisioni nuove, e resta sotto le 100 righe accorpando le righe
di tabella della stessa settimana.

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

37 KiB
Raw Blame History

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/sei-arrivato-qua-search-recursive-kettle.md, fuori dal repo.

In produzione dal 2026-08-19 (commit 4b135ce19ed377):

  • 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 proiettataclient-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 frontiADMIN_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 settingsnessuna 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.

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 e .planning/REQUIREMENTS.md.

⚠️ I due piani della milestone stanno in ~/.claude/plans/, fuori dal repo (…woolly-puddle.md per il documento, …radiant-valley.md per il motore). Senza quei file la milestone non è ricostruibile.

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
  • [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

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

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/.
  • 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.

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/