Compare commits

..

59 Commits

Author SHA1 Message Date
simone 9ce6f02ee4 fix(conversazioni): il menu dei tag mostra una riga per persona, non per alias
Con un cliente «Gian Luca Caruso / Caruso Speaker» il menu proponeva tre voci
per un destinatario solo. Non era un difetto di resa: al menu era stata passata
la stessa lista che il parser usa per RICONOSCERE un tag rileggendo il testo.

Sono due domande diverse, e il codice le aveva confuse:
  - come lo si puo' scrivere -> tutti gli alias, invisibili, solo in lettura
  - chi si puo' scegliere    -> una riga per destinatario

`MentionTarget` le separa: `label` per il menu, `aliases` per la rilettura.
Scrivere «@Gian» a mano continua a valere come tag e a far partire la mail --
cambia solo cosa viene offerto, non cosa viene accettato.

Il filtro del menu ora passa da `normalizeForSearch`, la stessa
normalizzazione del parser: con un toLowerCase() a parte, digitare «nicolo»
non avrebbe trovato «Nicolò» nel menu mentre nel messaggio sarebbe stato
riconosciuto -- due regole per la stessa domanda, e la seconda si scopre solo
quando la mail non parte.

Il difetto scalava peggio del valore: con tre contatti per cliente sarebbero
diventate nove righe. `mentionTargets()` ritorna gia' un array per questo, ma
il tag per singola persona resta impossibile finche' `client_emails` ha gli
indirizzi e non i nomi: serve una migration, ed e' un lavoro a se'.

Verificato: build e lint puliti, 23 test su parser e menu. Mai visto a schermo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 11:22:38 +02:00
simone 614eb00040 docs(state): il push e' passato, il 403 non c'e' piu'
Il bloccante sul push va tolto, non lasciato: una memoria sbagliata e' peggio
di una assente, e "push 403" avrebbe fatto ripartire la prossima sessione da
un problema gia' risolto.

Resta scritto COME si e' risolto -- l'accesso a Gitea si recupera dalla CLI
admin dentro il container, non dal web -- perche' quello sara' ancora vero la
prossima volta che il portachiavi scade.

In cima ai Next: rigenerare il token, che e' stato incollato in chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:36:45 +02:00
simone d71fede4e4 docs(state): conversazioni scritte e committate, push bloccato da un 403 di gitea
Il 403 va scritto come bloccante e non come nota di passaggio: finche' dura,
NESSUN commit arriva in produzione -- Coolify deploya sul push a `main`.
La credenziale nel portachiavi legge ma non scrive, e la chiave SSH locale non
e' registrata su Gitea, quindi non c'e' via alternativa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:53:21 +02:00
simone 41530b556a feat(conversazioni): scrivere per primo, taggare il cliente, notificarlo via mail
Tre cose che mancavano all'inbox admin, tutte senza migration.

1. Dall'inbox era impossibile aprire una conversazione: getConversations()
   costruiva la lista dai commenti, quindi un cliente compariva solo dopo aver
   scritto lui. Ora la lista parte da `clients` e i commenti la arricchiscono.
   Ordine: prima chi ha scritto (per recenza), in coda i clienti muti in
   alfabetico, cosi' l'inbox resta un inbox.

2. Menzioni «@Nome», rinviate dalla chat a canali. Modello senza schema: il tag
   si riconosce confrontando il testo con i nomi noti del cliente (nome intero,
   nome di battesimo, brand), insensibile ad accenti e maiuscole. Il body resta
   quello che l'admin ha scritto, quindi la menzione sopravvive alla modifica di
   un messaggio e resta leggibile ovunque finisca, mail compresa.
   Confini di parola su ENTRAMBI i lati: senza quello a sinistra,
   «scrivimi a mario@teckell.it» conteneva un tag «@Teckell».

3. Un tag manda una mail. E' l'unico messaggio che esce dal portale: per il
   resto il cliente entra quando gli pare, ma il tag e' la dichiarazione che
   quel messaggio non puo' aspettare il prossimo accesso. Nessuno scheduler --
   parte dalla stessa azione che scrive il messaggio, fuori transazione: se
   Resend e' giu' il messaggio in chat resta comunque scritto.
   Destinatari: whitelist OTP + email della scheda, deduplicati. Con zero
   indirizzi il compositore lo dice PRIMA, invece di lasciar credere che sia
   partita una mail che non partira'.

Il pulsante della mail punta a `?chat=<canale>`, validato lato server e passato
come prop: leggerlo nel browser vorrebbe dire renderizzare il pannello chiuso e
riaprirlo dopo l'idratazione.

La casella di risposta diventa controllata (ReplyComposer): il suggerimento del
tag deve leggere il testo mentre lo scrivi e reinserirlo al caret giusto.
Invio manda, Shift+Invio va a capo -- come nel pannello del cliente.

Verificato: `npm run build` e `eslint` puliti, parser delle menzioni provato su
9 casi. NON verificato a schermo ne' contro il DB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:51:58 +02:00
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
simone bec7e039d7 fix(portale): la legenda spiega "Cancellata" solo a chi ne ha una
Due sostituzioni del commit precedente non avevano agganciato — il testo
cercato aveva un a capo diverso — e la frase che spiega lo stato non è mai
finita nel file. Rifatte con verifica.

Nel rimetterla, condizionata: la voce «Cancellata» e la sua riga di spiegazione
compaiono solo se quel progetto ha davvero un task cancellato. Nella maggior
parte dei casi non ce n'è nessuno, e raccontare a tutti uno stato che non
vedranno mai è rumore in un riquadro che serve a togliere dubbi. La condizione
guarda i task veri, mai una preferenza: se una cancellata c'è, la legenda la
spiega.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:54:31 +02:00
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
simone fe767899b9 feat(portale): task cancellati e le date dei pagamenti
Due cose che il cliente non poteva sapere guardando il portale.

**Task cancellati.** Fino a ieri un'attività tolta dal lavoro poteva solo
sparire (cancellata dal DB) o restare lì a far finta di essere ancora da
fare. Ora `cancelled` è il quinto stato: X dentro il cerchio, titolo barrato,
pill "Cancellata" — l'unico stato chiuso che la porta, perché "fatto" e
"cancellato" sono entrambi barrati e confonderli significa credere consegnato
qualcosa che non esiste.

Esce da tutti i denominatori — fase, progetto, board di consegna, riepilogo
admin — con un unico `countsTowardProgress()` in task-status.ts invece di
cinque `!== "cancelled"` sparsi. Contarlo terrebbe la fase sotto il 100% per
un lavoro che nessuno farà; contarlo come fatto racconterebbe una consegna
mai avvenuta. `recomputePhaseStatus` lo ignora allo stesso modo: senza questo,
cancellare l'ultima voce lasciava la fase "in corso" per sempre.

Nel kanban cliente la colonna compare solo se ha dentro qualcosa — le quattro
che raccontano il lavoro si tengono la larghezza — ma mai se è piena, quindi
nessun task sparisce dalla board. Nell'admin la colonna c'è sempre: è così che
si cancella un task, trascinandocelo.

**Date dei pagamenti** (migration 0023, già applicata in produzione).
`payments` sapeva solo quando una rata era stata incassata, mai quando era
attesa: il portale non poteva rispondere a "quando devo pagare?" e non c'era
niente su cui agganciare il promemoria email. Ora c'è `due_date`, nullable —
una rata senza data concordata è normale, e il portale la mostra solo se c'è.

Il cliente vede in cima al box la prossima scadenza col conto alla rovescia
("tra 12 giorni", "domani", "scaduto da 3 giorni" in rosso), e su ogni riga
la 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. È lo stesso modulo che userà il
promemoria email, così la mail e il portale non si contraddicono.

Lato admin ogni rata ha il campo Scadenza, e "Incassato nel mese" diventa
"Incassato il" — precisione al giorno, che le analytics (raggruppate per mese)
non notano. Il warning sul cambio schema ora conta anche le scadenze: una rata
"da saldare" con una data è già sotto gli occhi del cliente, e sparirebbe in
silenzio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:49:11 +02:00
simone 15b01e3e05 docs: stati dei task nel portale — STATUS, STATE, e la correzione su cosa significhi
Il punto che vale la pena scrivere non e' la UI ma la semantica: "in revisione" significa
"aspetta l'OK del cliente", non controllo qualita' interno come dice 5547e55. Quel commit
resta nel repo a raccontare la versione sbagliata, quindi la correzione deve stare dove
qualcuno la trova — STATUS.md, non solo nel messaggio di questo commit.

Annotato anche cosa NON e' stato verificato: il bundle in produzione contiene il codice
nuovo (controllato dentro il container), ma nessuno l'ha ancora visto a schermo, perche'
il portale sta dietro il gate OTP e l'anteprima vuole una sessione admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:29:18 +02:00
simone 8b54f48afd a11y(portale): la pill di stato non ripete quello che lo screen reader ha gia' sentito
L'icona porta gia' uno sr-only con lo stato esteso, su tutti e quattro gli stati. Senza
aria-hidden sulla pill, i due che ce l'hanno venivano annunciati due volte: "In corso —
ci stiamo lavorando noi", titolo, "In corso". La pill e' ridondanza *visiva*, che e'
esattamente cio' che chiede il design system; per l'assistive tech e' rumore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:27:39 +02:00
simone 2e9bd2ab60 feat(portale): gli stati dei task si distinguono, e "in revisione" dice di chi e' la palla
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 ne' testo, ne' tooltip, ne' aria-label — l'opposto della
regola UX 3 del design system, che chiede colore + testo, mai colore da solo.

Il punto pero' non era la somiglianza fra i due colori. "In revisione" non significa
quello che dice 5547e55, che lo descriveva come controllo qualita' interno: nel flusso
reale vuol dire "consegnato, aspetta l'OK del cliente". E' uno stato che richiede
un'azione, e per questo un cerchietto muto era il problema vero.

- 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 a lavoro finito e si riempie a lavoro confermato.
- Pill di testo solo su "in corso" e "in revisione". "Da fare" e' un cerchio vuoto e
  "fatto" ha il titolo barrato: etichettarli aggiungeva rumore, non informazione.
- Il conteggio dei task in attesa sta nell'header della fase, quindi si legge anche a
  card chiusa. Serve davvero: l'admin puo' 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: 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 gia'.
- Colonne del kanban cliente colorate con le stesse tinte dell'icona in Timeline: prima
  erano quattro colonne grigie identiche.
- Icona aria-hidden + sr-only, cosi' lo screen reader annuncia lo stato anche sui due
  che non portano la pill.

La barra di avanzamento non e' stata toccata: un task in revisione conta come uno in
corso, cioe' zero. Cambia il tipo di lavoro, non l'avanzamento — gonfiare la percentuale
le avrebbe fatto dire una cosa che il contatore "x di y task" smentiva una riga sotto.

Visual e copy in un solo file, e la legenda si genera da TASK_STATUSES: un quinto stato
non lascera' indietro una lista scritta a mano. Stessa disciplina di 5547e55, nata
proprio perche' due liste fisse avevano fatto sparire dei task senza un errore.

Nessuna migration: e' solo UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:24:35 +02:00
simone c3d2afa61f docs: modifica messaggi e firma in chat — STATUS, STATE, design system
La cosa da rileggere fra sei mesi: propagare una modifica richiede
insieme il filtro allargato a edited_at e il watermark del client sul
massimo dei due timestamp. Una sola delle due e o la modifica non
arriva, o arriva a ogni giro per sempre.

Più il perché di due scelte che sembrano sviste: il non-letto resta su
created_at, e la foto è un URL esterno perché l'upload su volume non
esiste (la deroga LOCKED #5 è scritta ma mai costruita).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:44:30 +02:00
simone 94f4a54248 feat(chat): modifica dei messaggi e firma di chi risponde
Due mancanze emerse provando la chat a canali in produzione.

**Modifica dei messaggi** (migration 0022, additiva, già applicata a
prod). 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, annotata in STATUS.md.

Il punto delicato non è la scrittura ma la propagazione: il poll chiede
`created_at > since` e una modifica non cambia `created_at`, quindi
l'altra parte vedrebbe il testo vecchio fino a un reload. Il filtro ora
guarda anche `edited_at`, e il watermark del client è il massimo fra i
due su tutti i messaggi — senza, il server rispedirebbe lo stesso
messaggio a ogni giro per sempre. Il merge per id già esistente fa il
resto, quindi niente duplicati.

Il non-letto resta ancorato a `created_at` di proposito: correggere un
refuso non deve riaccendere il pallino di un canale già letto.

Si modifica solo ciò di cui si è autori — il controllo è su `author`,
non solo sulla proprietà dell'entità, e lo rifà il server.

**Firma in chat.** Il nome era la stringa "iamcavalli" cablata nel
pannello: il cliente leggeva il marchio dove si aspetta una persona. Ora
arriva da `settings` (nessuna migration) con foto via URL esterno, che
rispetta il vincolo LOCKED #5 — l'upload su volume non esiste, la
deroga per l'audit è scritta in CLAUDE.md ma non è mai stata costruita.
Avatar rotto o assente ricade sul monogramma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:42:48 +02:00
simone 0d7186ba03 docs: chat a canali — design system, STATUS, STATE
Il pattern che vale la pena rileggere: la chiave del canale è derivata,
non è una colonna, e sta in un modulo condiviso perché le due sponde
devono concordare al carattere. Più il perché del letto/non-letto
asimmetrico (tabella per il cliente, timestamp per l'admin).

Chiude anche il caveat in STATUS.md sulla risposta admin che finiva
sempre sul thread generale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:53:33 +02:00
simone ac74a81a72 feat(chat): chat a canali nel portale cliente e inbox admin per canale
Il portale aveva una sola conversazione con un selettore di fase in un
dropdown: il cliente non vedeva dove c'era del non letto, e una risposta
admin poteva atterrare su un'entità diversa da quella della domanda.

Ora i messaggi si organizzano in canali — "Generale" più uno per fase —
derivati in un solo posto (src/lib/chat-channels.ts) così che le due
sponde concordino sulla stessa chiave. 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.

- migration 0021 (additiva, già applicata in prod): client_channel_reads
  per il letto/non-letto per canale lato cliente, più il primo indice mai
  esistito su comments (entity_id, created_at)
- GET/POST /api/client/chat: polling dei messaggi e ricevuta di lettura
- ChatPanel: tab per canale, pallino di non letto, modalità full-screen
- inbox admin: tab per canale con targeting dell'entità corretta in
  risposta e snapshot di adminLastReadAt sul thread

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:50:44 +02:00
simone 53f1758f52 docs: l'\override è già stato usato in produzione
Guardando il DB: Caruso Speaker / B ha offer_value_override = 7500 a
fronte di un calcolo di 20.250. La 0020 non fa backfill, quindi quel
numero è stato messo a mano dal tab Offerte — updateOfferValueOverride è
provata sul campo, non più solo buildata.

Resta scoperta Rossi Inc / B: venduta a 7.000, il portale mostra 20.250.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:30:23 +02:00
simone d14f95c80d docs: verifica del giro 0020, e .env.local non è più utilizzabile
Cosa è stato verificato davvero: build e lint verdi, migration 0020
applicata a prod prima del push con la colonna riletta a conferma,
deploy atterrato (immagine taggata 44be190 = HEAD).

Cosa no, e ora è scritto: le pagine non sono state rese a runtime.
.env.local è scaduto su tre fronti — ADMIN_PASSWORD, NEXTAUTH_SECRET e
la password del DB — e l'host che dichiara (…:5432) è chiuso dal
firewall; il DB vero sta su 127.0.0.1:54321. Estrarre la password viva
dal container è bloccato dal classifier e non è stato aggirato. La nota
tecnica che dava per buone quelle credenziali diceva il falso.

Aggiunto come vedere se un deploy è atterrato: il tag dell'immagine in
docker ps è lo SHA del commit.

REQUIREMENTS: HUB-14 e HUB-15 registrati come fatti, HUB-16 apre la
verifica a mano che resta da fare, e il riallineamento di .env.local
entra fra le cose aperte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:05:39 +02:00
simone 44be190631 feat(portale): barra full-width, card offerta senza accordion, valore override
Barra di avanzamento a tutta larghezza (via il max-w-[1200px]).

Card offerta: rimosso l'accordion "Cosa è compreso". La lista servizi non
arriva più al client — client-view.ts legge dai servizi i soli prezzi — e
OffersSection perde il suo unico useState, quindi anche il "use client".

"Valore incluso" diventa "Valore dell'offerta" e accetta un override manuale
(migration 0020, project_offers.offer_value_override). Prima era sempre la
somma dei prezzi di catalogo del tier: in produzione mostrava €20.250 su
offerte vendute a 7.000 e 5.500. NULL torna al calcolo, 0 nasconde la riga.
Si gestisce dal tab Offerte del progetto, che affianca la cifra calcolata
per far vedere cosa si sta sostituendo.

La somma calcolata vive ora in src/lib/offer-value.ts, letta sia dal portale
sia dall'admin: duplicarla avrebbe fatto divergere le due viste.

"Prezzo finale" diventa "Investimento finale" (il ricorrente resta "Canone
mensile").

Migration 0020 applicata a prod prima del push. Override tutti NULL, quindi
il comportamento resta invariato finché non se ne imposta uno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:54:55 +02:00
simone 7e58031528 feat(portale): progress bar compatta, meno altezza
Segue il mock design-reference/Client-Portal-Progress-Bar: "Step N" e lo
stato passano sulla stessa riga, il titolo fase resta sotto — due righe di
testo invece di tre — e il padding del contenitore scende da py-8 a py-3.
Altezza della sezione da ~147px a ~90px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:08:00 +02:00
simone 4666fc4058 fix(task): key mancante sul ramo a task singolo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:44:34 +02:00
simone c9855d58ce docs: STATE.md rientra sotto le 100 righe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:42:47 +02:00
simone 4e3907d382 docs: tab pagamenti e riordino task in produzione
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:42:23 +02:00
simone 1fa8e1ab5e feat(task): riordino dei task dentro la fase, trascinando
tasks.sort_order esisteva e veniva letto in ORDER BY, ma non era mai scritto se
non come max+1 all'inserimento: nel repo non c'era alcun riordino, per nessuna
entita'. @dnd-kit/sortable era gia' installato e mai importato.

Il nodo era PhasesTab: e' un Server Component con quattro closure "use server"
inline, che in un modulo client non compilano. Quindi niente conversione: le
righe restano renderizzate dal server e arrivano a SortableTaskList come
ReactNode opachi, che ci monta attorno solo la maniglia. E' la stessa forma di
PhasesViewToggle, che gia' passa un tab server-renderizzato come prop.

reorderTasks riscrive sort_order come 0..n-1 per tutta la fase invece di
scambiare due righe. Non e' pigrizia: in produzione una fase ha 14 task con
sort_order sparsi su 0..23 (buchi lasciati dai delete), le righe legacy stanno
sullo 0 di default e non esiste unique index su (phase_id, sort_order), quindi i
duplicati sono ammessi. La riscrittura completa normalizza tutto a ogni drop. Gli
id arrivano dal client, quindi vengono filtrati su quelli che appartengono
davvero alla fase.

Niente pacchetti nuovi: @dnd-kit/modifiers non c'e', e il vincolo verticale si
ottiene azzerando la X della transform.

Verificato a runtime contro il DB di produzione (build di produzione + tunnel
SSH, in sola lettura): la pagina progetto risponde 200 e rende 27 maniglie, che
sono esattamente i task delle fasi con piu' di un task. Il passaggio di nodi
server con "use server" inline attraverso il confine client regge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:41:46 +02:00
simone b49d4bfaaa feat(pagamenti): ordine stabile delle rate, label e importi modificabili
Mettere una rata su "saldato" la faceva saltare in fondo. Non era un'impressione:
payments non aveva NESSUNA colonna d'ordine (ne' sort_order ne' created_at) e
nessuna delle 13 query che la leggono aveva un ORDER BY. Postgres fa seq-scan e
restituisce l'ordine fisico; una UPDATE in MVCC riscrive la tupla in coda, quindi
la riga aggiornata tornava ultima.

In produzione 3 progetti su 5 mostravano gia' l'ordine sbagliato (uno 30/20/50,
uno del tutto rovesciato 20/30/50, e la coppia legacy con Saldo prima di Acconto).
Per questo la migration 0019 NON fa il backfill per ctid, che avrebbe fotografato
lo scombinamento: ordina per percent DESC con tie su label, che ricostruisce
l'intento di tutti gli schemi esistenti. Verificato: rimette a posto tutti e 5.

Aggiunto anche l'indice su (project_id, sort_order): una FK non crea indice sul
lato referenziante, e payments non ne aveva alcuno oltre alla PK.

Ora le rate si rinominano e gli importi si sovrascrivono a mano (EditableCell +
updatePaymentField, con la stessa normalizzazione it-IT di updateServiceField).
L'importo scritto a mano e' legge: amount_locked lo esclude dal ricalcolo. Quando
la somma delle rate non corrisponde al totale il tab lo dice, con la cifra esatta,
invece di aggiustare di nascosto.

Lo schema a 3 rate passa da 50/30/20 a 50/25/25 (le righe gia' esistenti non
cambiano: vale solo quando lo si riseleziona).

Due bug trovati per strada e chiusi:

- rescalePayments decideva con some(percent !== null): bastava UNA riga con
  percent per far entrare tutto il progetto nel ramo percentuale, che calcolava
  newTotal * 0 e azzerava ogni riga con percent NULL. splitPayment inserisce la
  Rata 2 proprio cosi', quindi splittare una rata e poi toccare il totale la
  portava a zero. Ora la regola e' per riga, non per progetto.
- Il selettore di schema fa DELETE+INSERT e cancellava in silenzio anche status e
  paid_at, con due rate gia' saldate in produzione. Ora chiede conferma, ma solo
  quando c'e' davvero storico da perdere.

Migration 0019 applicata a prod prima del push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:35:41 +02:00
simone df4671236a docs: distingue deployato da provato a mano
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:14:43 +02:00
simone 330749a883 docs: STATE.md rientra sotto le 100 righe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:12:02 +02:00
simone 9a57e450fc docs: registra rinomina tassonomie e stato task "In revisione"
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:11:47 +02:00
simone 5547e555bd feat(tasks): stato "In revisione", con gli stati finalmente in un posto solo
Mancava il modo di dire "finito, ma da controllare prima di consegnarlo".
Il nuovo stato sta fra "In corso" e "Fatto" ed e' visibile anche al cliente:
il lavoro c'e' ed e' in controllo qualita', non e' fermo.

Il costo non era la logica ma la dispersione: tre letterali ricopiati a mano in
otto file, in tre forme diverse (allow-list a runtime, union TS, colonne kanban,
opzioni della select) e nessun CHECK in DB a tenerli insieme. Invece di
modificarne quattordici occorrenze, tutto deriva da TASK_STATUSES in
src/lib/task-status.ts: la prossima aggiunta costa una riga.

Due punti perdevano dati in silenzio, ed erano il vero motivo per centralizzare:

- recomputePhaseStatus considerava "iniziato" solo in_progress|done, come lista.
  Una fase con tutti i task in revisione non rientrava ne' in allDone ne' in
  anyActive e retrocedeva a "upcoming": si leggeva "non iniziata" quando era
  quasi finita. Ora e' la negazione di "todo", e regge anche il prossimo stato.
- ClientKanban ripartiva i task con un oggetto a tre chiavi fisse, non derivato
  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.

Chiuso anche il cast non verificato al confine del portale (page.tsx), che era
la causa a monte di entrambi: ora ci passa normalizeTaskStatus.

Nessuna migration: tasks.status e' text senza CHECK, le righe esistenti valgono.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:10:52 +02:00
simone 3fcb10dac6 feat(impostazioni): rinomina di un valore di tassonomia, fasi dei progetti incluse
Dalle impostazioni si poteva solo aggiungere o eliminare un valore: per
cambiargli nome bisognava cancellarlo — strappandolo via da ogni servizio che
lo usava — e ricrearlo a mano. La matita nel chip fa il rename in un passo.

renamePoolValue esisteva gia' e propagava ovunque, tranne in un punto:
importOfferIntoProject copia services.fase dentro phases.title e poi ritrova
la fase confrontando i titoli (phases.offer_phase_id non viene mai popolata,
quindi il titolo e' l'unico legame). Un rename fermo al catalogo lasciava le
fasi dei progetti col vecchio nome e al re-import ne nasceva una duplicata.
Ora propaga anche li', con lo stesso match trim+lowercase del merge.

E' l'unico rename di tassonomia che scrive fuori dal dominio catalogo/offerte,
quindi e' l'unico che chiede conferma, dicendo quante fasi e quanti progetti
sta per toccare.

Tolte anche le UPDATE manuali in renameServiceOption/renameOfferOption: erano
la stessa scrittura che renamePoolValue faceva subito dopo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:08:20 +02:00
simone 97cc6460a0 docs: registra le modifiche hub e mette v2.5 in pausa dichiarata
STATE.md diceva "Phase 27, nessun bloccante" mentre v2.5 e' ferma per scelta e
in produzione e' andato altro. Ora dice cosa e' vero: v2.5 in pausa, modifiche
hub in corso, blocchi A/B/C1 in produzione, e due bloccanti scritti con cosa
manca e chi li sblocca — le credenziali API di TidyCal e LEAD_WEBHOOK_SECRET
su Coolify, senza la quale la route rifiuta tutti (fallimento chiuso voluto).

Sta di nuovo sotto le 100 righe: ci e' rientrato togliendo cio' che STATUS.md
gia' racconta per esteso, non accorciando i bloccanti.

REQUIREMENTS.md guadagna HUB-01..13, con lo stato reale: otto fatti, cinque no.
Fra quelli aperti c'e' anche la conferma del payload Elementor, che oggi e'
gestito in modo difensivo e non verificato sul campo — distinguere "scritto" da
"visto funzionare" e' il punto della regola.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:13:50 +02:00
simone 19ed377214 feat(pipeline): endpoint di ingresso lead, indipendente dalla sorgente
POST /api/webhooks/lead con header x-webhook-secret. Un endpoint solo per il
form del sito e per qualunque bridge: il contratto e' un POST, e chi lo manda
non cambia la route.

La normalizzazione dei campi sta in lead-intake.ts, non nella route, perche' e'
la parte che cambia quando si aggiunge una sorgente. Regge tre forme senza
doverle distinguere: payload piatto, `fields` annidati con {value} (Elementor
Pro), e urlencoded per i form che non mandano JSON. Riconosce i nomi italiani
(nome, telefono, azienda, messaggio), che e' come li chiama un form Elementor
scritto in italiano.

Chi compila due volte non diventa due lead. Il riconoscimento e' sull'email: il
secondo invio aggiorna last_contact_date e lascia un'attivita' con quello che
ha scritto, cosi' il messaggio non si perde ma la scheda resta una. Senza email
non si puo' dedurre nulla e si crea.

Due scelte di sicurezza, entrambe diverse dalle route /api/internal:

- Segreto assente in ambiente = 403, non "passa". Le internal possono
  permetterselo perche' sono raggiungibili solo da localhost; questa e' esposta
  a internet, e un deploy con la variabile dimenticata deve smettere di
  accettare lead, non accettarli da chiunque.
- Il rate limit viene PRIMA del confronto sul segreto, altrimenti tentare
  segreti a raffica costerebbe zero. Confronto a tempo costante con safeEqual,
  lo stesso del gate admin.

src/proxy.ts non intercetta /api/*, quindi da monte non arriva nessuna
protezione: sta tutto dentro la route.

Provato contro il DB di produzione via tunnel SSH, poi ripulito (2 lead e 2
attivita' prima, 2 e 2 dopo): senza segreto 403, segreto sbagliato 403, nome
mancante 422, payload piatto 201, ripetuto 200 "updated" senza duplicare,
forma Elementor 201 con nome/telefono/messaggio mappati, urlencoded 201, e con
starts_at valorizzato il lead nasce con la data della call e un'attivita'
"meeting".

Resta da confermare con un invio VERO da Elementor la forma esatta del suo
payload: qui e' gestita in modo difensivo, non verificata sul campo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:57:42 +02:00
simone d6d3be00f4 feat(dashboard): timeline delle consegne con semaforo ritardo/anticipo
Quali progetti vanno consegnati, a che punto sono e se il ritmo regge.

La data attesa non esisteva nello schema. Si deduce da start_date +
duration_months dell'offerta, e projects.due_date (migration 0018) la
sovrascrive quando la durata a catalogo non descrive quel progetto li'.

Entrano solo le offerte una_tantum. Un retainer e' continuativo e una consegna
non ce l'ha per costruzione: in questa lista risulterebbe in ritardo per
sempre. E' lo stesso discrimine che gia' regge il forecast. Teckell, che ha
solo un "Mantenimento", sparisce dalla vista — e sparisce del tutto, non
finisce fra i "senza scadenza" dove sembrerebbe una dimenticanza.

Il semaforo confronta due percentuali, task chiusi e tempo trascorso, con dieci
punti di tolleranza: sotto, la differenza e' rumore, e un semaforo che vira al
rosso ogni settimana storta smette di essere guardato. Oltre la data di
consegna e' rosso e basta.

La barra le mostra entrambe: pieno = fatto, tacca = tempo passato. La distanza
fra le due E' il ritardo, e si legge senza doversi fidare del semaforo.

I progetti senza scadenza calcolabile restano elencati sotto invece di
sparire: sono quelli a cui non e' assegnata un'offerta, cioe' esattamente il
problema che la vista dovrebbe far notare.

StatusBadge guadagna i toni. Serviva perche' i quattro stati di consegna non
sono stadi di lead e cadevano tutti nel grigio di fallback: quattro pillole
grigie non sono un semaforo. I lead continuano a usarlo come prima.

Atteso e verificato sul DB: una sola riga, Rossi Inc in ritardo (22 giu + 1
mese = 22 lug, oggi 19 ago, 3 task su 28), piu' tre progetti senza scadenza.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:48:42 +02:00
simone 8f2b3255ab feat(dashboard): analytics per linea di prodotto, con il residuo in chiaro
Entry (l'Audit), Signature e Retainer a confronto: quante offerte sono partite
questo mese, quante nell'anno, quanto valgono e quanto e' stato incassato. Le
categorie si leggono da offer_macros.category, cioe' dalla stessa tassonomia
che si edita da /admin/impostazioni: aggiungerne una la fa comparire, senza
toccare il codice. Le categorie configurate compaiono sempre, anche a zero —
"questo mese non e' partito nessun audit" e' una risposta, una riga mancante no.

Il punto delicato e' l'attribuzione dell'incassato. I pagamenti stanno sul
PROGETTO, non sull'offerta: per dire quanto ha incassato una linea di prodotto
bisogna ridiscendere dal progetto alle sue offerte. Un'offerta sola prende
tutto; piu' offerte si spartiscono in proporzione all'accepted_total; nessuna
offerta finisce in una riga "Senza offerta", separata e visibile.

Quella riga separata non e' prudenza teorica. Sui dati di oggi vale il 100%
dell'incassato: 5.300 EUR su 5.300. I due progetti che hanno incassato (Caruso
Speaker, Protocollo Estetico) non hanno nessuna offerta assegnata; i due che
l'hanno (Rossi Inc, Teckell) non hanno ancora incassato. Spalmando quei soldi
sulle tre categorie la dashboard avrebbe mostrato numeri inventati con un
totale che quadra. Cosi' invece si vede che c'e' da assegnare le offerte.

Lo stato dell'offerta non filtra niente: un retainer disdetto oggi ha comunque
incassato quello che ha incassato. Stessa ragione gia' scritta in
getOffersSoldBreakdown — il ciclo di vita riguarda il forecast, non lo storico.

Attesi per il 2026, verificati a mano sul DB: Entry 0, Retainer 1 offerta /
200 EUR, Signature 1 / 7.000 EUR, Senza offerta 5.300 EUR incassati. Nessuna
partenza ad agosto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:45:23 +02:00
simone 1115bb2265 feat(dashboard): l'inbox sale in cima e dice da quanto aspettano
Il widget dei messaggi esisteva gia', ma era il terzo riquadro della colonna
stretta: sotto la piega, cioe' invisibile. Un messaggio non letto e' la cosa
piu' urgente della giornata e ora sta in testa alla dashboard, a piena
larghezza, due colonne.

Due dati erano gia' in ConversationSummary e non venivano mostrati: da quanto
aspetta il messaggio e su cosa e' stato scritto (fase, task, deliverable o
generale). Sono esattamente i due che dicono con che fretta rispondere. Il
tempo relativo si ferma alla settimana e poi passa alla data: oltre, "23 giorni
fa" non aiuta piu' nessuno.

La fascia sparisce del tutto quando non c'e' niente da leggere, invece di
lasciare a video un riquadro vuoto che si impara a saltare — e con lui si
imparerebbe a saltare anche quello pieno.

relativeTime va in src/lib/dates.ts perche' serve anche altrove. FollowUpWidget
ha ancora la sua copia locale con il prefisso "Contattato": la si unifica
quando la si tocca, non oggi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:42:52 +02:00
simone d6e95ef66a feat(progetti): riepilogo soldi e avanzamento in testa al progetto
Per sapere a che punto era un progetto bisognava aprire tre tab e sommare a
mente. Ora la risposta e' sopra i tab: contrattualizzato, incassato, da
incassare, redditivita' oraria, e una barra con i task fatti sul totale.

Nessuna query nuova: sono tutti dati che getProjectFullDetail restituisce gia'
per i tab sottostanti. E' aritmetica su quello che c'e'.

Una scelta: l'incassato si legge dai `payments`, non da `accepted_total`. Il
contratto dice quanto vale il progetto, le rate dicono quanto e' entrato
davvero, e quando i due non tornano e' un'informazione — non un errore di
calcolo da nascondere pareggiando i conti.

MetricCard esce da admin/page.tsx e diventa un componente condiviso: due copie
della stessa card avrebbero iniziato a divergere alla prima modifica.

Numeri verificati contro il DB di produzione, progetto per progetto. Rossi Inc:
7.000 EUR su 30h tracciate = 233 EUR/h sopra il target di 100, 3 task su 28,
1 fase su 4. Teckell, che ha ore ma nessun contratto, mostra "—" e non uno
zero travestito da dato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:40:48 +02:00
simone a9358da96f feat(timer): il tempo si imputa a fase e task, non solo al progetto
"Quanto e' costata questa fase" non era una domanda che si potesse fare:
time_entries aveva la sola project_id.

Migration 0018 (gia' applicata a prod): phase_id e task_id su time_entries,
piu' due indici, piu' projects.due_date che serve al blocco successivo. Solo
ADD COLUMN e CREATE INDEX.

Due scelte che vale la pena spiegare:

- ON DELETE SET NULL, non CASCADE. Cancellare un task NON deve cancellare le
  ore lavorate su di esso: sono storico fatturabile. L'entry ricade a livello
  progetto e il totale del progetto non cambia mai. Con CASCADE, ripulire una
  fase avrebbe silenziosamente abbassato il fatturato tracciato. Verificato
  sul DB di produzione dentro una transazione con ROLLBACK: cancellato il
  task, l'entry sopravvive con task_id NULL, phase_id intatto e i secondi
  invariati.
- Il timer su un task scrive ENTRAMBE le colonne. Cosi' il totale di una fase
  e' un group-by diretto su phase_id, senza risalire dai task, e comprende
  anche il tempo imputato alla fase ma a nessun task in particolare.

Resta un solo timer attivo in tutto l'hub. Da qui una conseguenza in UI: se
sta girando su un task, il timer del tab "Timer" NON si mostra acceso —
mostrarlo acceso farebbe credere che siano due cronometri diversi. Il tab lo
dice a parole e avvisa che avviarlo fermerebbe l'altro.

Le 8 entry esistenti restano valide con entrambe le colonne a NULL, cioe'
"tempo di progetto": e' esattamente cio' che sono.

PhasesTab passa ai token semantici mentre lo si tocca. Non e' zelo: ci si
infila dentro una TimerCell che i token li usa gia', e in dark mode un badge a
token dentro una card bg-white si vede. Un pezzo di DEBT-01 in meno.

Cade startTimerForClient, senza chiamanti da quando la lista progetti non ha
piu' il timer.

Build pulito. L'avvio/arresto dal browser non e' ancora stato provato: si
verifica in produzione, che e' l'unico posto dove esiste il DB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:38:06 +02:00
simone 4b135ce67f refactor(progetti): via il tab Commenti e il timer dalla lista
Due rimozioni chieste esplicitamente, che tolgono due doppioni.

Il tab "Commenti" del progetto duplicava /admin/conversazioni. Non era una
scorciatoia: era la seconda copia. `buildEntityMap()` in conversations-queries
cammina clienti -> progetti -> fasi -> task -> deliverable e raccoglie TUTTI i
commenti con l'etichetta dell'entita' di origine, quindi l'inbox e' un
sovrainsieme stretto di quel tab. La lettura non perde niente.

Una cosa la perde, e va detta: dal tab si poteva rispondere sulla singola
entita', mentre `replyToConversation` salva sempre sul thread generale. Non e'
una regressione introdotta qui — e' una scelta di prodotto gia' presa e gia'
annotata in conversazioni/actions.ts — ma da oggi e' l'unica via, e il commento
la' sopra ora lo dice.

Il timer nella lista progetti era l'altro doppione: si avvia e si ferma dentro
il progetto, dove c'e' il contesto per sapere su cosa stai lavorando. Toglierlo
elimina anche una query per pagina (la scansione delle entry aperte).

Cade di conseguenza il codice rimasto senza chiamanti: CommentsTab.tsx,
`postAdminComment`, il campo `comments` di ProjectFullDetail con la sua query, e
i due campi activeTimer* di ProjectWithPayments. `totalTrackedSeconds` resta:
serve al calcolo del EUR/h, che in lista ci sta ancora.

Build e lint puliti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:30:58 +02:00
simone 571f58bff8 docs: STATE.md registra il deploy di v2.5 e distingue "in prod" da "in uso"
Il push era rimasto bloccato e la riga delle fonti diceva ancora "non in prod".
Ora e' vero il contrario, ma "in produzione" da solo sarebbe fuorviante: i cinque
moduli sono deployati e nessuna route li chiama. La riga dice entrambe le cose,
perche' confondere *deployato* con *funzionante* e' il modo piu' rapido per
credere di avere una feature che non esiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:01:52 +02:00
simone 8000d562dc docs: apre v2.5 "Audit" e rimette in pari roadmap e requisiti
La roadmap era ferma al 2026-08-08 e diceva ancora "nessuna fase aperta" mentre
v2.5 era gia' partita e Phase 27 era a meta'; REQUIREMENTS.md era ancora quello
di v2.4. STATE.md invece era corretto — segno che aggiornare solo quello non
basta. Da qui la divisione dei ruoli, ora esplicita in testa a ogni file:

- STATE.md        orientamento breve (99 righe): dove sta cosa, come funziona il
                  motore, i blocchi vivi. Niente narrativa.
- ROADMAP.md      tutte le fasi 1->30, con lo stato di ciascuna
- REQUIREMENTS.md i 25 requisiti di v2.5 (AUD-01..25) e il backlog
- STATUS.md       l'unica narrativa lunga: lezioni e note tecniche

v2.4 chiusa e archiviata in milestones/v2.4-REQUIREMENTS.md.

Decisione nuova: il documento di restituzione usa il design system dell'area
admin ("Quiet Luxury"), non una tipografia sua — token semantici, Plus Jakarta
Sans, Geist Mono per metriche e date, StatusBadge per gli impatti. Sostituisce
la deroga tipografica prevista dal piano. I font sono gia' self-hostati da
next/font/google, quindi la CSP font-src 'self' e' soddisfatta senza lavoro, e
il documento non aggiunge debito a DEBT-01 perche' nasce gia' a token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:46 +02:00
simone 24213e7251 chore(audit): spike del motore e seed della rubrica
- scripts/spike-audit.ts        lo spike che ha risposto alla domanda "la
                                verifica delle voci su un sito reale e'
                                affidabile e produce problemi concreti?".
                                Deliberatamente ISOLATO: non importa da src/,
                                non tocca il database, non tocca l'hub.
- scripts/seed-checklist.ts     emette SQL su stdout, cosi' il popolamento della
                                rubrica passa dalla stessa procedura SSH+docker
                                exec delle migration invece che da uno script
                                usa e getta puntato al DB di produzione.
- scripts/data/checklist.json   le 264 voci, 71 delle quali valgono anche per i
                                siti non-ecommerce.

.gitignore esclude spike-audit-*.json: sono i dati del sito di un cliente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:34 +02:00
simone 08b0a60bae feat(audit): le fonti del motore di analisi
src/lib/audit/sources/ — raccolta dati, nessun LLM. Cinque moduli:

- fetch.ts      home + fino a 3 pagine interne per profilo, piu' gli helper di
                rete condivisi dalle altre fonti (ritentativi su 429/5xx e
                timeout, tetto di concorrenza, navigazione JSON difensiva)
- pagespeed.ts  153 audit Lighthouse fatti sul DOM renderizzato, falliti
                ordinati per gravita' con elementi concreti, per_id per la
                checklist, fasi LCP, screenshot
- crux.ts       dati di utenti reali, con scala di ripiego a quattro gradini
- history.ts    Wayback CDX, istantanee a 1/3/5 anni, confronto con la home
- signals.ts    RDAP, robots/sitemap, JSON-LD, hreflang, piattaforma, header

Regola comune: nessuna fonte puo' uccidere la pipeline. Chi fallisce restituisce
un risultato con `errore` valorizzato — e "non ha risposto" resta distinto da
"ha risposto che non ci sono dati", perche' il documento deve poterlo dire.

Provate sul campo su giojello.com prima di costruirci sopra, e il giro ha
trovato quattro cose che il typecheck non poteva vedere:

- fasi_lcp usciva vuoto: largest-contentful-paint-element non esiste piu'
  nell'API pubblica, ora e' lcp-breakdown-insight con subpart/duration e senza
  percentuali (si calcolano). Dice che il 91% dell'LCP e' ritardo nel *trovare*
  la risorsa, non peso dell'immagine: comprimere le foto non toccherebbe nulla.
- ttfb_ms era un nome pericoloso. Lighthouse da' 7 ms, CrUX da' 3.553 ms di p75:
  il server risponde in fretta al datacenter Google e lento a tutti gli altri.
  Con lo stesso nome il sintetizzatore li tratterebbe come un numero solo, da
  qui risposta_server_ms.
- le dimensioni dello screenshot erano sempre null: configSettings.screenEmulation
  non esiste. Ora si leggono dai byte dell'immagine — 250x498, leggibile.
- Wayback andava in timeout a 30 s e la fonte usciva vuota.

Nessun renderer headless, da nessuna parte: il VPS non regge Chromium e non
serve, gli audit Lighthouse arrivano gia' fatti sul DOM renderizzato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:27 +02:00
simone 31237da11c feat(audit): schema del documento di restituzione (migration 0017)
Sette tabelle additive per la milestone v2.5 "Audit": audits, audit_findings,
audit_optimizations, checklist_items, audit_checklist_results, audit_runs e
audit_visits. Nessun DROP, nessun TRUNCATE, nessuna colonna rimossa.

Tre scelte che vale la pena spiegare:

- Colonne scalari su audits, non un jsonb unico. A differenza di proposals non
  c'e' snapshot da congelare: il copy fisso sta in moduli TS versionati e
  l'editor mappa 1:1 sui campi. Tutti i campi di contenuto sono NULLABLE — e'
  cio' che rende possibile "si salva sempre, anche a meta'".
- checklist_items.profili e' jsonb: 71 voci su 264 valgono per entrambi i
  profili, una colonna singola costringerebbe a duplicarle.
- audit_checklist_results.esito ammette 'non_verificabile'. E' l'esito piu'
  frequente misurato sullo spike (107 su 204) e serve a sapere quanto il motore
  NON riesce a vedere: buttarlo via renderebbe impossibile misurare se le
  rilevazioni Lighthouse stanno recuperando terreno.

Migration gia' applicata in produzione il 2026-08-18, dati esistenti intatti.
CLAUDE.md annota la deroga al vincolo LOCKED #5, limitata agli asset di audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:48:11 +02:00
simone ee47f35e97 docs: allinea CLAUDE.md e i mock design-reference
I mock per pagina erano file HTML senza estensione, mentre CLAUDE.md li
descriveva come cartelle design-reference/pagina-*/ - un path che non
esisteva. Rinominati in pagina-*.html e aggiornati i riferimenti anche in
DESIGN-SYSTEM.md.

CLAUDE.md:
- puntatori corretti dopo il riordino di .planning/ (security/, STATE.md a
  digest, REQUIREMENTS.md come backlog corrente)
- rimosso il paragrafo sui doc superseded: i file non esistono piu
- annotato che i mock sono scritti in slate-* raw perche precedono la
  regola dei token: vanno tradotti, non copiati
- rese esplicite le eccezioni sanzionate alla regola dei token (colori di
  stato di StatusBadge, verde brand della sidebar, HTML delle email)
- vincolo LOCKED #4: annotata l'unica deroga, getClientGate() legge
  getServerSession per l'anteprima admin in sola lettura e solo con
  ?preview=1 (Phase 26). Testo approvato dall'utente

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:38:58 +02:00
simone 0aad9caf46 docs: STATUS.md unico documento narrativo, STATE.md a digest
STATUS.md e .planning/STATE.md raccontavano la stessa storia in due posti
con date diverse, e STATE.md si contraddiceva: frontmatter fermo a
"milestone v2.3 / executing" quando v2.3 e shipped, l'anteprima admin data
per "NON committata" mentre e il commit 187550f deployato l'8 agosto,
Session Continuity ferma al 29/07 e la tabella Performance Metrics spezzata
a meta.

Il template GSD dice esplicitamente che STATE.md deve stare sotto le 100
righe ("a DIGEST, not an archive"): ne aveva 177, quasi tutte narrativa.

- STATUS.md assorbe la narrativa e diventa l'unico posto dove si racconta
  il progetto. Nuova sezione "Lezioni operative" per le trappole in cui si
  ricasca: il gate OTP non va nel layout App Router (il payload RSC
  trapela), ricreare il dominio Resend rigenera la chiave DKIM, .env.local
  non e allineato a produzione dal 28/07, Playwright non funziona contro
  npm run dev
- STATE.md sceso a 98 righe, con i campi che state.cjs legge davvero.
  Frontmatter corretto a v2.4, blocchi gia risolti (DKIM, env Coolify)
  rimossi, nulla risulta piu "non committato"
- il debito design era sottostimato: non 11 pagine ma ~40 file e ~450
  occorrenze. Esclusi perche legittimi AdminSidebar (eccezione brand),
  mailer.ts (HTML email) e i colori di stato di StatusBadge

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:38:49 +02:00
simone f7eb7eec23 docs(planning): archivia v2.1/v2.2/v2.3 e documenta v2.4
.planning/ documentava in dettaglio cio che era vecchio e per niente cio
che e in produzione: le fasi 11-22 (v2.1 e v2.2, chiuse a giugno) erano
ancora in phases/ mentre v1.0 e v2.0 stavano gia in milestones/, e il
lavoro degli ultimi due mesi - gate OTP e ciclo di vita dei retainer, cioe
quello che gira su hub.iamcavalli.net - non aveva nessuna cartella.

- phases/{11,12,14} -> milestones/v2.1-phases/, phases/{18..22} ->
  milestones/v2.2-phases/. Ora phases/ contiene solo la milestone in
  corso, che e quello che state.cjs conta per il progresso
- v2.1-ROADMAP.md ricostruito: era l'unica milestone senza archivio,
  interrotta dal reset del 19/06 e mai chiusa formalmente
- v2.3-ROADMAP.md + v2.3-REQUIREMENTS.md: v2.3 e stata eseguita fuori dal
  ciclo GSD, non esistono PLAN/SUMMARY per fase. L'archivio E la doc
- REQUIREMENTS.md riscritto per v2.4 con il backlog reale
- phases/13 e phases/26: SUMMARY ricostruiti da commit, migration e
  STATUS.md. 26 e il primo numero libero
- research/: cancellate 4 varianti dello stesso PITFALLS e FEATURES/
  SUMMARY, superati da PROJECT.md. Diverse anti-feature erano ormai
  contraddette dai fatti (il Kanban e stato costruito in Phase 19,
  l'email in v2.3, il time tracking esiste)
- cancellati UI-RULES.md e DESIGN-SYSTEM.md (CLAUDE.md li dichiara
  superseded: impongono l'inverso della regola attuale) e HANDOFF.md,
  fermo al 13/06
- SECURITY-*.md -> security/: audit chiuso, ma i report restano la doc di
  cosa e stato ruotato e perche
- PROJECT.md/MILESTONES.md/ROADMAP.md allineati: milestone corrente v2.4,
  sessione OTP 90gg non 30, migrazioni fino alla 0016

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:38:36 +02:00
simone 187550fedf feat(client): anteprima admin in sola lettura del portale cliente
Quando un cliente segnalava "non trovo una cosa" non c'era modo di
guardare il portale con i suoi occhi: il gate OTP lascia entrare solo
lui. Dall'elenco clienti ora un'icona apre /client/<slug>?preview=1.

getClientGate() accetta { previewRequested } e salta il gate solo se il
query param c'è E getServerSession(authOptions) è valida. Senza param
anche un admin vede il gate OTP, così il gate resta testabile dal vivo.
Ritorna preview: true senza sintetizzare una ClientSession: un admin in
anteprima non è un cliente autenticato, e confondere i due stati li
renderebbe indistinguibili proprio dove serve distinguerli.

Sola lettura perché il portale scrive davvero: /api/client/approve e
/api/client/comment autenticano sul token nel body, non sulla sessione,
e deliverables.approved_at è immutabile una volta impostato (LOCKED #3).
La protezione è a livello di UI, non di API — impedisce l'incidente, non
difende da sé stessi. Il flag passa da PreviewProvider e non per prop
drilling: ApproveButton sta quattro livelli sotto la dashboard.

Deviazione consapevole dal vincolo LOCKED #4: una route client ora legge
anche la sessione Auth.js. CLAUDE.md non è aggiornato, la sezione LOCKED
richiede approvazione esplicita.

Verificato col build di produzione contro il DB reale (sole letture):
gate OTP senza sessione admin, con cookie contraffatto e con preview=0/
abc/vuoto; portale con banner e composer disattivato con sessione valida,
sia a progetto singolo sia a due progetti. Il ramo ApproveButton non è
esercitabile dal vivo: in produzione deliverables è vuota.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:19:22 +02:00
simone 09a5b1ff4f feat(auth): toggle mostra/nascondi password sul login admin
Il campo password non offriva modo di rileggere quanto digitato, quindi
un accesso fallito era indistinguibile da un errore di battitura.

Toggle inline e non nuovo primitivo in ui/: `type="password"` compare una
sola volta in tutto il codebase. type="button" perché dentro un <form> il
default è submit, e tabIndex -1 per tenere il Tab sulla sequenza campo →
Accedi. Classi a token semantici; gli hex literal preesistenti di questa
pagina restano da migrare a parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:19:09 +02:00
simone 5177a3700a feat(offers): ciclo di vita dei servizi ricorrenti (v2.4 Phase 13)
Un retainer, una volta assegnato, non si poteva fermare: project_offers
aveva solo start_date e il forecast sommava il canone a ogni mese
dell'orizzonte da li in poi, per sempre. Un cliente che disdiceva
continuava a gonfiare il forecast a 12 mesi e a vedersi l'abbonamento
attivo nel portale.

- migration 0016 (gia applicata a prod): project_offers.status
  (attivo|sospeso|cessato, CHECK) + end_date. Additiva pura, default
  'attivo' cosi le righe esistenti conservano il comportamento di prima
- forecast: i retainer si fermano a end_date, sospesi e cessati escono.
  getOffersSoldBreakdown NON filtra per stato: e uno storico di vendita,
  escludere le cessate riscriverebbe il passato
- offersAcceptedTotal esclude le cessate (default del piano pagamenti)
- admin: comandi Sospendi/Riattiva/Cessa + data fine nella tab Offerte,
  solo per i ricorrenti. setProjectOfferLifecycle valida con Zod e filtra
  anche per project_id, cosi un id arbitrario non tocca altri progetti
- portale: "Attivo dal", "fino al", badge In pausa, "Canone mensile"
  invece di "Prezzo finale"; le cessate non arrivano al client
- fix: un retainer sospeso continuava a intestare i pagamenti "Totale
  Pagamento Mensile" e a sovrascrivere l'importo

Igiene nello stesso giro:
- STATUS.md riscritto: era fermo al 22 giugno e diceva che node/docker non
  sono disponibili sul server e che le migrazioni si applicano da locale
  con uno script postgres.js — il contrario della procedura reale
- rimossi ChatSection/CommentList/CommentForm, senza importatori (308 righe)
- overrides postcss>=8.5.18 e sharp>=0.35.0: 3 CVE high transitive di Next
  senza fix upstream. npm audit ora pulito, build verde

Verifica: forecast controllato sui dati veri in 5 scenari (baseline
invariata, end_date, sospeso, cessato, ripristino); portale verificato nei
4 stati; tab admin verificata con Playwright sul build di produzione
(i comandi non compaiono sulle una tantum). Dati di test ripuliti,
tabelle protette invariate 4/5/11/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:18:28 +02:00
simone d57b0f3e04 docs(state): v2.3 in produzione e verificata end-to-end
Gate OTP live su hub.iamcavalli.net. Verificato: gate senza cookie con zero
dati di progetto nell'HTML, no-enumeration, codice sbagliato/corretto,
cookie Secure+HttpOnly+SameSite 90 giorni, rientro col cookie, isolamento
fra clienti. Dati di test rimossi, tabelle protette invariate.

Resta da popolare la whitelist dei 3 clienti reali.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:36:31 +02:00
simone 27da969963 docs(state): dominio Resend verificato, v2.3 pronta al deploy
Il dominio e stato ricreato su Resend (nuovo id) e i DNS rimessi: DKIM,
SPF TXT e MX tutti verified. Invio da no-reply@iamcavalli.net verso un
indirizzo esterno confermato riuscito.

Annotato che ricreare il dominio su Resend rigenera la chiave DKIM, quindi
i valori DKIM annotati in passato non sono affidabili.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:32:35 +02:00
simone c9b5cd7451 docs(state): Coolify configurato + mailer verificato; deploy fermo sul record DKIM
- RESEND_API_KEY e RESEND_FROM create su Coolify (production + preview)
- sendEmail() verificato con il template OTP reale: {ok:true}
- unico blocco residuo: TXT resend._domainkey.iamcavalli.net ha una chiave
  vecchia, dominio Resend status=failed, invio dal dominio rifiutato 403
- valore DKIM corretto e sequenza di ripresa annotati in STATE.md
- note API Coolify: envs non accetta is_build_time (422)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:20:47 +02:00
simone 8158038145 feat(auth): gate OTP email sul portale cliente (v2.3 Phases 23-25)
Il portale /client/<slug> era protetto dal solo token in URL: chiunque
ricevesse o intercettasse il link entrava, per sempre, senza identificarsi.
Ora l'admin registra le email autorizzate per cliente e il cliente si
identifica con un codice usa-e-getta prima di vedere qualsiasi dato.

- Resend 6.18.1 + src/lib/mailer.ts (Result tipizzato, mai catch silenzioso)
- migration 0015 (gia applicata a prod): client_emails, otp_codes,
  clients.sessions_valid_from. Additiva pura, conteggi verificati pre/post
- admin: sezione "Accessi al portale" in /admin/clients/[id] con whitelist
  e revoca sessioni in blocco
- gate: codice 6 cifre CSPRNG, hash SHA-256 (mai il codice in chiaro),
  TTL 15 min, max 5 tentativi, rate limit su entrambi gli endpoint,
  risposta identica per email in whitelist e non (no enumeration)
- sessione: cookie HMAC per-cliente, 90 giorni, httpOnly/secure/lax

Il gate sta in cima alla page, NON nel layout: nell'App Router il segmento
page viene renderizzato in parallelo al layout, quindi gattare nel layout
nascondeva la dashboard a schermo ma lasciava fasi, task e pagamenti nel
payload RSC dell'HTML (46907 byte -> 17594 dopo il fix). Verificato.

Verifica: build OK, 9/9 test E2E in locale contro il DB di produzione.

NON DEPLOYARE prima di: RESEND_API_KEY+RESEND_FROM su Coolify e whitelist
popolata per i 3 clienti reali (oggi vuota) - altrimenti il gate li chiude
fuori dal loro portale. Checklist in .planning/STATE.md.

SEND-01/02 (invio preventivo via email) rinviati a v2.4 su richiesta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:10:33 +02:00
simone b27b9d07ac chore: svuota il cestino e allinea CLAUDE.md alla nuova struttura
Verificato prima di cancellare: le 10 cartelle di fasi in
planning-fasi-duplicate/ erano byte-per-byte identiche alle copie in
.planning/milestones/ (diff -rq su ognuna), i 13 script one-off erano
gia eseguiti su fasi chiuse, e CLAUDE-SECURITY-*/ conteneva solo i
metadati di una run interrotta. Tutto resta comunque in git fino a
94b3b2f^.

CLAUDE.md citava ancora gli scripts/push-*.ts come vecchio metodo per
le migrazioni: quei file non esistono piu, quindi il riferimento
puntava a fantasmi. Ora la procedura SSH+docker exec e l'unica indicata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:30:19 +02:00
simone 94b3b2f766 chore: riorganizzazione e pulizia della cartella di progetto
Rimossi i doppioni e gli artefatti accumulati, senza cancellare nulla di
definitivo: tutto cio' che serviva una revisione e' parcheggiato in cestino/
(gitignored), documentato in cestino/LEGGIMI.md.

- .planning/phases/01-10: 10 cartelle identiche byte-per-byte alle copie in
  .planning/milestones/v1.0-phases e v2.0-phases. HANDOFF.md:33 documentava che
  furono copiate e non spostate, lasciando la pulizia 'facoltativa in futuro'.
  Verificata l'identita' con diff -rq prima di spostare ciascuna.
- scripts/: 13 script one-off gia' eseguiti (push-*, migrate-*, validate-*,
  verify-12-03-*) piu' reset-and-import-services.ts, che cancella dati.
  Restano i 3 riutilizzabili: seed, import-services-notion, import-service-offer-tags.
- CLAUDE-SECURITY-20260727-210226/: cartella di lavoro della run interrotta.

Cancellati subito, senza revisione: 6 .DS_Store, le due cache .impeccable/
(una era dentro src/) e tsconfig.tsbuildinfo.

.gitignore: aggiunti cestino/, .impeccable/ e CLAUDE-SECURITY-*/ per evitare
che si riformino.

src/ non e' stato toccato: la struttura e' dettata dall'App Router di Next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:13:32 +02:00
simone fb6ab92fd0 docs(security): correzione severita finding 1 + chiusura punti Coolify
CORREZIONE: la password Postgres trovata in git NON era attiva. La verifica
iniziale si limitava a constatare che la stringa comparisse in .env.local e ne
deduceva che fosse quella viva. Il confronto del verifier SCRAM-SHA-256 di
pg_authid contro i due candidati mostra che quella committata non combacia:
era gia stata ruotata. La voce DATABASE_URL porta 5432 di .env.local e' stale.
Severita' reale: BASSA, non CRITICA. Nessuna rotazione necessaria.

Chiusi via API Coolify: INTERNAL_SECRET creata (le route /api/internal/*
rispondono ora 403 invece di 404, oracolo di enumerazione token chiuso) e
ADMIN_PASSWORD portata da 15 a 32 caratteri. Redeploy verificato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 11:06:48 +02:00
simone bcff4aad48 docs(security): slug cliente ruotati in produzione
I 4 slug esistenti avevano suffissi da 4 caratteri generati con Math.random();
ora 12 caratteri CSPRNG. Lo slug risolve prima del token, quindi era il vero
anello debole dell'accesso alla dashboard cliente (C-2).

Lo script SQL NON e' committato di proposito: contiene gli slug in chiaro, che
sono credenziali bearer verso /client/<slug> — committarlo ripeterebbe la fuga
di credenziali trovata al punto 1 dell'audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 10:55:38 +02:00
simone e2bd1d95ed fix(security): audit completo — secondo gate admin, hardening slug, XSS, CSP/HSTS, update CVE
Audit di sicurezza su tutta l'app. Report in .planning/SECURITY-SCAN.md (codice),
.planning/SECURITY-AUDIT-INFRA.md (dipendenze/segreti/deploy) e piano in
.planning/SECURITY-REMEDIATION-PLAN.md.

CRITICO — l'autorizzazione admin era un unico punto di rottura: nessuna delle 21
pagine /admin controllava la sessione e admin/layout.tsx renderizzava comunque i
figli quando mancava. L'unico guard era proxy.ts, su un Next.js affetto da
GHSA-6gpp-xcg3-4w24 (proxy bypass). Ora il layout è un secondo gate indipendente;
proxy.ts marca il path con un token derivato da NEXTAUTH_SECRET, così il gate non
è aggirabile forgiando header e fallisce chiuso se il proxy non gira.

ALTO — gli slug cliente avevano 4 caratteri casuali da Math.random() (~20 bit,
1.7M tentativi) e risolvono prima del token: ora 12 caratteri via nanoid
(CSPRNG, ~62 bit). Aggiunto rate limit al ramo /client/, che ne era privo.

ALTO — src/lib/quote-actions.ts esponeva due server action pubbliche senza
autenticazione, una delle quali scriveva su DB. Codice morto, zero chiamanti:
rimosso.

MEDIO — i quattro dangerouslySetInnerHTML nelle sezioni proposta rendevano output
AI come HTML grezzo su pagina pubblica, alimentato da transcript di terzi. Sostituiti
con RichText (whitelist di emphasis, nessun HTML al DOM). I transcript ora sono
recintati in tag che il system prompt dichiara essere dati, non istruzioni.

Inoltre: next 16.2.6 -> 16.2.12 e next-auth 4.24.14 -> 4.24.15 (chiude 9 CVE Next
piu GHSA-xmf8-cvqr-rfgj su getToken, raggiungibile dal proxy); HSTS e CSP;
potatura della Map di rate-limit.ts, che cresceva senza limite; espunta la password
Postgres di produzione dai due 07-01-SUMMARY.md.

Verificato: tsc pulito, build OK, smoke test su login/redirect/header forgiati.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:35:46 +02:00
simone dd2d148457 feat: pagina Impostazioni redesign Quiet Luxury — sezioni tokenizzate + PoolManager tassonomie dual-mode
- impostazioni/page.tsx: sezione Analytics con input tariffa bordato (€/h, font-mono) + header uppercase
- TaxonomyManager: card sezione tokenizzata, griglie Offerte (2col) / Catalogo (3col)
- PoolManager: box bg-muted, badge conteggio mono, chip valori, add su Enter, delete cascade confermata
- DESIGN-SYSTEM.md: note pagina Impostazioni

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 12:14:52 +02:00
simone d444bd6064 feat: pagina Progetti redesign Quiet Luxury — tabella tokenizzata + badge pagamento/timer dual-mode
- projects/page.tsx: tabella in contenitore bg-card/shadow-card, header uppercase muted
- ProjectRow: badge pagamento pill rounded-full dual light/dark (saldato/da_saldare/inviata)
- TimerCell: pill rounded-full mono, idle neutro + running emerald con contatore live
- ConversationsView: bordo item attivo 3px per coerenza
- DESIGN-SYSTEM.md: note pagina Progetti

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 12:14:31 +02:00
333 changed files with 20826 additions and 35536 deletions
+77
View File
@@ -0,0 +1,77 @@
# `.claude/` — la cartella di configurazione di ClientHub
Questo file si carica quando si lavora **dentro `.claude/`**. Spiega cosa va dove, e basta.
**Per il progetto comanda [`../CLAUDE.md`](../CLAUDE.md)**: stack, vincoli LOCKED, procedura
di deploy, accesso al DB, design system. Qui non si duplica niente di quello — una seconda
copia è il modo più veloce per averne due che si contraddicono.
## Cosa va in ogni cartella
| Cartella | Cosa ci va | Cosa **non** ci va |
|---|---|---|
| `rules/` | Regole caricate per contesto. Oggi: `memory-discipline.md`, importata da `../CLAUDE.md` | Documentazione di feature — quella sta in `STATUS.md` |
| `skills/` | **Solo** skill specifiche di ClientHub: `preventivo/`, `audit/` | Le skill globali (`/seo-audit`, `/copywriting`, `/docx`…) — stanno in `~/.claude/skills/` |
| `agents/` | **Solo** agenti specifici di ClientHub. Oggi vuota: quello dell'audit nasce col motore | I 18 agenti globali di `~/.claude/agents/` |
| `hooks/` | Script eseguibili richiamati da `settings.json` | Logica di prodotto |
| `plans/` | I piani delle milestone, versionati | Piani usa-e-getta di una singola sessione |
| `commands/`, `workflows/`, `projects/` | Vuote per ora, sono lo scheletro | — |
| `memory/` | Appunti di lavoro versionati nel repo | **La memoria persistente. Non è qui** → vedi sotto |
| `worktrees/` | Generata dagli strumenti | — |
### Agenti e skill globali non si copiano qui
Regola già fissata in [`../../CLAUDE.md`](../../CLAUDE.md): agenti e skill che valgono per
tutti i progetti vivono in `~/.claude/` e si invocano da qualunque cartella. Copiarne uno qui
crea due file destinati a divergere, e il primo a cambiare vince a caso.
In locale ci va solo ciò che **senza questo repo non ha senso**: le due skill qui sotto.
### `.claude/memory/` ≠ memoria persistente
Due posti diversi con lo stesso nome, e confonderli fa perdere il lavoro:
- **`.claude/memory/`** (questa cartella) — appunti versionati nel repo, li vede chiunque
faccia clone.
- **`~/.claude/projects/-Users-simonecavalli-Vault-IAMCAVALLI-hub/memory/`** — la memoria
persistente vera, un file per fatto più `MEMORY.md` come indice. Sta fuori dal repo, non
si committa, e viene iniettata in automatico a inizio sessione.
Ci va quello che **non si deduce dal repo**: perché una decisione è stata presa, un vincolo
operativo, una cosa provata che non funziona. Regole complete in
[`rules/memory-discipline.md`](rules/memory-discipline.md).
## Le skill del progetto
- **`/preventivo`** — l'attrezzo per lavorare sulla pipeline che genera i preventivi
(`src/lib/proposal/`) senza romperla. Non contiene un prompt di generazione: quello vive
in `agent.ts` ed è l'unico.
- **`/audit`** — fa girare le cinque fonti di `src/lib/audit/sources/` su un URL e dice cosa
è stato **misurato** e cosa no. Le fonti sono in produzione ma inerti: nessuna route le
chiama ancora.
## Gli hook attivi
Tutti e tre in [`settings.json`](settings.json). Si provano a mano prima di fidarsi.
| Hook | Quando | Cosa fa |
|---|---|---|
| Promemoria memoria | `Stop` | Se `src/` o `.planning/` hanno modifiche non committate, ricorda di aggiornare `STATE.md`. Non blocca |
| [`guardia-migration.sh`](hooks/guardia-migration.sh) | `PreToolUse` su Write/Edit in `src/db/migrations/` | **Blocca** l'SQL che cancella dati dalle entità protette (`clients`, `projects`, `payments`, `phases`). È il vincolo Data Safety LOCKED fatto rispettare dalla macchina |
| [`guardia-token.sh`](hooks/guardia-token.sh) | `PostToolUse` su `.tsx`/`.css` | **Avvisa** se compaiono classi Tailwind grezze o hex letterali. Non blocca: le eccezioni sanzionate esistono e stanno nella whitelist dello script |
Per provarli senza passare da Claude:
```bash
echo '{"tool_input":{"file_path":"src/db/migrations/9999_x.sql","content":"DROP TABLE payments;"}}' \
| .claude/hooks/guardia-migration.sh; echo "exit=$?" # atteso: 2
echo '{"tool_input":{"file_path":"src/x.tsx","content":"<div className=\"bg-slate-100\"/>"}}' \
| .claude/hooks/guardia-token.sh; echo "exit=$?" # atteso: 0 + avviso
```
## I piani
`plans/` contiene i piani della milestone v2.5, portati dentro il repo il 2026-08-26 perché
stavano solo in `~/.claude/plans/` e `STATE.md` avvertiva che senza quelli la milestone non
era ricostruibile. Dettaglio in [`plans/README.md`](plans/README.md).
View File
View File
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# PreToolUse su Write/Edit — vincolo Data Safety (LOCKED) di ../../CLAUDE.md.
#
# Blocca la scrittura di una migration che cancella dati dalle entita' protette.
# Non e' una prova: e' una rete. Un DROP scritto in modo esotico puo' passare —
# la revisione a occhio dell'SQL resta obbligatoria.
#
# exit 0 = passa · exit 2 = bloccato, il messaggio su stderr torna a Claude.
set -uo pipefail
INPUT=$(cat)
FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
# Fuori da src/db/migrations/ questo hook non ha voce in capitolo.
case "$FILE" in
*src/db/migrations/*) ;;
*) exit 0 ;;
esac
# Write porta `content`, Edit porta `new_string`. MultiEdit porta un array.
SQL=$(printf '%s' "$INPUT" | jq -r '
[ .tool_input.content?,
.tool_input.new_string?,
(.tool_input.edits? // [] | .[].new_string?)
] | map(select(. != null)) | join("\n")
')
[ -z "$SQL" ] && exit 0
PROTETTE='clients|projects|payments|phases'
# Via i commenti, tutto minuscolo, una riga per statement: cosi' "DROP" e il nome
# della tabella devono stare nella STESSA istruzione per far scattare il blocco.
PULITO=$(printf '%s' "$SQL" \
| sed -E 's/--.*$//' \
| tr '\n' ' ' \
| sed -E 's;/\*[^*]*\*+([^/*][^*]*\*+)*/; ;g' \
| tr '[:upper:]' '[:lower:]' \
| tr ';' '\n')
COLPEVOLI=$(printf '%s\n' "$PULITO" \
| grep -E 'drop[[:space:]]+table|drop[[:space:]]+column|truncate|delete[[:space:]]+from' \
| grep -E "\\b($PROTETTE)\\b" || true)
if [ -n "$COLPEVOLI" ]; then
{
echo "BLOCCATO — Data Safety (LOCKED, ../CLAUDE.md)."
echo
echo "Questa migration cancella dati da un'entita' protetta (clients, projects,"
echo "payments, phases). Le migration sono additive: si aggiungono colonne e"
echo "tabelle, non si tolgono righe."
echo
echo "Istruzioni incriminate:"
printf '%s\n' "$COLPEVOLI" | sed 's/^[[:space:]]*/ · /'
echo
echo "Se la rimozione serve davvero, e' una decisione da confermare a voce con"
echo "l'utente prima di scriverla — non da aggirare qui."
} >&2
exit 2
fi
exit 0
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# PostToolUse su .tsx/.css — regola cardinale del design system:
# solo token semantici (bg-card, text-muted-foreground, border-border).
#
# AVVISA, non blocca: le eccezioni sanzionate esistono (vedi WHITELIST) e il
# debito storico e' gia' di ~450 occorrenze. Bloccare renderebbe l'hook un
# ostacolo da disattivare invece di un promemoria da leggere.
#
# exit 0 sempre. Il messaggio su stderr arriva a Claude come contesto.
set -uo pipefail
INPUT=$(cat)
FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.tsx|*.css) ;;
*) exit 0 ;;
esac
# Eccezioni sanzionate da ../CLAUDE.md § Design System:
# - StatusBadge: i colori di stato usano la palette con variante dark: esplicita
# - AdminShell: il verde brand della sidebar
# - mailer.ts / .html: l'HTML delle email non puo' usare variabili CSS
# - globals.css: e' il posto dove i token vengono *definiti*
# - design-reference/: i mock precedono la regola, si traducono non si copiano
case "$FILE" in
*StatusBadge*|*AdminShell*|*mailer*|*globals.css|*design-reference/*) exit 0 ;;
esac
TESTO=$(printf '%s' "$INPUT" | jq -r '
[ .tool_input.content?,
.tool_input.new_string?,
(.tool_input.edits? // [] | .[].new_string?)
] | map(select(. != null)) | join("\n")
')
[ -z "$TESTO" ] && exit 0
PALETTE='slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'
GREZZE=$(printf '%s\n' "$TESTO" \
| grep -oE "\\b(bg|text|border|ring|from|to|via|fill|stroke|divide|outline|shadow|accent|decoration|placeholder)-($PALETTE)-[0-9]{2,3}\\b" \
| sort -u | head -12 || true)
HEX=$(printf '%s\n' "$TESTO" \
| grep -oE '#[0-9a-fA-F]{3,8}\b' | sort -u | head -6 || true)
if [ -n "$GREZZE" ] || [ -n "$HEX" ]; then
{
echo "AVVISO design system — token semantici, non palette grezza."
echo "File: $FILE"
[ -n "$GREZZE" ] && { echo " classi grezze: $(printf '%s ' $GREZZE)"; }
[ -n "$HEX" ] && { echo " hex letterali: $(printf '%s ' $HEX)"; }
echo
echo "Usa bg-card / text-muted-foreground / border-border: e' quello che fa"
echo "funzionare chiaro e scuro sul solo toggle della classe .dark."
echo "Riferimento: design-reference/DESIGN-SYSTEM.md"
echo "Eccezione sanzionata? Aggiungi il file alla WHITELIST di questo hook."
} >&2
fi
exit 0
View File
+17
View File
@@ -0,0 +1,17 @@
# Piani
I piani delle milestone, **dentro il repo**. Ci sono arrivati il 2026-08-26: prima
stavano solo in `~/.claude/plans/`, con nomi generati a caso, e `STATE.md` avvertiva
che senza quei file v2.5 non era ricostruibile. Un piano che vive solo sul portatile
di chi l'ha scritto non e' documentazione, e' un ricordo.
| File | Cosa contiene | Origine |
|---|---|---|
| `v2.5-audit-documento.md` | Il documento di audit: tre livelli (Radiografia / Prima-Dopo / Rotta) come configurazioni di un unico documento su `/audit/[slug]` | `dovremmo-fare-una-cosa-woolly-puddle.md` |
| `v2.5-audit-motore.md` | Il motore: raccolta in parallelo, quattro sub-agent, sintetizzatore. Il vincolo che regge tutto — **un numero entra solo se misurato** | `vorrei-solo-farti-capire-radiant-valley.md` |
| `v2.5-modifiche-hub.md` | I blocchi A/B/C delle modifiche all'hub chieste il 2026-08-18 | `sei-arrivato-qua-search-recursive-kettle.md` |
Sono **piani, non stato**: dicono cosa era stato deciso di fare, non cosa e' fatto.
Per quello ci sono `STATUS.md` (narrativa) e `.planning/STATE.md` (digest).
Passati al setaccio per credenziali prima del commit: dentro compaiono nomi di
variabili d'ambiente, mai i loro valori.
+413
View File
@@ -0,0 +1,413 @@
# Audit — documento di restituzione
## Context
iamcavalli vende un servizio di analisi sito in tre livelli (La Radiografia / Il Prima-Dopo / La Rotta). Il deliverable è un documento di restituzione presentato in una call da 40 minuti. Oggi si fa a mano fuori dall'hub.
Il servizio si chiama **audit** → rotta `/audit/[slug]`. Il nome del livello acquistato compare **solo in copertina**.
```
acquisto Whop ──┐
├─► audit creato ─► intake dati ─► agent: analisi profonda
creazione manuale ┘ │
call (fissata a mano) ◄── consegna ◄── revisione + redesign (manuale)
```
Il redesign lo prepari tu mentre gli agent lavorano. **Deve essere possibile creare un cliente e far partire l'audit in manuale, senza acquisto.**
Volume: ~50 audit/anno. Overhead non-analitico sotto i 10 minuti per audit. Regola guida: *tu scrivi solo analisi e redesign, tutto il resto si popola*.
### Documento canonico
La **Spec V1** (blocchi, copy fisso, campi) è il documento canonico. L'Excel *Ecommerce Growthlist* è **rubrica interna del motore di analisi**, non struttura del documento — vedi §4.
### Verdetto di fattibilità
Fattibile. Tre pezzi da costruire da zero: **motore di analisi**, **PDF**, **hosting immagini**. Il resto ha precedenti diretti in casa.
### Conflitti risolti a favore del progetto
| Spec V1 | Qui | Perché |
|---|---|---|
| Supabase | Neon + Drizzle | Lo stack è quello |
| `uuid` | `text` + `nanoid()` | Convenzione di tutte le tabelle in `schema.ts` |
| PDF serverless headless | Print CSS | Deploy Docker su Coolify, non serverless. Stesso risultato, zero infra |
| `/r/[slug]?k=[token]`, slug = nome cliente | `/audit/[slug]`, slug nanoid nel path | **Sicurezza** (sotto) |
**Sullo slug.** `teckell-2026` è indovinabile: la segretezza si sposterebbe tutta sul token in query string, e i parametri di query finiscono nei log d'accesso e nei referrer molto più facilmente di un path. Il `Referrer-Policy: strict-origin-when-cross-origin` già presente mitiga in parte, ma la convenzione collaudata qui è **nanoid non indovinabile nel path, niente query** (`/preventivo/[slug]`, `/quote/[token]`). Un audit nomina un'azienda reale e ne elenca le debolezze: è il contenuto più sensibile che il portale pubblicherà.
**Due correzioni ad altre premesse:**
1. **I `gsd-audit-*` non c'entrano.** Verificato: `gsd-audit-milestone` verifica una milestone GSD, `gsd-audit-uat` raccoglie i test in sospeso, `gsd-audit-fix` sistema i finding nel *tuo* codebase. Auditano il progetto, non il sito del cliente. Motore custom; il precedente è `src/lib/proposal/agent.ts`.
2. **Il deck dei preventivi non è riusabile come layout.** `ProposalDeck.tsx:151` monta solo la slide corrente e `:133` imposta `body.overflow = "hidden"`: stamparlo produce una pagina sola. Il documento è **a scorrimento**. Si riusa schema e macchina a stati, non il guscio visivo.
### Decisioni prese
| Punto | Decisione |
|---|---|
| Motore | In-app, background, polling dall'admin (§5) |
| "La direzione" (blocco 8) | Manuale, foglio bianco, **con il materiale grezzo a fianco** |
| PDF | Print CSS — stesso DOM, vincolo "una sola fonte" per costruzione |
| Immagini | Volume persistente. **Modifica il vincolo LOCKED #5** (§9) |
| Redesign | Immagini caricate **e** link Figma, con ruoli distinti (§6) |
| Miglioramento nel tempo | Template versionato: tocca gli audit futuri, mai i consegnati (§3) |
| Copertina | Titolo = nome del livello acquistato |
| Scadenza | Nessuna. Depubblicazione manuale reversibile |
| Tracking | Prima apertura, ultima apertura, conteggio |
---
## 1 · Spike sul motore — si parte da qui
È la parte più incerta e quella su cui si regge tutto il resto. Se la qualità dell'analisi non regge, meglio scoprirlo prima di costruirci sopra schema, editor e documento.
**Non tocca il database, non tocca l'hub.** Uno script isolato, `scripts/spike-audit.ts`, sul sito di un cliente attuale, che stampa l'output grezzo.
Deve dimostrare che:
- Il fetch delle pagine chiave e l'estrazione del testo reggono su un sito reale
- La verifica delle voci di checklist è **affidabile e ripetibile** (§4)
- I problemi sono **concreti, non generici** — "il messaggio non è chiaro" non vale niente, "l'headline non nomina il destinatario" sì
- La conseguenza per il business è specifica
Da riusare subito: la protezione da prompt injection di `src/lib/proposal/agent.ts:32-35` (blocco SICUREZZA che dichiara il contenuto come dati, non istruzioni) e `:49` (neutralizzazione dei tag di chiusura). Con HTML scrapato da un sito esterno serve **più** che con le trascrizioni: è contenuto di terzi e può contenere istruzioni ostili.
> Il resto si esegue **dopo** esito positivo dello spike.
---
## 2 · Il documento
Un solo documento ben progettato; i tre livelli sono **configurazioni** di quello.
| # | Blocco | Radiografia | Prima/Dopo | Rotta |
|---|---|:--:|:--:|:--:|
| 1 | Copertina | ● | ● | ● |
| 2 | Sintesi | ● | ● | ● |
| 2b | **Cosa funziona già** | ● | ● | ● |
| 3 | Stato di fatto | ● | ● | ● |
| 4 | I problemi, per impatto | ● | ● | ● |
| 5 | Analisi per area | ● | ● | ● |
| 6 | Il redesign | — | ● | ● |
| 6b | **Cosa il redesign non risolve** | — | ● | ● |
| 7 | Le ottimizzazioni | — | — | ● |
| 8 | La direzione | ● | ● | ● |
| 9 | Come si prosegue | ● | ● | ● |
I blocchi non pertinenti **non esistono nel DOM**, non sono nascosti via CSS.
Il **copy fisso** di ogni blocco è quello della Spec V1, riportato integralmente in `template/v1.ts` (§3). Nel blocco 9 le *fasi già completate* si derivano dal livello acquistato, e il credito riconosciuto è `importo_pagato`.
**I due blocchi 2b e 6b sono aggiunte del prototipo Giojello, non della Spec V1**, e vanno tenute:
- *Cosa funziona già* costruisce credibilità prima di criticare, e dichiara cosa non va toccato
- *Cosa il redesign non risolve* è onesto e commercialmente più efficace della vendita: apre al progetto completo senza promettere
Campi: `punti_forza` (lista) e `redesign_limiti` (testo).
### Il prototipo Giojello è il riferimento del template v1
Il file HTML prodotto per Giojello **non è il documento: è la fonte di `template/v1.ts`** — copy, tipografia, gerarchia. Va spacchettato in componenti alimentati dal record. Quattro difetti da NON portarsi dietro:
1. **Contenuto cablato nel markup** (699€, 8,0s, 500€, le date). Viola il vincolo "un record → una pagina → un PDF": tutto viene dal DB
2. **Google Fonts via `<link>`** — la CSP del progetto è `font-src 'self' data:` e `style-src 'self' 'unsafe-inline'`: verrebbe **bloccato** e il documento cadrebbe sui font di sistema. **Font self-hostati** (Instrument Serif / Inter / IBM Plex Mono, se si conferma quel trio in luogo di Plus Jakarta Sans — è una deroga consapevole a DESIGN-SYSTEM.md, giustificabile perché è un documento pubblico, non la UI admin)
3. **Print CSS di sei righe.** Nasconde la legenda interna ma **non i colori che la legenda spiega**: i bordi e i testi rossi/blu dei blocchi di lavorazione finiscono stampati senza più nulla che li spieghi. I marcatori di lavorazione non devono esistere nel documento consegnato
4. **Colore come unico portatore di informazione nelle metriche**`3,4 s` rosso e `1,0 s` verde diventano identici in scala di grigi. Serve un secondo canale (glifo, peso, etichetta)
⚠️ **Tassonomia degli impatti.** Il prototipo usa cinque valori ("medio-alto", "basso-medio"…), lo schema ne prevede tre. Con cinque l'ordinamento automatico su tre non funziona. Decisione: si resta a **tre valori**, e la sfumatura sta nell'ordine dentro il gruppo (`sort_order`).
### Regola di collocazione: finding vs analisi
Il prototipo v2 ha aggiunto una sezione discorsiva "Interfaccia" con otto paragrafi che contengono almeno quattro **veri finding** — contrasto insufficiente sulla CTA della hero, riflesso che mangia il 40% dell'immagine prodotto, titoli troncati, e soprattutto il carrello (spedizione assicurata 39,90€ contro 6,13€ con la non assicurata preselezionata). Quest'ultimo è da primi cinque ed era in coda a una sezione discorsiva.
**Regola per il motore e per l'editor:** se una cosa ha un impatto e una conseguenza, è un finding — va nel blocco 4, prende un numero e viene ordinata. Il blocco 5 resta discorsivo e resta a **tre aree** (struttura / messaggio / conversione). Nessuna quarta area.
Motivo: il documento promette "non un elenco di quaranta punti", e una seconda lista non ordinata dopo quella ordinata annulla la promessa. La forza del documento è la **selezione**.
### Disciplina sui numeri — vincolo del motore
Confronto con un audit parallelo dello stesso sito prodotto da un altro modello: conteneva "~65% abbandono stimato" e "+60% velocità mobile immediata". **Nessuno dei due è misurato**: sono congetture presentate come rilevazioni.
Il prototipo Claude è disciplinato su questo — dice "una parte importante del traffico" proprio perché non può quantificarla. **Quella disciplina va imposta nel prompt di sistema del motore**: un numero compare nel documento solo se proviene da una rilevazione (PageSpeed, conteggio DOM, peso pagina, dati del sito). Mai stime di conversione o di guadagno percentuale. In un deliverable premium basta un cliente che verifichi per bruciare tutta la credibilità.
Voce di verifica: nessun numero nel documento consegnato è privo di fonte in `audit_runs.raw`.
### Contributi da assorbire dall'audit parallelo
- **La misura/taglia nella griglia prodotti** — su pezzi unici rigenerati è criterio di scelta primario e non compare da nessuna parte. Si aggancia al finding sull'unicità e apre a un servizio ("messa a misura disponibile"). Va nella checklist profilo `ecommerce` come voce
- **Conteggio dei nodi DOM** — quantifica il problema di peso meglio dei MB. Da aggiungere alle rilevazioni di `fetch.ts`
- **Stime in giornate** — alimentano la colonna *Impegno* del blocco 7
### Estensione opzionale — allegato tecnico (NON in fase 1)
Il blocco 9 promette *"il documento resta tuo e puoi darlo a chiunque lavorerà sul sito"*, ma consegna prosa da imprenditore a uno sviluppatore.
Un **allegato tecnico generato dallo stesso record** — stessi finding, registro da sviluppatore, con selettori, file coinvolti e stime — renderebbe quella promessa molto più preziosa. È una seconda vista sugli stessi dati, quindi non viola il vincolo della fonte unica e non richiede contenuto aggiuntivo. Da valutare dopo il primo audit consegnato.
---
## 3 · Versionamento del template
Requisito: migliorare il documento deve toccare gli audit **successivi**, mai quelli già consegnati.
Precedente in casa: `proposals.content` congela profilo e prezzi alla generazione, così modificarli dopo non retro-cambia un preventivo pubblicato.
Copy fisso e configurazione dei blocchi vivono in **moduli TS versionati**, non nel database:
```
src/lib/audit/template/
v1.ts copy fisso di ogni blocco + ordine + mappa livello→blocchi
index.ts registry versione→modulo + LATEST_TEMPLATE_VERSION
```
- `audits.template_version` impostata alla versione corrente **alla creazione**
- Un audit **consegnato resta congelato** sulla sua versione, per sempre
- Migliorare il documento = aggiungere `v2.ts` e alzare `LATEST_TEMPLATE_VERSION`. Nessuna migration, nessun backfill, storia completa in git
- Le **bozze** si portano all'ultima versione con un bottone esplicito, mai in automatico
---
## 4 · La checklist come rubrica del motore
L'Excel ha 264 voci su 7 step di funnel, con scoring ICE e peso di importanza per sezione. Serve al **motore**, non al documento.
**Perché è preziosa:** ogni voce è un'asserzione binaria e verificabile ("Il checkout consente l'acquisto come ospite"). Verificare 264 affermazioni falsificabili è molto più affidabile che chiedere a un modello "analizza questo sito": trasforma l'analisi da generativa a **verificativa**, controllabile voce per voce. E lo scoring ICE risolve gratis l'ordinamento del blocco 4 e le priorità del blocco 7.
**Tre vincoli emersi dalla lettura del file:**
1. **È al 73% ecommerce.** Generale (50) e Homepage (21) valgono per qualsiasi sito; Categoria, Scheda Prodotto, Carrello, Checkout, Ringraziamento — **193 voci su 264** — presuppongono un carrello. Servono **due profili**: `ecommerce` e `servizi`. Il secondo va scritto, non esiste nel file.
2. **Misura la conformità, non l'adeguatezza.** Un sito può fare 250/264 e continuare a descrivere un'azienda che non esiste più. Nessuna voce chiede se il posizionamento dichiarato corrisponde a quello che vendi oggi, o se le prove sono della fascia di cliente giusta. La checklist è il **pavimento** (guasti meccanici, li trova l'agent); lo scarto strategico è il **soffitto** e resta analisi tua. Se il documento diventa il rendering della checklist, torna a sembrare un audit automatico gratuito.
3. **Molte voci sono tattiche da ecommerce a volume** — scarsità, urgenza, countdown, popup di social proof. Su un brand premium **danneggiano**: abbassano il segnale di prezzo mentre tu vendi il contrario. Ogni voce porta quindi un campo `registro` (`volume` / `premium` / `neutro`) e l'audit di un brand premium non propone mai i trigger da discount.
⚠️ Note tecniche sul file: il foglio *Algoritmo* ha errori **`#REF!`**, e la colonna *Facilità* è **1.0 su tutte le righe** — non compilata, quindi lo Score attuale è di fatto solo Impatto × Confidenza. Da sistemare prima di seminarne i default.
Il foglio *Best Ecommerce List* (88 siti per settore) alimenta confronti concreti: "il tuo checkout chiede 11 campi, i riferimenti del settore ne chiedono 6".
**Nel documento** la checklist non compare come elenco. Il blocco 3 mostra al massimo il grado di conformità **per step di funnel** (una barra per step), accanto alle metriche di performance. I problemi del blocco 4 sono una **selezione curata** — voci non conformi ad alto impatto più lo scarto strategico — non il dump delle non conformità.
---
## 5 · Migration `0017_audits.sql`
Additiva e idempotente. Template di stile: `src/db/migrations/0016_retainer_lifecycle.sql` (header in italiano che spiega il *perché*, `ADD COLUMN IF NOT EXISTS`, `CHECK ... NOT VALID` dentro `DO $$ ... pg_constraint`, `CREATE INDEX IF NOT EXISTS`).
Convenzioni da `src/db/schema.ts`: id `text` con `$defaultFn(() => nanoid())`, **nessun `pgEnum`** (text + CHECK in SQL + tupla `as const` in TS + Zod nell'action), timestamp `withTimezone: true`, `updated_at` bumpato a mano nell'action.
**`audits`** — colonne scalari, non jsonb: non c'è snapshot da congelare (ci pensa `template_version`) e l'editor mappa 1:1.
- Identità: `id`, `slug` unique nanoid, `lead_id``leads` SET NULL, `client_id``clients` SET NULL (nullable entrambi, come `proposals`)
- Config: `livello` CHECK `('radiografia','prima_dopo','rotta')`, `template_version`, `profilo` CHECK `('ecommerce','servizi')`, `cliente_nome`, `cliente_referente`, `sito_url`, `importo_pagato numeric(10,2)`, `data_consegna date`
- Origine: `origin` CHECK `('manuale','whop')` default `'manuale'`, `external_ref` — predispone Whop senza costruirlo
- Stato: `state` CHECK `('draft','published')` default `'draft'` (etichettati *Bozza* / *Consegnata* nella UI), `published_at`
- Tracking: `first_viewed_at`, `last_viewed_at`, `view_count integer default 0`
- Rilevazioni (blocco 3): `perf_mobile`, `perf_desktop`, `lcp numeric(6,2)`, `cls numeric(5,3)`, `inp`, `pagine_indicizzate`, `screenshot_desktop_url`, `screenshot_mobile_url`, `measured_at`
- Contenuto (blocchi 2/2b/5/8): `sintesi`, `punti_forza jsonb` (lista, blocco 2b), `analisi_struttura`, `analisi_messaggio`, `analisi_conversione`, `direzione`
- Redesign (blocchi 6/6b): `redesign_sezione`, `redesign_prima_url`, `redesign_dopo_url`, `redesign_razionale`, `redesign_limiti`, `redesign_figma_url`
- Intake: `intake jsonb` — i dati che il cliente condivide, forma ancora da definire
**Tutti i campi di contenuto sono nullable.** È ciò che rende possibile "si salva sempre, anche a metà": la validazione di completezza scatta solo alla consegna.
**`audit_findings`** (blocco 4) — `audit_id` CASCADE, `titolo`, `impatto` CHECK `('alto','medio','basso')`, `area` CHECK `('struttura','messaggio','conversione','performance')`, `descrizione`, `conseguenza`, `screenshot_url`, `sort_order integer default 0`, `origin` CHECK `('agent','manuale')`
**`audit_optimizations`** (blocco 7) — `audit_id` CASCADE, `intervento`, `priorita` CHECK `('alta','media','bassa')`, `motivazione`, `impegno`, `sort_order integer default 0`
**`checklist_items`** (rubrica, versionata come il template) — `profilo`, `step`, `focus`, `testo`, `impatto_default`, `confidenza_default`, `registro` CHECK `('volume','premium','neutro')`, `sort_order`
**`audit_checklist_results`** — `audit_id` CASCADE, `item_id`, `esito` CHECK `('conforme','non_conforme','non_rilevante')`, `note`, `evidenza`, `origin` CHECK `('agent','manuale')`
**`audit_runs`** (§6) — `audit_id` CASCADE, `status` CHECK `('queued','running','done','error')`, `step`, `started_at`, `finished_at`, `heartbeat_at`, `error`, `raw jsonb` (output grezzo: materiale per il blocco 8)
Indici: unique su `slug`; index su `client_id`, `lead_id`, `(audit_id, sort_order)` per le figlie, `(audit_id, started_at desc)` per le run, `(audit_id, item_id)` unique per i risultati.
**Ordinamento automatico** (blocchi 4 e 7): la query ordina per rango di impatto/priorità con `sort_order` come spareggio *dentro* il gruppo. Tu non ordini niente.
> ⚠️ **Checkpoint bloccante.** Migration applicata in produzione **prima** di pushare il codice schema-dipendente (regola CLAUDE.md "Ordering"), via SSH/docker-exec. Aggiornare `src/db/schema.ts` a mano in parallelo — `drizzle-kit generate` è rotto.
---
## 6 · Motore di analisi
`src/lib/audit/` modellato su `src/lib/proposal/` ma multi-step:
```
src/lib/audit/
pipeline.ts funzione pura: (auditId) => risultato. Non conosce chi la chiama
agent.ts chiamate Anthropic, una per step di funnel
schema.ts validazione Zod dell'output
fetch.ts recupero pagine + estrazione testo + PageSpeed
```
**Passi:** recupero pagine chiave → metriche PageSpeed → verifica delle voci di checklist del profilo → sintesi dei problemi con impatto+area+descrizione+conseguenza.
Tutto l'output è **bozza**: finisce in `audit_checklist_results` e in `audit_findings` con `origin='agent'`. Tu rivedi e correggi prima di consegnare. Si automatizza il meccanico, **il giudizio resta tuo**.
**Il blocco 8 "La direzione" resta manuale, foglio bianco.** L'editor mostra a lato il materiale grezzo — problemi ad alto impatto e temi ricorrenti, da `audit_runs.raw`. Nessun testo proposto, nessun template. È il blocco che giustifica il prezzo: se diventa formula, il cliente lo sente.
**Esecuzione: in-app, background, con polling.**
- "Avvia analisi" → riga `audit_runs` con `status='queued'`, lancia la pipeline
- L'admin fa polling e mostra `step` corrente e avanzamento
- Progresso scritto a ogni passo: un'interruzione non perde il lavoro fatto
> ⚠️ **Rischio da mettere in conto.** Con `output: "standalone"` su singolo container Coolify, un redeploy **uccide un job in corso** e lascia una riga bloccata su `running`. Mitigazione: `heartbeat_at` aggiornato a ogni passo, le run senza heartbeat da N minuti vanno in `error`, e "Rilancia" riparte dall'ultimo passo completato. Non è un sistema a code: è deliberatamente il minimo che regge 50 audit/anno.
`pipeline.ts` è una **funzione pura riusabile**: oggi la chiama il bottone, domani il webhook Whop. Nessuna riscrittura.
---
## 7 · Storage immagini e redesign
Servono: 2 screenshot home, N screenshot dei problemi, 2 immagini redesign. Con URL esterni incollati a mano il budget dei 10 minuti non regge, e un URL morto uccide il documento del cliente.
**Volume persistente, non servizio esterno.** Nessuna dipendenza npm nuova, nessun account terzo, nessuna credenziale.
- Volume Coolify montato su `/app/uploads`
- Upload via **server action** che riceve `File` da `FormData`. Niente upload diretto dal browser: la CSP ha `connect-src 'self'`, un POST verso un host esterno sarebbe bloccato — passare dal nostro origin è l'unica strada e va bene così
- Validazione: whitelist MIME `image/png|jpeg|webp`, max 5MB, nome file `nanoid()` (mai l'originale)
- Lettura via `src/app/api/uploads/[...path]/route.ts` con guardia sul path traversal e `Cache-Control` lungo
- CSP invariata: serviamo dal nostro origin, coperto da `img-src 'self'`
> ⚠️ **Checkpoint bloccante.** Volume creato in Coolify prima del deploy, altrimenti gli upload si perdono a ogni redeploy.
Nuovo componente `ImageUploadField` — in tutto `src/` non c'è un solo `<input type="file">`.
**Il redesign usa entrambi i formati, con ruoli distinti:**
- **Immagini caricate** — rappresentazione canonica, quelle che si vedono nello slider e **le uniche che finiscono nel PDF**
- **Link Figma** — opzionale, "apri il redesign interattivo", nuova scheda
Perché non embeddare Figma: la CSP ha `default-src 'self'` senza `frame-src`, quindi l'iframe sarebbe bloccato; e un iframe **in stampa non produce nulla**, rompendo il vincolo "una sola fonte" proprio sul blocco che vale di più.
---
## 8 · Pagina pubblica e PDF
`/audit/[slug]`, modellata su `src/app/preventivo/[slug]/page.tsx`: `export const revalidate = 0`, fetch by slug, `notFound()` se assente.
**Differenze deliberate dal preventivo:**
- **Documento a scorrimento.** Nessun `h-screen`, nessun `overflow-hidden`, nessuna manipolazione di `body.style`
- **Rate limit**: aggiungere `/audit` al matcher di `src/proxy.ts` riusando `src/lib/rate-limit.ts`. Il preventivo non ce l'ha — non ereditare quell'omissione
- **noindex**: `X-Robots-Tag: noindex, nofollow` in `next.config.ts` più `metadata.robots`
- **Anteprima admin**: con `state === 'draft'` mostrare comunque il documento se `getServerSession` restituisce una sessione admin, altrimenti il placeholder. Non tocca LOCKED #4, che riguarda `/client/*`
- **Tracking**: alla prima render non-admin aggiornare `first_viewed_at` / `last_viewed_at` / `view_count`
Nuovi componenti in `src/components/public/audit/`, uno per blocco, server components salvo slider e bottone stampa. **Non riusare** le 20 sezioni di `public/proposal/sections/`: sono saldate a `ProposalContent` con copy hard-coded.
Da riusare: **`RichText.tsx`** per ogni testo libero (obbligatorio su output del modello — mai `dangerouslySetInnerHTML`), e le convenzioni visive esistenti (eyebrow `text-xs font-mono tracking-widest uppercase`, headline `text-5xl font-light`, card `border border-border rounded-xl p-8`).
⚠️ Il deck preventivi è light-only (`bg-white` hard-coded in `ProposalDeck.tsx:139,141,156`, `PricingSection.tsx:46`) e viola la regola dei token in due punti (`TimelineSection.tsx:5-9`, `ClosingSection.tsx:20,34`). **Non replicare quei difetti**: solo token semantici, dual light/dark.
**Leggibilità in B/N**: il livello di impatto non può dipendere dal colore. Etichetta + glifo — `●●● alto` / `●●○ medio` / `●○○ basso`.
### PDF via print CSS
Stessa pagina, stesso DOM, stesso record: il vincolo "una sola fonte" è soddisfatto per costruzione.
In `src/app/globals.css` — oggi **zero regole print** in 218 righe — un blocco `@media print`:
- Palette chiara forzata, `print-color-adjust: exact` dove serve
- `break-inside: avoid` su ogni card problema e riga della tabella; `break-before: page` sui blocchi maggiori
- Nascosti: bottone stampa, nav, controllo dello slider
- **Slider prima/dopo**: a schermo interattivo, in stampa due immagini impilate con etichetta. Tecnica: entrambe le `<img>` **sempre** nel DOM, clippate in overlay via CSS a schermo; in `@media print` si toglie il clip e si impila. Nessun ramo JS, nessuna divergenza possibile
- Bottone "Scarica PDF" sticky in alto a destra = client island con `window.print()`
---
## 9 · Admin
Mutazioni come Server Actions colocate in `actions.ts` (niente REST per l'admin).
- `src/app/admin/audit/page.tsx` — lista. Riusare `PageHeader`, `SearchInput`, badge di stato
- `src/app/admin/audit/nuovo/`**creazione manuale**: cliente o lead, livello, profilo, URL, importo → redirect all'editor
- `src/app/admin/audit/[id]/edit/page.tsx` — RSC sottile + client component
- `src/components/admin/audit/AuditEditor.tsx`
- `src/app/admin/audit/actions.ts`
- Voce in `AdminSidebar.tsx`
**Pattern editor: `saveOfferEditor` + `OfferEditorClient`** (`src/app/admin/offers/actions.ts:177-274`, `src/components/admin/offers/OfferEditorClient.tsx`) — unico precedente di salvataggio a payload intero con array di figli:
- Zod annidato con `z.array(findingSchema)` / `z.array(optimizationSchema)`, `id` opzionale sui figli
- Nell'action: update degli scalari, loop sui figli con **upsert per presenza di `id`**, delete delle righe sparite, `sort_order: index` assegnato server-side
- Client: array in `useState`, update immutabili via `.map`, `startTransition` + try/catch → `setSaveError`
- ⚠️ **Il re-sync degli id dopo il primo salvataggio** (`OfferEditorClient.tsx:93-98`): senza, i figli appena creati vengono re-inseriti invece che aggiornati al salvataggio successivo. È il bug più facile da introdurre qui
**Aggiungi / rimuovi / riordina righe**: non esiste nel codebase — `sort_order` oggi è write-once. `@dnd-kit/sortable` è già in `package.json` e usato in `KanbanBoard.tsx`: riusarlo, con l'indice dell'array che diventa `sort_order` al salvataggio.
**Campi condizionali**: redesign e ottimizzazioni non si renderizzano se il livello non li prevede.
**Bozza**: niente autosave (non esiste nel codebase, complessità sproporzionata). "Salva bozza" esplicito + avviso all'uscita con modifiche pendenti. Tutti i campi nullable. La **validazione di completezza scatta solo su "Consegna"**, sui soli blocchi pertinenti.
**Consegna / Ritira**: transizioni guardate `WHERE id = ? AND state = ?` come `publishProposal` (`src/app/admin/preventivi/actions.ts:117-127`). Entrambe reversibili.
**Automatismi** (budget 10 minuti): credito = `importo_pagato`, fasi già completate derivate dal livello, date da `published_at`/`measured_at`, ordinamento dei blocchi 4 e 7, analisi draftata dagli agent.
---
## 10 · Ingresso: manuale ora, Whop dopo
Fase 1 = **solo creazione manuale**, che resta comunque un requisito permanente.
Predisposto senza costruirlo: `origin` + `external_ref` sulla tabella, e `pipeline.ts` chiamabile da un webhook. L'intake del cliente è **ancora da definire** — la colonna `intake jsonb` accoglie qualunque forma prenderà, senza migration aggiuntive.
---
## 11 · Modifica al vincolo LOCKED #5 — richiede approvazione
`CLAUDE.md` oggi: `5. No file hosting v1 — documenti come URL esterni`
Nuova versione proposta:
```
5. No file hosting per i documenti — restano URL esterni.
Deroga (Phase 27, 2026-08-16): le immagini dell'audit (screenshot e
redesign) sono caricate su volume persistente e servite da
/api/uploads/[...path], con whitelist MIME e limite di dimensione.
Non estendere l'upload ad altre entità senza modificare questo vincolo.
```
---
## 12 · GSD e memoria di progetto
**Non c'è niente da costruire**: state file, roadmap e checkpoint automatico esistono già. `.planning/STATE.md` è il digest che comanda i comandi `/gsd-*`, `.planning/ROADMAP.md` traccia la posizione, `execute-plan.md` aggiorna STATE.md **dopo ogni piano**, e `.claude/rules/memory-discipline.md` è la regola scritta.
Due difetti reali:
1. **`.planning/config.json` non ha la chiave `hooks`.** Verificato: entrambi gli hook globali (`gsd-session-state.sh`, `gsd-phase-boundary.sh`) escono a vuoto senza `"hooks": { "community": true }`. Aggiungerla riattiva l'iniezione di STATE.md all'avvio sessione e il promemoria a ogni scrittura in `.planning/`. Una riga.
2. **`gsd-sdk` e `gsd-tools` non sono nel PATH** (verificato: `not found`). Le mutazioni automatiche di STATE.md in `execute-plan.md` non hanno backend e ricadono su edit manuali — è il motivo per cui le fasi 13 e 26 hanno SUMMARY ricostruiti a posteriori.
L'hook `Stop` in `.claude/settings.json` è solo advisory (`echo`) e guarda solo `src` e `.planning`: non nota modifiche a `STATUS.md` o `CLAUDE.md`.
**Inquadramento**: nuovo milestone **v2.5 — Audit** via `/gsd-new-milestone`. Numerazione progressiva e mai riusata (l'ultima è 26 ⇒ si parte da **27**), come impone `.planning/PROJECT.md`.
Da aggiornare a fine lavoro: `.planning/STATE.md`, `.planning/ROADMAP.md`, `STATUS.md`, `CLAUDE.md`.
---
## 13 · File toccati
**Nuovi**`scripts/spike-audit.ts` · `src/db/migrations/0017_audits.sql` · `src/lib/audit/{pipeline,agent,schema,fetch}.ts` · `src/lib/audit/template/{v1,index}.ts` · `src/lib/audit-queries.ts` · `src/lib/audit-view.ts` · `src/app/audit/[slug]/page.tsx` · `src/components/public/audit/*` · `src/app/admin/audit/**` · `src/components/admin/audit/AuditEditor.tsx` · `src/components/ui/ImageUploadField.tsx` · `src/app/api/uploads/[...path]/route.ts`
**Modificati**`src/db/schema.ts` · `src/proxy.ts` · `next.config.ts` · `src/app/globals.css` · `src/components/admin/AdminSidebar.tsx` · `CLAUDE.md` · `.planning/config.json` · `Dockerfile` + volume Coolify
---
## 14 · Verifica
Non esiste test suite (nessun vitest/jest/playwright, nessuno script `test`). **`npm run build` è la verifica di riferimento** — fa il typecheck. `npm run lint` è `eslint` nudo.
**Fase 1 (spike)** — gira su un sito reale, i problemi sono concreti e verificabili, la valutazione delle voci di checklist è ripetibile. È un giudizio tuo, non un test automatico.
**Dopo:**
1. `npm run build` — typecheck e compilazione
2. Migration applicata in prod **prima** del push; verificare che le sei tabelle esistano
3. Un audit di prova per ciascuno dei tre livelli, su entrambi i profili: i blocchi condizionali compaiono e spariscono correttamente
4. Salvare a metà, ricaricare, nulla si perde. Salvare due volte di fila: i figli si aggiornano e **non si duplicano** (test del re-sync degli id)
5. Riordinare i problemi, salvare, ricaricare: l'ordine regge
6. Avviare un'analisi e **riavviare il container a metà**: la run va in `error` e "Rilancia" riparte senza perdere i passi completati
7. Upload di un'immagine → redeploy → l'immagine è ancora lì (prova del volume)
8. Slug consegnato in incognito: si apre. Bozza in incognito: placeholder. Bozza da admin loggato: si vede
9. `X-Robots-Tag: noindex, nofollow` presente nella risposta
10. **Stampa**: Cmd+P sul documento consegnato → interruzioni di pagina corrette, slider impilato in due immagini, nessun marcatore di lavorazione (bordi/testi colorati) sopravvissuto, e in **scala di grigi** sia i livelli di impatto sia i valori delle metriche restano distinguibili
10b. **Rete disattivata dopo il primo caricamento**: i font restano quelli giusti (prova che sono self-hostati e che nessuna risorsa esterna è rimasta)
11. Il tracking apertura si incrementa da visitatore e **non** dalla preview admin
12. Consegnato un audit su `v1`, alzare `LATEST_TEMPLATE_VERSION` a `v2`: il consegnato continua a rendere `v1`, uno nuovo nasce `v2`
13. Un audit su brand premium **non** propone mai voci con `registro='volume'`
⚠️ Playwright non funziona contro `npm run dev` (la CSP blocca `eval`, i client component non si idratano). Per E2E usare il build di produzione.
+200
View File
@@ -0,0 +1,200 @@
# Audit — motore multi-agente, rilevazioni e tracciamento
## Context
Il piano approvato il 2026-08-16 (`dovremmo-fare-una-cosa-woolly-puddle.md`) resta in vigore per struttura del documento, versionamento del template, rubrica checklist, editor admin, pagina pubblica e print CSS. **Questo file ne sostituisce tre parti** — §6 (motore), §7 (immagini) e il tracciamento in §5/§8 — e non tocca il resto.
Cosa è successo da allora:
- **Lo spike è girato su giojello.com** e ha prodotto un audit di buona qualità. Ma `psi: {}` — le rilevazioni PageSpeed sono fallite tutte per quota anonima esaurita. Quell'audit è stato scritto **con zero misurazioni**, solo HTML statico.
- **Il 52% della checklist non è verificabile da HTML statico** (204 voci → 107 non verificabili; lo step `generale` 40 su 50). Barra di ricerca, widget carrello, hover, animazioni, banner cookie esistono solo a runtime.
- **Il VPS non può ospitare Chromium**: 1.492 MB di RAM disponibili su 3.819, 736 MB già in swap, 2 vCPU, con Coolify/n8n/Gitea/Vaultwarden sopra. Il disco invece è libero (21 GB su 38). Immagine `node:20-alpine`, non supportata da Playwright.
- **Il processo bersaglio è: incolli un URL, il motore fa ricerche incrociate con le API gratuite, esce un documento pronto da inoltrare.** Tu carichi a mano solo due immagini: hero prima e hero dopo, JPG ≤ 0,5 MB.
### Decisioni
| Punto | Decisione | Data |
|---|---|---|
| Renderer headless | **Nessuno, da nessuna parte.** Motore 100% in-app, self-service | 2026-08-18 |
| Buco a runtime | Coperto da audit Lighthouse completi + screenshot renderizzato letto in visione + CrUX + Wayback | 2026-08-18 |
| Immagini manuali | Solo le due del redesign, JPG ≤ 0,5 MB | 2026-08-18 |
| Screenshot stato di fatto | Automatici, da PageSpeed | 2026-08-18 |
| Tracciamento | Registro delle singole visite + evento stampa | 2026-08-18 |
**Due decisioni del 2026-08-17 sono superate da questa revisione** e vanno corrette prima di eseguire (§8): il renderer locale sul Mac e la frase "la copia master è la cartella di cattura locale" scritta in `CLAUDE.md` — non esiste più nessuna cattura locale.
---
## 1 · Prerequisito bloccante — chiave PageSpeed e verifica dell'ipotesi
Tutto il resto poggia su un'ipotesi **non ancora verificata**: la quota anonima risponde `429` e senza chiave non si prova. Primo passo, prima di scrivere codice:
1. Creare un progetto Google Cloud, abilitare *PageSpeed Insights API* e *Chrome UX Report API*, generare una chiave (gratuita, nessuna carta). In `.env.local` e in Coolify come `PAGESPEED_API_KEY`.
2. Una chiamata a mano, e **verificare che esistano davvero**:
- `lighthouseResult.fullPageScreenshot.screenshot.data``data:image/jpeg;base64,…` a pagina intera
- `lighthouseResult.audits` → l'insieme completo (~150 voci), non i 10 scalari che lo spike estrae oggi
**Se lo screenshot a pagina intera non c'è**, ripiego su `lighthouseResult.audits["final-screenshot"]` (solo viewport, quindi di fatto l'hero — che è comunque il blocco più importante). **Se non c'è nemmeno quello**, l'analisi visiva esce dalla fase 1 e gli screenshot dello stato di fatto tornano manuali: cambia §5 di questo piano, non il resto.
> ⚠️ Non costruire il sub-agent visivo prima di aver visto quel campo con i tuoi occhi.
---
## 2 · Le fonti — "tutte le api gratuite che servono"
| Fonte | Cosa dà | Chiave | Alimenta |
|---|---|---|---|
| **PageSpeed Insights v5** (mobile + desktop) | ~150 audit Lighthouse, punteggi, screenshot renderizzato a pagina intera | sì, gratuita | blocco 3, finding tecnici, sub-agent visivo |
| **CrUX API** | LCP/INP/CLS **p75 di utenti reali**, per origin e per URL | stessa chiave | blocco 3 — la distinzione lab/campo |
| **Wayback CDX** | storico degli snapshot, diff del testo hero rispetto a 1/3/5 anni fa | no | la tesi "l'azienda è cresciuta, il sito no" |
| **RDAP** (`rdap.org`) | età del dominio | no | contesto in blocco 3 |
| **Mozilla HTTP Observatory** | voto sugli header di sicurezza | no | segnali di fiducia |
| **Fetch diretto** | `robots.txt`, `sitemap.xml` (→ `pagine_indicizzate`), JSON-LD, `hreflang`, impronta della piattaforma | no | blocco 3, finding tecnici |
| **Le pagine del sito** | già nello spike: home + fino a 3 pagine per profilo | no | verifica checklist |
**Perché questo elenco e non altro:** ognuna di queste è una **rilevazione**, non una stima. È esattamente ciò che impone la disciplina sui numeri del piano approvato — un numero entra nel documento solo se misurato. CrUX in particolare porta dati di utenti veri, la cosa più difendibile che si possa scrivere in un audit.
**Il guadagno più grande è già in casa e viene buttato via:** lo spike chiama PageSpeed e ne estrae 10 numeri. Gli audit Lighthouse coprono da soli una fetta consistente del 52% non verificabile — `color-contrast`, `tap-targets`, `font-size`, `image-alt`, `link-text`, `crawlable-anchors`, `structured-data`, `unsized-images`, `errors-in-console`, `viewport`, `canonical`. Sono verifiche fatte **sul DOM renderizzato**, cioè proprio quello che l'HTML statico non vede.
---
## 3 · Il motore
`src/lib/audit/`, modellato su `src/lib/proposal/` ma multi-step e con fan-out:
```
src/lib/audit/
pipeline.ts orchestratore: (auditId) => risultato. Non sa chi lo chiama
sources/ raccolta dati, nessun LLM
fetch.ts pagine + estrazione testo (dallo spike, quasi invariato)
pagespeed.ts PSI: audit completi + screenshot + estrazione scalari
crux.ts dati di campo
history.ts Wayback CDX + diff
signals.ts RDAP, header, robots/sitemap/JSON-LD
agents/ un file per sub-agent, ognuno con prompt e schema Zod propri
checklist.ts verificatore, Sonnet, batch da 12 per step di funnel
visual.ts analisi visiva, Opus con input immagine
history.ts scarto fra il sito di allora e l'azienda di oggi, Sonnet
technical.ts audit Lighthouse + header + dati strutturati, Sonnet
synthesis.ts sintetizzatore, Opus
schema.ts Zod per ogni output di sub-agent + per la sintesi
```
**Sequenza.** Raccolta in parallelo (I/O di rete puro) → quattro sub-agent in parallelo, ognuno sul proprio materiale → sintetizzatore che riceve i quattro output e **incrocia**.
**Cosa vuol dire "incrociate", in concreto.** I sub-agent producono osservazioni, non finding. È il sintetizzatore che le fonde:
> checklist: "nessun segnale di fiducia sulla scheda prodotto" + tecnico: "nessun dato strutturato `Product`/`Review`" + storico: "il testo dell'hero non cambia dal 2021" + visivo: "le recensioni sono sotto tre schermate di scroll"
> → **un solo finding**, con quattro evidenze indipendenti, invece di quattro finding deboli.
Questo serve la regola di selezione del piano approvato: il documento promette "non un elenco di quaranta punti", e la forza sta nella selezione. Massimo 10 problemi, ordinati per impatto.
**Tre cose che lo spike non fa e vanno fatte:**
1. **Validazione Zod di ogni output di modello.** Lo spike fa `as Record<string, unknown>` e stampa. `proposal/schema.ts` è il precedente: `safeParse`, fallimento duro, nessun loop di riparazione.
2. **Concorrenza limitata e retry.** Fan-out sì, ma con un tetto (4 chiamate contemporanee) e un retry con backoff sulle 429/529 di Anthropic. Lo spike non ha né l'uno né l'altro.
3. **Heartbeat a ogni passo.** `audit_runs.heartbeat_at`; una run senza battito da N minuti va in `error`, "Rilancia" riparte dall'ultimo passo completato. È la mitigazione già prevista per il redeploy che uccide il job.
**Da riusare invariati dallo spike:** i blocchi `SICUREZZA` e `NUMERI`, la funzione `fence()` che neutralizza i tag di chiusura, e l'estrazione del primo blocco `text` con guardia su `stop_reason === "max_tokens"` (più robusta di `content[0]` in `proposal/agent.ts`).
**Impronta sul VPS:** I/O di rete e JSON. Nessun processo pesante, nessun Chromium. Il picco è la dimensione dello screenshot in memoria prima dell'invio ad Anthropic — vedi §7.
---
## 4 · Schema — differenze rispetto a §5 del piano approvato
La migration resta `0017_audits.sql`, additiva e idempotente, applicata in prod **prima** del push. Rispetto all'elenco già approvato:
**Nuova tabella `audit_visits`** — il registro delle singole visite:
`id`, `audit_id` CASCADE, `occurred_at` default now, `event` CHECK `('view','print')`, `referrer`, `user_agent`, `ip_hash`. Indice su `(audit_id, occurred_at desc)`.
L'IP **non si salva in chiaro**: SHA-256 di `ip + NEXTAUTH_SECRET` via Web Crypto, come già si fa per il digest del gate admin in `src/lib/admin-gate.ts`. Serve a distinguere due aperture dello stesso lettore da due lettori diversi, non a identificare qualcuno.
**Campi aggiunti su `audits`** — separare laboratorio e campo, perché è la distinzione che rende credibile il blocco 3:
`lcp_field`, `inp_field`, `cls_field` (numeric, da CrUX, nullable — un sito senza traffico sufficiente non ha dati di campo, ed è un'informazione anch'essa).
**Restano come da piano approvato** le colonne di roll-up `first_viewed_at` / `last_viewed_at` / `view_count`: sono la lettura veloce per la lista admin, aggiornate insieme alla riga di `audit_visits`.
**`screenshot_desktop_url` / `screenshot_mobile_url`** ora li scrive la pipeline, non tu.
---
## 5 · Immagini
**Due upload manuali per audit**, non di più: `redesign_prima_url` e `redesign_dopo_url`. JPG, limite **512 KB**. Il componente `ImageUploadField` accetta `image/jpeg|png|webp` — non ha senso rifiutare un PNG per principio — ma il limite di dimensione è quello.
**Due file scritti dalla pipeline**: gli screenshot PageSpeed mobile e desktop, decodificati da base64 e salvati come JPG sullo stesso volume.
Quattro file per audit, circa 1,5 MB. Il volume Coolify su `/app/uploads` e la lettura via `src/app/api/uploads/[...path]/route.ts` con guardia sul path traversal restano come da §7 del piano approvato — con un margine molto più comodo di quanto si era dimensionato.
> ⚠️ **Checkpoint bloccante.** Volume creato in Coolify **prima** del deploy, altrimenti gli upload si perdono a ogni redeploy.
---
## 6 · Tracciamento
Un'isola client `<AuditVisitTracker>` montata nella pagina pubblica chiama una server action fire-and-forget:
- **all'mount** → evento `view`
- **su `window.print()`** e sull'evento `beforeprint` → evento `print`
La server action, lato server, chiama `getServerSession`: **se c'è una sessione admin non scrive niente.** È così che l'anteprima admin non inquina i numeri — requisito già scritto nella verifica del piano approvato (test 11).
Perché un'isola client e non una scrittura nel render RSC: la pagina ha `revalidate = 0` e quindi ri-esegue a ogni richiesta, ma scrivere sul DB dentro un render è un anti-pattern Next e conterebbe anche i prefetch e i bot. La convenzione del progetto è comunque "le mutazioni sono Server Actions".
Lato admin: il registro visite compare nell'editor dell'audit, sotto forma di elenco cronologico.
---
## 7 · Rischi da mettere in conto
- **Lo screenshot a pagina intera può essere altissimo** (una home lunga arriva a 10-15.000 px). Va ridimensionato prima di mandarlo a Opus, o mandato a fette. Non risolto: da misurare sul primo audit vero.
- **Latenza PageSpeed**: 30-60 s per strategia, due strategie. Il job in background lo regge, ma incide sul tempo totale — stimare il totale della pipeline dopo il primo giro completo, non prima.
- **Wayback e Observatory sono lenti e ballerini.** Ogni fonte deve fallire in modo non fatale, come già fa PageSpeed nello spike (`psi[s] = null` e si prosegue).
- **CrUX non risponde per i siti a basso traffico.** Non è un errore: è un dato. Il documento deve saperlo dire ("non ci sono abbastanza visitatori perché Google raccolga dati di campo") invece di lasciare un buco.
- **La ripetibilità è già stata vista traballare**: lo step `generale` ha dato 0 non conformi nel giro completo e 2 in quello isolato, a parità di sito e voci. Con il fan-out il rischio non diminuisce. Da tenere sotto osservazione al primo audit di prova.
---
## 8 · Da correggere prima di eseguire
Due cose scritte ieri sono ora sbagliate:
1. **`CLAUDE.md`, deroga a LOCKED #5** — dice "la copia master è la cartella di cattura locale". Non esiste più nessuna cattura locale. Sostituire con la formulazione di §11 del piano approvato, che è già corretta e non parla di copie locali.
2. **`.planning/STATE.md`** — la decisione "il renderer headless gira in LOCALE sul Mac" va riscritta come "nessun renderer headless: il buco a runtime è coperto dagli audit Lighthouse e dallo screenshot PageSpeed". Il *perché* il VPS non regge Chromium resta valido e va tenuto: è la ragione per cui l'opzione non tornerà.
Stessa correzione nel file di memoria persistente `project_clienthub_vps_no_headless.md`.
---
## 9 · File toccati
**Nuovi**`src/db/migrations/0017_audits.sql` · `src/lib/audit/pipeline.ts` · `src/lib/audit/sources/{fetch,pagespeed,crux,history,signals}.ts` · `src/lib/audit/agents/{checklist,visual,history,technical,synthesis}.ts` · `src/lib/audit/schema.ts` · `src/lib/audit/template/{v1,index}.ts` · `src/lib/audit-queries.ts` · `src/app/audit/[slug]/page.tsx` · `src/components/public/audit/*` (uno per blocco, più `AuditVisitTracker`) · `src/app/admin/audit/**` · `src/components/admin/audit/AuditEditor.tsx` · `src/components/ui/ImageUploadField.tsx` · `src/app/api/uploads/[...path]/route.ts`
**Modificati**`src/db/schema.ts` · `src/proxy.ts` (aggiungere `/audit` al matcher, con `rateLimit`) · `next.config.ts` (`X-Robots-Tag: noindex, nofollow`) · `src/app/globals.css` (blocco `@media print`) · `src/components/admin/AdminSidebar.tsx` · `CLAUDE.md` · `.planning/STATE.md` · Dockerfile + volume Coolify
**Riusati senza modifiche**`src/lib/rate-limit.ts` · `src/components/public/proposal/RichText.tsx` · `src/lib/admin-gate.ts` (per l'hash) · `src/app/admin/offers/actions.ts` come modello di editor a payload intero · `@dnd-kit/sortable` per il riordino
---
## 10 · Verifica
Non c'è test suite: **`npm run build` è la verifica di riferimento** (fa il typecheck).
**Prima di tutto** — la chiave PageSpeed funziona e `fullPageScreenshot` esiste davvero (§1). Se non esiste, fermarsi e rivedere §5.
Poi, nell'ordine:
1. `npm run build` pulito
2. Migration applicata in prod **prima** del push; le sette tabelle esistono
3. Un audit completo su giojello.com: **confrontare la quota di non verificabili con il 52% dello spike**. È la misura che dice se l'approccio senza Chromium ha funzionato
4. Ogni numero nel documento consegnato ha una fonte rintracciabile in `audit_runs.raw`. Nessuna percentuale inventata
5. Un sito senza dati CrUX: il documento lo dice, non lascia un buco
6. Riavviare il container a metà analisi: la run va in `error`, "Rilancia" riparte senza rifare i passi completati
7. Upload di un'immagine → redeploy → l'immagine è ancora lì (prova del volume)
8. Aprire il documento consegnato in incognito: compare una riga in `audit_visits`. Aprirlo da admin loggato: **non** compare. Premere "Scarica PDF": compare una riga `print`
9. Cmd+P sul documento: interruzioni di pagina corrette, slider impilato in due immagini, nessun marcatore di lavorazione sopravvissuto, e in **scala di grigi** impatti e metriche restano distinguibili
10. Rete disattivata dopo il primo caricamento: i font restano quelli giusti (prova che sono self-hostati)
11. I test 3, 4, 5, 8, 12 e 13 del piano approvato — blocchi condizionali per livello, salvataggio a metà, re-sync degli id dei figli, riordino, congelamento del template, e nessuna voce `registro='volume'` su un brand premium
⚠️ Playwright non funziona contro `npm run dev` (la CSP blocca `eval`). Vale solo se un giorno servirà un E2E sul portale: usare il build di produzione.
+319
View File
@@ -0,0 +1,319 @@
# Modifiche all'hub — dashboard, progetti, pipeline
## Contesto
Il 2026-08-18 hai elencato una serie di modifiche all'hub divise per sezione
(dashboard, progetti, pipeline). La sessione si è fermata sul limite settimanale
subito dopo aver lanciato l'esplorazione, e quel lavoro non è mai stato ripreso.
Questo piano lo raccoglie.
Il punto di partenza è che **quasi tutto quello che chiedi è calcolabile con i
dati che l'hub già ha**: le categorie di offerta esistono, le fasi e i task hanno
uno stato, i pagamenti hanno una data di incasso, le conversazioni sono già
aggregate. Serve poco schema nuovo — una sola migration additiva — e molta
query + UI.
L'eccezione è l'ingresso da Whop per gli audit: dipende dal motore di analisi
della v2.5, che è fermo alle sole fonti. Hai scelto di fare **prima le modifiche
hub e poi il motore**, quindi quel blocco resta documentato ma non costruito.
### Decisioni prese
| Domanda | Scelta |
|---|---|
| Sequenza | Hub prima, motore audit dopo |
| Fonte "Audit" negli analytics | È la tua **Entry Offer** — si legge da `offer_macros.category`, come Signature e Retainer |
| Data di consegna attesa | **Derivata** da offerta + durata, con **override manuale** opzionale |
| Ingresso lead | TidyCal + form sito — vedi il blocco C, dove c'è un vincolo che cambia le carte |
---
## Blocco A — Progetti
Il blocco a rischio più basso e a resa più immediata. Da fare per primo.
### A1 · Togliere il tab Commenti e il timer dalla lista
Due rimozioni chieste esplicitamente.
- **Tab "Commenti"** in [page.tsx:75](src/app/admin/projects/[id]/page.tsx#L75) e
[page.tsx:118-120](src/app/admin/projects/[id]/page.tsx#L118-L120), più l'import a riga 8.
**Non si perde niente**: ho verificato che `buildEntityMap()` in
[conversations-queries.ts:57](src/lib/conversations-queries.ts#L57) cammina
clienti → progetti → fasi → task → deliverable e raccoglie *tutti* i commenti
con la loro etichetta d'entità. `/admin/conversazioni` è un sovrainsieme
stretto di quel tab. Il campo `comments` in `ProjectFullDetail` diventa morto:
va tolto anche da [admin-queries.ts:567](src/lib/admin-queries.ts#L567) e dalla query.
- **Colonna "Timer"** dalla lista progetti: `<TimerCell>` in
[ProjectRow.tsx:85](src/components/admin/ProjectRow.tsx#L85) e l'intestazione in
[projects/page.tsx:47](src/app/admin/projects/page.tsx#L47). Il timer resta dove
ha senso, dentro il progetto.
### A2 · Mini-dashboard nel singolo progetto
Una striscia sopra i tab, prima di `<Tabs>` in
[projects/[id]/page.tsx:69](src/app/admin/projects/[id]/page.tsx#L69). **Nessuna
query nuova**: `getProjectFullDetail` restituisce già tutto.
**Finance** — incassato / contrattualizzato / residuo, da `payments` +
`offersAcceptedTotal`; ore tracciate e €/h reale da `totalTrackedSeconds` e
`targetHourlyRate` (già passato alla pagina a riga 24).
**Avanzamento** — task `done` su totale, con barra. Stessa aritmetica del
`progress_pct` già usato per le fasi.
Riusa il pattern `MetricCard` di [admin/page.tsx:18](src/app/admin/page.tsx#L18):
va estratto in `src/components/admin/MetricCard.tsx` e importato da entrambe.
Scrivere la striscia **a token semantici** (`bg-card`, `text-muted-foreground`) —
questa route è il cluster peggiore di DEBT-01 (~182 occorrenze di palette raw) e
non va peggiorata.
### A3 · Timer per task e fase
Oggi `time_entries` ha solo `project_id`
([schema.ts:245](src/db/schema.ts#L245)). Servono due colonne nullable.
- **Migration `0018`** (dettagli in fondo): `phase_id`, `task_id` su
`time_entries`, entrambe `ON DELETE SET NULL`. Cancellare un task **non deve**
cancellare il tempo tracciato: è storico fatturabile, l'entry ricade a livello
progetto. `project_id` resta obbligatoria, quindi ogni entry è sempre
attribuita.
- **Server actions** in [timer-actions.ts](src/app/admin/timer-actions.ts):
`startTimer` prende `phaseId`/`taskId` opzionali; stessa cosa per
`addManualTimeEntry`. La regola "un solo timer attivo alla volta" (righe 19-38)
resta com'è — vale globalmente, non per task.
- **UI**: `<TimerCell>` su ogni riga task dentro
[PhasesTab.tsx](src/components/admin/tabs/PhasesTab.tsx), con il totale della
fase come somma dei suoi task. `TimerTab` continua a mostrare il totale progetto.
---
## Blocco B — Dashboard
### B1 · Analytics per linea di prodotto
Tre righe — **Entry (Audit), Signature, Retainer** — lette da
`offer_macros.category`, che è già una taxonomy editabile da
`/admin/impostazioni` ([taxonomy.ts:37](src/lib/taxonomy.ts#L37)). In produzione
oggi ci sono esattamente quelle tre categorie.
Per categoria:
- **Iniziati questo mese** — `count(project_offers)` con `start_date` nel mese corrente.
- **Totale anno** — `count` + somma `accepted_total` con `start_date` nell'anno.
- **Incassato** — `payments` in stato `saldato` con `paid_at` nell'anno.
L'incasso ha un problema di attribuzione da risolvere esplicitamente: i pagamenti
stanno sul **progetto**, non sull'offerta. La regola:
1. progetto con **una** offerta → tutto l'incasso va a quella categoria;
2. progetto con **più** offerte → ripartito in proporzione ai rispettivi `accepted_total`;
3. progetto **senza** offerta → riga separata **"Senza offerta"**, mostrata a video.
Il terzo caso non va nascosto. Oggi in prod ci sono 5 progetti e 2 righe in
`project_offers`: la maggior parte dell'incassato finirebbe lì, e vederlo è il
modo per accorgersene.
Nuovo modulo `src/lib/product-analytics.ts` (non gonfiare `analytics-queries.ts`),
nuovo componente `src/components/admin/dashboard/ProductBreakdown.tsx`.
> **Nota da verificare al primo giro:** l'unica Entry Offer in produzione si
> chiama *"Sblocca Business"*. Se l'audit è un prodotto diverso, va creata la sua
> `offer_macro` con categoria `Entry Offer` — è configurazione, non codice.
### B2 · Timeline delle consegne
Riguarda i progetti che **si consegnano** — Entry e Signature. I retainer sono
continuativi e non hanno una consegna: restano fuori, filtrando su
`offer_macros.offer_type = 'una_tantum'`.
Per ogni progetto non archiviato:
- **Consegna attesa** = `projects.due_date` se valorizzata, altrimenti
`project_offers.start_date + offer_micros.duration_months`. La colonna
`due_date` arriva con la migration 0018 ed è l'override manuale che hai chiesto.
- **% completamento** = task `done` / task totali.
- **% tempo trascorso** = `(oggi inizio) / (consegna inizio)`.
- **Semaforo** — soglia ±10 punti: `in linea` dentro la banda, `in ritardo` se il
completamento è sotto, `in anticipo` se è sopra. Un progetto oltre la data di
consegna e non completo è `in ritardo` a prescindere.
I progetti senza scadenza calcolabile vanno elencati sotto come **"senza
scadenza"** invece di sparire — altrimenti un progetto senza offerta assegnata
diventa invisibile proprio nella vista che dovrebbe segnalarlo.
Query in `src/lib/delivery-queries.ts`, componente
`src/components/admin/dashboard/DeliveryTimeline.tsx`. `StatusBadge` per il
semaforo, Geist Mono per date e percentuali.
### B3 · Inbox
**Questo esiste già.** [MessagesWidget.tsx](src/components/admin/dashboard/MessagesWidget.tsx)
mostra i clienti con messaggi non letti, l'anteprima dell'ultimo messaggio e il
link "Rispondi →" verso `/admin/conversazioni`. È il widget stretto in colonna
1/3, in fondo: probabilmente non l'hai visto perché sta sotto la piega.
Quindi non si costruisce, si promuove:
- fascia **a piena larghezza in cima** alla dashboard, sopra i KPI, e solo quando
c'è almeno un non letto;
- 6 righe invece di 4, con **data relativa** e **etichetta dell'entità**
(`lastMessageAt` e `lastEntityLabel` sono già in `ConversationSummary`,
[conversations-queries.ts:8](src/lib/conversations-queries.ts#L8) — oggi non
vengono usati);
- anteprima più lunga del messaggio.
Nuovo layout in [admin/page.tsx](src/app/admin/page.tsx): Inbox → KPI →
Timeline consegne → Prodotti → il resto.
---
## Blocco C — Pipeline: ingresso lead
### Il vincolo che cambia le carte
**TidyCal non ha webhook.** È scritto nella loro FAQ: nessun supporto nativo, e
la strada suggerita è passare da Zapier / Make / Pabbly. Quello che TidyCal *ha*
è una REST API con OAuth 2.0 e **Personal Access Token disponibile su tutti i
piani** (si crea da `tidycal.com/integrations/oauth`), con endpoint sulle
prenotazioni.
Elementor Pro, invece, ha un'azione **Webhook** nativa in "Actions After Submit":
si incolla un URL e lui fa POST dei campi del form. Se invece andate su Astro,
il POST lo scrivete voi. In entrambi i casi è una chiamata in ingresso.
Da qui la forma della soluzione: **un endpoint solo, sia per il form che per
TidyCal**, e per TidyCal un pezzo in più che va a prendersi le prenotazioni.
### C1 · Endpoint di ingresso — `POST /api/webhooks/lead`
Un solo endpoint, indipendente da chi chiama.
- **Guardia**: header `x-webhook-secret` confrontato con una env nuova
(`LEAD_WEBHOOK_SECRET`), stesso pattern di
[validate-slug/route.ts:7](src/app/api/internal/validate-slug/route.ts#L7) —
ma **senza** la scorciatoia "se non è configurato passa": questa route è
esposta a internet, secret assente deve voler dire 403.
- **Rate limit** con `rateLimit()` da [rate-limit.ts](src/lib/rate-limit.ts),
bucket per IP. `src/proxy.ts` **non intercetta** `/api/*` (matcher a riga 120),
quindi la difesa è tutta dentro la route.
- **Payload** validato con Zod: `name` obbligatorio, `email`/`phone`/`company`/
`notes` opzionali, più un `source` libero. Mappatura campi in un modulo a
parte così aggiungere una sorgente non tocca la route.
- **Effetto**: crea un `lead` in stato `contacted` riusando le funzioni di
[lead-service.ts](src/lib/lead-service.ts). Se l'email esiste già, aggiorna
`last_contact_date` invece di duplicare.
Questo è tutto ciò che serve per Elementor, ed è pronto in anticipo per Astro:
la decisione Elementor-vs-Astro **non blocca niente**, perché il contratto è
un POST JSON in entrambi i casi.
### C2 · TidyCal — sincronizzazione a polling
Senza webhook, l'unico modo pulito senza terze parti è andare a leggere.
- `src/lib/tidycal.ts` — client con Personal Access Token in
`TIDYCAL_ACCESS_TOKEN`, che chiede le prenotazioni create dall'ultimo giro.
- `GET /api/internal/tidycal-sync` — protetta da `INTERNAL_SECRET`, come le due
internal esistenti. Per ogni prenotazione nuova crea un lead con nome, email e
la data della call in `next_action_date`, e registra un'`activity` di tipo
`meeting`. Idempotente sull'id TidyCal, così rilanciarla non duplica.
- **Schedulazione**: Coolify ha gli Scheduled Tasks. Un `curl` ogni 15 minuti
verso quella route. Non serve infrastruttura cron nell'app — e infatti nel
repo non ce n'è.
**Prima di scrivere il client va guardata la documentazione vera dell'API**, che
è dietro login (`tidycal.com/integrations` → API Keys) e non è indicizzata: forma
esatta dell'endpoint prenotazioni, filtro per data, paginazione. È il primo passo
del blocco, non un dettaglio: vale la regola già scritta in memoria — *ogni
estrazione da API di terzi va vista funzionare, non dedotta dai docs*.
Se l'API risultasse inadatta, il ripiego è Zapier/Make che POSTa su C1, che a
quel punto esiste già.
### C3 · "Alleggerire l'hub"
Questa richiesta non ha ancora un perimetro. Non la pianifico al buio: quando i
blocchi A e B sono in produzione ti porto l'elenco di cosa è davvero poco usato
(`/admin/quotes` e `/admin/preventivi` convivono, `service_catalog` e
`offer_services` sono legacy da DEBT-02) e decidi tu cosa togliere. Cancellare
route è irreversibile e va fatto guardando, non indovinando.
---
## Blocco D — Whop → audit *(rinviato, dipende dal motore)*
L'ingresso Whop è già predisposto nello schema: `audits.origin` accetta
`'whop'` e `audits.external_ref` tiene il riferimento esterno
([schema.ts:688-690](src/db/schema.ts#L688-L690)).
Ma "partono gli agent e fanno il lavoro" oggi **non è possibile**: in
`src/lib/audit/` ci sono solo le cinque fonti; agent, sintetizzatore e pipeline
(AUD-06 → AUD-11) non sono scritti. Costruire il webhook adesso significa
costruire un innesco che non innesca niente.
Quando il motore c'è, il blocco è: `POST /api/webhooks/whop` con verifica firma →
crea cliente + progetto con la Entry Offer assegnata (così l'audit compare
automaticamente in B1 e B2) → crea l'`audit` con `origin='whop'` → accoda la run.
---
## Migration 0018 — additiva
`src/db/migrations/0018_timer_scope_and_due_date.sql`, in parallelo alle stesse
modifiche in [schema.ts](src/db/schema.ts) (drizzle-kit generate è rotto, i due
file si tengono in pari a mano).
```
ALTER TABLE time_entries ADD COLUMN phase_id text REFERENCES phases(id) ON DELETE SET NULL;
ALTER TABLE time_entries ADD COLUMN task_id text REFERENCES tasks(id) ON DELETE SET NULL;
CREATE INDEX time_entries_phase_idx ON time_entries(phase_id);
CREATE INDEX time_entries_task_idx ON time_entries(task_id);
ALTER TABLE projects ADD COLUMN due_date timestamptz;
```
Solo `ADD COLUMN` e `CREATE INDEX`: nessun DROP, nessun TRUNCATE, nessuna colonna
rimossa — conforme a Data Safety (LOCKED). Le righe esistenti di `time_entries`
restano valide con le due colonne a NULL, cioè "tempo di progetto".
**Ordine di applicazione**, come da CLAUDE.md: prima a produzione via
`ssh root@178.104.27.55` + `docker exec`, **poi** il push del codice che le usa.
---
## Ordine di esecuzione
1. **A1** — le due rimozioni (nessuna dipendenza, effetto immediato)
2. **Migration 0018** in produzione
3. **A3** timer su task/fase · **A2** mini-dashboard progetto
4. **B3** inbox (piccola) → **B1** analytics prodotto → **B2** timeline consegne
5. **C1** endpoint lead → **C2** TidyCal, dopo aver letto l'API vera
6. **C3** e **D** dopo, con le informazioni che oggi non abbiamo
Commit per unità logica, come negli ultimi quattro.
## Verifica
Non c'è suite di test in questo progetto: **`npm run build` è la verifica di
riferimento** (fa il typecheck), più `npm run lint`.
Controlli manuali, in ordine:
- **A1** — il progetto non mostra più il tab Commenti; gli stessi commenti si
vedono ancora in `/admin/conversazioni` con la loro etichetta d'entità; la
lista progetti non ha più la colonna Timer.
- **A3** — timer avviato su un task: `time_entries` ha `task_id` valorizzato; il
totale della fase è la somma dei suoi task; il totale progetto non cambia.
Poi cancellare quel task e verificare che **l'entry sopravviva** con
`task_id` a NULL.
- **A2 / B1 / B2** — confrontare ogni numero a video con la stessa query lanciata
a mano su prod via `psql`. Su un dataset di 5 progetti si controllano tutti a
occhio; è l'unico momento in cui questo è ancora possibile.
- **B2** — mettere una `due_date` manuale su un progetto e verificare che vinca
sulla data derivata dall'offerta.
- **C1** — `curl` con secret giusto (lead creato), con secret sbagliato (403),
senza secret (403), e ripetuto per verificare che non duplichi.
- Dual light/dark su ogni schermata nuova, e nessuna classe palette raw.
Il portale cliente non va toccato da nessuna di queste modifiche: `client-view.ts`
non si tocca, quindi il vincolo LOCKED #2 non è in gioco.
View File
+30
View File
@@ -0,0 +1,30 @@
# Regola: la memoria di progetto si aggiorna, sempre
`.planning/STATE.md` è la fonte di verità su **dove siamo**. È rimasto fermo dal 2026-06-21 al 2026-07-28 mentre venivano chiusi un audit di sicurezza, una riorganizzazione della cartella e mezzo design system: chi riapriva il progetto leggeva uno stato falso. Questa regola esiste per impedire che si ripeta.
## Quando aggiornare
Dopo **ogni** unità di lavoro conclusa — una fase, una migration applicata, un fix deployato, una decisione presa che cambia la rotta. Non a fine milestone: a fine cosa.
## Cosa scrivere in `.planning/STATE.md`
- **Frontmatter**: `last_updated` (ISO, data reale), `last_activity`, `status`, `progress`.
- **Current Position**: fase, stato, e soprattutto **se qualcosa blocca**.
- **Blocchi**: marcati `[BLOCCANTE]`, con *cosa* manca e *chi/cosa* lo sblocca. Un blocco non scritto è un blocco che si riscopre a caro prezzo.
- **Lezioni**: quando un approccio si rivela sbagliato, scrivere *perché* falliva, non solo cosa si è fatto al suo posto. Serve a non riprovarci fra due mesi.
- **Date assolute**, mai "ieri" o "la settimana scorsa".
## Cosa scrivere nella memoria persistente
`~/.claude/projects/-Users-simonecavalli-Vault-IAMCAVALLI-hub/memory/` — un file per fatto, più la riga di indice in `MEMORY.md`.
⚠️ **La chiave finisce in `-hub`.** Quella senza suffisso (`…-Vault-IAMCAVALLI/memory/`) è la memoria del *workspace*, un altro posto con altri file: scriverci un fatto di ClientHub significa non ritrovarlo più, perché a inizio sessione qui viene iniettata solo quella con `-hub`. Da non confondere nemmeno con `.claude/memory/`, che è versionata nel repo — la distinzione sta in [`../CLAUDE.md`](../CLAUDE.md).
Ci va quello che **non si deduce dal repo**: decisioni e il loro perché, vincoli operativi, cose che sono state provate e non funzionano. Non ci va quello che il codice già dice: struttura, cronologia dei fix, contenuto di `CLAUDE.md`.
Se un fatto in memoria diventa falso, **correggerlo o cancellarlo**. Una memoria sbagliata è peggio di una memoria assente.
## Cosa NON fare
- Non scrivere "completato" per lavoro che compila ma non è stato verificato. Distinguere sempre *scritto* / *testato* / *in produzione* — sono tre stati diversi e confonderli è il modo più veloce per deployare un disastro.
- Non lasciare `STATE.md` a raccontare la milestone precedente.
+32 -9
View File
@@ -17,15 +17,38 @@
"Read(//Users/simonecavalli/Downloads/**)"
]
},
"enabledPlugins": {
"impeccable@impeccable": true
},
"extraKnownMarketplaces": {
"impeccable": {
"source": {
"source": "github",
"repo": "pbakaus/impeccable"
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guardia-migration.sh"
}
]
}
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guardia-token.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "git -C \"$CLAUDE_PROJECT_DIR\" diff --quiet HEAD -- src .planning 2>/dev/null || echo 'PROMEMORIA memory-discipline: ci sono modifiche non committate in src/ o .planning/. Prima di chiudere, aggiorna .planning/STATE.md (last_updated, Current Position, blocchi marcati [BLOCCANTE]) e la memoria persistente se e cambiata una decisione. Regola: .claude/rules/memory-discipline.md'"
}
]
}
]
}
}
+77
View File
@@ -0,0 +1,77 @@
---
name: audit
description: Far girare le fonti di rilevazione dell'audit sito di ClientHub su un URL e capire cosa e' stato misurato e cosa no. Da usare quando si lavora al motore audit v2.5, quando serve una rilevazione su un sito reale, quando si vuole sapere se una fonte risponde, o prima di scrivere qualsiasi pezzo del documento di audit.
---
# Audit — le rilevazioni, e cosa vale come misura
Il motore v2.5 **non e' scritto**. Quello che esiste, e che questa skill mette in moto,
sono le fonti di rilevazione: nessun LLM, nessun database, nessun browser headless.
## Due attrezzi, due domande diverse
| Comando | Cosa fa | Quando |
|---|---|---|
| `npx tsx scripts/audit-fonti.ts <url>` | Le **cinque fonti** di `src/lib/audit/sources/` — pagine, CrUX, PageSpeed, storico Wayback, segnali. Puramente meccanico | «Cosa si riesce a misurare su questo sito?» |
| `npx tsx scripts/spike-audit.ts <url>` | Verifica le **264 voci di checklist** con Sonnet 5, poi sintetizza con Opus 5. Volutamente **isolato** da `src/`: non importa nulla, non tocca il DB | «Cosa c'e' che non va, in parole?» |
Opzioni comuni: `--profilo=servizi|ecommerce`, `--no-psi` (salta PageSpeed, che da solo
vale meta' del tempo). Riferimento misurato il 2026-08-26 su `giojello.com`: cinque fonti
su cinque, **42,7 s** in tutto, PageSpeed 34,2 s. Se una fonte tace, **quella e' la notizia**
va riportata, non aggirata.
`PAGESPEED_API_KEY` viene letta dall'ambiente o da `.env.local`. Senza, PageSpeed e CrUX
rispondono a vuoto **senza errore**: lo script lo dice in testa, leggerlo.
Il grezzo finisce in `audit-fonti-<dominio>.json` (gitignorato). E' li' che ogni numero del
documento dovra' essere rintracciabile.
## Le tre regole che il motore erediterà
Non sono preferenze. Sono gia' costate, e stanno per esteso in `STATUS.md` § Lezioni operative.
1. **Un numero entra solo se e' stato misurato.** Rintracciabile nel grezzo, e nel motore in
`audit_runs.raw`. Nessun numero dedotto, arrotondato o ricordato.
2. **Laboratorio e campo non si fondono — la differenza *e'* il risultato.** Su giojello.com
Lighthouse dava `server-response-time` **7 ms** e CrUX TTFB p75 **3.553 ms con l'1% 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 solo — per questo il campo si chiama
`risposta_server_ms` e non `ttfb_ms`.
3. **Un punteggio va sempre con la sua data.** Performance mobile 52, poi 64 sullo stesso sito
mezz'ora dopo. Mai presentato come una costante del sito.
E una regola di metodo che vale per ogni fonte nuova: **ogni estrazione da un'API di terzi va
vista funzionare, non dedotta dai docs.** Scrivendo `sources/`, due estrazioni prese dalla
documentazione hanno restituito valori vuoti *senza errore*`largest-contentful-paint-element`
non esiste piu' e `configSettings.screenEmulation` non esiste affatto nelle risposte pubbliche.
Trovate solo perche' le fonti sono state fatte girare su un sito vero.
## Perche' niente browser headless
Misurato il 2026-08-17 sul VPS: **1,5 GB di RAM liberi su 3,8**, Chromium ne prende 500 MB1 GB
→ OOM kill sui servizi con dati reali. Verificato il 2026-08-18 che non serve comunque: 153 voci
si verificano sul DOM renderizzato che PageSpeed restituisce, e lo screenshot buono e'
`final-screenshot`, non `fullPageScreenshot`.
## Lo stato di v2.5, per non ripartire dal posto sbagliato
**In pausa dal 2026-08-19** per scelta: prima le modifiche all'hub, poi il motore.
| Pezzo | Dove | Stato |
|---|---|---|
| Schema, 7 tabelle + rubrica 264 voci | `0017_audits.sql`, `checklist_items` | in produzione |
| Le cinque fonti | `src/lib/audit/sources/` | **in prod ma inerti**: nessuna route le chiama |
| Agent, sintetizzatore, pipeline, editor, pagina | `src/lib/audit/`, `src/app/{admin/audit,audit}` | **da scrivere** |
I piani stanno in [`../../plans/`](../../plans/): `v2.5-audit-documento.md` (i tre livelli
Radiografia / Prima-Dopo / Rotta come configurazioni di **un unico** documento) e
`v2.5-audit-motore.md` (raccolta in parallelo → quattro sub-agent → sintetizzatore che
**incrocia** le osservazioni in massimo 10 finding).
## L'agente, quando lo progetteremo
Va in `.claude/agents/audit-*.md`, non qui. Non esiste ancora e non va abbozzato: il design
dei quattro sub-agent cambiera' scrivendo il motore. Quando nascera' eredita le tre regole
qui sopra — sono il perimetro, non il contorno.
+89
View File
@@ -0,0 +1,89 @@
---
name: preventivo
description: Lavorare sulla pipeline che genera i preventivi di ClientHub (src/lib/proposal/) senza romperla. Da usare quando si modifica il prompt, lo schema Zod, il montaggio o la resa di un preventivo, quando una generazione fallisce con "Contenuto AI non valido", quando una sezione del documento esce vuota, o prima di pubblicare una proposta a un cliente.
---
# Preventivo — la catena e come non spezzarla
Il preventivo lo genera **l'app**, da `/admin/preventivi/genera`. Questa skill non contiene
un prompt di generazione e non ne va aggiunto uno: il prompt vive in `agent.ts` ed e' l'unico.
Un secondo prompt qui resterebbe indietro rispetto a quello vero senza che nessuno se ne accorga.
## La catena: quattro file che devono dire la stessa cosa
```
src/lib/proposal/agent.ts il prompt chiede N campi ─┐
src/lib/proposal/schema.ts lo Zod pretende quegli N campi │ se non
src/lib/proposal/assemble.ts li impacchetta con prezzi+profilo │ coincidono,
src/components/public/proposal/ ProposalDeck + sections/ li resa ─┘ si rompe
```
**Si tocca il prompt, si riapre lo schema. Sempre.** I due fallimenti hanno forma diversa e
solo uno si vede subito:
| Cosa hai fatto | Cosa succede |
|---|---|
| Campo aggiunto allo **schema**, non al prompt | `ProposalContentSchema.safeParse` fallisce → **ogni** generazione muore con «Contenuto AI non valido». Rumoroso, si scopre subito |
| Campo aggiunto al **prompt**, non allo schema | Zod lo scarta in silenzio. Il documento esce senza quella parte, e te ne accorgi davanti al cliente |
| Campo nello schema, nessuna `<Section>` che lo legge | Generazione verde, sezione assente. Il piu' subdolo: niente segnala l'errore |
Quando aggiungi un campo, il giro completo e' **quattro file**: prompt in `buildUserPrompt`,
schema Zod, eventuale passaggio in `assembleProposal`, e la sezione in
`src/components/public/proposal/sections/` piu' la riga in `ProposalDeck.tsx` che la monta.
I vincoli di cardinalita' stanno nello schema (`.min(3).max(5)` sui problemi, `.length(5)` sui
nodi del diagramma, `.min(4).max(10)` sulla matrice): se li cambi li' e non nel prompt, il
modello continua a produrre il numero vecchio e Zod lo rifiuta.
## Prezzi: cosa puo' vedere il cliente
Vincolo **LOCKED #2** (`../../CLAUDE.md`): al cliente vanno i totali, mai le righe di prezzo.
`PricingSection.tsx` renderizza `tier.publicPrice ?? tier.servicesTotal` e il **nome** dei
servizi — corretto. Ma `assembleProposal` mette in `content.offer.tiers[].services[].unitPrice`
anche il prezzo unitario, e `ProposalDeck` e' `"use client"`: riceve l'intero `proposal` come
prop da un server component, quindi **tutto** l'oggetto finisce serializzato nel payload RSC
della pagina, renderizzato o no.
E' la stessa trappola del gate OTP annotata in `STATUS.md`*sparire a schermo non e' sparire*.
Prima di toccare la pagina pubblica, e prima di mandare un preventivo a un cliente che potrebbe
aprire il sorgente:
```bash
curl -s https://<host>/preventivo/<slug> > /tmp/p.html
LC_ALL=C grep -c unitPrice /tmp/p.html # atteso a regime: 0
```
Se e' > 0, la correzione non e' nascondere la sezione: e' una proiezione client-safe in
`assemble.ts` o al confine del componente, come gia' fa `src/lib/client-view.ts` per il portale.
## Prima di pubblicare
1. **Preflight sui dati finti**`./check-profilo.sh` da questa cartella. `profile.ts` e' uno
**snapshot**: quello che c'e' dentro al momento della generazione finisce in
`proposals.content` e ci resta anche se poi correggi il file.
2. **Le citazioni sono verbatim o non sono.** Il prompt lo impone; verificarlo a campione contro
il transcript e' il controllo che nessuna macchina fa al posto tuo. Una citazione inventata
in un preventivo e' peggio di un preventivo senza citazioni.
3. **Copy** — italiano, registro di `../../../../brand/voce.md`, e **nessun numero che non stia
in `../../../../brand/prove.md` marcato divulgabile** (`../../../../.claude/rules/lingua-e-tono.md`).
⚠️ `brand/` sta **fuori dal repo hub**: su un clone senza il workspace questo passo si salta
dichiarandolo, non si finge di averlo fatto.
4. **Stato**`draft` non e' visibile (la pagina risponde «non ancora disponibile»).
Pubblicare = `publishProposal` in `src/app/admin/preventivi/actions.ts`.
## Come si prova davvero
- `npm run build` e' **la verifica di riferimento**: non esiste test suite, e il build fa il
typecheck. Verde qui significa «i tipi tornano», non «il documento e' giusto».
- Per vedere l'output serve una **generazione vera** dall'admin, con un'offerta e almeno un
transcript. In locale oggi **non si puo'**: `.env.local` non autentica piu' contro il DB
(dal 2026-08-21, vedi `STATUS.md`). Si guarda in produzione.
- Un preventivo si legge a schermo prima di mandarlo. «Buildato» non e' «funziona», e
«generato» non e' «verificato».
## Note sulla chiamata al modello
`agent.ts` usa `max_tokens: 8192` per uno schema che chiede fino a 5 problemi + 5 soluzioni +
matrice. Se la risposta viene troncata, il fallimento **non** dice «troncata»: dice
«L'AI ha prodotto JSON non valido», e manda a cercare nel posto sbagliato. Se capita,
controllare `message.stop_reason === "max_tokens"` prima di dare la colpa al parsing.
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Preflight: src/lib/proposal/profile.ts contiene dati placeholder?
#
# profile.ts e' uno SNAPSHOT: finisce dentro proposals.content al momento della
# generazione e ci resta. Un placeholder spedito una volta non si corregge piu'
# modificando il file — va rigenerato il preventivo.
#
# exit 0 = pulito · exit 1 = placeholder trovati
set -uo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
F="$ROOT/src/lib/proposal/profile.ts"
[ -f "$F" ] || { echo "profile.ts non trovato in $F" >&2; exit 1; }
# Ogni riga: etichetta|regex. Sono i segni concreti di "da riempire".
PATTERNS=(
'testimonianza segnaposto|→ Aggiungere'
'nome cliente finto|"Cliente [0-9]"'
'ruolo generico|role: "Professione"'
'campo da compilare|// → '
'dominio sbagliato (il brand e iamcavalli.net)|iamcavalli\.com'
)
TROVATI=0
for p in "${PATTERNS[@]}"; do
ETICHETTA="${p%%|*}"; RE="${p#*|}"
N=$(grep -cE "$RE" "$F" || true)
if [ "$N" -gt 0 ]; then
printf ' ✗ %-48s %s occorrenz%s\n' "$ETICHETTA" "$N" "$([ "$N" = 1 ] && echo a || echo e)"
TROVATI=$((TROVATI + N))
fi
done
if [ "$TROVATI" -gt 0 ]; then
cat >&2 <<'MSG'
BLOCCO — profile.ts spedisce dati placeholder in ogni preventivo generato.
Non si riempiono a occhio: i numeri divulgabili stanno in brand/prove.md, che oggi
e' ancora `stato: scheletro-intervista`. Finche' non sono verificati, la mossa giusta
non e' inventare — e' non renderizzare il blocco.
Regola: ../../../.claude/rules/lingua-e-tono.md — «un numero affermato con sicurezza e
mai verificato e' il modo piu' veloce per bruciare la credibilita' che il tono costruisce».
MSG
exit 1
fi
echo " ✓ profile.ts pulito"
View File
+17
View File
@@ -10,3 +10,20 @@ ADMIN_PASSWORD=use-a-strong-password-min-20-chars
# Internal API secret — shared between proxy.ts and /api/internal/* routes
# Generate with: openssl rand -base64 32
INTERNAL_SECRET=generate-with-openssl-rand-base64-32
# Resend — invio del codice OTP per l'accesso al portale cliente
# RESEND_FROM deve usare un dominio verificato su Resend
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
RESEND_FROM=Nome Mittente <no-reply@iamcavalli.net>
# Base pubblica dei link nelle email (pulsante "Apri la conversazione" della
# notifica di tag). Opzionale: se manca si usa NEXTAUTH_URL, che in ogni
# ambiente e' gia' l'origine giusta. Serve solo se le due devono divergere.
# APP_BASE_URL=https://hub.iamcavalli.net
# Ingresso lead da fuori (form del sito, bridge Zapier/Make) su
# POST /api/webhooks/lead, header x-webhook-secret.
# A differenza di INTERNAL_SECRET questa route e' esposta a internet: se la
# variabile manca, la route risponde 403 invece di lasciar passare.
# Generate with: openssl rand -base64 32
LEAD_WEBHOOK_SECRET=generate-with-openssl-rand-base64-32
+12
View File
@@ -24,6 +24,12 @@
.DS_Store
*.pem
# cache del plugin impeccable (globale), si rigenera
.impeccable/
# cartelle di lavoro lasciate dal plugin claude-security
/CLAUDE-SECURITY-*/
# debug
npm-debug.log*
yarn-debug.log*
@@ -40,3 +46,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Output degli spike audit (dati di siti di clienti, non vanno committati)
spike-audit-*.json
# Grezzo delle rilevazioni audit (npx tsx scripts/audit-fonti.ts)
audit-fonti-*.json
-58
View File
@@ -1,58 +0,0 @@
# Design System — Offer Studio UI direction (v2.1)
**Definito:** 2026-06-13 (via skill `ui-ux-pro-max`)
**Scope:** Applies to Phase 11 (Catalog DB-view), 12 (Offer composition/DnD), 13 (Servizi Attivi), 14 (CRM Attio-style) — any "database view" table in `/admin/*`.
## Direzione
ClickUp / Pipedrive: dense ma leggibile, flat, zero decorazione. **Pattern:** Minimalism & Swiss Style + Flat Design — grid-based, alto contrasto, hover/transition rapidi (150-250ms), nessuna ombra/gradiente pesante.
## Brand tokens — INVARIATI (da `src/app/globals.css`)
Non introdurre una nuova palette: ClickUp/Pipedrive è una direzione di LAYOUT/interazione, non di colore. Il brand iamcavalli resta:
| Token | Valore | Uso |
|---|---|---|
| `--color-primary` | `#1A463C` (verde scuro) | azioni primarie, focus ring, link attivi |
| `--color-accent` | `#DEF168` (lime) | highlight/badge di stato attivo, CTA secondarie |
| `--color-background` | `#ffffff` | sfondo pagina/tabella |
| `--color-muted` / `--color-bg-subtle` | `#f9f9f9` | righe alternate, header tabella, quick-add row |
| `--color-border` | `#e5e7eb` | bordi cella sottili (1px), MAI ombre pesanti |
| `--color-foreground` | `#1a1a1a` | testo primario |
| `--color-muted-foreground` | `#71717a` | placeholder, metadati, celle vuote |
| Font | Geist Sans (già configurato) | nessun cambio — coerente con "Minimal Swiss" |
## Pattern tabella database-view (Phase 11-13)
- **Riga**: altezza compatta (~40px), padding orizzontale `px-3`, bordo inferiore `border-border` 1px — NO bordi verticali tra celle (look ClickUp, non Excel)
- **Inline edit**: click su cella → diventa `<input>`/`<select>` borderless con `ring-1 ring-primary` on focus → Enter salva, Esc annulla, blur salva. Nessun modal, nessun reload.
- **Tag multi-select**: `Badge` (già in `components/ui/badge.tsx`) con colori derivati da una palette fissa a rotazione (6-8 colori pastello su sfondo, testo scuro per contrasto AA) + pulsante "+" inline per creare un nuovo tag senza uscire dalla riga
- **Quick-add row**: ultima riga della tabella, sempre visibile, placeholder "+ Aggiungi servizio" — stile identico alle righe dati ma `text-muted-foreground`, diventa riga normale dopo il primo salvataggio
- **Filtri/ricerca**: barra sopra la tabella, input singolo con icona search (Lucide), filtro client-side istantaneo su nome/tag — NO bottone "Cerca", NO reload
- **Header tabella**: sticky, `bg-muted`, font-weight 600, NO maiuscolo decorativo eccessivo (small-caps ok, ALL-CAPS pesante no)
- **Hover riga**: `bg-muted/50`, transizione `transition-colors duration-150`, cursore pointer solo su celle editabili
## Componenti shadcn da riusare/estendere
Già presenti: `table`, `badge`, `dialog`, `select`, `input`, `button`, `form`. Per Phase 11 servirà probabilmente:
- Un componente `EditableCell` (input/select inline, non in shadcn — da costruire ad-hoc su `input.tsx`)
- Un `TagMultiSelect` (combobox + badge, da costruire su `select.tsx`/`badge.tsx` — shadcn `command`/`popover` non ancora installati, valutare in planning)
## Anti-pattern da evitare
- Ombre pesanti, glassmorphism, gradienti decorativi
- Icone emoji (usare SVG Lucide, coerente col resto dell'app)
- Tabelle senza filtro/ricerca
- Azioni riga-per-riga quando serve bulk (Phase 12+: valutare checkbox + action bar per operazioni multiple)
- Hover che causa layout shift (no scale transform su righe tabella)
## Checklist pre-delivery (per ogni componente nuovo)
- [ ] Contrasto testo ≥ 4.5:1 (light mode — testo muted minimo `#475569`/`text-muted-foreground` attuale è `#71717a`, verificare su `bg-muted`)
- [ ] `cursor-pointer` su celle/righe editabili e cliccabili
- [ ] Focus ring visibile (`ring-1 ring-primary` o `--color-ring`) su input inline e bottoni
- [ ] Transizioni 150-250ms, `transform`/`opacity` non `width`/`height`
- [ ] Responsive: tabella in `overflow-x-auto` wrapper sotto 1024px, niente layout rotto
---
*Riferimento per CONTEXT.md (Phase 11) e per eventuale `/gsd-ui-phase` su fasi 11-14.*
-57
View File
@@ -1,57 +0,0 @@
# Handoff
Living document — update at the end of each session so the next one can resume without re-deriving context. Overwrite stale sections; keep it short and actionable.
---
## 2026-06-13 — Milestone v2.1 "Offer Studio + Proposal AI" pianificata — pronta per esecuzione
### Cosa è stato fatto
Eseguito ciclo completo `/gsd-new-milestone "Offer Studio + Proposal AI"` (research saltata su scelta utente):
- **PROJECT.md**: nuovo milestone v2.1 con goal, 4 target feature, sezione "Validated" aggiornata con v2.0 (Phase 7-10), "Active" riscritta in 4 categorie prioritizzate, nuove Key Decisions (compartimenti stagni confermato, ordine Offer Studio→Proposal AI, tab Preventivo→Servizi Attivi zero-perdita verificata)
- **v2.0 archiviata** (copie, non spostamenti): `REQUIREMENTS.md`/`ROADMAP.md`/phases 07-10 → `.planning/milestones/v2.0-*`
- **REQUIREMENTS.md** riscritto: 23 requisiti v1 in 5 categorie (Offer Studio, Workspace Servizi Attivi, CRM Attio, Dashboard [bloccata], Proposal AI) + deferred v2 (OFFER-14, AUTH-OTP-01, ARCH-01) + out of scope
- **ROADMAP.md** creato: 7 nuove fasi (11-17), copertura 100% (23/23 requisiti mappati), tutte approvate dall'utente
- **STATE.md**: switch a v2.1, focus = Phase 11
### Roadmap v2.1 (Phase 11-17)
| Fase | Titolo | Requisiti | Note |
| --- | --- | --- | --- |
| 11 | Catalog Database-View UX & Legacy Consolidation | OFFER-07,08,09,10,13 | unifica `service_catalog`/`offer_services``services` PRIMA della nuova UX |
| 12 | Offer Composition Drag&Drop & CSV Import | OFFER-11,12 | `@dnd-kit`, totale live durante drag, import CSV one-shot |
| 13 | Workspace — Servizi Attivi | PROJ-06..10 | rimuove tab Preventivo (zero perdita, `accepted_total` resta via Payments) e Forecast; nuova tab Servizi Attivi (one-shot/ricorrenti + tracking incassi mensili) |
| 14 | CRM Attio-style & Fix | CRM-08..12 | inline edit lead + tag, fix FollowUpWidget IT / LeadForm types / SendQuoteModal |
| 15 | Dashboard Revenue Stats | DASH-11 | **BLOCCATA** — attesa mockup utente, isolata/skippabile, non blocca 16/17 |
| 16 | Proposal AI — Data Foundations & Auto-Provisioning | PROP-03,04 | campo Stripe Payment Link + auto-provisioning su accettazione (ex-Phase 11) |
| 17 | Proposal AI — Builder, Pagina Pubblica & Email | PROP-01,02,05 | AI builder + redesign `/quote/[token]` + invio email Resend (ex-Phase 12) |
### Nota trasparenza — deviazione dal workflow
Il workflow `/gsd-new-milestone` prevede uno step "phases clear" che farebbe `rm -rf` di `.planning/phases/01-10/` senza backup. **Non l'ho eseguito**: è distruttivo, senza archiviazione automatica, e CLAUDE.md richiede conferma prima di operazioni distruttive/di investigare prima di rimuovere lavoro storico. Le fasi 07-10 sono state invece COPIATE (non spostate) in `.planning/milestones/v2.0-phases/`; le directory originali `01-10` restano in `.planning/phases/`. Nessuna perdita — solo directory duplicate, pulizia facoltativa in futuro.
### Prossima sessione
1. **Pianificare Phase 11** (Catalog Database-View UX & Legacy Consolidation): `/gsd-plan-phase 11` (oppure `/gsd-discuss-phase 11` prima per decisioni aperte: schema tag, formato CSV import, strategia consolidamento `service_catalog`/`offer_services`)
2. Se arriva il **mockup dashboard** dall'utente: Phase 15 (DASH-11) può essere sbloccata, usare `/gsd-ui-phase` come contratto UI
3. Migration Phase 11 (consolidamento catalogo) e Phase 13/16 (nuovi campi recurring/payment link) vanno applicate a prod via SSH+docker exec PRIMA del push del codice dipendente (regola storica, vedi sotto)
---
## 2026-06-12 — Direzione "Offer Studio" + "Proposal AI" → ora pianificata (vedi sopra)
- **BUG fixato e deployato**: `/admin/leads/[id]` 500 per `params` non awaited (Next.js 16) → fix commit `ea20685`, confermato live in prod (container `857af5c1...`).
- Decisioni strutturali (Preventivo→Servizi Attivi, Forecast→Dashboard, CRM Attio-style, compartimenti stagni) sono ora formalizzate in PROJECT.md/REQUIREMENTS.md/ROADMAP.md — vedi sezione 2026-06-13 sopra.
---
## 2026-06-11 (sera) — Phase 10 redo COMPLETATO, root cause risolta (storico)
- **Root cause del crash post-deploy Phase 10**: il DB prod non aveva NESSUNA migration dopo la 0000 (mancavano `services`, `leads`, `offer_phases`, `quotes`…). Catalogo e quote già rotti prima di Phase 10; il deploy Phase 10 ha aggiunto il crash dashboard (FollowUpWidget→leads). NON era un problema di piattaforma (l'app è Gitea→Coolify, non Vercel).
- **Fix**: migrations 0001+0003+0004+0005 applicate atomicamente al DB prod via `ssh root@178.104.27.55``docker exec -i xwkk0040w0kk0gsgcgog8owk psql` (porta 54321 firewallata dall'esterno, si passa da SSH). Dati protetti verificati intatti (4 clients / 5 projects / 13 payments / 6 phases).
- **Redo Phase 10 deployato**: commit `5aa6614` (deps+UI primitives) e `008a434` (modulo CRM completo). Utente conferma pagine visibili in prod.
- **REGOLA**: le migration qui sono manuali — applicare al DB prod PRIMA di pushare codice che usa il nuovo schema. Pattern: `cat migration.sql | ssh root@178.104.27.55 "docker exec -i xwkk0040w0kk0gsgcgog8owk sh -c 'psql -U \$POSTGRES_USER -d clienthub -v ON_ERROR_STOP=1 --single-transaction'"` (verificare prima che sia additive-only).
- Branch `phase10-wip` (= `8e2752a`) cancellabile quando il redo è considerato definitivo. Dangling ancora recuperabile: `5d75752` (sidebar App shortcuts).
- Script riusabile: `scripts/push-phase10-migration.ts` (solo dal server o con tunnel).
+39
View File
@@ -1,5 +1,40 @@
# Milestones
## v2.4 Post-vendita (Phases 13 + 26, in corso)
**Consegnato:** 2 fasi, entrambe in produzione e verificate.
**Key accomplishments:**
- Ciclo di vita dei servizi ricorrenti (Phase 13, prod 2026-08-01): `project_offers.status` (attivo/sospeso/cessato) + `end_date` via migr. 0016; il forecast a 12 mesi smette di sommare un retainer fermo; comandi Sospendi/Riattiva/Cessa nella tab Offerte; il cliente vede stato, "attivo dal / fino al" e canone mensile (RET-01..05)
- Anteprima admin del portale + toggle password sul login (Phase 26, prod 2026-08-08): `?preview=1` con sessione Auth.js valida apre il portale di un cliente in sola lettura, senza passare dal gate OTP (PREV-01/02, AUTH-09)
**Deviazione registrata:** vincolo LOCKED #4 — una route `/client/*` ora legge anche la sessione Auth.js (Phase 26).
**Aperto:** RET-06 (canoni mensili tracciabili), più il backlog ereditato. Vedi `REQUIREMENTS.md`.
Fasi: [13-ciclo-vita-servizi-ricorrenti](phases/13-ciclo-vita-servizi-ricorrenti/13-SUMMARY.md) · [26-anteprima-admin-e-login](phases/26-anteprima-admin-e-login/26-SUMMARY.md)
---
## v2.3 Email & Accesso (Phases 2325, shipped 2026-07-29)
**Phases completed:** 3 fasi (2325) · eseguite fuori dal ciclo GSD (nessun PLAN/SUMMARY per fase)
**Key accomplishments:**
- Resend Setup (Phase 23): `resend@6.18.1`, `src/lib/mailer.ts` con Result tipizzato, template OTP in italiano, env configurate su Coolify
- Schema + Whitelist Admin (Phase 24): migr. 0015 additiva pura applicata a prod — `client_emails`, `otp_codes`, `clients.sessions_valid_from`; sezione "Accessi al portale" in `/admin/clients/[id]` (OTP-01, OTP-08)
- OTP Gate + Sessione (Phase 25): codice 6 cifre CSPRNG hashato, TTL 15 min, monouso, max 5 tentativi; cookie HMAC per-cliente, 90 giorni; rate limiting e no-enumeration (OTP-02..07)
**Verificata end-to-end in produzione** su `hub.iamcavalli.net` il 2026-07-29: senza cookie il gate non lascia trapelare **nessun dato di progetto** nell'HTML.
**Known deferred items at close:** SEND-01/SEND-02 (invio preventivo via email) — rinviati, il mailer resta comunque in prod.
Archive: [`milestones/v2.3-ROADMAP.md`](milestones/v2.3-ROADMAP.md) · [`milestones/v2.3-REQUIREMENTS.md`](milestones/v2.3-REQUIREMENTS.md)
---
## v2.2 Sales Loop (Phases 1822, shipped 2026-06-20)
**Phases completed:** 5 phases (1822) · 9 plans · 27 commits · 87 files · +7.349/-842 righe
@@ -28,6 +63,10 @@ Archive: `.planning/milestones/v2.2-ROADMAP.md` · `.planning/milestones/v2.2-RE
- Offer Editor Tier A/B/C (Phase 12): editor offerte con matrice checkbox servizi×tier, totale live, prezzo pubblico manuale, tag 4-dimensioni, promessa di trasformazione; 55 servizi reali caricati (OFFER-11, OFFER-15..18)
- CRM Attio-style (Phase 14): `/admin/leads` ridisegnata con inline edit + tag multi-select; FollowUpWidget in italiano; LeadForm tipizzato; SendQuoteModal senza rami irraggiungibili (CRM-08..12)
Phase 13 è poi tornata in vita come milestone v2.4, consegnata il 2026-08-01.
Archive: [`milestones/v2.1-ROADMAP.md`](milestones/v2.1-ROADMAP.md) (ricostruito il 2026-08-08) · fasi in [`milestones/v2.1-phases/`](milestones/v2.1-phases/)
---
## v2.0 Business Operations Suite (Phases 710, completato 2026-06-13)
+29 -15
View File
@@ -8,15 +8,16 @@ Suite operativa per un consulente di personal branding, live su hub.iamcavalli.n
Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
## Current Milestone: v2.3 Email & Accesso
## Current Milestone: v2.4 Post-vendita
**Goal:** Aggiungere uno strato email all'app — OTP gate per il portale cliente e invio link preventivo dall'admin — con un'unica integrazione Resend condivisa.
**Goal:** Chiudere il ciclo di vita di ciò che è già venduto — un retainer deve poter finire, e l'admin deve poter vedere il portale con gli occhi del cliente.
**Target features:**
- AUTH-OTP-01 — OTP gate portale cliente (whitelist email + sessione 30gg + admin UI)
- PUB-03 — Invio link `/preventivo/[slug]` via email Resend dall'admin
**Consegnato (in produzione):**
**Backlog v2.4+:** PROP-03 (Stripe Payment Link), PROP-04 (auto-provisioning al "Vinto"), Phase 13 (servizi ricorrenti)
- Phase 13 — Ciclo di vita dei servizi ricorrenti (RET-01..05), prod 2026-08-01
- Phase 26 — Anteprima admin del portale + toggle password sul login (PREV-01/02, AUTH-09), prod 2026-08-08
**Backlog:** RET-06 (canoni mensili tracciabili), SEND-01/02 (invio preventivo via email), PROP-03 (Stripe Payment Link), PROP-04 (auto-provisioning al "Vinto"), DEBT-01 (debito design). Elenco completo in `REQUIREMENTS.md`.
## Requirements
@@ -57,10 +58,19 @@ Validated in v2.2 Sales Loop (shipped 2026-06-20):
- ✓ Agente AI: Claude Opus 4.8, Zod schema 20+ sezioni, snapshot JSONB proposals, form admin — Phase 21 (AI-01, AI-02)
- ✓ Deck pubblico `/preventivo/[slug]`: 20+ slide 100vh, keyboard nav; accept/reject `accepted_at` immutabile — Phase 22 (PUB-01, PUB-02)
### Active — v2.3
Validated in v2.3 Email & Accesso (shipped 2026-07-29):
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email: whitelist `client_emails`, gate `/client/[token]/*`, sessione 30gg, admin whitelist UI
- [ ] PUB-03 — Invia link `/preventivo/[slug]` via email Resend dall'admin
- ✓ Gate OTP sul portale cliente: whitelist `client_emails`, codice 6 cifre via Resend, sessione firmata **90 giorni** (non 30: modificata il 2026-07-28), revoca in blocco dall'admin — Phase 23/24/25 (OTP-01..08). Migr. 0015.
- ✗ PUB-03 / SEND-01/02 (invio preventivo via email) **non consegnato**: rinviato al backlog il 2026-07-28. Il preventivo si manda a mano; l'infrastruttura Resend è comunque in prod.
Validated in v2.4 Post-vendita (in produzione):
- ✓ Ciclo di vita dei servizi ricorrenti: `project_offers.status` + `end_date`, forecast che si ferma, comandi Sospendi/Riattiva/Cessa, stato visibile al cliente — Phase 13 (RET-01..05). Migr. 0016.
- ✓ Anteprima admin in sola lettura del portale cliente + toggle password sul login — Phase 26 (PREV-01/02, AUTH-09).
### Active
Nessun requisito in lavorazione. Il prossimo va scelto dal backlog in `REQUIREMENTS.md`.
### Out of Scope
@@ -79,8 +89,8 @@ Validated in v2.2 Sales Loop (shipped 2026-06-20):
- Tutto sotto la stessa app: `/admin/*` (sessione Auth.js) + `/client/[token]/*` (token) + `/preventivo/[slug]` (pubblico)
- La sidebar admin include: Dashboard, Leads (con toggle Lista/Kanban), Offerte, Catalogo, Preventivi (con CTA globale "Genera preventivo")
- Stack v2.2: `@anthropic-ai/sdk@0.105.0` (Claude Opus 4.8), `@dnd-kit` (Kanban), `nanoid` (slug proposals)
- DB live: 10 migrazioni applicate a prod (00000010); `proposals` table con `content jsonb` snapshot; `client_transcripts` per lead
- Migrations sono manuali: SSH tunnel → `node` script PRIMA di pushare codice schema-dipendente; `drizzle-kit generate` rotto da Phase 8
- DB live: migrazioni applicate a prod fino alla **0016**; `proposals` con `content jsonb` snapshot; `client_transcripts` per lead; `client_emails`/`otp_codes` per il gate OTP
- Migrations sono manuali: SQL a mano applicato via **SSH + docker exec** PRIMA di pushare il codice schema-dipendente (procedura in `CLAUDE.md`); `drizzle-kit generate` rotto da Phase 8
- `ANTHROPIC_API_KEY` in Coolify — aggiunta 2026-06-20 via PHP artisan; costo ~$0.44/preventivo (Opus 4.8)
- Il flusso commerciale reale: call con lead → transcript incollato → genera preventivo AI → deck pubblica → cliente sceglie tier → vinto/perso
@@ -90,7 +100,7 @@ Validated in v2.2 Sales Loop (shipped 2026-06-20):
- **Architettura (LOCKED)**: `clients.token` separato e rotatable; `quote_items` mai esposti via client API; `deliverables.approved_at` immutabile; no file hosting
- **Compartimenti stagni**: un'unica app Next.js, moduli isolati (route group + service layer propri) su Postgres condiviso; migrations solo additive; niente deploy separati per ora (modello OMC adattato)
- **NO database esterno / Excel come fonte dati**: Postgres resta l'unica fonte di verità — il problema è la UX, non il dato
- **Numerazione fasi**: v2.0 ha chiuso a Phase 10; v2.1 parte da Phase 11
- **Numerazione fasi**: progressiva e mai riusata — v1.0 16, v2.0 710, v2.1 1117 (13/15/16/17 mai eseguite), v2.2 1822, v2.3 2325, v2.4 13 (ripresa dal congelamento) + 26
## Key Decisions
@@ -103,14 +113,18 @@ Validated in v2.2 Sales Loop (shipped 2026-06-20):
| Catalogo servizi unificato (una tabella `services`) | Due cataloghi paralleli (service_catalog + offer_services) duplicano manutenzione prezzi | ✓ Good — tabella `services` live da Phase 7, consolidamento legacy in v2.1 |
| Tier offerte indipendenti (A/B/C separati, stesso tag) | Più semplice di un meccanismo di ereditarietà; ogni tier configurato a sé | ✓ Good — usato in deck slide Pricing/StagesRecap/Comparison |
| Prezzi pacchetti per-preventivo, non da catalogo | Permette di alzare i prezzi nel tempo senza toccare il catalogo | ✓ Good — `public_price` per tier, snapshot in `proposals.content` |
| Al "Vinto" le fasi dell'offerta sono COPIATE nel progetto | Il progetto resta modificabile senza toccare il template offerta | — Pending (PROP-04, backlog v2.3) |
| Al "Vinto" le fasi dell'offerta sono COPIATE nel progetto | Il progetto resta modificabile senza toccare il template offerta | — Pending (PROP-04, backlog) |
| NO DB esterno/Excel, Postgres unica fonte | Lezione 2026-06-11: due fonti disallineate hanno causato il crash Phase 10 | ✓ Good |
| Catalogo/Offerte UX = database view custom (non Notion-clone) | Notion troppo complesso per v1; serve velocità, non sezioni analitiche | ✓ Good — confermato in v2.1 |
| Tab "Preventivo" rimossa, "Offerte" → "Servizi attivi" | Preventivo Builder è l'unico flusso; `accepted_total` già coperto da Payments | ✓ Confermato — zero perdita funzionale verificata (2026-06-13) |
| Ordine: Offer Studio (UX dato) prima, Proposal AI (AI) dopo | L'AI è l'ultimo miglio, serve un dato pulito e veloce da gestire prima | ✓ Good — strategia validata: catalogo+offerte puliti → AI in v2.2 |
| Output AI = JSON strutturato Zod → template fisso | Coerenza visiva garantita; zero rischio HTML rotto dall'AI | ✓ Good — 20+ sezioni Zod validate, deck sempre coerente |
| `proposals.content` = JSONB snapshot immutabile | Prezzi e profilo consulente "bloccati" al momento della generazione | ✓ Good — invariante di audit, coerente con `accepted_at` |
| Email Resend (PUB-03) deferred | Scope minimo funziona; link condiviso manualmente per ora | — Pending (v2.3 candidato #1) |
| Email Resend (PUB-03) deferred | Scope minimo funziona; link condiviso manualmente per ora | — Pending — rinviata di nuovo il 2026-07-28, il mailer però è in prod |
| Il gate OTP sta in cima alla `page`, mai nel layout | Nell'App Router il `page` è renderizzato in parallelo al layout: gattare nel layout lascia i dati nel payload RSC | ✓ Good — verificato: 46.907 → 17.594 byte di HTML |
| Sessione OTP a 90 giorni invece di 30 | Rientro più fluido per il cliente, compensato dalla revoca in blocco lato admin (OTP-08) | ✓ Good — in prod dal 2026-07-29 |
| Storico di vendita ≠ forecast | `getOffersSoldBreakdown` non filtra per stato: escludere le offerte cessate riscriverebbe il fatturato passato | ✓ Good — Phase 13 |
| Anteprima admin del portale in sola lettura | Le API client autenticano sul token nel body, non sulla sessione: un click distratto approverebbe un deliverable, e `approved_at` è immutabile (LOCKED #3) | ✓ Good — protezione a livello UI, deviazione da LOCKED #4 accettata (Phase 26) |
## Evolution
@@ -130,4 +144,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state
---
*Last updated: 2026-06-21 — v2.3 milestone started: Email & Accesso (AUTH-OTP-01 + PUB-03)*
*Last updated: 2026-08-08 — v2.3 archiviata, v2.4 Post-vendita corrente (Phase 13 + 26 in produzione)*
+108 -49
View File
@@ -1,68 +1,127 @@
# Requirements: ClientHub v2.3 Email & Accesso
# Requirements: ClientHub v2.5 Audit
**Defined:** 2026-06-21
**Core Value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
**Definiti:** 2026-08-16 (piano approvato) · **rivisti:** 2026-08-18 (motore)
**Core Value della milestone:** L'imprenditore paga un'analisi del suo sito e riceve un
documento che gli dice, con numeri misurati, cosa non funziona e cosa costa — non un
elenco di quaranta punti generato da un tool gratuito.
## v2.3 Requirements
Milestone precedente: [v2.4 Post-vendita](milestones/v2.4-REQUIREMENTS.md), chiusa 2026-08-08.
### Email OTP Gate (AUTH-OTP-01)
Piani di riferimento (fuori dal repo, in `~/.claude/plans/`):
`dovremmo-fare-una-cosa-woolly-puddle.md` (documento, editor, template) +
`vorrei-solo-farti-capire-radiant-valley.md` (motore — sostituisce §6/§7 del primo).
Portale cliente blindato da email OTP. Nuovo `client_emails` table (whitelist) + `otp_codes` table (codice, email, expires_at, consumed). Resend come provider email. Sessione 30gg con cookie dopo verifica.
## Il prodotto
- [ ] **OTP-01**: Admin può aggiungere e rimuovere email dalla whitelist di ogni cliente nell'admin UI
- [ ] **OTP-02**: Cliente senza sessione OTP vede una schermata "inserisci email" invece della dashboard
- [ ] **OTP-03**: Sistema invia OTP via Resend solo se l'email inserita è nella whitelist di quel cliente
- [ ] **OTP-04**: Cliente inserisce il codice OTP ricevuto e ottiene sessione autenticata (cookie 30 giorni)
- [ ] **OTP-05**: Codici OTP scadono dopo 15 minuti dall'invio
- [ ] **OTP-06**: Endpoint OTP è rate-limited per prevenire brute force
- [ ] **OTP-07**: Messaggi di errore OTP non rivelano se l'email è in whitelist o no (no enumeration)
Tre livelli venduti, che sono **configurazioni di un unico documento**, non tre documenti:
### Invio Link Preventivo via Email (PUB-03)
| Livello | Blocchi inclusi |
|---|---|
| **Radiografia** | 1, 2, 2b, 3, 4, 5, 8, 9 |
| **Prima/Dopo** | + 6 (il redesign), 6b (cosa il redesign non risolve) |
| **Rotta** | + 7 (le ottimizzazioni, con priorità e impegno in giornate) |
Admin invia link deck pubblico al lead direttamente dall'admin UI. Usa stessa infrastruttura Resend del OTP gate.
I blocchi non pertinenti **non esistono nel DOM**, non sono nascosti via CSS.
- [ ] **SEND-01**: Admin può inviare link `/preventivo/[slug]` via email al lead con un'azione dall'admin UI
- [ ] **SEND-02**: Email inviata via Resend include link deck + nome cliente, template minimale in italiano
## Requisiti
## v2.4+ Backlog
### Motore (Phase 27)
### Conversione Commerciale
- [x] **AUD-01**: Schema additivo per audit, finding, ottimizzazioni, rubrica, esiti, run e visite — *migration `0017_audits.sql`, in prod 2026-08-18*
- [x] **AUD-02**: La rubrica del motore (264 voci falsificabili) vive in `checklist_items`, non nel documento — *in prod 2026-08-18*
- [x] **AUD-03**: Le fonti raccolgono **rilevazioni, non stime**: PageSpeed (153 audit sul DOM renderizzato), CrUX (utenti reali), Wayback, RDAP, robots/sitemap/JSON-LD, header — *`src/lib/audit/sources/`, provato sul campo 2026-08-18, non ancora pushato*
- [x] **AUD-04**: Ogni fonte fallisce in modo **non fatale** e dice *perché*: "non ha risposto" e "ha risposto che non ci sono dati" sono informazioni diverse
- [x] **AUD-05**: Quando CrUX non ha dati di campo il documento lo **dice** ("i visitatori non sono abbastanza numerosi perché Google raccolga dati"), non lascia un buco — *`nota` in `crux.ts`; il caso "zero dati" resta da vedere su un sito vero*
- [ ] **AUD-06**: Ogni output di modello è validato con Zod, `safeParse`, fallimento duro — nessun loop di riparazione (precedente: `src/lib/proposal/schema.ts`)
- [ ] **AUD-07**: Quattro sub-agent in parallelo (checklist, visivo, storico, tecnico) più un sintetizzatore che **incrocia** le loro osservazioni in un solo finding con più evidenze indipendenti
- [ ] **AUD-08**: Massimo **10 finding**, ordinati per impatto su tre soli valori (`alto|medio|basso`); la sfumatura sta nell'ordine dentro il gruppo
- [ ] **AUD-09**: Disciplina sui numeri imposta nel prompt di sistema — un numero entra nel documento solo se misurato, e ogni numero consegnato è rintracciabile in `audit_runs.raw`
- [ ] **AUD-10**: Fan-out con tetto di concorrenza e retry con backoff sulle 429/529 di Anthropic
- [ ] **AUD-11**: Heartbeat a ogni passo su `audit_runs`; una run senza battito va in `error` e "Rilancia" riparte dall'ultimo passo completato *(un redeploy Coolify uccide un job in corso)*
- **PROP-03**: Stripe Payment Link su deck pubblico `/preventivo/[slug]`
- **PROP-04**: Auto-provisioning cliente/progetto/fasi al "Vinto" nel CRM
### Storage immagini (Phase 28)
### Post-Vendita
- [ ] **AUD-12**: Volume persistente Coolify su `/app/uploads`, lettura da `/api/uploads/[...path]` con guardia sul path traversal, whitelist MIME e limite di dimensione
- [ ] **AUD-13**: Due immagini caricate a mano per audit (hero **prima** e **dopo** del redesign, JPG ≤ 512 KB); due scritte dalla pipeline (screenshot mobile e desktop da PageSpeed)
- **Phase 13**: Gestione servizi attivi/ricorrenti post-vendita nel portale cliente (congelata da v2.1)
### Editor admin (Phase 29)
## Out of Scope
- [ ] **AUD-14**: Creazione **manuale** di un audit (livello, profilo, URL, cliente/lead). L'ingresso Whop è predisposto nello schema (`origin`, `external_ref`) ma **non costruito**
- [ ] **AUD-15**: Editor a payload intero (modello: `admin/offers/actions.ts`) che **salva sempre, anche a metà** — tutti i campi di contenuto sono nullable, la validazione di completezza scatta solo alla consegna
- [ ] **AUD-16**: Riordino di finding e ottimizzazioni con `@dnd-kit/sortable`, con re-sync degli id dei figli
- [ ] **AUD-17**: Il **blocco 8 (La direzione) resta manuale, foglio bianco** — è il blocco che giustifica il prezzo; se diventa formula il cliente lo sente
- [ ] **AUD-18**: Il registro delle visite è visibile nell'editor, in ordine cronologico
| Feature | Reason |
|---------|--------|
| Self-registration cliente | Solo whitelist admin-gestita — nessun accesso senza approvazione esplicita |
| Magic link senza OTP | OTP è più sicuro e già deciso come design; magic link = scope creep |
| Email marketing / newsletter | Non pertinente al portale |
| Multi-admin | Ancora single admin per ora |
### Documento pubblico (Phase 30)
## Traceability
- [ ] **AUD-19**: `/audit/[slug]` — pagina privata, `X-Robots-Tag: noindex, nofollow`, rate limit sul matcher di `proxy.ts`
- [ ] **AUD-20**: Il template è **congelato alla creazione** (`template_version`): migliorare il documento tocca gli audit successivi, mai quelli già consegnati
- [ ] **AUD-21**: PDF via **print CSS**, non libreria: interruzioni di pagina corrette, slider impilato in due immagini, **nessun marcatore di lavorazione sopravvissuto**
- [ ] **AUD-22**: In **scala di grigi** impatti e metriche restano distinguibili — il colore non può essere l'unico portatore di informazione
- [ ] **AUD-23**: Il documento usa **il design system dell'area admin** ("Quiet Luxury", `design-reference/DESIGN-SYSTEM.md`): token semantici, Plus Jakarta Sans per il testo, **Geist Mono per metriche, punteggi e date**, e i primitivi già esistenti (`StatusBadge` per gli impatti). *Decisione del 2026-08-18, sostituisce la deroga tipografica prevista dal piano.* Due conseguenze: i font sono già self-hostati da `next/font/google`, quindi la CSP `font-src 'self'` è soddisfatta senza lavoro; e il documento **non aggiunge debito a DEBT-01** perché nasce già a token.
- [ ] **AUD-24**: Tracciamento delle aperture (`view`) e delle stampe (`print`) via isola client + Server Action; **un admin loggato non viene contato** (altrimenti i numeri li inquiniamo noi rileggendo le bozze)
- [ ] **AUD-25**: L'IP non si salva in chiaro — SHA-256 di `ip + NEXTAUTH_SECRET`, come il digest del gate admin
| Requirement | Phase | Status |
|-------------|-------|--------|
| OTP-01 | Phase 24 | Pending |
| OTP-02 | Phase 25 | Pending |
| OTP-03 | Phase 25 | Pending |
| OTP-04 | Phase 25 | Pending |
| OTP-05 | Phase 25 | Pending |
| OTP-06 | Phase 25 | Pending |
| OTP-07 | Phase 25 | Pending |
| SEND-01 | Phase 23 | Pending |
| SEND-02 | Phase 23 | Pending |
## Vincoli che questa milestone tocca
**Coverage:**
- v2.3 requirements: 9 total
- Mapped to phases: 9
- Unmapped: 0 ✓
- **LOCKED #5 (no file hosting)** — emendato limitatamente agli asset di audit, deroga già annotata in `CLAUDE.md`. Non estendere ad altre entità.
- **Nessun renderer headless, da nessuna parte.** Il VPS non regge Chromium (RAM), e non serve: gli audit Lighthouse arrivano già fatti sul DOM renderizzato.
---
*Requirements defined: 2026-06-21*
*Last updated: 2026-06-21 — traceability filled after roadmap creation*
## Modifiche hub (richieste 2026-08-18, in corso)
Fuori dalla milestone v2.5, che è in pausa. Piano in
`~/.claude/plans/sei-arrivato-qua-search-recursive-kettle.md`.
- [x] **HUB-01**: Via il tab Commenti dal progetto — `/admin/conversazioni` li aggrega già tutti con l'etichetta dell'entità. *Perde solo la risposta sulla singola entità, che era già confluita sul thread generale.*
- [x] **HUB-02**: Via il timer dalla lista progetti — si avvia dove c'è il contesto
- [x] **HUB-03**: Riepilogo soldi + avanzamento in testa al progetto, senza query nuove
- [x] **HUB-04**: Timer per fase e task — migration `0018`, `ON DELETE SET NULL` perché le ore sopravvivono al task
- [x] **HUB-05**: Inbox in cima alla dashboard, con da-quanto-aspetta e contesto del messaggio
- [x] **HUB-06**: Analytics per linea di prodotto (Entry/Signature/Retainer) dalla tassonomia, con l'incassato non attribuibile mostrato a parte
- [x] **HUB-07**: Timeline delle consegne con semaforo ritardo/anticipo; scadenza dedotta da offerta + durata, `projects.due_date` come override
- [x] **HUB-08**: `POST /api/webhooks/lead` — un endpoint per form del sito e bridge, con dedup sull'email
- [ ] **HUB-09**: Confermare la forma del payload Elementor con un invio **vero** — oggi è gestita in modo difensivo
- [ ] **HUB-10**: `LEAD_WEBHOOK_SECRET` su Coolify — finché manca, la route risponde 403 a tutti
- [ ] **HUB-11**: TidyCal. **[BLOCCANTE]** Niente webhook (loro FAQ): serve polling della REST API. Path e filtri stanno dietro il login → servono token o documentazione dall'utente
- [ ] **HUB-12**: Alleggerire l'hub. Senza perimetro: si definisce guardando cosa è poco usato
- [ ] **HUB-13**: Whop → progetto + audit automatico. Dipende dal motore v2.5 (AUD-06→11)
- [x] **HUB-14**: Rifiniture dall'uso reale del pannello — rinomina di un valore di tassonomia con propagazione alle fasi dei progetti, stato task "In revisione", tab pagamenti con ordine stabile e importi a mano, riordino task per trascinamento. *In prod 2026-08-20, migration `0019`.*
- [x] **HUB-15**: Portale cliente — barra di avanzamento compatta e a tutta larghezza, card offerta senza accordion, "Valore dell'offerta" con override admin (`project_offers.offer_value_override`). *In prod 2026-08-21, migration `0020`. Serviva perché la somma dei prezzi di catalogo mostrava €20.250 su offerte vendute a 7.000 e 5.500.*
- [ ] **HUB-16**: Guardare a mano HUB-14 e HUB-15 in produzione. `updateOfferValueOverride` **è provata** (Caruso Speaker ha un override a 7.500 messo dal pannello); restano da cliccare `reorderTasks`, `updatePaymentField`, `clearPaymentOverride` e il drag, e dal 2026-08-21 la verifica locale contro i dati veri non è più possibile
## Backlog (ereditato, nessuno in corso)
- [ ] **SEND-01 / SEND-02** — Invio del link `/preventivo/[slug]` via email. Il mailer è già in produzione dalla v2.3: manca il pulsante e l'action. *Rinviati il 2026-07-28.*
- [ ] **PROP-03** — Stripe Payment Link sul deck pubblico del preventivo.
- [ ] **PROP-04** — Auto-provisioning cliente / progetto / fasi al passaggio del lead a "Vinto".
- [ ] **RET-06** — Canoni mensili tracciabili. **Serve una tabella nuova**: `payments` è protetta dai vincoli di Data Safety.
- [ ] **OFFER-14** — Sezioni analitiche stile Notion sull'offerta.
- [ ] **ARCH-01** — Split del modulo "compartimento stagno" in un deploy separato. *Solo se cresce.*
- [ ] **DEBT-01** — Debito design: ~40 file, ~450 occorrenze di palette raw/hex al posto dei token. Cluster in `/admin/projects/[id]` (~182), `/admin/offers/[id]/edit` (~79), `/admin/clients/[id]` (~59), `/quote/[token]` (~48, ed è rivolto al cliente), `ChatPanel` (37), `ui/dialog.tsx`. *Misurato il 2026-08-08.*
- [ ] **DEBT-02** — Tabelle legacy `service_catalog` / `offer_services` / `offer_micro_services`; `createService` / `serviceSchema` dead code.
## Rinviati esplicitamente da v2.5
- **Allegato tecnico** — seconda vista sugli stessi finding con registro da sviluppatore (selettori, file, stime). Renderebbe vera la promessa del blocco 9 *"il documento resta tuo e puoi darlo a chiunque lavorerà sul sito"*. Da valutare **dopo il primo audit consegnato**.
- **Affettare lo screenshot a pagina intera** — richiede `sharp`, da verificare su `node:20-alpine`. Non serve in fase 1: `final-screenshot` (250×498) è leggibile.
- **Ingresso via webhook Whop** — schema predisposto, costruzione in fase 2.
## Aperto, non un requisito
**`.env.local` da riallineare a Coolify.** `ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la
password del DB** sono stale, e l'host che il file dichiara (`178.104.27.55:5432`) è chiuso
dal firewall — il DB vero è su `127.0.0.1:54321` dietro tunnel. Finché resta così **una
modifica al portale si verifica solo in produzione**: build, migration e query di controllo,
poi occhio umano sul sito. Recuperare la password viva dal container è bloccato dal
classifier dei permessi e non va aggirato: la sblocca l'utente copiando le variabili da
Coolify. Le migration non ne soffrono (`docker exec` non usa quelle credenziali).
**Whitelist del portale vuota per 3 clienti su 4.** La migration 0015 ha seedato solo
`mario@test.it`. Protocollo Estetico, Caruso Speaker e Teckell hanno whitelist vuota e
finché lo è **il loro portale non è accessibile**. Si popola da `/admin/clients/<id>`
"Accessi al portale", poi va reinviato il link.
## Fuori scope
- Tabella utenti / multi-admin: l'auth resta una singola credenziale da env.
- File hosting per i documenti del portale cliente: restano URL esterni (LOCKED #5, non emendato per quelli).
+64 -92
View File
@@ -4,9 +4,11 @@
-**v1.0 Client Portal & Offer System** — Phases 16 (shipped 2026-06-10) — [archive](milestones/v1.0-ROADMAP.md)
-**v2.0 Business Operations Suite** — Phases 710 (shipped 2026-06-13) — [archive](milestones/v2.0-ROADMAP.md)
-**v2.1 Offer Studio + CRM** — Phases 1114 parziale (chiuso 2026-06-19, reset → v2.2)
-**v2.1 Offer Studio + CRM** — Phases 11, 12, 14 (chiusa per reset 2026-06-19) — [archive](milestones/v2.1-ROADMAP.md)
-**v2.2 Sales Loop** — Phases 1822 (shipped 2026-06-20) — [archive](milestones/v2.2-ROADMAP.md)
- 🔨 **v2.3 Email & Accesso** — Phases 2325 (in corso)
- **v2.3 Email & Accesso** — Phases 2325 (shipped 2026-07-29) — [archive](milestones/v2.3-ROADMAP.md)
-**v2.4 Post-vendita** — Phases 13 + 26 (entrambe in produzione, 2026-08-01 / 2026-08-08)
- 🔨 **v2.5 Audit** — Phases 2730 — documento di restituzione del servizio di analisi sito
## Phases
@@ -14,10 +16,10 @@
<summary>✅ v1.0 + v2.0 + v2.1 (Phases 117) — SHIPPED / CHIUSE</summary>
Vedi archivi:
- `milestones/v1.0-ROADMAP.md` — Phases 16
- `milestones/v2.0-ROADMAP.md` — Phases 710
- Phases 11, 12, 14 — Offer Studio + CRM Attio (shipped in prod)
- Phases 13, 15, 16, 17 — congelate/abbandonate/ri-scopate in v2.2
- `milestones/v2.1-ROADMAP.md` — Phases 11, 12, 14 shipped; 15/16/17 abbandonate o ri-scopate; **Phase 13 ripresa in v2.4**
</details>
@@ -34,113 +36,83 @@ Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md)
</details>
### 🔨 v2.3 — Email & Accesso (Phases 2325)
<details>
<summary>✅ v2.3 Email & Accesso (Phases 2325) — SHIPPED 2026-07-29</summary>
- [ ] **Phase 23: Resend Setup + Invio Preventivo** — Integrazione Resend e invio link deck dall'admin
- [ ] **Phase 24: Schema + Whitelist Admin** — Tabelle `client_emails` e `otp_codes`, admin UI gestione whitelist
- [ ] **Phase 25: OTP Gate + Sessione**Gate OTP completo, sessione 30gg, rate limiting, no enumeration
- [x] Phase 23: Resend Setup — SDK + `src/lib/mailer.ts` + template OTP *(SEND-01/02 rinviati al backlog il 2026-07-28)*
- [x] Phase 24: Schema + Whitelist Admin `client_emails`, `otp_codes`, UI whitelist + revoca sessioni (migr. 0015)
- [x] Phase 25: OTP Gate + Sessione — gate completo, sessione **90gg**, rate limiting, no enumeration
## Phase Details
Shipped col commit `27da969`, verificata end-to-end su `hub.iamcavalli.net`.
Archivio completo: [milestones/v2.3-ROADMAP.md](milestones/v2.3-ROADMAP.md)
### Phase 23: Resend Setup + Invio Preventivo
**Goal**: Admin può inviare il link `/preventivo/[slug]` via email con un click dall'admin UI
**Depends on**: Nothing — primo uso di Resend, nessuna dipendenza DB
**Requirements**: SEND-01, SEND-02
**Success Criteria** (what must be TRUE):
1. Admin fa click su "Invia preventivo" nel dettaglio lead/preventivo e l'email parte senza uscire dall'app
2. Il destinatario riceve un'email in italiano con nome cliente e link cliccabile al deck pubblico
3. L'invio usa Resend con le variabili d'ambiente configurate su Coolify (`RESEND_API_KEY`, `RESEND_FROM`)
4. In caso di errore Resend, l'admin vede un messaggio di errore chiaro nell'UI (non un crash silenzioso)
**Plans**: TBD
**UI hint**: yes
</details>
### Phase 24: Schema + Whitelist Admin
**Goal**: Admin può gestire la whitelist email di ogni cliente, con le tabelle DB pronte per l'OTP gate
**Depends on**: Phase 23 (Resend SDK già installato e variabili d'ambiente configurate)
**Requirements**: OTP-01
**Success Criteria** (what must be TRUE):
1. Admin può aggiungere una o più email alla whitelist di un cliente dalla pagina dettaglio cliente
2. Admin può rimuovere un'email dalla whitelist di un cliente
3. Le tabelle `client_emails` e `otp_codes` esistono in produzione (migration additive applicata via SSH prima del codice)
4. La migration non tocca nessuna delle tabelle protette (`clients`, `projects`, `payments`, `phases`)
**Plans**: TBD
**UI hint**: yes
### ✅ v2.4 — Post-vendita (Phases 13 + 26)
### Phase 25: OTP Gate + Sessione
**Goal**: Il portale `/client/[token]/*` richiede verifica OTP email prima di mostrare la dashboard
**Depends on**: Phase 24 (tabelle `client_emails` e `otp_codes` in prod)
**Requirements**: OTP-02, OTP-03, OTP-04, OTP-05, OTP-06, OTP-07
**Success Criteria** (what must be TRUE):
1. Cliente senza sessione OTP valida vede una schermata "inserisci la tua email" al posto della dashboard
2. Inserita un'email in whitelist, il cliente riceve il codice OTP via Resend; inserendo il codice corretto ottiene accesso con cookie valido 30 giorni
3. Un'email non in whitelist non riceve OTP — il messaggio d'errore mostrato è identico a quello per email valide (no enumeration)
4. Un codice OTP non utilizzato entro 15 minuti viene rifiutato; il cliente deve richiederne uno nuovo
5. Tentativi ripetuti sugli endpoint OTP vengono bloccati dal rate limiter (no brute force)
**Plans**: TBD
- [x] **Phase 13: Ciclo di vita dei servizi ricorrenti**`project_offers.status` + `end_date` (migr. 0016), forecast che si ferma davvero, comandi Sospendi/Riattiva/Cessa nella tab Offerte, stato dell'abbonamento visibile al cliente. ✅ **prod 2026-08-01** (`5177a37`) — [13-SUMMARY.md](phases/13-ciclo-vita-servizi-ricorrenti/13-SUMMARY.md)
- [x] **Phase 26: Anteprima admin del portale + toggle password**`?preview=1` con sessione Auth.js valida apre il portale di un cliente in sola lettura, senza gate OTP. ✅ **prod 2026-08-08** (`09a5b1f`, `187550f`) — [26-SUMMARY.md](phases/26-anteprima-admin-e-login/26-SUMMARY.md)
### 🔨 v2.5 — Audit (Phases 2730) · *in corso*
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. Piano approvato il 2026-08-16, motore ripianificato il
2026-08-18.
- [ ] **Phase 27: Motore di analisi***in corso, ~50%*
- [x] Migration `0017_audits.sql` (7 tabelle additive) — **applicata in prod 2026-08-18**
- [x] `checklist_items` seminata, 264 voci — **in prod**
- [x] `src/lib/audit/sources/` — 5 moduli, **provati sul campo su giojello.com** (73 s, tutte le fonti hanno risposto). *Scritti, non pushati.*
- [ ] `src/lib/audit/schema.ts` + `agents/` (checklist, visual, history, technical, synthesis) con validazione Zod dura
- [ ] `src/lib/audit/pipeline.ts` con heartbeat su `audit_runs`
- [ ] **Phase 28: Storage immagini** — volume persistente Coolify, `ImageUploadField`, `/api/uploads/[...path]` con guardia sul path traversal. ⚠️ **Checkpoint bloccante: il volume va creato in Coolify PRIMA del deploy**, altrimenti gli upload si perdono a ogni redeploy.
- [ ] **Phase 29: Editor admin** — lista audit, editor a payload intero (modello: `admin/offers/actions.ts`), riordino finding e ottimizzazioni con `@dnd-kit/sortable`, registro visite
- [ ] **Phase 30: Pagina pubblica + PDF + tracciamento**`/audit/[slug]`, blocchi condizionali per livello, print CSS per il PDF, `<AuditVisitTracker>` che non conta le aperture da admin loggato
> I nomi di 2830 sono **derivati dalle sezioni §7/§8/§9 del piano approvato**, non ancora
> passati da `/gsd-plan-phase`. La numerazione riprende da 27 perché 1517 sono state
> abbandonate o ri-scopate.
**Ingresso Whop**: predisposto nello schema (`origin`, `external_ref`), **non costruito**
è fase 2, fuori da v2.5.
## Progress
Tutte le fasi del progetto, dalla 1 alla 30. La numerazione è **progressiva e mai
riusata**: i buchi (1517) sono fasi abbandonate o ri-scopate, non fasi mancanti.
| Phase | Milestone | Plans | Status | Completed |
|-------|-----------|-------|--------|-----------|
| 16. Foundation → UX Overhaul | v1.0 | 24/24 | ✅ Done | 2026-06-10 |
| 710. Unified Catalog → CRM Pipeline | v2.0 | 12/12 | ✅ Done | 2026-06-13 |
| 1. Foundation & Client Dashboard | v1.0 | | ✅ Done | 2026-06 |
| 2. Admin Area & Interactive Features | v1.0 | | ✅ Done | 2026-06 |
| 3. Service Catalog & Quote Builder | v1.0 | — | ✅ Done | 2026-06 |
| 4. Progetti — Multi-Project per Cliente | v1.0 | — | ✅ Done | 2026-06 |
| 5. Offer System | v1.0 | — | ✅ Done | 2026-06 |
| 6. UX Overhaul — Sidebar + Dashboard | v1.0 | 24/24 tot. | ✅ Done | 2026-06-10 |
| 7. Claude AI Onboarding (v2) | v2.0 | — | ✅ Done | 2026-06 |
| 810. Unified Catalog → CRM Pipeline | v2.0 | 12/12 tot. | ✅ Done | 2026-06-13 |
| 11. Catalog Database-View UX | v2.1 | 4/4 | ✅ Done | 2026-06-13 |
| 12. Offer Editor Tier A/B/C | v2.1 | 5/5 | ✅ Done | 2026-06-18 |
| 13. Workspace Servizi Attivi | v2.1 | — | ❌ Congelata | — |
| 14. CRM Attio-style & Fix | v2.1 | 3/3 | ✅ Done | 2026-06-14 |
| 15. Dashboard Revenue Stats | v2.1 | — | ❌ Abbandonata | — |
| 1617. Proposal AI originale | v2.1 | — | Ri-scopata in v2.2 | — |
| 1617. Proposal AI originale | v2.1 | — | ♻️ Ri-scopata in v2.2 | — |
| 18. Cleanup & Consolidamento | v2.2 | 3/3 | ✅ Done | 2026-06-19 |
| 19. Pipeline CRM Kanban | v2.2 | 1/1 | ✅ Done | 2026-06-19 |
| 20. Knowledge Base Cliente | v2.2 | 3/3 | ✅ Done | 2026-06-20 |
| 21. Agente AI Preventivo | v2.2 | 1/1 | ✅ Done | 2026-06-20 |
| 22. Pagina Pubblica + Deck | v2.2 | 1/1 | ✅ Done | 2026-06-20 |
| 23. Resend Setup + Invio Preventivo | v2.3 | 0/? | Not started | — |
| 24. Schema + Whitelist Admin | v2.3 | 0/? | Not started | — |
| 25. OTP Gate + Sessione | v2.3 | 0/? | Not started | — |
| 23. Resend Setup | v2.3 | 1/1 | ✅ Done | 2026-07-28 |
| 24. Schema + Whitelist Admin | v2.3 | 1/1 | ✅ Done | 2026-07-28 |
| 25. OTP Gate + Sessione | v2.3 | 1/1 | ✅ Done | 2026-07-29 |
| 13. Ciclo di vita servizi ricorrenti | v2.4 | 1/1 | ✅ Done | 2026-08-01 |
| 26. Anteprima admin + login | v2.4 | 1/1 | ✅ Done | 2026-08-08 |
| 27. Motore di analisi | v2.5 | 0/1 | 🔨 In corso (~50%) | — |
| 28. Storage immagini | v2.5 | 0/1 | ⏳ Da pianificare | — |
| 29. Editor admin | v2.5 | 0/1 | ⏳ Da pianificare | — |
| 30. Pagina pubblica + PDF | v2.5 | 0/1 | ⏳ Da pianificare | — |
---
*Roadmap aggiornata: 2026-08-18 — v2.4 chiusa, v2.5 "Audit" aperta (era assente: la roadmap
è rimasta ferma al 2026-08-08 mentre v2.5 partiva e Phase 27 arrivava a metà).*
## Requirement Coverage (v2.3)
| Requirement | Phase |
|-------------|-------|
| SEND-01 | Phase 23 |
| SEND-02 | Phase 23 |
| OTP-01 | Phase 24 |
| OTP-02 | Phase 25 |
| OTP-03 | Phase 25 |
| OTP-04 | Phase 25 |
| OTP-05 | Phase 25 |
| OTP-06 | Phase 25 |
| OTP-07 | Phase 25 |
**Mapped: 9/9. No orphans.**
---
## Dependency Chain (v2.3)
```
Phase 23 (Resend config + SDK)
└── Phase 24 (schema DB additive: client_emails + otp_codes)
└── Phase 25 (OTP gate + sessione cookie 30gg)
```
Phase 23 first: Resend SDK e variabili d'ambiente sono infrastruttura condivisa con Phase 25 (email OTP).
Phase 24 before Phase 25: le tabelle `client_emails` e `otp_codes` devono essere in prod (via SSH migration) prima del gate.
---
## Implementation Notes (v2.3)
**Migration constraint:** `client_emails` e `otp_codes` sono nuove tabelle — migration additiva pura. SQL a mano (drizzle-kit generate rotto da Phase 8). Applicare via SSH tunnel PRIMA di pushare il codice dipendente.
**OTP middleware layer:** Il token middleware esistente (proxy.ts → `/api/internal/validate-token`) rimane invariato. Il gate OTP è uno strato aggiuntivo dopo la validazione del token, non un suo rimpiazzo.
**Resend shared infra:** La stessa istanza Resend client e le stesse variabili d'ambiente (`RESEND_API_KEY`, `RESEND_FROM`) servono sia Phase 23 (email preventivo) sia Phase 25 (email OTP). Configurare una volta in Phase 23, riusare in Phase 25.
**Security invariants (Phase 25):** Rate limiting su entrambi gli endpoint OTP; risposta identica per email in whitelist e non; OTP 6 cifre, scade 15 minuti, monouso (`consumed_at` impostato al primo uso); cookie HttpOnly, SameSite=Lax, MaxAge 30 giorni.
---
*Roadmap created: 2026-06-21 — v2.3 Email & Accesso*
+69 -87
View File
@@ -1,121 +1,103 @@
---
gsd_state_version: 1.0
milestone: v2.3
milestone_name: Email & Accesso
status: planning
stopped_at: ""
last_updated: "2026-06-22T09:00:00.000Z"
last_activity: 2026-06-22 -- Lead→Cliente (A+B) in prod; cleanup tab progetto + offerta→fasi in corso
milestone: v2.5
milestone_name: Audit
status: executing
stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1, rifiniture, chat a canali (0021), modifica messaggi + firma (0022), stati task e date pagamenti (0023) in prod; C2 (TidyCal) bloccato sulle credenziali API. Conversazioni (scrittura per primo, menzioni, mail sul tag) pushata il 2026-09-01, nessuna migration, MAI provata a mano. Nulla del portale e' stato visto a schermo: .env.local non autentica piu', serve ?preview=1 in prod. Il riquadro 'Prossimo pagamento' non compare finche' nessuna rata ha una due_date."
last_updated: "2026-09-01T00:15:00.000Z"
last_activity: 2026-09-01 -- conversazioni: scrivere per primo, menzioni, mail sul tag. Pushata dopo aver recuperato l'accesso a gitea dalla CLI admin
progress:
total_phases: 3
total_phases: 4
completed_phases: 0
total_plans: 0
total_plans: 4
completed_plans: 0
percent: 0
percent: 25
---
# Project State
> **Digest breve, per orientarsi.** Narrativa e lezioni → **`STATUS.md`** (root);
> requisiti → **`REQUIREMENTS.md`**; tutte le fasi → **`ROADMAP.md`**.
> Questo file resta sotto le 100 righe: lo impone il template GSD.
## Project Reference
See: .planning/PROJECT.md (updated 2026-06-21)
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
**Current focus:** Milestone **v2.3 "Email & Accesso"** (started 2026-06-21). North-star: OTP gate per il portale cliente + invio link preventivo via email — un'unica integrazione Resend condivisa. Roadmap: `.planning/ROADMAP.md` (Phases 2325). Prossimo passo: `/gsd-plan-phase 23`.
See: .planning/PROJECT.md · **Core value:** il cliente apre il link e vede a che punto è
il suo progetto, senza scrivere email. · **Current focus:** modifiche hub (v2.5 in pausa).
## Current Position
Phase: Pre-23 fixes & flow wiring (fuori roadmap formale)
Plan: `.claude/plans/te-li-scrivo-tutti-lucky-hearth.md`
Status: In esecuzione — cleanup tab progetto + collegamento offerta→fasi/task
Last activity: 2026-06-22 — Lead→Cliente consegnato in prod
**v2.5 è in pausa per scelta** (2026-08-19): prima le modifiche all'hub, poi il motore.
Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
### Lavoro recente (pre-fase-23, in prod)
| Blocco (modifiche hub) | Stato |
|---|---|
| A — Progetti (via commenti/timer, riepilogo, timer per task) | ✅ in produzione 2026-08-19 |
| B — Dashboard (inbox, linee di prodotto, timeline consegne) | ✅ in produzione 2026-08-19 |
| C1 — `POST /api/webhooks/lead` | ✅ in produzione, provato contro il DB vero |
| C2 — TidyCal | ⛔ **[BLOCCANTE]** vedi sotto |
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
| Rifiniture — tassonomie, tab pagamenti, riordino task | ✅ in prod 2026-08-20 (`0019`) |
| Portale cliente — stepper compatto/full-width, card offerta | ✅ in prod 2026-08-21 (`0020`); override provato su Caruso Speaker |
| Chat — canali, modifica messaggi, firma admin | ✅ in prod 2026-08-21 (`0021`, `0022`); **mai provata a mano**; manca l'attribuzione |
| Conversazioni — scrivere per primo, menzioni `@Nome`, mail sul tag | ✅ pushata 2026-09-01 (`41530b5`), nessuna migration. Menu dei tag corretto lo stesso giorno: mostrava **una riga per alias** invece che per persona. Build + lint puliti, 23 test sul parser e sul menu; **nessuna mail di tag mai partita davvero** |
| Portale — stati task (forma, pill, legenda, «Cancellata») + date dei pagamenti | ✅ in prod 2026-08-22 (`2e9bd2a`, `8b54f48`, `fe76789`, migration `0023`); **mai visto a schermo**, nessuna `due_date` ancora inserita |
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
- **Tassonomie**: gestione centralizzata categorie/tag in Impostazioni (modello Notion, pool persistenti, `src/lib/taxonomy.ts`).
- **Lead → Cliente (A+B)**: campi `clients.email/phone` + `leads.archived` (migrazione 0011 applicata a prod); `convertLeadToClient` (riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won"); tasto Converti/Convertito; lead archiviati nascosti da lista/kanban.
- **In corso**: rimozione tab Preventivo dal progetto, riordino sidebar, tab Offerte rifatta, import offerta→fasi/task per `services.fase`.
Progress: [███░░░░░░░] 25% (v2.5)
### Fasi completate (v2.2, storico)
## Dove sta cosa
Phase 18 (cleanup), Phase 19 (Kanban CRM), Phase 20 (transcript KB), Phase 21 (AI agent), Phase 22 (deck pubblico) — consegnate e in prod 2026-06-20.
I piani della milestone sono **nel repo dal 2026-08-26**: `.claude/plans/v2.5-*.md`.
## Performance Metrics
| Cosa (audit) | Dove | Stato |
|---|---|---|
| Schema, 7 tabelle + rubrica 264 voci | `0017_audits.sql`, `checklist_items` | **in produzione** |
| Fonti del motore (5 moduli) | `src/lib/audit/sources/` | **in prod ma inerte**: nessuna route lo chiama |
| Agent, sintetizzatore, pipeline, editor, pagina | `src/lib/audit/`, `src/app/{admin/audit,audit}` | **da scrivere** |
| L'unico audit prodotto finora | `spike-audit-giojello.com.json` (gitignorato) | spike 2026-08-16, **zero rilevazioni** |
**Velocity:**
## Come funziona il motore
- Total plans completed: 7 (v2.1) + 9 (v2.2) = 16 totali
- Average duration: —
- Total execution time: —
**By Phase:**
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| Phase 11 P01 | 25min | 2 tasks | 6 files |
| 11 | 4 | - | - |
| 14 | 3 | - | - |
**Recent Trend:**
- Last 5 plans: —
- Trend: —
*Updated after each plan completion*
| Phase 11 P02 | 12min | 2 tasks | 2 files |
| Phase 11 P03 | 9min | 2 tasks | 2 files |
| Phase 11 P04 | 12min | 2 tasks | 4 files |
Raccolta in parallelo (nessun LLM, nessun browser headless) → quattro sub-agent →
sintetizzatore che **incrocia** le osservazioni in massimo 10 finding. Vincolo che
regge tutto: **un numero entra solo se misurato**, rintracciabile in `audit_runs.raw`.
Passo per passo in `STATUS.md` e in `.claude/plans/v2.5-audit-motore.md`.
## Accumulated Context
### Decisions
Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
- **[v2.3 2026-06-21] Resend come provider email unico** — PUB-03 (invio preventivo) e AUTH-OTP-01 (OTP gate) condividono la stessa integrazione Resend. Phase 23 configura SDK + env vars, Phase 25 li riusa.
- **[v2.3 2026-06-21] OTP gate è strato aggiuntivo, non rimpiazzo del token middleware** — proxy.ts e `/api/internal/validate-token` rimangono invariati. Il gate OTP interviene dopo la validazione del token, nel rendering della route `/client/[token]/*`.
- **[v2.3 2026-06-21] Migration Phase 24 è additiva pura** — `client_emails` e `otp_codes` sono nuove tabelle. Nessun drop/truncate. SQL a mano (drizzle-kit generate rotto). Applicare via SSH prima del codice dipendente.
- **[RESET 2026-06-19] Milestone v2.2 "Sales Loop"** sostituisce le fasi residue v2.1. Decisioni bloccate: (1) URL preventivo = `/preventivo/[slug]` pubblico; (2) tagliare Forecast + quote builder manuale + Phase 15, fondere `/admin/analytics` nella dashboard; (3) portale post-vendita resta core, non si tocca (Phase 13 congelata); (4) agente AI = "io scelgo l'offerta, l'AI personalizza" leggendo i transcript, provider Claude. Piano: `.claude/plans/glittery-sprouting-pudding.md`
- [SUPERSEDED dal reset] v2.1 roadmap: Offer Studio (Phases 11-15) sequenced before Proposal AI (Phases 16-17) — clean/fast data UX before the AI builder
- Phase 11 bundles catalog database-view UX (OFFER-07..10) with legacy consolidation (OFFER-13) since the new UX should be built on a single unified `services` table, not on top of legacy `service_catalog`/`offer_services`
- Phase 13 (Workspace — Servizi Attivi) is independent of Phases 11/12 — can execute in parallel order if useful, but numbered after for narrative flow
- Phase 15 (Dashboard Revenue Stats / DASH-11) is isolated and BLOCKED on user-provided mockup; no other phase depends on it — can be deferred/skipped without blocking Phase 16/17
- Phase 16/17 split: schema/automation (payment link field + auto-provisioning) first, then AI builder + public page redesign + email — keeps the AI-dependent work last
- [Phase 11]: Phase 11: hand-write Drizzle migration SQL (0006_add_tags_table.sql) following the project's established convention since drizzle-kit generate is non-functional (meta snapshots out of sync since migration 0001, pre-existing since Phase 8) — Avoids architectural snapshot-reconciliation work (Rule 4, out of scope) while matching exact precedent from migrations 0003-0005
- [Phase 11]: Phase 11 Plan 02: onConflictDoNothing() without explicit target compiles cleanly for tags table (single unique index tags_entity_name_unique) — used as written in plan, no fallback needed — Avoids unnecessary deviation; Drizzle's no-target ON CONFLICT DO NOTHING is correct given the single unique constraint from Plan 01
- [Phase 11]: Phase 11 Plan 03: removed the plan's prescribed value-sync useEffect (and a follow-up render-time ref-read attempt) from EditableCell — both violate this project's react-hooks lint rules (set-state-in-effect, refs-during-render / React Compiler). tempValue is now only (re)initialized in startEdit()/cancel(), and the toggle display branch reads `value` directly instead of `tempValue` — Rule 1 lint fix, no behavioral change to the 8 spec'd test behaviors
- [Phase 11]: Phase 11 Plan 04: left `createService`/`serviceSchema` in `src/app/admin/catalog/actions.ts` as unused dead code after deleting `ServiceForm.tsx` (its only consumer) — `actions.ts` was outside this plan's `files_modified` scope and `updateService` still depends on `serviceSchema`; logged to deferred-items.md for future cleanup
- [Phase 18-02]: fmtEur unified to number version (analytics/page.tsx variant); KPI card callers using DB string values wrapped with parseFloat() — cleaner than maintaining two named variants
- [Phase 18-02]: /admin/analytics route deleted; YearSelector now routes to /admin?year=Y — single admin entry point for statistics (CLEAN-03)
### Pending Todos
[From .planning/todos/pending/ — ideas captured during sessions]
None yet.
- **[2026-09-01] Una lista che serve a leggere non è una lista da offrire in scrittura** — il menu dei tag mostrava tre voci (nome, brand, nome di battesimo) per una persona sola, perché gli era stata passata la stessa lista che il parser usa per **riconoscere** un tag nel testo. Sono due domande diverse: *come lo si può scrivere* (tutti gli alias, invisibili) e *chi si può scegliere* (una riga per destinatario). Ora `MentionTarget` le tiene separate — `label` per il menu, `aliases` per la rilettura — e il filtro passa da `normalizeForSearch`, la stessa normalizzazione del parser: con un `toLowerCase()` a parte, digitare «nicolo» non troverebbe «Nicolò» nel menu mentre nel messaggio verrebbe riconosciuto. Il difetto scalava peggio del valore: con tre contatti sarebbero state nove righe.
- **[2026-09-01] L'accesso a Gitea si recupera dalla sua CLI, non dal web** — il push rispondeva 403 (la credenziale nel portachiavi leggeva ma non scriveva) e l'account web non era piu' accessibile, quindi la via del browser era chiusa in partenza. Si rientra da dentro il container: `docker exec -u git gitea-tgw04ws48sogkso84oogwckk gitea admin user change-password` per la password e `... generate-access-token --scopes write:repository --raw` per il token del push. Il token va messo nel portachiavi con `git credential approve`, **non** nell'URL del remote: li' finirebbe in chiaro dentro `.git/config`. Da ricordare perche' senza push non esiste deploy — Coolify parte da `main` e basta.
- **[2026-08-22] Un task cancellato esce dai denominatori, non dalla lista** — resta visibile barrato (il cliente ha letto quella voce e deve capire che fine ha fatto) ma non conta, via un solo `countsTowardProgress()` in `task-status.ts`: contarlo terrebbe la fase sotto il 100% per sempre, contarlo come fatto racconterebbe una consegna mai avvenuta. Se in una fase restano solo cancellati torna «Da iniziare»: degenere, ma «Completata» mentirebbe.
- **[2026-08-22] Le date dei pagamenti sì, gli importi per riga no** — LOCKED #2 parla di cifre, non di date. Salvate a **mezzogiorno UTC** (a mezzanotte il giorno civile a Roma è già quello dopo) e contate sui giorni civili a Roma in `src/lib/payment-dates.ts`, lo stesso modulo del futuro promemoria email: mail e portale non devono contraddirsi su quanti giorni mancano.
- **[2026-08-20] Gli importi scritti a mano non si ricalcolano** — `amount_locked` esclude la riga da `rescalePayments`, e lo scarto fra somma rate e totale si dichiara invece di aggiustarlo. Il backfill dell'ordine rate va per `percent DESC`, non per `ctid`: 3 progetti su 5 erano già scombinati e l'ordine fisico avrebbe fissato l'errore.
- **[2026-08-20] Rinominare una fase rinomina anche le fasi dei progetti** — non c'è FK fra tassonomia e `phases`: `importOfferIntoProject` riconosce una fase solo dal titolo (`offer_phase_id` non viene mai popolata). Senza propagazione, il re-import di un'offerta crea una fase duplicata accanto a quella vecchia. È l'unico rename che scrive fuori dal catalogo, quindi l'unico con conferma.
- **[2026-08-19] Prima l'hub, poi il motore** — le modifiche all'hub sono indipendenti e a basso rischio, il motore no. Il Whop → audit resta ultimo perché dipende dal motore.
- **[2026-08-19] L'incassato non attribuibile si mostra, non si spalma** — i pagamenti stanno sul progetto, non sull'offerta. Un progetto senza offerta finisce in una riga "Senza offerta" separata: spalmarlo darebbe un totale che quadra e righe che mentono.
- **[2026-08-19] Il tempo lavorato sopravvive alla cancellazione del task** — `ON DELETE SET NULL`, mai cascade: con cascade, ripulire una fase abbasserebbe in silenzio il fatturato tracciato.
- **[2026-08-18] Audit:** design system dell'area admin; nessun renderer headless (il VPS non regge Chromium); laboratorio ≠ campo, quindi nomi distinti per Lighthouse e CrUX; la checklist alimenta il **motore**, non il documento. Per esteso in `STATUS.md`.
### Blockers/Concerns
- **Migrations (sempre valido)**: ogni fase con schema (Phase 24: `client_emails` + `otp_codes`) DEVE avere la migration applicata a prod via tunnel SSH (`ssh -L 54321:localhost:54321 root@178.104.27.55`, `DATABASE_URL` riscritto a `127.0.0.1:54321`) PRIMA di pushare il codice dipendente. `drizzle-kit generate` rotto → SQL a mano.
- **Resend env vars**: `RESEND_API_KEY` e `RESEND_FROM` devono essere aggiunti a Coolify prima di testare Phase 23 in prod. Stessa procedura di `ANTHROPIC_API_KEY` (2026-06-20).
- **Debito tecnico (non bloccante)**: tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` restano come deadweight; `createService`/`serviceSchema` dead code in `catalog/actions.ts`.
- **[BLOCCANTE] TidyCal non ha webhook** (verificato 2026-08-19 sulla loro FAQ; la via suggerita è Zapier/Make). La REST API c'è, con Personal Access Token su tutti i piani, ma path, filtri e paginazione **stanno dietro il login**. Sblocca: l'utente apre `tidycal.com/integrations` → API Keys e passa token o documentazione. Non dedurre la forma dell'API dai docs.
- **[BLOCCANTE] `LEAD_WEBHOOK_SECRET` non è su Coolify**: finché manca, `/api/webhooks/lead` risponde 403 a tutti (fallimento chiuso voluto). Sblocca: l'utente la imposta.
- **Il 100% dell'incassato è "Senza offerta"** — Caruso Speaker e Protocollo Estetico: 5.300 € senza offerte assegnate. Si sistema assegnandole dai rispettivi progetti. Il payload Elementor, intanto, non è ancora verificato sul campo: gestito in modo difensivo, serve un invio vero.
- **Il copy del template v1 non ha fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi da recuperare prima di Phase 30.
- **Audit, da vedere sul campo:** il caso "zero dati CrUX" (test 5) e quanto del 52% non verificabile da HTML statico recuperi Lighthouse (test 3). **Whitelist portale vuota per 3 clienti su 4** — si popola da `/admin/clients/<id>`.
- **`.env.local` NON è allineato a Coolify**: `ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la password del DB** sono stale, e l'host che scrive (`178.104.27.55:5432`) è chiuso — il DB vero è su `127.0.0.1:54321` dietro tunnel SSH. Estrarre la password viva dal container è **bloccato dal classifier** e non va aggirato. Rendere in locale contro i dati veri **oggi non si può** (2026-08-21); sblocca: l'utente riallinea il file alle variabili di Coolify. Le migration non ne soffrono, e resta valido il resto della procedura: **ogni fase con schema applica la migration a prod prima del push del codice**.
- **Debito design (DEBT-01)** — ~40 file, ~450 occorrenze. Dettaglio in `STATUS.md`.
## Deferred Items
Items acknowledged and carried forward from previous milestone close:
| Category | Item | Status | Deferred At |
|----------|------|--------|-------------|
| v2.4 | PROP-03 — Stripe Payment Link su deck pubblico | Backlog | v2.3 kickoff |
| v2.4 | PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto" | Backlog | v2.3 kickoff |
| v2+ | Phase 13 — Servizi attivi/ricorrenti post-vendita | Congelata | v2.1 kickoff |
| v2 | OFFER-14 — Sezioni analitiche stile Notion | Backlog | v2.1 kickoff |
| v2 | ARCH-01 — Split modulo "compartimento stagno" in deploy separato | Backlog (only if module grows) | v2.1 kickoff |
## Deferred Items — vedi `REQUIREMENTS.md` § Backlog e § Rinviati da v2.5.
## Session Continuity
Last session: 2026-06-21T11:05:00.000Z
Stopped at: Roadmap v2.3 created — Phases 2325, 9/9 requirements mapped. Next: `/gsd-plan-phase 23`
Resume file: .planning/ROADMAP.md
Last session: 2026-08-26
Stopped at: **stato task «Cancellata» + date dei pagamenti nel portale** (`fe76789`, migration `0023` applicata a prod prima del push). Un task tolto dal lavoro ora ha dove stare: **X nel cerchio, titolo barrato**, pill «Cancellata» — l'unico stato chiuso con la pill, perché «Fatto» e «Cancellata» sono entrambi barrati e scambiarli significa credere consegnato ciò che non esiste. Esce da **tutti** i denominatori via `countsTowardProgress()`. Nel Kanban cliente la colonna compare solo se piena (mai nascosta se ha dentro qualcosa); nell'admin c'è sempre, ed è così che si cancella un task. Lato pagamenti: `payments.due_date` (nullable, più indice parziale per il futuro promemoria email), riquadro «Prossimo pagamento» con conto alla rovescia in parole e rosso se scaduto, «Scade il…» / «Pagato il…» su ogni riga, **zero importi**. Admin: campo Scadenza per rata, «Incassato nel mese» → «Incassato il» (giorno; le analytics raggruppano per mese e non se ne accorgono). Build, typecheck e lint verdi.
**2026-08-26 — architettura `.claude/`**: skill `/preventivo` e `/audit`, due hook di guardia, piani nel repo. Nessun tocco al prodotto. Le due cose trovate e non risolte sul preventivo → `STATUS.md`.
Next: (0) **[SICUREZZA] rigenerare il token Gitea creato il 2026-09-01**: e' stato incollato in chat, quindi va considerato esposto. Si revoca da Settings -> Applications e si rifa'; (1) verificare in prod con `?preview=1` **entrambe** le cose: stati task su **Caruso Speaker, fase «3 - Esecuzione»** (l'unica con «In corso» e «In revisione» insieme, 4 + 2 su 11) e box pagamenti — ma prima **inserire una scadenza** dal tab Pagamenti, altrimenti il riquadro non compare per definizione; (2) due `paid_at` storici valgono il primo del mese (2026-03-01, 2026-01-01, scritti dal vecchio selettore a mese) e il cliente ora li legge come «Pagato il 1 mar 2026»: correggerli se il giorno vero era un altro; (3) provare la chat in prod: modificare un messaggio admin e vederlo cambiare da solo entro ~20s senza duplicarsi; (4) sbloccare TidyCal con token o documentazione; (5) `LEAD_WEBHOOK_SECRET` su Coolify, senza cui `/api/webhooks/lead` risponde 403 a tutti; (6) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`.
Resume file: None
-68
View File
@@ -1,68 +0,0 @@
# UI Rules — ClientHub admin
Derived from the live codebase (`src/app/globals.css`, design system usage across admin pages).
These are the rules to follow for any new or modified admin page.
---
## Brand palette
All colours must be written as **hex literals** — no Tailwind semantic tokens (`text-foreground`, `bg-muted`, etc.) in admin UI. Semantic tokens are defined in `globals.css` but mixing hex and tokens creates inconsistency.
| Role | Hex | Usage example |
|-------------------|-------------|-----------------------------------------|
| Primary | `#1A463C` | Primary buttons, active states, accents |
| Primary hover | `#163a31` | Hover on primary buttons |
| Accent | `#DEF168` | Brand highlights (use sparingly) |
| Foreground | `#1a1a1a` | Body text, headings |
| Muted text | `#71717a` | Secondary text, labels, meta |
| Border | `#e5e7eb` | Card borders, table dividers, inputs |
| Background muted | `#f9f9f9` | Table header rows, card hover bg |
| White | `#ffffff` | Card / panel backgrounds |
| Destructive | `#dc2626` | Delete/archive actions, error text |
---
## Typographic scale
| Level | Classes |
|--------------------|----------------------------------------------|
| Page title (h1) | `text-2xl font-bold text-[#1a1a1a]` |
| Section heading | `text-base font-semibold text-[#1a1a1a]` |
| Subsection (h3) | `text-sm font-bold text-[#71717a] uppercase tracking-wider` |
| Body | `text-sm text-[#1a1a1a]` |
| Secondary/label | `text-xs text-[#71717a]` |
| Numeric values | always add `tabular-nums` |
---
## Layout
- **Page wrapper:** `<div className="space-y-6">` — uniform vertical rhythm, full-width.
- **Page header:** always use `<PageHeader>` from `src/components/admin/PageHeader.tsx` — never hand-roll the title + action row.
- **No width constraints on list pages:** do not add `max-w-*` or `mx-auto` to the page root. Width is controlled by the sidebar layout (`src/app/admin/layout.tsx`).
- **Detail/form pages** (e.g. OfferEditorClient) may keep their own `max-w-4xl mx-auto` — this rule applies to list/overview pages only.
- **Cards:** `rounded-lg border border-[#e5e7eb] bg-white p-4`; hover: `hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]`.
- **Tables:** `bg-white rounded-xl border border-[#e5e7eb] overflow-hidden`; thead `bg-[#f9f9f9] border-b border-[#e5e7eb]`; row divider `border-b border-[#e5e7eb]`.
---
## Interaction
- Clickable elements: `cursor-pointer`
- Colour transitions: `transition-colors duration-150`
- Focus ring: `focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1A463C]/30`
- Minimum touch target on buttons: 44 × 44 px (use `py-2 px-4` minimum or `h-10`)
- Primary CTA: `bg-[#1A463C] text-white hover:bg-[#163a31] transition-colors`
- Secondary/ghost CTA: `border border-[#e5e7eb] text-[#1a1a1a] hover:bg-[#f9f9f9] transition-colors`
- Destructive action: `text-[#dc2626] hover:bg-red-50 transition-colors`
---
## Anti-patterns (do not do these)
- **No emoji as UI icons** — use Lucide React icons instead.
- **No mixed semantic tokens and hex** — pick hex throughout any given page/component.
- **No per-page `max-w-*` on list pages** — layout width is the sidebar shell's responsibility.
- **No hand-rolled page header divs** — always use `<PageHeader>` to keep title size/weight/colour uniform.
- **No inline `style={{}}` for colours that have a Tailwind class** — reserve `style` for dynamic values only (e.g. progress bar width percentages).
@@ -185,21 +185,21 @@ When the Postgres database is reachable:
1. **Apply schema migration:**
```bash
DATABASE_URL="postgresql://clienthub:clienthub_secure_2026@178.104.27.55:5432/clienthub?sslmode=disable" \
DATABASE_URL="postgresql://clienthub:$DB_PASSWORD@178.104.27.55:5432/clienthub?sslmode=disable" \
npx tsx scripts/push-services-migration.ts
```
This creates the `services` table in production.
2. **Run backfill:**
```bash
DATABASE_URL="postgresql://clienthub:clienthub_secure_2026@178.104.27.55:5432/clienthub?sslmode=disable" \
DATABASE_URL="postgresql://clienthub:$DB_PASSWORD@178.104.27.55:5432/clienthub?sslmode=disable" \
npx tsx scripts/migrate-services.ts
```
Migrates 21 rows from service_catalog + 35 rows from offer_services.
3. **Validate migration:**
```bash
DATABASE_URL="postgresql://clienthub:clienthub_secure_2026@178.104.27.55:5432/clienthub?sslmode=disable" \
DATABASE_URL="postgresql://clienthub:$DB_PASSWORD@178.104.27.55:5432/clienthub?sslmode=disable" \
npx tsx scripts/validate-services-migration.ts
```
All checks must print PASS.
+64
View File
@@ -0,0 +1,64 @@
# Archivio milestone v2.1 — Offer Studio + CRM
**Fasi previste:** 1117 · **Consegnate:** 11, 12, 14 · **Aperta:** 2026-06-13 · **Chiusa per reset:** 2026-06-19
> **Ricostruito a posteriori il 2026-08-08.** v2.1 è l'unica milestone rimasta senza
> archivio: è stata interrotta da un reset di scope e nessuno l'ha chiusa
> formalmente, così le sue fasi sono rimaste in `.planning/phases/` per due mesi.
> Questo file è ricostruito da `MILESTONES.md`, dalla tabella Progress di
> `ROADMAP.md` e dalle cartelle di fase archiviate in [v2.1-phases/](v2.1-phases/).
> Non esiste un `v2.1-REQUIREMENTS.md`: i 23 requisiti originali sono stati
> sovrascritti quando `REQUIREMENTS.md` è stato riscritto per v2.3.
## Obiettivo originale
Offer Studio (fasi 1115) prima di Proposal AI (fasi 1617): prima una UX dati
pulita e veloce, poi il builder AI costruito sopra.
## Esito per fase
| Fase | Titolo | Plans | Esito |
|---|---|---|---|
| 11 | Catalog Database-View UX + consolidamento legacy | 4/4 | ✅ 2026-06-13 |
| 12 | Offer Editor Tier A/B/C | 5/5 | ✅ 2026-06-18 |
| 13 | Workspace — Servizi Attivi | — | ❄️ Congelata → ripresa in **v2.4** |
| 14 | CRM Attio-style & Fix | 3/3 | ✅ 2026-06-14 |
| 15 | Dashboard Revenue Stats | — | ❌ Abbandonata (bloccata su un mockup mai fornito) |
| 1617 | Proposal AI (impianto originale) | — | ♻️ Ri-scopate in v2.2 (fasi 2122) |
Documentazione di dettaglio (PLAN, SUMMARY, RESEARCH, VERIFICATION) in
[v2.1-phases/](v2.1-phases/).
## Cosa è stato consegnato
- **Phase 11 — Catalog Database-View UX** (OFFER-07..10, OFFER-13): il catalogo
`services` come tabella a edit inline, tag multi-select, quick-add, ricerca
istantanea; consolidamento delle tabelle legacy.
- **Phase 12 — Offer Editor Tier A/B/C** (OFFER-11, OFFER-15..18): editor offerte
con matrice checkbox servizi × tier, totale live, prezzo pubblico manuale, tag su
4 dimensioni, promessa di trasformazione. 55 servizi reali caricati.
- **Phase 14 — CRM Attio-style** (CRM-08..12): `/admin/leads` ridisegnata con edit
inline e tag multi-select; FollowUpWidget in italiano; LeadForm tipizzato;
SendQuoteModal ripulita dai rami irraggiungibili.
## Il reset del 2026-06-19
A metà milestone il piano è stato riscritto: la milestone **v2.2 "Sales Loop"**
sostituisce le fasi residue. Decisioni bloccate in quel momento:
1. L'URL del preventivo è `/preventivo/[slug]`, pubblico.
2. Si tagliano Forecast, quote builder manuale e Phase 15; `/admin/analytics` viene
fusa nella Dashboard.
3. Il portale post-vendita resta core e non si tocca (Phase 13 congelata).
4. L'agente AI è "io scelgo l'offerta, l'AI personalizza" leggendo i transcript;
provider Claude.
Phase 13 è poi tornata in vita come **milestone v2.4**, consegnata il 2026-08-01.
## Debito lasciato aperto
- Tabelle legacy `service_catalog` / `offer_services` / `offer_micro_services`
rimaste come deadweight.
- `createService` / `serviceSchema` dead code in `src/app/admin/catalog/actions.ts`
(Phase 11 Plan 04, annotato in `deferred-items.md`).
- `offer_micros` senza `created_at` — nessun "tier più vecchio" affidabile.
+69
View File
@@ -0,0 +1,69 @@
# Requirements: ClientHub v2.3 Email & Accesso
**Defined:** 2026-06-21
**Core Value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
## v2.3 Requirements
### Email OTP Gate (AUTH-OTP-01)
Portale cliente blindato da email OTP. Nuovo `client_emails` table (whitelist) + `otp_codes` table (codice, email, expires_at, consumed). Resend come provider email. Sessione **90 giorni** con cookie dopo verifica, revocabile dall'admin.
- [x] **OTP-01**: Admin può aggiungere e rimuovere email dalla whitelist di ogni cliente nell'admin UI
- [x] **OTP-02**: Cliente senza sessione OTP vede una schermata "inserisci email" invece della dashboard
- [x] **OTP-03**: Sistema invia OTP via Resend solo se l'email inserita è nella whitelist di quel cliente
- [x] **OTP-04**: Cliente inserisce il codice OTP ricevuto e ottiene sessione autenticata (cookie **90 giorni**)
- [x] **OTP-05**: Codici OTP scadono dopo 15 minuti dall'invio
- [x] **OTP-06**: Endpoint OTP è rate-limited per prevenire brute force
- [x] **OTP-07**: Messaggi di errore OTP non rivelano se l'email è in whitelist o no (no enumeration)
- [x] **OTP-08**: Admin può revocare in blocco tutte le sessioni attive di un cliente
> **[2026-07-28] Modifiche alla spec del 2026-06-21**, decise in sessione:
> - Sessione **90 giorni** invece di 30 (rientro più fluido), compensata da OTP-08.
> - **SEND-01/SEND-02 spostati al backlog v2.4**: il preventivo si invia a mano, l'automazione non serve ora. Phase 23 si è ridotta alla sola infrastruttura Resend, che l'OTP usa comunque.
> - **Il gate NON sta nel layout** ma in cima a ogni page sotto `/client/[token]/`. Nell'App Router il segmento `page` viene renderizzato in parallelo al layout: gattare nel layout nascondeva la dashboard a schermo ma lasciava fasi, task e pagamenti nel payload RSC dell'HTML (verificato: 46.907 byte con i dati → 17.594 dopo il fix). Helper: `src/lib/client-gate.ts`.
## v2.4+ Backlog
### Conversione Commerciale
- **PROP-03**: Stripe Payment Link su deck pubblico `/preventivo/[slug]`
- **PROP-04**: Auto-provisioning cliente/progetto/fasi al "Vinto" nel CRM
- **SEND-01/SEND-02**: invio del link `/preventivo/[slug]` via email dall'admin UI — *rinviato da v2.3 il 2026-07-28, l'invio si fa a mano. L'infrastruttura Resend (`src/lib/mailer.ts`) è già pronta, manca solo l'azione e il pulsante.*
### Post-Vendita
- **Phase 13**: Gestione servizi attivi/ricorrenti post-vendita nel portale cliente (congelata da v2.1)
## Out of Scope
| Feature | Reason |
|---------|--------|
| Self-registration cliente | Solo whitelist admin-gestita — nessun accesso senza approvazione esplicita |
| Magic link senza OTP | OTP è più sicuro e già deciso come design; magic link = scope creep |
| Email marketing / newsletter | Non pertinente al portale |
| Multi-admin | Ancora single admin per ora |
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| OTP-01 | Phase 24 | ✅ Done (2026-07-28) |
| OTP-02 | Phase 25 | ✅ Done (2026-07-28) |
| OTP-03 | Phase 25 | ✅ Done (2026-07-28) |
| OTP-04 | Phase 25 | ✅ Done (2026-07-28) |
| OTP-05 | Phase 25 | ✅ Done (2026-07-28) |
| OTP-06 | Phase 25 | ✅ Done (2026-07-28) |
| OTP-07 | Phase 25 | ✅ Done (2026-07-28) |
| OTP-08 | Phase 24 | ✅ Done (2026-07-28) |
| SEND-01 | — | ⏭️ Rinviato a v2.4 |
| SEND-02 | — | ⏭️ Rinviato a v2.4 |
**Coverage:**
- v2.3 requirements: 8 in scope (OTP-01…08) + 2 rinviati
- Implementati: 8/8 ✓ — verificati con 9 test E2E in locale contro il DB di produzione
- **Non ancora in produzione**: il codice è scritto e testato ma NON pushato. Vedi i blocchi in `STATE.md`.
---
*Requirements defined: 2026-06-21*
*Last updated: 2026-07-28 — sessione 90gg, OTP-08 aggiunto, SEND-01/02 rinviati, OTP-01…08 implementati*
+99
View File
@@ -0,0 +1,99 @@
# Archivio milestone v2.3 — Email & Accesso
**Fasi:** 2325 · **Aperta:** 2026-06-21 · **Shipped:** 2026-07-29 (commit `27da969`)
**Requisiti:** [v2.3-REQUIREMENTS.md](v2.3-REQUIREMENTS.md)
> **Nota di archivio.** v2.3 è stata eseguita **fuori dal ciclo GSD**: non sono mai
> esistite cartelle `phases/23`, `24`, `25` con PLAN/SUMMARY. Questo file *è*
> la documentazione della milestone — non cercare altrove.
## Obiettivo
Aggiungere uno strato email all'app: gate OTP per il portale cliente e invio del
link preventivo dall'admin, con un'unica integrazione Resend condivisa.
Il portale non doveva più essere apribile col solo link: chiunque avesse l'URL
vedeva il progetto del cliente.
## Fasi
### Phase 23 — Resend Setup ✅ 2026-07-28
**Goal:** infrastruttura email condivisa.
**Requisiti:** SEND-01, SEND-02 (poi ridotti — vedi sotto).
Consegnato: `resend@6.18.1`, `src/lib/mailer.ts` (Result tipizzato, mai un catch
silenzioso), template OTP in italiano. `RESEND_API_KEY` e `RESEND_FROM` configurate
su Coolify (production **e** preview).
**Riduzione di scope del 2026-07-28:** SEND-01/SEND-02 (invio del preventivo via
email dall'admin) spostati al backlog. Il preventivo si manda a mano; l'automazione
non serviva subito. Phase 23 si è ridotta alla sola infrastruttura Resend, che il
gate OTP usa comunque.
### Phase 24 — Schema + Whitelist Admin ✅ 2026-07-28
**Goal:** l'admin gestisce la whitelist email di ogni cliente; tabelle pronte per il gate.
**Requisiti:** OTP-01. **Dipende da:** Phase 23.
Migration `0015_otp_access.sql`, **additiva pura**, applicata a prod via SSH prima
del codice dipendente: `client_emails` (whitelist, unique case-insensitive),
`otp_codes` (hash del codice, mai il codice in chiaro), `clients.sessions_valid_from`
(revoca in blocco). Conteggi pre/post identici sulle tabelle protette —
clients 4 / projects 5 / payments 11 / phases 10.
UI: sezione "Accessi al portale" in `/admin/clients/[id]` — aggiungi/rimuovi email,
"Revoca sessioni attive". Server actions in `clients/[id]/actions.ts`.
### Phase 25 — OTP Gate + Sessione ✅ 2026-07-29
**Goal:** `/client/[token]/*` richiede verifica OTP prima di mostrare la dashboard.
**Requisiti:** OTP-02..OTP-07. **Dipende da:** Phase 24.
Consegnato: `src/lib/otp.ts` (codice 6 cifre CSPRNG, hash SHA-256 con
`NEXTAUTH_SECRET`+clientId, TTL 15 minuti, monouso, max 5 tentativi),
`src/lib/client-session.ts` (cookie HMAC per-cliente `ch_sess_<id>`, httpOnly +
secure + SameSite=lax, `path=/client`), `src/lib/client-gate.ts`, le route
`/api/client/otp/request|verify`, il componente `OtpGate`.
**Scostamento dalla spec del 21/06:** sessione **90 giorni** invece di 30 — rientro
più fluido, compensato da OTP-08 (revoca in blocco lato admin).
## Catena di dipendenze
```
Phase 23 (Resend SDK + env)
└── Phase 24 (schema additivo: client_emails + otp_codes)
└── Phase 25 (gate OTP + sessione cookie 90gg)
```
## Copertura requisiti
| Requisito | Fase | Esito |
|---|---|---|
| SEND-01, SEND-02 | 23 | ⏭ Rinviati al backlog il 2026-07-28 |
| OTP-01 | 24 | ✅ |
| OTP-02 … OTP-07 | 25 | ✅ |
| OTP-08 (revoca) | 24 | ✅ |
## Verifica in produzione (2026-07-29, `hub.iamcavalli.net`)
Gate mostrato senza cookie e **zero dati di progetto nell'HTML** (12.487 byte);
email fuori e dentro whitelist danno risposta identica e solo la seconda genera un
OTP; codice sbagliato rifiutato, corretto accettato; cookie `ch_sess_<id>` con
`Secure` + `HttpOnly` + `SameSite=lax` + `Max-Age=7776000`; rientro col cookie
mostra la dashboard; la sessione di un cliente sull'URL di un altro mostra il gate.
Nessun errore d'invio nei log del container. Dati di test rimossi, tabelle protette
invariate.
## Lezioni
Le due lezioni operative di questa milestone (il gate non va nel layout App Router;
ricreare il dominio su Resend rigenera la chiave DKIM) sono in `STATUS.md`,
sezione "Lezioni operative" — è lì che si vanno a cercare.
## Strascico alla chiusura
La whitelist è stata seedata solo con `mario@test.it` (cliente di test). Tre clienti
reali su quattro hanno whitelist vuota e finché lo è **il loro portale non è
accessibile**. Voce aperta in `STATUS.md`.
+47
View File
@@ -0,0 +1,47 @@
# Requirements: ClientHub v2.4 Post-vendita
**Definiti:** 2026-08-08 (ricostruiti a posteriori — v2.4 è partita senza requisiti scritti)
**Core Value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
Milestone precedente: [v2.3 Email & Accesso](milestones/v2.3-ROADMAP.md), shipped 2026-07-29.
## Consegnati
### Ciclo di vita dei servizi ricorrenti (Phase 13) — ✅ in produzione 2026-08-01
- [x] **RET-01**: Un'offerta ricorrente assegnata a un progetto ha uno stato (attivo / sospeso / cessato) e una data di fine opzionale
- [x] **RET-02**: L'admin può sospendere, riattivare e cessare un retainer dalla tab Offerte del progetto
- [x] **RET-03**: Il forecast a 12 mesi smette di sommare un retainer sospeso, cessato o oltre la sua `end_date`
- [x] **RET-04**: Lo storico del venduto (`getOffersSoldBreakdown`) **non** filtra per stato — escludere le cessate riscriverebbe il passato
- [x] **RET-05**: Il cliente vede stato, "attivo dal / fino al" e "canone mensile"; le offerte cessate non gli arrivano
### Anteprima admin e login (Phase 26) — ✅ in produzione 2026-08-08
- [x] **PREV-01**: L'admin può aprire il portale di un cliente in sola lettura senza passare dal gate OTP (`?preview=1` + sessione Auth.js valida)
- [x] **PREV-02**: In anteprima approvazione e composer messaggi sono disattivati a livello di UI
- [x] **AUTH-09**: Il campo password del login admin ha un toggle mostra/nascondi
## Backlog v2.4+ (non pianificati)
Ereditati dalle chiusure di milestone precedenti, nessuno in corso:
- [ ] **SEND-01 / SEND-02** — Invio del link `/preventivo/[slug]` via email dall'admin. Il mailer (`src/lib/mailer.ts`) è già pronto e in produzione dalla v2.3: manca solo il pulsante e l'action. *Rinviati il 2026-07-28.*
- [ ] **PROP-03** — Stripe Payment Link sul deck pubblico del preventivo. *Rinviato al kickoff v2.3.*
- [ ] **PROP-04** — Auto-provisioning di cliente / progetto / fasi al passaggio del lead a "Vinto". *Rinviato al kickoff v2.3.*
- [ ] **RET-06** — Canoni mensili tracciabili (agosto pagato / settembre no). **Serve una tabella nuova**: `payments` è protetta dai vincoli di Data Safety e la sua riscalatura è pensata per i piani una tantum. *Fuori scope di Phase 13.*
- [ ] **OFFER-14** — Sezioni analitiche stile Notion sull'offerta. *Rinviato al kickoff v2.1.*
- [ ] **ARCH-01** — Split del modulo "compartimento stagno" in un deploy separato. *Solo se il modulo cresce.*
- [ ] **DEBT-01** — Debito design: **~40 file, ~450 occorrenze** di palette Tailwind raw e hex literal al posto 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` del portale (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, niente CSS var), i colori di stato di `StatusBadge` (sanzionati dal design system, hanno già le varianti `dark:`). *Misurato il 2026-08-08 — la stima precedente di "11 pagine" era sottostimata.*
- [ ] **DEBT-02** — Tabelle legacy `service_catalog` / `offer_services` / `offer_micro_services` come deadweight; `createService` / `serviceSchema` dead code in `src/app/admin/catalog/actions.ts`.
## Aperto, non un requisito
**Whitelist del portale vuota per 3 clienti su 4.** La migration 0015 ha seedato solo
`mario@test.it` (cliente di test). Protocollo Estetico, Caruso Speaker e Teckell hanno
whitelist vuota e finché lo è **il loro portale non è accessibile**. Si popola da
`/admin/clients/<id>` → "Accessi al portale", poi va reinviato il link.
## Fuori scope
- File hosting (vincolo LOCKED #5: i documenti restano URL esterni).
- Tabella utenti / multi-admin: l'auth resta una singola credenziale da env.
@@ -1,273 +0,0 @@
---
phase: "01-foundation-client-dashboard"
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- package.json
- tsconfig.json
- next.config.ts
- src/app/layout.tsx
- src/app/page.tsx
- .env.local
autonomous: true
requirements:
- DASH-01
- DASH-02
must_haves:
truths:
- "Next.js 15 App Router is bootstrapped and compiles without errors"
- "DATABASE_URL env var is set and Drizzle can connect to Postgres"
- "A simple test route exists and responds with 200"
- "TypeScript strict mode is enabled"
artifacts:
- path: "package.json"
provides: "All dependencies for Next.js + Drizzle + auth + UI"
contains: "next@15"
- path: "src/app/layout.tsx"
provides: "Root layout with Tailwind setup"
min_lines: 15
- path: ".env.local"
provides: "DATABASE_URL pointing to Coolify Postgres"
contains: "DATABASE_URL"
key_links:
- from: ".env.local"
to: "Drizzle client initialization"
via: "process.env.DATABASE_URL"
pattern: "DATABASE_URL=postgres://"
- from: "src/db/index.ts"
to: "Postgres on Coolify"
via: "postgres-js driver"
pattern: "import.*postgres.*from.*postgres-js"
---
<objective>
**Walking Skeleton:** Bootstrap the Next.js project, install all Phase 1 dependencies, configure Tailwind, connect to the Postgres database on Coolify via Drizzle ORM, and verify the entire stack is operational with a simple test route.
Purpose: Establish the project foundation so subsequent plans can build on a known-good state. This plan proves Next.js 15 + Drizzle + postgres-js + Tailwind work together before writing any feature code.
Output: Runnable Next.js dev server (`npm run dev`) with DB connection confirmed, TypeScript types working, Tailwind CSS active, ready for schema creation.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md
@.planning/research/STACK.md
@.planning/research/ARCHITECTURE.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Bootstrap Next.js 15 with TypeScript, App Router, src/ directory, and Tailwind CSS v4</name>
<files>
package.json
tsconfig.json
next.config.ts
src/app/layout.tsx
src/app/page.tsx
tailwind.config.ts
postcss.config.mjs
.gitignore
</files>
<read_first>
None (greenfield project)
</read_first>
<action>
Execute: `npx create-next-app@latest . --typescript --tailwind --app --src-dir --eslint --import-alias '@/*'`
Verify created:
- `src/` directory with `app/` subdirectory
- `tsconfig.json` with `"strict": true`
- `tailwind.config.ts` (v4, CSS-first)
- `postcss.config.mjs`
- Next.js 15.x in package.json
After creation, modify `src/app/layout.tsx`:
- Import Tailwind globals: `import './globals.css'`
- Set viewport and basic meta tags
- Ensure `<html>` and `<body>` exist with proper className for Tailwind
Modify `src/app/page.tsx`:
- Replace default template with a simple div: `<div className="text-center py-20">Welcome to ClientHub</div>`
- Keep it minimal — this route will be replaced in Phase 2
</action>
<verify>
<automated>grep -q "\"next\": \"^15" package.json && echo "Next.js 15 installed"</automated>
<automated>grep -q "\"strict\": true" tsconfig.json && echo "TypeScript strict mode enabled"</automated>
<automated>test -f src/app/layout.tsx && grep -q "globals.css" src/app/layout.tsx && echo "Tailwind globals imported"</automated>
<automated>test -f next.config.ts && echo "next.config.ts exists"</automated>
</verify>
<acceptance_criteria>
- `npm install` succeeds without errors
- `npm run build` succeeds (no TypeScript errors, no Next.js errors)
- `npm run dev` starts server without crashing
- Visiting http://localhost:3000 returns 200 and displays the welcome message
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 2: Install Drizzle ORM, postgres-js, and supporting libraries; create .env.local with DATABASE_URL</name>
<files>
package.json
.env.local
.env.example
src/db/index.ts
</files>
<read_first>
None (greenfield)
</read_first>
<action>
Install packages:
```
npm install drizzle-orm postgres
npm install -D drizzle-kit
```
Note: The package is `postgres` (not `postgres-js` — that's the npm package name for postgres-js driver).
Create `src/db/index.ts`:
```typescript
import { Client } from 'postgres';
import * as schema from './schema';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL env var is required');
}
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(client, { schema });
```
Create `.env.local`:
```
DATABASE_URL=postgresql://[user]:[password]@[coolify-host]:5432/[database]
```
Use the actual Coolify credentials. If not yet available, use a placeholder and update before plan 02.
Create `.env.example`:
```
DATABASE_URL=postgresql://user:password@host:5432/database
```
Install additional dependencies:
```
npm install nanoid zod @hookform/resolvers react-hook-form
npm install -D @types/node
```
Auth.js will be installed in a later plan (Phase 2 only).
</action>
<verify>
<automated>grep -q "drizzle-orm" package.json && echo "Drizzle installed"</automated>
<automated>grep -q "postgres" package.json && echo "postgres-js installed"</automated>
<automated>grep -q "drizzle-kit" package.json && echo "drizzle-kit installed"</automated>
<automated>test -f .env.local && grep -q "DATABASE_URL" .env.local && echo ".env.local exists with DATABASE_URL"</automated>
<automated>test -f .env.example && echo ".env.example exists"</automated>
<automated>grep -q "postgres" src/db/index.ts && echo "postgres-js driver imported in db/index.ts"</automated>
</verify>
<acceptance_criteria>
- `npm install` succeeds
- `src/db/index.ts` exists and exports `db` object
- `.env.local` contains DATABASE_URL (value will be filled in by executor or user)
- `npm run build` succeeds with no import errors
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 3: Install shadcn/ui components and configure; add lucide-react icons</name>
<files>
package.json
components.json
src/components/ui/*.tsx (multiple)
</files>
<read_first>
tailwind.config.ts
</read_first>
<action>
Initialize shadcn/ui:
```
npx shadcn@latest init --yes
```
This creates `components.json` with the proper configuration.
Add essential components for Phase 1:
```
npx shadcn@latest add button card badge progress input label select separator table textarea
```
Install lucide-react:
```
npm install lucide-react
```
Verify `src/components/ui/` directory contains all component files.
</action>
<verify>
<automated>test -f components.json && echo "components.json created"</automated>
<automated>test -d src/components/ui && ls src/components/ui/ | wc -l | grep -qE "[0-9]+" && echo "UI components installed"</automated>
<automated>grep -q "lucide-react" package.json && echo "lucide-react installed"</automated>
</verify>
<acceptance_criteria>
- `components.json` exists with proper shadcn configuration
- At least 8 component files exist in `src/components/ui/`
- `npm run build` succeeds
</acceptance_criteria>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client (browser) → API | Clients access `/c/[token]/*` routes; middleware must validate token |
| Client (browser) → Database | Drizzle queries filtered by token; no client can see other clients' data |
| Admin → Vercel environment variables | DATABASE_URL, future ADMIN_PASSWORD must be secret |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-001 | Information Disclosure | DATABASE_URL in .env.local | mitigate | Never commit .env.local; .gitignore enforces this; use Vercel Secrets for production |
| T-01-002 | Tampering | Schema initialization | mitigate | Use Drizzle migrations + drizzle-kit push before any data is written; immutable migration history |
| T-01-003 | Denial of Service | Database connection pooling | accept | postgres-js handles connection lifecycle; Coolify Postgres has resource limits acceptable for Phase 1 scale |
</threat_model>
<verification>
After plan execution:
1. Run `npm run build` → no errors
2. Run `npm run dev` → server starts on http://localhost:3000
3. Visit http://localhost:3000 → page loads with welcome message
4. Check `src/db/index.ts` → imports postgres-js correctly
5. Check `.env.local` → DATABASE_URL is set (value may be placeholder)
6. Check `components.json` → exists with @/ alias
</verification>
<success_criteria>
- Next.js dev server starts and responds to requests
- TypeScript compiles without errors
- Tailwind CSS is active (can verify via DevTools)
- Database connection string is configured (even if not yet tested with actual DB)
- All Phase 1 dependencies are installed
- Ready to proceed to Task 02 (schema creation)
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation-client-dashboard/01-01-SUMMARY.md`
</output>
@@ -1,190 +0,0 @@
---
phase: 01-foundation-client-dashboard
plan: 01
subsystem: infra
tags: [nextjs, drizzle-orm, postgres, tailwind, shadcn, typescript]
# Dependency graph
requires: []
provides:
- Next.js 16 App Router project with TypeScript strict mode
- Tailwind CSS v4 + shadcn/ui components (button, card, badge, progress, input, label, select, separator, table, textarea)
- Drizzle ORM + postgres-js driver configured (db client in src/db/index.ts)
- drizzle.config.ts ready for migrations
- .env.local with DATABASE_URL placeholder
- lucide-react icons
- src/lib/utils.ts cn() helper
affects:
- 01-02-schema
- 01-03-client-route
- 01-04-dashboard-ui
- 01-05-seed-deploy
# Tech tracking
tech-stack:
added:
- next@16.2.6
- drizzle-orm@0.45.2
- drizzle-kit@0.31.10
- postgres@3.4.9
- tailwindcss@4.x
- shadcn/ui (Radix preset)
- lucide-react@1.14.0
- nanoid@5.1.11
- zod@4.4.3
- react-hook-form + @hookform/resolvers
- clsx + tailwind-merge + class-variance-authority
patterns:
- App Router with Server Components as default
- Drizzle ORM with postgres-js driver (not neon-http) for Coolify Postgres
- shadcn/ui components in src/components/ui/ (copied, not wrapped)
- cn() utility for conditional classnames
key-files:
created:
- src/app/layout.tsx (root layout, metadata, viewport, Tailwind globals)
- src/app/page.tsx (placeholder route)
- src/app/globals.css (Tailwind v4 CSS-first)
- src/db/index.ts (Drizzle client with postgres-js)
- src/lib/utils.ts (cn() helper)
- src/components/ui/*.tsx (10 shadcn components)
- drizzle.config.ts (migration config)
- components.json (shadcn config)
- .env.example (public template)
modified:
- package.json (all deps added)
- .gitignore (allow .env.example, block all other .env*)
key-decisions:
- "Usato Next.js 16.2.6 (latest stable) invece di 15.x — create-next-app@latest installa la versione corrente"
- "viewport spostato in export dedicato (Next.js 16 API) invece che in metadata"
- "src/db/index.ts usa drizzle-orm/postgres-js con import default di postgres (non Client class)"
- ".env.example aggiunto con eccezione in .gitignore (non .env.local che resta ignorato)"
patterns-established:
- "Database client: import postgres from 'postgres' + drizzle(client) in src/db/index.ts"
- "shadcn/ui: componenti copiati in src/components/ui/, usabili come primitivi"
- "cn() utility per merge classi Tailwind in src/lib/utils.ts"
requirements-completed:
- DASH-01
- DASH-02
# Metrics
duration: 15min
completed: 2026-05-13
---
# Phase 1 Plan 01: Walking Skeleton — Next.js 16 + Drizzle + shadcn/ui bootstrapped su Coolify Postgres
**Next.js 16.2.6 App Router con TypeScript strict, Tailwind v4, Drizzle ORM + postgres-js per Coolify Postgres, e 10 componenti shadcn/ui installati e pronti.**
## Performance
- **Duration:** ~15 min
- **Started:** 2026-05-13T13:26:00Z
- **Completed:** 2026-05-13T13:41:00Z
- **Tasks:** 3/3
- **Files modified:** 20+
## Accomplishments
- Next.js 16.2.6 con App Router, TypeScript strict mode, Tailwind CSS v4 — `npm run build` passa senza errori TypeScript
- Drizzle ORM + postgres-js configurati con client in `src/db/index.ts`, pronto per le migrazioni del Plan 02
- 10 componenti shadcn/ui installati + lucide-react: base UI completa per i plan successivi
## Task Commits
1. **Task 1: Bootstrap Next.js 16** - `9563b87` (chore)
2. **Task 2: Drizzle ORM + postgres-js + librerie** - `6b5609b` (feat)
3. **Task 3: shadcn/ui + lucide-react** - `f842007` (feat)
## Files Created/Modified
- `src/app/layout.tsx` - Root layout con metadata ClientHub, lang="it", viewport export corretto per Next.js 16
- `src/app/page.tsx` - Placeholder minimale (sarà sostituito in Phase 2)
- `src/app/globals.css` - Tailwind v4 CSS-first con variabili CSS
- `src/db/index.ts` - Client Drizzle con postgres-js driver, guard su DATABASE_URL
- `src/lib/utils.ts` - cn() helper con clsx + tailwind-merge
- `src/components/ui/*.tsx` - 10 componenti: button, card, badge, progress, input, label, select, separator, table, textarea
- `drizzle.config.ts` - Config drizzle-kit per migrazioni (dialect postgresql, schema src/db/schema.ts)
- `components.json` - Configurazione shadcn/ui (Radix preset, @/ aliases, CSS variables)
- `.env.example` - Template pubblico DATABASE_URL
- `.gitignore` - Aggiunta eccezione per .env.example, blocco tutti gli altri .env*
- `package.json` - Tutte le dipendenze Phase 1 installate
## Decisions Made
- Installato Next.js 16.2.6 (latest stable via `create-next-app@latest`) invece di 15.x — versione superiore, retrocompatibile
- `viewport` spostato in export dedicato (`export const viewport: Viewport`) come richiede Next.js 16 API — evita warning di build
- `src/db/index.ts` usa `import postgres from 'postgres'` (default export, non `Client` class) — API corretta del driver postgres-js
- `drizzle-orm/postgres-js` come adapter Drizzle invece di `drizzle-orm/neon-http` — allineato con decisione D-02 (Coolify Postgres, non Neon)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] create-next-app rifiuta cartella con lettere maiuscole**
- **Found during:** Task 1
- **Issue:** `create-next-app .` fallisce con "name can no longer contain capital letters" perché la cartella si chiama `IAMCAVALLI`
- **Fix:** Creato progetto in directory temporanea `/Users/simonecavalli/clienthub` poi spostati tutti i file nel repo principale
- **Files modified:** Nessun file extra — stesso risultato del comando diretto
- **Verification:** `npm run build` passa, tutti i file sono al posto corretto
- **Committed in:** 9563b87
**2. [Rule 1 - Bug] viewport in metadata genera warning Next.js 16**
- **Found during:** Task 1 (prima build)
- **Issue:** `metadata.viewport` è deprecato in Next.js 16; Next.js emette warning e richiede export `viewport` separato
- **Fix:** Aggiunto `export const viewport: Viewport = { ... }` e rimosso `viewport` da `metadata`
- **Files modified:** src/app/layout.tsx
- **Verification:** Build pulita senza warning viewport
- **Committed in:** 9563b87
**3. [Rule 3 - Blocking] API postgres driver non è Client class**
- **Found during:** Task 2
- **Issue:** Il PLAN suggeriva `import { Client } from 'postgres'` ma il driver `postgres` esporta una funzione default, non una classe `Client`
- **Fix:** Usato `import postgres from 'postgres'` con `drizzle-orm/postgres-js` adapter — API corretta
- **Files modified:** src/db/index.ts
- **Verification:** TypeScript compila senza errori
- **Committed in:** 6b5609b
**4. [Rule 3 - Blocking] shadcn init interattivo non risponde a --yes**
- **Found during:** Task 3
- **Issue:** `npx shadcn@latest init --yes` richiede selezione manuale (libreria e preset) — non si automatizza
- **Fix:** Creato manualmente `components.json` con config corretta (Radix, CSS variables, @/ aliases) poi usato direttamente `shadcn add` per i componenti
- **Files modified:** components.json (creato manualmente)
- **Verification:** `npx shadcn@latest add button card ...` funziona senza problemi
- **Committed in:** f842007
---
**Total deviations:** 4 auto-fixed (2 Rule 3 blocking, 1 Rule 1 bug, 1 Rule 3 blocking)
**Impact on plan:** Tutte le deviazioni necessarie per il corretto funzionamento. Nessuno scope creep.
## Issues Encountered
- `.env.example` era bloccato da `.env*` pattern nel `.gitignore` — aggiunta eccezione `!.env.example` (file pubblico senza segreti, corretto da tracciare in git)
## User Setup Required
Prima di eseguire il Plan 02 (schema + migrazioni), aggiornare `.env.local` con le credenziali reali del database Coolify:
```
DATABASE_URL=postgresql://[user]:[password]@[coolify-host]:5432/clienthub
```
Le credenziali si trovano nel pannello Coolify su Hetzner. Il file `.env.local` è escluso dal git (`.gitignore`).
## Threat Surface Scan
Nessuna nuova superficie di sicurezza non prevista dal piano. Il threat model T-01-001 (DATABASE_URL in .env.local) è mitigato correttamente: `.env*` esclusi dal `.gitignore`, `.env.example` non contiene credenziali reali.
## Next Phase Readiness
- Plan 02 (schema Drizzle) può partire immediatamente — `src/db/index.ts` e `drizzle.config.ts` sono pronti
- L'utente deve aggiornare `DATABASE_URL` in `.env.local` con le credenziali reali Coolify prima di eseguire `drizzle-kit push`
- Build stabile, TypeScript strict attivo, zero errori
---
*Phase: 01-foundation-client-dashboard*
*Completed: 2026-05-13*
@@ -1,369 +0,0 @@
---
phase: "01-foundation-client-dashboard"
plan: 02
type: execute
wave: 2
depends_on:
- "01-01"
files_modified:
- src/db/schema.ts
- drizzle.config.ts
- .env.local
autonomous: true
requirements:
- DASH-01
- DASH-02
- DASH-03
- DASH-04
must_haves:
truths:
- "Drizzle schema is complete and matches the data model from ARCHITECTURE.md"
- "All 11 tables are defined: clients, phases, tasks, deliverables, comments, payments, documents, notes, service_catalog, quote_items"
- "Token field on clients is a separate UUID, not the primary key"
- "approved_at on deliverables is TIMESTAMPTZ"
- "drizzle-kit push has been run and database schema is live"
- "TypeScript types are exported from schema.ts for use in API routes"
artifacts:
- path: "src/db/schema.ts"
provides: "Complete Drizzle ORM schema definition for all entities"
min_lines: 200
contains: "export const clients = pgTable"
- path: "drizzle.config.ts"
provides: "Drizzle Kit configuration pointing to src/db/schema.ts"
contains: "schema:"
- path: "src/db/migrations/"
provides: "Migration files generated by drizzle-kit"
min_files: 1
key_links:
- from: "src/db/schema.ts"
to: "clients table"
via: "pgTable definition"
pattern: "export const clients.*pgTable"
- from: "src/db/schema.ts"
to: "token field"
via: "uuid().unique()"
pattern: "token.*uuid.*unique"
- from: "drizzle-kit push"
to: "Postgres on Coolify"
via: "DATABASE_URL"
pattern: "DATABASE_URL"
---
<objective>
**Database Schema + Drizzle Migrations:** Define the complete data model in Drizzle ORM, generate database migrations, and push the schema to Coolify Postgres. This plan creates the schema that all subsequent plans depend on.
Purpose: Establish the single source of truth for data shape. Enforces critical decisions: token as separate field, accepted_total denormalized, approved_at immutable, ClientView vs. AdminView separation in queries.
Output: `src/db/schema.ts` with all 11 tables fully defined, migration files, and Postgres schema live on Coolify.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/research/ARCHITECTURE.md
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md
@.planning/phases/01-foundation-client-dashboard/01-01-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Create Drizzle schema definition (src/db/schema.ts) with all 11 tables</name>
<files>
src/db/schema.ts
</files>
<read_first>
.planning/research/ARCHITECTURE.md (Data Model section, lines 69-142)
</read_first>
<action>
Create `src/db/schema.ts` with the following tables (exact order, exact field names):
```typescript
import { pgTable, text, uuid, integer, numeric, timestamp, boolean, unique, index } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { nanoid } from 'nanoid';
// ============ CLIENTS ============
export const clients = pgTable('clients', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
name: text('name').notNull(),
brand_name: text('brand_name').notNull(),
brief: text('brief').notNull(),
token: uuid('token').notNull().unique().defaultValue(nanoid()),
accepted_total: numeric('accepted_total', { precision: 10, scale: 2 }).default('0'),
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// ============ PHASES ============
export const phases = pgTable('phases', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
sort_order: integer('sort_order').notNull().default(0),
status: text('status').notNull().default('upcoming'), // upcoming | active | done
});
// ============ TASKS ============
export const tasks = pgTable('tasks', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
phase_id: uuid('phase_id').notNull().references(() => phases.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
status: text('status').notNull().default('todo'), // todo | in_progress | done
sort_order: integer('sort_order').notNull().default(0),
});
// ============ DELIVERABLES ============
export const deliverables = pgTable('deliverables', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
task_id: uuid('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
url: text('url'),
status: text('status').notNull().default('pending'), // pending | submitted | approved
approved_at: timestamp('approved_at', { withTimezone: true }), // immutable audit trail
});
// ============ COMMENTS ============
export const comments = pgTable('comments', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
entity_type: text('entity_type').notNull(), // task | deliverable
entity_id: uuid('entity_id').notNull(),
author: text('author').notNull(), // client | admin
body: text('body').notNull(),
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// ============ PAYMENTS ============
export const payments = pgTable('payments', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
label: text('label').notNull(), // "Acconto 50%" | "Saldo 50%"
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
status: text('status').notNull().default('da_saldare'), // da_saldare | inviata | saldato
paid_at: timestamp('paid_at', { withTimezone: true }),
});
// ============ DOCUMENTS ============
export const documents = pgTable('documents', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
label: text('label').notNull(),
url: text('url').notNull(),
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// ============ NOTES (Decision Log) ============
export const notes = pgTable('notes', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
body: text('body').notNull(),
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// ============ SERVICE CATALOG ============
export const service_catalog = pgTable('service_catalog', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
name: text('name').notNull(),
description: text('description'),
unit_price: numeric('unit_price', { precision: 10, scale: 2 }).notNull(),
active: boolean('active').notNull().default(true),
});
// ============ QUOTE ITEMS ============
export const quote_items = pgTable('quote_items', {
id: uuid('id').primaryKey().defaultValue(nanoid()),
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
service_id: uuid('service_id').notNull().references(() => service_catalog.id, { onDelete: 'restrict' }),
quantity: numeric('quantity', { precision: 10, scale: 2 }).notNull(),
unit_price: numeric('unit_price', { precision: 10, scale: 2 }).notNull(),
subtotal: numeric('subtotal', { precision: 10, scale: 2 }).notNull(),
});
// ============ RELATIONS ============
export const clientsRelations = relations(clients, ({ many }) => ({
phases: many(phases),
payments: many(payments),
documents: many(documents),
notes: many(notes),
quote_items: many(quote_items),
}));
export const phasesRelations = relations(phases, ({ one, many }) => ({
client: one(clients, { fields: [phases.client_id], references: [clients.id] }),
tasks: many(tasks),
}));
export const tasksRelations = relations(tasks, ({ one, many }) => ({
phase: one(phases, { fields: [tasks.phase_id], references: [phases.id] }),
deliverables: many(deliverables),
}));
export const deliverablesRelations = relations(deliverables, ({ one }) => ({
task: one(tasks, { fields: [deliverables.task_id], references: [tasks.id] }),
}));
```
Notes:
- Use `nanoid()` for all UUID primary keys (not SQL-generated UUIDs) — this ensures consistent, cryptographically secure IDs
- Token is `uuid().notNull().unique()` — separate from id, rotatable
- `approved_at` is nullable (no approval initially)
- Relations use cascading deletes for data integrity
- All timestamp fields use `withTimezone: true`
</action>
<verify>
<automated>test -f src/db/schema.ts && echo "schema.ts exists"</automated>
<automated>grep -c "export const" src/db/schema.ts | grep -q "1[1-9]\|2[0-9]" && echo "Multiple table exports found"</automated>
<automated>grep -q "token.*uuid.*unique" src/db/schema.ts && echo "Token field is separate and unique"</automated>
<automated>grep -q "approved_at.*timestamp" src/db/schema.ts && echo "approved_at field exists"</automated>
<automated>grep -q "accepted_total" src/db/schema.ts && echo "accepted_total denormalized field exists"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "TypeScript errors found" || echo "TypeScript compiles"</automated>
</verify>
<acceptance_criteria>
- `src/db/schema.ts` exists with all 11 tables defined
- All table exports are present: clients, phases, tasks, deliverables, comments, payments, documents, notes, service_catalog, quote_items
- Token field is separate from id PK and marked as unique
- Relations are defined for all foreign keys
- TypeScript compiles without errors
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 2: Create drizzle.config.ts and generate migrations</name>
<files>
drizzle.config.ts
src/db/migrations/*
</files>
<read_first>
src/db/schema.ts
.env.local
</read_first>
<action>
Create `drizzle.config.ts` in project root:
```typescript
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './src/db/migrations',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;
```
Run migration generation:
```
npx drizzle-kit generate
```
This creates `src/db/migrations/` directory with a numbered migration file (e.g., `0000_initial_schema.sql`).
Verify the generated SQL contains:
- All 11 CREATE TABLE statements
- Foreign key constraints
- Unique constraints on token
</action>
<verify>
<automated>test -f drizzle.config.ts && echo "drizzle.config.ts created"</automated>
<automated>test -d src/db/migrations && ls src/db/migrations/*.sql 2>/dev/null | wc -l | grep -q "[1-9]" && echo "Migration files generated"</automated>
<automated>grep -l "CREATE TABLE" src/db/migrations/*.sql | wc -l | grep -q "[1-9]" && echo "SQL migration contains CREATE TABLE"</automated>
</verify>
<acceptance_criteria>
- `drizzle.config.ts` exists with correct driver (pg) and schema path
- `src/db/migrations/` directory exists with at least one .sql file
- Generated SQL file contains CREATE TABLE statements for all 11 tables
</acceptance_criteria>
</task>
<task type="auto" gate="blocking">
<name>Task 3: [BLOCKING] Run drizzle-kit push to apply schema to Coolify Postgres</name>
<files>
None (schema is pushed to DB, not local files)
</files>
<read_first>
.env.local (verify DATABASE_URL is set)
src/db/migrations/ (ensure migrations exist)
</read_first>
<action>
Before running push, verify DATABASE_URL is set in .env.local:
```
cat .env.local | grep DATABASE_URL
```
If DATABASE_URL is not yet available (Coolify not configured), STOP here and ask executor to provide Coolify credentials. This task cannot proceed without a valid connection string.
Once DATABASE_URL is confirmed:
```
npx drizzle-kit push
```
Drizzle will connect to the database and apply all migrations.
If push succeeds, you will see:
```
✓ All migrations have been successfully applied
```
If the database schema was already created, drizzle-kit will detect it and skip unchanged tables.
</action>
<verify>
<automated>if grep -q "^DATABASE_URL=postgresql://" .env.local; then echo "DATABASE_URL is set"; else echo "DATABASE_URL NOT SET"; fi</automated>
<automated>npx drizzle-kit push 2>&1 | grep -q "successfully\|already\|applied" && echo "Schema push completed"</automated>
</verify>
<acceptance_criteria>
- DATABASE_URL env var is set in .env.local
- `npx drizzle-kit push` runs without connection errors
- Schema is created in Coolify Postgres (all 11 tables exist)
- Executor can confirm with: `npx drizzle-kit introspect` (shows all tables)
</acceptance_criteria>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Migration files → Database | Schema migrations are deployed via drizzle-kit push; any schema change is version-controlled |
| Schema definition → ORM runtime | TypeScript schema is the source of truth; Drizzle generates types from schema, not from introspection |
| Token field → Access control | Token is marked unique and separate from PK; enforced by DB constraints |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-001 | Tampering | Token field uniqueness | mitigate | Database enforces UNIQUE constraint on token field; no client can have duplicate token |
| T-02-002 | Information Disclosure | Schema version history | accept | Migrations are version-controlled in git; leaking migration files does not expose secrets (passwords in .env.local only) |
| T-02-003 | Denial of Service | quote_items table | accept | Admin-only; client API never queries it; no data loss from client-side DOS attacks |
</threat_model>
<verification>
After plan execution:
1. Run `npx drizzle-kit push` → "successfully applied" message
2. Run `npx drizzle-kit introspect` → lists all 11 tables
3. Check `src/db/migrations/` → at least one .sql file exists
4. Check `src/db/schema.ts` → all tables are exported
5. Verify TypeScript: `npm run build` → no errors
</verification>
<success_criteria>
- Drizzle schema is defined and exported from `src/db/schema.ts`
- All 11 tables are created in Coolify Postgres
- Token field is unique and separate from id
- Migrations are version-controlled in git
- TypeScript types are available for import in API routes
- Ready to proceed to Plan 03 (Middleware + Client Portal route)
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md`
</output>
@@ -1,144 +0,0 @@
---
phase: 01-foundation-client-dashboard
plan: 02
subsystem: database
tags: [drizzle-orm, postgres, schema, migrations, nanoid]
# Dependency graph
requires:
- 01-01 (drizzle-kit, postgres-js driver, DATABASE_URL in .env.local)
provides:
- src/db/schema.ts con 10 tabelle complete
- TypeScript types esportati per tutte le entità (Client, Phase, Task, ecc.)
- Migration file SQL in src/db/migrations/
- Schema live su Postgres 16 (Hetzner/Coolify)
affects:
- 01-03-client-route (usa clients, phases, tasks, deliverables, payments, documents, notes)
- 01-04-dashboard-ui (usa tutti i types esportati)
- 01-05-seed-deploy (inserisce dati con i types NewClient, NewPhase, ecc.)
# Tech tracking
tech-stack:
added: []
patterns:
- "ID strategy: text + nanoid() via $defaultFn (non uuid() nativo Postgres) — nanoid genera stringhe 21-char URL-safe, non UUID formato xxxxxxxx-xxxx-xxxx"
- "drizzle-kit push richiede DATABASE_URL passata esplicitamente come env var (non carica .env.local automaticamente)"
- "Relations Drizzle definite per tutti gli FK — usabili in query con with: { ... }"
key-files:
created:
- src/db/schema.ts (245 righe — 10 tabelle + relations + TypeScript types)
- src/db/migrations/0000_pretty_typhoid_mary.sql (migration SQL completa)
- src/db/migrations/meta/ (drizzle-kit metadata)
- src/db/migrations/relations.ts (relazioni per introspect)
- src/db/migrations/schema.ts (schema per introspect)
modified: []
key-decisions:
- "Usato text + $defaultFn(() => nanoid()) invece di uuid().defaultRandom() — nanoid genera ID URL-safe crittograficamente sicuri (21 char, ~126 bit entropia), non UUID formato PostgreSQL"
- "drizzle.config.ts dal Plan 01 già corretto (defineConfig + dialect postgresql + url:) — nessuna modifica necessaria"
- "clients.token: text notNull unique con nanoid — separato dall'id PK, rotabile con single UPDATE"
- "drizzle-kit push richiede DATABASE_URL come env var esplicita (non auto-load .env.local)"
# Metrics
duration: 15min
completed: 2026-05-13
---
# Phase 1 Plan 02: Drizzle Schema + Migration — 10 tabelle live su Postgres
**Schema Drizzle ORM completo con 10 tabelle, migration SQL generata e schema live sul database Postgres 16 (Hetzner/Coolify). TypeScript strict compila senza errori.**
## Performance
- **Duration:** ~15 min
- **Started:** 2026-05-13T20:21:00Z
- **Completed:** 2026-05-13T20:36:00Z
- **Tasks:** 3/3
- **Files modified:** 6
## Accomplishments
- `src/db/schema.ts` creato con 10 tabelle complete + relations Drizzle + TypeScript types esportati
- Vincoli architetturali LOCKED rispettati: `clients.token` separato dall'id PK (unique, notNull, nanoid), `accepted_total` denormalizzato, `approved_at` nullable (audit trail immutabile), `quote_items` mai esposto al client API
- Migration SQL (`0000_pretty_typhoid_mary.sql`) generata con tutti i `CREATE TABLE` e FK constraints
- `npx drizzle-kit push` eseguito con successo — tutte e 10 le tabelle create su `postgresql://178.104.27.55:5432/clienthub`
- Verifica via `information_schema.tables`: clients, comments, deliverables, documents, notes, payments, phases, quote_items, service_catalog, tasks
## Task Commits
1. **Task 1: Drizzle schema (src/db/schema.ts)** - `1bdbe7a` (feat)
2. **Task 2: Migration generation (drizzle-kit generate)** - `a6ec599` (chore)
3. **Task 3: [BLOCKING] drizzle-kit push → Postgres live** - `abcbb52` (feat)
## Files Created/Modified
- `src/db/schema.ts` — 10 tabelle: clients (token separato + accepted_total), phases, tasks, deliverables (approved_at nullable), comments (polimorfici), payments (da_saldare/inviata/saldato), documents, notes, service_catalog, quote_items
- `src/db/migrations/0000_pretty_typhoid_mary.sql` — Migration SQL completa con CREATE TABLE + FK + UNIQUE constraint su token
- `src/db/migrations/meta/` — Drizzle-kit metadata (snapshot JSON)
- `src/db/migrations/relations.ts` — Relations per introspect
- `src/db/migrations/schema.ts` — Schema per introspect
## Decisions Made
- **ID strategy:** `text + $defaultFn(() => nanoid())` invece di `uuid().defaultRandom()`. La colonna Drizzle `uuid()` si aspetta il formato PostgreSQL `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, mentre `nanoid()` genera stringhe 21-char URL-safe. Usare `text` è corretto e allineato con la decisione architetturale di token crittograficamente sicuro.
- **drizzle.config.ts invariato:** La versione dal Plan 01 usa già `defineConfig`, `dialect: "postgresql"` e `url:` (sintassi aggiornata drizzle-kit v0.31) — nessuna modifica necessaria rispetto alla versione suggerita nel piano (che usava l'API obsoleta `driver: 'pg'`).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] uuid() non compatibile con nanoid() come defaultFn**
- **Found during:** Task 1
- **Issue:** Il piano suggeriva `uuid('id').primaryKey().defaultValue(nanoid())` ma Drizzle `uuid()` si aspetta UUID nel formato `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. nanoid() genera stringhe come `Tcyf3muFXVOX9QO9pBUES` (21 char, non UUID validi). Usare `defaultValue(nanoid())` su una colonna `uuid()` avrebbe causato errori a runtime al primo INSERT.
- **Fix:** Cambiato a `text('id').primaryKey().$defaultFn(() => nanoid())` per tutte le PK e per il campo `token`. Semantica identica (ID crittograficamente sicuro), tipo colonna SQL `text` invece di `uuid`.
- **Files modified:** src/db/schema.ts
- **Commit:** 1bdbe7a
**2. [Rule 1 - Bug] drizzle.config.ts dal piano usa API obsoleta**
- **Found during:** Task 2
- **Issue:** Il piano suggeriva `driver: 'pg'` e `dbCredentials: { connectionString: ... }` — sintassi drizzle-kit <0.30. Il file esistente usa già `defineConfig` con `dialect: "postgresql"` e `dbCredentials: { url: ... }` — sintassi corretta per drizzle-kit 0.31.
- **Fix:** Mantenuto il file esistente senza modifiche (era già corretto).
- **Files modified:** nessuno
- **Commit:** nessuno necessario
**3. [Rule 3 - Blocking] drizzle-kit push non carica .env.local automaticamente**
- **Found during:** Task 3
- **Issue:** `npx drizzle-kit push` fallisce con "connection url required" perché drizzle-kit non carica `.env.local` automaticamente (solo `.env`).
- **Fix:** Passato `DATABASE_URL` esplicitamente come variabile d'ambiente al comando: `DATABASE_URL="..." npx drizzle-kit push`.
- **Files modified:** nessuno (solo comando di esecuzione)
- **Commit:** abcbb52
## Known Stubs
Nessuno. Il piano è infrastrutturale (schema + DB) — nessun componente UI o dato presentato al cliente. Le tabelle sono vuote, ma questo è intenzionale: il seed script è previsto nel Plan 05.
## Threat Surface Scan
Il threat model T-02-001 (unicità token) è mitigato: `CONSTRAINT "clients_token_unique" UNIQUE("token")` è attivo nel database. T-02-002 e T-02-003 sono accettati come da piano.
Nessuna nuova superficie di sicurezza non prevista dal threat model.
## Self-Check
- [x] `src/db/schema.ts` esiste (245 righe, 10 tabelle pgTable + relations + types)
- [x] `src/db/migrations/0000_pretty_typhoid_mary.sql` esiste con 10 CREATE TABLE
- [x] Commit `1bdbe7a` esiste (schema)
- [x] Commit `a6ec599` esiste (migrations)
- [x] Commit `abcbb52` esiste (push)
- [x] 10 tabelle verificate live su Postgres via `information_schema.tables`
- [x] `clients.token` è `text NOT NULL UNIQUE` con nanoid — separato dalla PK
- [x] `approved_at` è `timestamp with time zone` nullable
- [x] TypeScript strict: `npm run build` — zero errori TypeScript
## Self-Check: PASSED
## Next Phase Readiness
- Plan 03 (Middleware + route `/c/[token]`) può partire — lo schema è live e i types sono importabili
- Import pattern: `import { clients, phases, tasks, ... } from '@/db/schema'`
- Import types: `import type { Client, Phase, Task, ... } from '@/db/schema'`
---
*Phase: 01-foundation-client-dashboard*
*Completed: 2026-05-13*

Some files were not shown because too many files have changed in this diff Show More