Compare commits

...

70 Commits

Author SHA1 Message Date
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
simone dd4ae42542 feat: pagina Preventivi redesign Quiet Luxury — tabella tokenizzata + badge stato dual-mode
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:53:41 +02:00
simone c29fab8975 feat: pagina Catalogo redesign Quiet Luxury — griglia servizi tokenizzata + footer conteggio
- ServiceTable: contenitore bg-card/shadow-card, header uppercase muted, righe hover:bg-muted/40, footer "Visualizzazione di N servizi"
- CatalogSearch: usa il primitivo SearchInput
- page: sottotitolo sezione
- tokenizza OptionSelect ed EditableCell (accent-primary) — erano rimasti con hex hardcoded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:37:09 +02:00
simone 1fc0650dcf feat: pagina Offerte redesign Quiet Luxury — grid card tokenizzata + badge categoria
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:36:34 +02:00
simone c1ff5a7e67 feat: pagina Clienti redesign Quiet Luxury — tabella tokenizzata + copia link
- /admin/clients replica design-reference/pagina-clienti (dual light/dark via token)
- ClientRow: colonne numeriche font-mono right-aligned, incassato come pill emerald,
  cella "Link profilo" con pill mono troncata + CopyLinkButton
- nuovo primitivo CopyLinkButton (copia URL assoluto, feedback check emerald)
- PageHeader con sottotitolo + footer conteggio clienti
- DESIGN-SYSTEM.md: inventory (CopyLinkButton, ClientRow) + note pagina Clienti

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:32:34 +02:00
simone a1d8d18902 feat: dashboard box "Messaggi Clienti" — chat clienti in attesa di risposta
Nuovo MessagesWidget nella colonna 1/3 della dashboard: riusa
getConversations() filtrando le conversazioni unread, mostra le prime 4
con anteprima e deep-link "Rispondi" a /admin/conversazioni?c=<clientId>.
Pill emerald "N Nuovi" + pallino pulsante, empty state quando non c'è nulla
in attesa. Stessa fonte di verità (clients.admin_last_read_at) del badge
sidebar. Aggiornato DESIGN-SYSTEM.md (inventory + note Conversazioni/dashboard)
e il mock design-reference/pagina-dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:23:43 +02:00
simone c1cc13a99a feat: pagina Conversazioni — inbox unificata messaggi clienti
Nuova pagina admin /admin/conversazioni: vista WhatsApp-style (lista
conversazioni a sinistra, thread + risposta a destra) che aggrega i
messaggi di tutti i clienti dalla tabella comments, cross-cliente.

- Migration additiva 0014: clients.admin_last_read_at per tracciare
  letto/non-letto (pallini + badge in sidebar). Applicata a prod.
- conversations-queries.ts: aggregazione entity_id → cliente
  (general/phase/task/deliverable), getConversations /
  getConversationThread / getUnreadConversationsCount.
- Risposte admin salvate come commento "general" (visibili anche nella
  chat cliente e nel CommentsTab della scheda).
- Voce di menu + badge non-letti (AdminSidebar/AdminShell/layout).
- Token semantici per dual light/dark; refresh manuale (no polling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:10:14 +02:00
simone a20a9de2d7 style: rifinitura Quiet Luxury dashboard — bordi soft, badge, colori
Allinea la dashboard alla rifinitura del mock (finezza bordi/colori/badge):

- Card su border-border-light (slate-100) per il bordo sottile
- KPI: valore verde su Incassato Reale, delta verde "+N questo mese" su
  Clienti Attivi (nuova metrica getDashboardStats.clientiNuoviMese)
- Redditività: badge "Ottimo" con tint emerald soft (non brand primary)
- Follow-up: righe come box bordati (no avatar), link "Apri →" primary
- Forecast: selettore anno (pill interattiva) spostato nell'header della card,
  barre non-picco grigie (bg-border), rimosso il numero "mese prossimo"
- Offerte Più Richieste: rimossa la pill totale nell'header

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:18:10 +02:00
simone 08aadc1d97 feat: Dashboard admin redesign to Quiet Luxury + client profitability widget
Replica il mock design-reference/pagina-dashboard: layout condensato a
schermata singola (4 KPI + griglia 2/3-1/3), token semantici per dual-theme.

- Nuova query getClientProfitability(year): valore orario reale per cliente
  (contrattualizzato / ore tracciate) con badge margine, target 100 €/h
- Nuovo widget ClientProfitability "Analisi Oraria & Redditività Clienti"
- Rewrite /admin: KPI strip + Cashflow + Redditività | Follow-up + Offerte
  Più Richieste. Rimossi chart mensile, card extra, barre ore/cliente
- Tokenizzati ForecastChart, OffersSoldChart, FollowUpWidget, YearSelector
  (ora pill interattiva); rimosso MonthlyChart e prop availableYears inutile
- DESIGN-SYSTEM.md: inventario + note dashboard (applied 2026-07-11)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:57:10 +02:00
simone e5fa07bba3 feat: Lead Details redesign to Quiet Luxury + unified interaction timeline
Rewrite /admin/pipeline/[id] LeadDetail with token-based dual-theme layout:
bespoke header with inline StatusBadge, asymmetric 1/3 profile + 2/3 content
columns, and a unified reverse-chronological timeline merging activities and
transcripts (transcript node highlighted + expandable + deletable).

Restyle modal triggers (Registra Attività, Aggiungi Transcript, Modifica-as-icon)
to the mock outline style. Remove the confusing "Invia Preventivo" button (it only
linked a token + advanced stage, no email). Keep Converti in cliente for won leads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:33:18 +02:00
simone c110689b6a fix: chat drawer above header + vertical "Chiudi" tab on left edge
Panel z-index raised to z-[60] so it sits above the sticky page header
(z-50) that was covering the close control. Replaced the header X with a
notebook-divider-style vertical "Chiudi" tab protruding from the left edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 08:34:25 +02:00
simone 11870e15d3 feat: Client Portal redesign to Quiet Luxury + milestone stepper
- New MilestoneStepper primitive (horizontal per-phase progress)
- Token-migrate portal shell, phase cards, sidebar cards, kanban to dual-theme
- Soft-tint status pills (emerald/amber/muted) replacing solid badges
- embedded prop on ClientDashboard fixes double-header in multi-project tabs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:54:58 +02:00
simone 6d5e04bf9c style: refine Pipeline fidelity to Quiet Luxury reference
- Sidebar: revert to white palette (drop emerald pass), remove right border
- Table: softer slate-100 borders, remove min-height gap, "Mostrando N di M
  lead" footer, empty Tag cell = dashed-circle add button
- SegmentedToggle: more visible track (slate-200/60), neutral active text
- Kanban: roomy fixed-width columns (280px, horizontal scroll), sober column
  headers (no colored dots), airy cards matching mock (name+badge/email/
  company+"Tag +"); 6 stages + @dnd-kit intact

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:33:26 +02:00
simone 26d752892a style: align light palette to Quiet Luxury reference
- Page background now #F8F9FA (was white); tokens matched to reference:
  foreground slate-900, muted slate-50/slate-500, border slate-200,
  card pure white, tertiary slate-400, border-light slate-100
- Sidebar colors matched to mock: emerald-200/70 inactive, white/8 active
  with emerald-400 active icon, emerald-50 logo, red-300 logout, right border
- Content wrapped in max-w-[1400px] centered container (px-8 lg:px-10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:33:00 +02:00
simone 9eb2d45c67 fix: sidebar width/centering + kanban card badge & table details
- Sidebar: expanded 256->200px, collapsed 80->64px; collapsed state now
  renders icon-only (label removed from flow) so icons are centered, not
  pushed left by an invisible text span
- Kanban cards: add StatusBadge + email line to match design reference
- Lead table: add "Mostrando N lead" footer and center/right-align
  Stato/Tag/Azioni headers per reference

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:28:43 +02:00
simone 86e1499e8f feat: Quiet Luxury design system + Pipeline (ex-Lead) redesign
- Design system foundations: Plus Jakarta Sans font, shadow-card/radius
  tokens in @theme, design-reference/DESIGN-SYSTEM.md with component inventory
- Collapsible admin shell (AdminShell): smooth w-64<->w-20 sidebar, new top
  header with "Admin" + avatar placeholder, localStorage-persisted state
- Rename route /admin/leads -> /admin/pipeline (redirect stubs preserved),
  nav label Lead -> Pipeline; DB table unchanged
- Reusable primitives: StatusBadge, SearchInput, SegmentedToggle (dual-theme)
- Luxury restyle of lead table, kanban (6 stages + @dnd-kit intact), PageHeader
- Tokenize editable-cell / option-multi-select for dark-mode legibility

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:16:44 +02:00
simone 43cb7e7469 feat: dark/light theming system + design tokens (OMC design system port)
- Restructure design tokens into :root/.dark raw vars mapped via @theme inline
- Add light/dark/system theming: useTheme hook + FOUC guard + ThemeToggle
- Keep iamcavalli palette (primary #1A463C, accent #DEF168) and Geist fonts
- Derive brand-consistent dark palette (verde-nero bg, lightened primary)
- Add scrollbar utilities (.no-scrollbar/.thin-scrollbar)
- Install tailwindcss-animate, register via @plugin (Tailwind v4)
- Mount ThemeToggle in admin sidebar footer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:08:56 +01:00
simone add2176a6b feat: tier names + per-tier prices on offer cards, unified admin UI
- offer editor: per-tier name input (mirrors public_name/internal_name)
- offer list cards: show 3-tier services total + manual public price
- shared PageHeader component, full-width layout across all admin pages
- UI-RULES.md design conventions doc

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:58:18 +02:00
simone 9abe1fe4bb fix: offer name in client detail, retainer forecast, LTV with offers, offers-sold chart
- Client detail offers: show offer macro name ("Mantenimento") instead of tier letter
- Forecast: retainers project the monthly fee across every month from start_date
  (no longer capped by duration_months); una_tantum unchanged
- Clients list LTV: per project max(accepted_total, sum of assigned offers) so a
  retainer with unset quote still counts
- Dashboard: new "Offerte vendute" chart (count by offer + tier)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:14:13 +02:00
simone ae355c33a6 feat: offer badges, unified dashboard, income forecast, Pipedrive-style follow-up
- Client detail: category + tier badges on active offers
- Dashboard: remove top KPI cards + recent activity; fold current KPIs into
  bottom MetricCard style; add 12-month income forecast chart
- Forecast query: branch by offer_type (retainer = monthly fee, una_tantum =
  spread over duration), filter archived projects
- Payments: set the month a payment was collected (paid_at) from PaymentsTab
- Redesign FollowUpWidget in clean Pipedrive style (brand green, no orange)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:28:12 +02:00
simone fc766ca1ee feat: add iamcavalli logo as favicon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:51:05 +02:00
simone 0b8a7b3809 feat: add Opus/Sonnet model selector to "Genera preventivo" page
Adds a "Modello AI" toggle (Opus default, Sonnet option) styled like the
existing Soggetto toggle. The server action validates the keyword and maps
it to the real model id; the agent receives the resolved id and uses it in
the Anthropic messages.create call. The proposals DB record stores the
actual model used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:34:04 +02:00
simone e10d1f70cb fix: cumulative price from offer_tier_services, offers accordion, retainer phase placeholder
- client-view.ts: read cumulative_price + services list from offer_tier_services→services
  (Phase 12 catalog); legacy fallback to offer_micro_services→offer_services when no
  new-style rows exist. Extend activeOffers type with services[] in both ProjectView and ClientView.
- OffersSection.tsx: extract OfferCard client component with useState accordion;
  hide "Valore incluso" row when price is 0; add "Cosa è compreso" chevron toggle
  listing included services (name + description when present).
- client-dashboard.tsx: hide progress bar and PhaseViewToggle when hasRetainer;
  render polished empty-state placeholder with RefreshCw icon instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:53:25 +02:00
simone 64030afef3 feat: sidebar CTA removal, delete phase/task, public dashboard UX, chat panel
1. AdminSidebar: removed yellow "Genera preventivo" CTA (duplicated Preventivi tab nav item)
2. Delete phase/task: new deletePhase/deleteTask server actions with FK cascade and phase-status recompute; DeletePhaseTaskButton client component with window.confirm; trash icons in PhasesTab per phase and per task
3. Public dashboard: extend activeOffers query with offer_macros join (offer_name, offer_type); reorder sidebar 1°Offerte 2°Pagamenti 3°Documenti; OffersSection shows macro public_name as heading (never tier letter/internal_name); PaymentStatus gains totalLabel/overrideAmount/hideRows props — retainer=monthly label + no Acconto/Saldo rows
4. Offer editor: Descrizione textarea (nota interna) and Modalità toggle moved directly under title; Tags section now contains only Categoria/Ticket/Tipo/Obiettivo
5. Basecamp chat: API route supports entity_type="phase" with authorization scoping; comments scope in client-view broadened to include phaseIds and client_id (general); CommentsTab updated with phase/general entities; ChatProvider React context; ChatPanel fixed slide-in panel with FAB (bottom-right) and composer tag selector (Generale + phases); PhaseCard gets chat-bubble icon pre-tagging that phase; inline ChatSection block removed from dashboard main column

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:11:55 +02:00
simone 4b28f254ba docs: correct DB-access procedure (direct SSH+docker exec, remote is gitea)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:39:45 +02:00
simone 186bb9ea19 feat: payments plans, phase auto-cascade, transcripts, manual timer
- Pagamenti: totale ereditato dalla somma accepted_total delle offerte
  attive (con override) + selettore schema rate 1/2/3 step; nuova colonna
  payments.percent per rescalare gli importi preservando label/stato
- Fasi & Task: recomputePhaseStatus auto-cascade (task -> fase) su
  updateTaskStatus/addTask, fix UI stale, etichette Da iniziare/In corso/Completata
- Pagina pubblica: colori fasi (grigio/blu/#1A463C) + fasi collassabili
- Transcript del cliente visibili in tab Documenti admin e dashboard pubblica
- Timer: inserimento manuale (+30/+60, minuti liberi, data) + elimina entry
- CLAUDE.md: procedura Deploy & DB Access; push su main automatico

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:38:56 +02:00
simone 988f6f425a docs: add STATUS.md session handoff 2026-06-22 14:34:17 +02:00
simone f98828f75e feat: offer_type (una tantum/retainer), dedup tiers, redesigned Offerte tab
- offer_macros.offer_type ('una_tantum'|'retainer') + editor "Modalità" toggle
  (migration 0012, applied to prod)
- migration 0012 also adds UNIQUE(macro_id, tier_letter) — prevents duplicate
  tiers; ran after a one-time dedup of Web Domination's duplicate A/B/C tiers
- OffersTab redesigned: two-step assign (Offer → Tier cards with price), type
  badge "Una tantum/Ricorrente" instead of misleading "X mesi", no redundant
  "· A" when public_name == tier letter, cleaner active-offers cards
- getProjectFullDetail: availableMicros grouped by macro + defensive dedup;
  projectOffers/availableMicros carry offer_type/category/price
- proposal deck PricingSection shows offer type (fallback to duration for
  pre-existing proposals)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:32:59 +02:00
simone 1824cb643f feat: wire offer→project phases/tasks, redo Offerte tab, cleanup project tabs
- importOfferIntoProject(): materializes project phases/tasks from the assigned
  tier's services grouped by services.fase (merge-by-title, dedup tasks)
- assignOfferToProject: optional import_phases flag triggers the import
- getProjectFullDetail: projectOffers/availableMicros now include macro name +
  tier_letter (dropdown shows "Offerta — Tier X"); availableMicros filters
  non-archived macros
- OffersTab redone: shows macro + tier badge, "Importa fasi e task" checkbox
- removed project "Preventivo" tab + deleted QuoteTab.tsx (legacy quote_items)
- sidebar: Lead before Clienti
- no DB migration (reuses existing tables)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:08:28 +02:00
simone f5f90cd643 feat: lead → client conversion + client contact fields (email/phone)
Blocco A+B (milestone v2.3). Migration 0011 is additive (ADD COLUMN only):
clients.email, clients.phone, leads.archived.

- createClientCore() extracted from createClient and reused by conversion
- clients.email/phone added to create + edit forms and shown on client header
  (never exposed on the public /client/[token] page)
- convertLeadToClient(): reuses core, carries over lead transcripts
  (client_transcripts.client_id), links project.created_from_lead_id,
  archives the lead while keeping its "won" status; idempotent
- "Converti in cliente" button on won leads; "Convertito → Apri cliente"
  once converted (clientId resolved via created_from_lead_id)
- archived leads hidden from list/kanban; detail page fetches by id incl. archived

Migration must be applied to the prod DB BEFORE this is deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:18:41 +02:00
simone e1b3e8c3d5 style: refine settings taxonomy UI (field cells, icon headers, 2-col grid)
- PoolManager: defined field cells with value count, refined chips,
  inline ghost + button, focus ring, pending/empty states
- TaxonomyManager: icon-badged section headers, ordered 2-col grid,
  sync explainer note
- Settings page: wider container (max-w-4xl) for the grid to breathe

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:46:13 +02:00
simone e80c95f838 feat: centralized Notion-style taxonomy management in settings
Single persistent option pool per taxonomy (7 fields: offer
categoria/ticket/tipo/obiettivo + catalog fase/offerta/pacchetto),
stored as JSON in the settings table (no DB migration).

- src/lib/taxonomy.ts: pools with lazy seed from in-use values,
  add/remove(cascade)/rename helpers
- Inline creation anywhere registers into the pool (save offer,
  addServiceOption, updateServiceField fase, quickAdd, create macro)
- Deselecting on a row never touches the pool; global delete only
  from settings (cascade-strips tags / nulls columns)
- getOfferFieldOptions + getCatalogFieldOptions read from pools
- Settings: TaxonomyManager (offer + catalog groups), confirm on delete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:47 +02:00
simone 320827e13a fix: flag A/B/C persistence, catalog sort+reorder, offer category pools
- Fix duplicate tier INSERT bug (useState not syncing after router.refresh)
- Add column sort by clicking headers in service catalog
- Add drag-and-drop column reordering (persisted in localStorage)
- Add Categorie Offerta section in Impostazioni (tipo/obiettivo/categoria pools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 12:12:49 +02:00
simone ba3e824157 docs: create milestone v2.3 roadmap (3 phases, 9 requirements mapped)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 11:23:56 +02:00
simone 19a7ffb6a6 docs: define milestone v2.3 requirements (9 reqs — OTP gate + email send)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 11:01:28 +02:00
simone bc9051c899 docs: start milestone v2.3 Email & Accesso
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 10:54:14 +02:00
364 changed files with 24800 additions and 36813 deletions
+28
View File
@@ -0,0 +1,28 @@
# 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/memory/` — un file per fatto, più la riga di indice in `MEMORY.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.
+43
View File
@@ -0,0 +1,43 @@
{
"permissions": {
"allow": [
"mcp__plugin_claude-mem_mcp-search__get_observations",
"Bash(rtk tsc *)",
"Bash(rtk git *)",
"Bash(rtk grep *)",
"Read(//Users/simonecavalli/.claude/get-shit-done/references/**)",
"Skill(gsd-execute-phase)",
"Skill(gsd-execute-phase:*)",
"Skill(gsd-plan-phase)",
"Skill(gsd-plan-phase:*)",
"Skill(gsd-progress)",
"Skill(gsd-progress:*)",
"Skill(gsd-complete-milestone)",
"Skill(gsd-complete-milestone:*)",
"Read(//Users/simonecavalli/Downloads/**)"
]
},
"hooks": {
"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'"
}
]
}
]
},
"enabledPlugins": {
"impeccable@impeccable": true
},
"extraKnownMarketplaces": {
"impeccable": {
"source": {
"source": "github",
"repo": "pbakaus/impeccable"
}
}
}
}
+12
View File
@@ -10,3 +10,15 @@ 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>
# 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
+9
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,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Output degli spike audit (dati di siti di clienti, non vanno committati)
spike-audit-*.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)
+30 -19
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 State: v2.2 Sales Loop ✅ SHIPPED 2026-06-20
## Current Milestone: v2.4 Post-vendita
Il loop di vendita end-to-end è live in produzione: lead in pipeline Kanban → transcript datati → agente AI (Claude Opus 4.8) genera preventivo personalizzato → deck pubblico 20+ slide a `/preventivo/[slug]` → cliente sceglie tier A/B/C e accetta → vinto/perso nel CRM.
**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.
**Prossima milestone:** `/gsd-new-milestone` — candidati backlog:
- **PUB-03** — Invio link preventivo via email Resend (primo candidato, piccola effort)
- **PROP-03/04** — Stripe Payment Link + auto-provisioning al "Vinto"
- **AUTH-OTP-01** — Accesso cliente via OTP email (design già pronto)
- **Phase 13** — Servizi attivi/ricorrenti post-vendita (congelata, ripescabile)
**Consegnato (in produzione):**
- 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,12 +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 (prossima milestone)
Validated in v2.3 Email & Accesso (shipped 2026-07-29):
- [ ] PUB-03 — Invio link preventivo via email Resend (primo candidato)
- [ ] PROP-03 Stripe Payment Link su offerta pubblica
- [ ] PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto"
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email (design pronto)
- ✓ 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
@@ -70,7 +78,6 @@ Validated in v2.2 Sales Loop (shipped 2026-06-20):
- App mobile nativa — solo web responsive
- Multi-utente con team — solo tu come admin per ora
- Prezzi singoli visibili al cliente — vede solo il totale accettato
- Email OTP per accesso cliente — design pronto ma deferito a batch successivo su richiesta utente
- File hosting — documenti solo come URL esterni (v1 constraint, ancora valido)
- Sezioni analitiche stile Notion (psicologia, rating, performance) — fuori v2.1, eventuale milestone futura
- Deploy separati per modulo (architettura OMC multi-app) — non finché un modulo non cresce abbastanza da giustificarlo
@@ -82,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
@@ -93,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
@@ -106,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
@@ -133,4 +144,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state
---
*Last updated: 2026-06-20 — v2.2 milestone complete: Sales Loop end-to-end shipped (Phases 1822)*
*Last updated: 2026-08-08 — v2.3 archiviata, v2.4 Post-vendita corrente (Phase 13 + 26 in produzione)*
+116
View File
@@ -0,0 +1,116 @@
# Requirements: ClientHub v2.5 Audit
**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.
Milestone precedente: [v2.4 Post-vendita](milestones/v2.4-REQUIREMENTS.md), chiusa 2026-08-08.
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).
## Il prodotto
Tre livelli venduti, che sono **configurazioni di un unico documento**, non tre documenti:
| 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) |
I blocchi non pertinenti **non esistono nel DOM**, non sono nascosti via CSS.
## Requisiti
### Motore (Phase 27)
- [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)*
### Storage immagini (Phase 28)
- [ ] **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)
### Editor admin (Phase 29)
- [ ] **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
### Documento pubblico (Phase 30)
- [ ] **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
## Vincoli che questa milestone tocca
- **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.
## 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)
## 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
**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).
+70 -16
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** — prossima milestone (in definizione)
- **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,31 +36,83 @@ Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md)
</details>
### 📋 v2.3 — Prossima Milestone (in definizione)
<details>
<summary>✅ v2.3 Email & Accesso (Phases 2325) — SHIPPED 2026-07-29</summary>
Candidati backlog (da formalizzare con `/gsd-new-milestone`):
- [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
- [ ] PUB-03 — Invio link preventivo via email Resend
- [ ] PROP-03 — Stripe Payment Link su offerta pubblica
- [ ] PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto"
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email (design pronto)
- [ ] Phase 13 — Servizi attivi/ricorrenti post-vendita (congelata, ripescabile)
Shipped col commit `27da969`, verificata end-to-end su `hub.iamcavalli.net`.
Archivio completo: [milestones/v2.3-ROADMAP.md](milestones/v2.3-ROADMAP.md)
</details>
### ✅ v2.4 — Post-vendita (Phases 13 + 26)
- [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+. v2.3 TBD | v2.3 | TBD | 📋 Planned | |
| 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à).*
+69 -82
View File
@@ -1,113 +1,100 @@
---
gsd_state_version: 1.0
milestone: v2.2
milestone_name: — Sales Loop
status: complete
stopped_at: "v2.2 completa — tutto in prod. Backlog: PUB-03 email Resend, PROP-03 Stripe, PROP-04 auto-provisioning"
last_updated: "2026-06-20T16:30:00.000Z"
last_activity: 2026-06-20 -- v2.2 chiusa — 5/5 fasi complete, REQUIREMENTS aggiornati, SUMMARY.md scritti per 21+22
milestone: v2.5
milestone_name: Audit
status: executing
stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1 e le due rifiniture del 2026-08-20 in prod; C2 (TidyCal) bloccato sulle credenziali API."
last_updated: "2026-08-20T15:40:00.000Z"
last_activity: 2026-08-20 -- modifiche hub: rinomina tassonomie e stato task "In revisione" (5547e55)
progress:
total_phases: 5
completed_phases: 5
total_plans: 7
completed_plans: 7
percent: 100
total_phases: 4
completed_phases: 0
total_plans: 4
completed_plans: 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-13)
**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.2 "Sales Loop"** (reset 2026-06-19). North-star: lead in pipeline Kanban → transcript call → agente AI genera preventivo → pagina pubblica `/preventivo/[slug]` → vinto/perso. Piano: `.claude/plans/glittery-sprouting-pudding.md`. Prossimo passo: eseguire Phase 19 (R2) Pipeline CRM Kanban.
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: 20 (R3) Knowledge Base Cliente — ready to execute
Plan: 3 piani (20-01 migration, 20-02 data layer, 20-03 UI)
Status: Ready to execute
Last activity: 2026-06-19 -- Phase 20 planned (3 piani, verification passed — KB-01/KB-02)
**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.
Progress (v2.2): [████░░░░░░] 40% — 2/5 fasi complete
| 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 — rinomina tassonomie, stato task "In revisione" | ✅ in prod 2026-08-20 |
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
### Fasi completate (v2.1, storico)
Progress: [███░░░░░░░] 25% (v2.5)
Phase 11 (catalogo), Phase 12 (offer editor), Phase 14 (CRM Attio) — consegnate e in prod. 55 servizi reali + tag offerta caricati.
## Dove sta cosa
**Fuori dal repo, e senza questi niente è ricostruibile:** i piani in `~/.claude/plans/`
`…woolly-puddle.md` (documento audit), `…radiant-valley.md` (motore),
`sei-arrivato-qua-search-recursive-kettle.md` (modifiche hub).
| 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** |
## Come funziona il motore
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 `…radiant-valley.md`.
## Performance Metrics
**Velocity:**
- Total plans completed: 7 (v2.1)
- 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 |
**Velocity:** 21 plans (v2.1v2.4). Phase 27: spike ~1h, schema ~1h, fonti ~2h. Modifiche hub: A+B+C1 in una sessione.
## 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:
- **[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-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 (es. Phase 20 transcript) 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.
- **Phase 21 (AI)**: nessuna integrazione AI ancora nel codice. Servirà chiave Anthropic API in env (Coolify) + decisioni modello/prompt in fase di planning.
- **Debito tecnico (non bloccante)**: tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` restano come deadweight; `createService`/`serviceSchema` dead code in `catalog/actions.ts`. Script di validazione consolidamento Phase 11 (migrate/validate) mai eseguiti ma OFFER-13 è di fatto soddisfatto (catalogo `services` in uso, 55 servizi reali caricati).
- **[RISOLTO]** ~~Phase 15 bloccata su mockup~~ → fase abbandonata dal reset 2026-06-19.
- **[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 fisso del template v1 non ha una fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi vanno recuperati prima di Phase 30.
- **Audit, da vedere sul campo:** il caso "zero dati CrUX" (test 5) e quanto del 52% di checklist non verificabile da HTML statico recuperino gli audit Lighthouse (test 3).
- **Whitelist portale vuota per 3 clienti su 4** — si popola da `/admin/clients/<id>`.
- **`.env.local` punta al DB di PRODUZIONE**, non allineato a Coolify per `ADMIN_PASSWORD` / `NEXTAUTH_SECRET`.
- **Ogni fase con schema**: migration applicata 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 | OFFER-14 — Sezioni analitiche stile Notion (psicologia/rating/performance) | Backlog | v2.1 kickoff |
| v2 | AUTH-OTP-01 — Accesso dashboard cliente via OTP email | Design ready, deferred | 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-19T18:05:00.000Z
Stopped at: Phase 19 complete — 19-01-SUMMARY.md scritto, PIPE-01/PIPE-02 verificati, checkpoint umano approvato. Next: /gsd-plan-phase 20
Resume file: .planning/phases/19-pipeline-crm-kanban/19-01-SUMMARY.md
Last session: 2026-08-19
Stopped at: modifiche hub, blocchi A + B + C1 in produzione (commit `19ed377`, migration `0018` applicata prima del push). C2 fermo sulle credenziali TidyCal.
Next: (1) sbloccare TidyCal con token o documentazione; (2) confermare il payload Elementor con un invio vero; (3) impostare `LEAD_WEBHOOK_SECRET` su Coolify — finché manca la route risponde 403 a tutti; (4) poi riprendere v2.5 da `src/lib/audit/schema.ts` + `agents/`.
Resume file: None
@@ -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.
@@ -0,0 +1,433 @@
# Phase 19: Pipeline CRM Kanban — Research
**Researched:** 2026-06-19
**Domain:** @dnd-kit drag-drop, Next.js App Router client components, CRM leads view toggle
**Confidence:** HIGH — all findings verified directly from codebase
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| PIPE-01 | I lead sono visualizzabili in una board Kanban stile Pipedrive con colonne per stage e drag-drop per cambiare stage | `leads.status` enum verified (6 stages); `@dnd-kit/core` v6.3.1 already installed; exact analog in `KanbanBoard.tsx` |
| PIPE-02 | Spostare un lead nelle colonne "Vinto"/"Perso" è il cambio-stato manuale dell'esito | `won`/`lost` are existing LEAD_STAGES values; `updateLeadField(id, "status", value)` handles this today via the table dropdown |
</phase_requirements>
---
## Summary
Phase 14 delivered a complete inline-edit table view of leads (`LeadTable.tsx`) backed by a solid data layer: `getLeadsWithTags()`, `updateLeadField()`, typed `LEAD_STAGES`, and a polymorphic tag system. Phase 19 adds a second view — Kanban — toggled from the same page, without replacing or changing any of that.
The project already ships a working `KanbanBoard.tsx` (for project tasks) that uses exactly the `@dnd-kit` primitives needed here. The new `LeadsKanbanBoard` is a direct structural analog: swap task status columns (`todo/in_progress/done`) for lead stage columns (`contacted/qualified/proposal_sent/negotiating/won/lost`), swap task cards for lead cards, swap `updateTaskStatus` for `updateLeadField(id, "status", newStage)`.
The view-toggle pattern is also ready in `PhasesViewToggle.tsx` — a client component that holds `useState<"list" | "kanban">` and renders either `listView` (a `ReactNode` passed as prop) or the kanban. The leads page only needs a `LeadsViewToggle` wrapper that receives the existing `LeadsSearch` as the `listView` slot and the new `LeadsKanbanBoard` as the kanban.
No schema changes. No new server actions. No new dependencies. This is a pure UI addition.
**Primary recommendation:** Copy the `KanbanBoard.tsx` structure exactly; adapt for 6 lead-stage columns; wire to `updateLeadField`; wrap with a `LeadsViewToggle` component in `LeadsSearch` or at the page level.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Kanban board rendering + drag state | Browser / Client | — | Drag-drop is inherently client-side; `"use client"` required |
| Lead status persistence on drop | API / Backend (Server Action) | — | `updateLeadField` is already a `"use server"` action |
| Lead data fetching | Frontend Server (SSR) | — | `LeadsPage` is a server component; passes data down as props |
| View toggle state (table / kanban) | Browser / Client | — | `useState` in a client wrapper component |
| Column definitions (stage labels, colors) | Browser / Client | — | Derived from `LEAD_STAGES` constant, purely presentational |
---
## Standard Stack
### Core (already installed — no new installs needed)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| @dnd-kit/core | ^6.3.1 [VERIFIED: package.json] | DndContext, useDraggable, useDroppable, sensors | Already used in KanbanBoard.tsx |
| @dnd-kit/sortable | ^10.0.0 [VERIFIED: package.json] | Available but NOT used by existing KanbanBoard | Not needed; existing pattern uses useDraggable + useDroppable directly |
| @dnd-kit/utilities | ^3.2.2 [VERIFIED: package.json] | CSS.Transform helper | Imported if transform style needed |
| React (useTransition, useState) | via Next.js 16 | Optimistic state + async server action bridging | Project pattern |
**Installation:** None required. All dependencies already present.
### Existing Primitives Used by KanbanBoard.tsx [VERIFIED: src/components/admin/kanban/KanbanBoard.tsx]
```typescript
import {
DndContext, // Root context — wraps the entire board
DragEndEvent, // Event type for onDragEnd handler
DragOverlay, // Ghost card rendered at cursor during drag
PointerSensor, // Mouse/touch activation
KeyboardSensor, // Accessibility
useSensor,
useSensors,
useDroppable, // Applied to column containers
useDraggable, // Applied to individual cards
} from "@dnd-kit/core";
```
Note: `@dnd-kit/sortable` / `SortableContext` / `useSortable` are NOT used. The existing pattern uses the lower-level `useDraggable` + `useDroppable` primitives, which is appropriate for cross-column drag (not intra-column reordering).
---
## Architecture Patterns
### System Architecture Diagram
```
LeadsPage (server component)
├─ getLeadsWithTags() ──────────────────────────────► Postgres / leads + tags
├─ getLeadFieldOptions() ───────────────────────────► Postgres / tags
└─ renders LeadsViewToggle (client component)
├─ [view="list"] → LeadsSearch → LeadTable (existing, unchanged)
└─ [view="kanban"] → LeadsKanbanBoard (new)
├─ DndContext (onDragEnd → updateLeadField server action)
├─ DroppableColumn × 6 (one per LEAD_STAGES value)
└─ DraggableLeadCard × N (one per lead)
└─ useTransition + router.refresh() (after persist)
```
### Recommended File Structure
```
src/
├─ components/admin/leads/
│ ├─ LeadTable.tsx # EXISTING — unchanged
│ └─ LeadsKanbanBoard.tsx # NEW — analogous to KanbanBoard.tsx
├─ app/admin/leads/
│ ├─ page.tsx # MODIFIED — wrap with LeadsViewToggle
│ ├─ LeadsSearch.tsx # MODIFIED — receives view toggle or replaced by LeadsViewToggle
│ └─ actions.ts # EXISTING — updateLeadField already handles status changes
```
The view toggle can live either at the page level (simpler) or inside `LeadsSearch` (keeps search state alive across views). Recommended: extract a `LeadsViewToggle` client wrapper at the page level (same pattern as `PhasesViewToggle`), passing `<LeadsSearch leads={leads} options={options} />` as the `listView` ReactNode and `<LeadsKanbanBoard leads={leads} />` as the kanban.
### Pattern 1: Column definition for 6 lead stages
```typescript
// Source: VERIFIED from src/lib/lead-validators.ts + src/components/admin/leads/LeadTable.tsx
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
const LEAD_COLUMNS: {
id: LeadStage;
label: string;
headerClass: string;
dotClass: string;
}[] = [
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
{ id: "qualified", label: "Qualificato", headerClass: "text-[#1A463C]", dotClass: "bg-purple-400" },
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
];
```
### Pattern 2: Lead Kanban Board (adapted from KanbanBoard.tsx)
```typescript
// Source: VERIFIED structure from src/components/admin/kanban/KanbanBoard.tsx
// Key adaptation: replace taskStatuses/updateTaskStatus with leadStatuses/updateLeadField
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
DndContext, DragEndEvent, DragOverlay,
PointerSensor, KeyboardSensor, useSensor, useSensors,
useDroppable, useDraggable,
} from "@dnd-kit/core";
import { updateLeadField } from "@/app/admin/leads/actions";
import type { LeadWithTags } from "@/lib/admin-queries";
export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
const router = useRouter();
const [, startTransition] = useTransition();
const [activeId, setActiveId] = useState<string | null>(null);
const [leadStatuses, setLeadStatuses] = useState<Record<string, LeadStage>>(
() => Object.fromEntries(leads.map((l) => [l.id, l.status as LeadStage]))
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor)
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const leadId = active.id as string;
const newStage = over.id as LeadStage;
if (newStage === leadStatuses[leadId]) return;
// Optimistic update
setLeadStatuses((prev) => ({ ...prev, [leadId]: newStage }));
// Persist
startTransition(async () => {
await updateLeadField(leadId, "status", newStage);
router.refresh();
});
}
// ... render DndContext with LEAD_COLUMNS mapped to DroppableColumn
}
```
### Pattern 3: View toggle (adapted from PhasesViewToggle.tsx)
```typescript
// Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx
"use client";
import { useState, type ReactNode } from "react";
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
export function LeadsViewToggle({
listView,
leads,
}: {
listView: ReactNode;
leads: LeadWithTags[];
}) {
const [view, setView] = useState<"list" | "kanban">("list");
return (
<div>
{/* Toggle buttons — same pill pattern as PhasesViewToggle */}
{view === "list" ? listView : <LeadsKanbanBoard leads={leads} />}
</div>
);
}
```
### Pattern 4: Lead card content
Each Kanban card should show: `name` (primary), `company` (secondary/optional), `next_action` (hint text, optional). Avoid showing `email`/`phone`/`tags` on the card to keep it compact — these are available in the table view.
```typescript
// Fields available on LeadWithTags (VERIFIED: src/lib/admin-queries.ts line 890)
// Lead & { tags: string[] }
// Relevant for card: name, company, next_action, status
```
### Anti-Patterns to Avoid
- **Using `useSortable` / `SortableContext`:** The existing project pattern does NOT use these. They are for intra-column reordering. Use `useDraggable` + `useDroppable` to match the established `KanbanBoard.tsx` pattern.
- **Calling `router.refresh()` before `await updateLeadField`:** Always await the server action first, then refresh. The existing KanbanBoard does this correctly inside `startTransition`.
- **Dropping `react-hook-form` / Zod on drag-drop:** No form validation needed for a status change — `updateLeadField` already validates via `LEAD_STAGES.includes(value)` check.
- **Removing `LeadsSearch` / `LeadTable`:** PIPE-01 requires the table to remain as an alternative view. Do not replace it.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Drag detection (distance threshold) | Custom mouse event tracking | `PointerSensor` with `activationConstraint: { distance: 5 }` | Already proven in KanbanBoard.tsx; prevents accidental drag on click |
| Keyboard accessibility for drag | Custom key handlers | `KeyboardSensor` from @dnd-kit/core | a11y for free |
| Drag ghost/overlay | CSS clone positioning | `DragOverlay` from @dnd-kit/core | Correct portal rendering, no z-index fights |
| Optimistic UI update | Complex local state with rollback | `useState` + `useTransition` (React pattern) | Already used in KanbanBoard.tsx and LeadTable.tsx |
| Status validation | Re-implementing LEAD_STAGES check | `updateLeadField` server action already validates status | DRY — the action throws on invalid stage |
**Key insight:** The entire drag-drop + persist pattern is already implemented and tested in `KanbanBoard.tsx`. This phase is a structural copy with domain adaptation, not a new implementation.
---
## Common Pitfalls
### Pitfall 1: Columns wider than viewport on 6-stage board
**What goes wrong:** 6 columns in `grid-cols-6` become too narrow on typical laptop screens (12801440px). The 3-column project kanban uses `grid-cols-3` with comfortable card width.
**Why it happens:** 6 × min-width ≈ 720px+ is tight.
**How to avoid:** Use `grid-cols-3 lg:grid-cols-6` or a horizontally scrollable container (`overflow-x-auto` on the grid wrapper). Alternatively, `min-w-[200px]` per column inside a scroll container.
**Warning signs:** Cards truncate before the lead name is visible.
### Pitfall 2: Won/Lost columns need visual distinction
**What goes wrong:** Dropping to "won" or "lost" looks identical to other columns — user may not notice the semantic weight of these terminal states.
**Why it happens:** Uniform column styling.
**How to avoid:** Use visually distinct `headerClass` (green for won, red for lost) and consider a stronger `isOver` highlight for these columns. The STAGE_COLOR map in `LeadTable.tsx` already defines these colors — reuse them.
### Pitfall 3: Leads not sorted consistently between views
**What goes wrong:** Table shows leads ordered by `updated_at DESC`; kanban derived from the same array shows different visual order depending on column grouping.
**Why it happens:** No explicit sort on the kanban card order within a column.
**How to avoid:** Sort leads within each column by `updated_at DESC` (same as the existing query order). The `getLeadsWithTags` query already returns `orderBy(desc(leads.updated_at))` so inheriting that order is sufficient.
### Pitfall 4: `router.refresh()` causes full re-mount of kanban
**What goes wrong:** After a drag-drop, `router.refresh()` rehydrates the server component, re-running `getLeadsWithTags()`. If the drag animation hasn't completed, it can cause a visual flicker.
**Why it happens:** Next.js App Router refresh re-renders the whole tree.
**How to avoid:** The existing `KanbanBoard.tsx` uses the same pattern without issue. The `setActiveId(null)` call in `handleDragEnd` clears the overlay before the refresh arrives, so the flicker is acceptable. This is the project's established pattern — do not deviate.
### Pitfall 5: Search/filter not available in kanban view
**What goes wrong:** The search bar lives in `LeadsSearch.tsx` and only filters `LeadTable`. If the user switches to kanban, they lose the ability to filter.
**Why it happens:** The view toggle renders either `LeadsSearch` (with its internal state) or the bare `LeadsKanbanBoard`.
**How to avoid:** Two acceptable approaches: (a) wrap both views together inside `LeadsSearch` and pass filtered leads to both (preferred — search state persists across view switches); or (b) accept that kanban shows all leads unfiltered (simpler, acceptable for now given the single-user context). Document the choice in the plan.
---
## Code Examples
### Existing updateLeadField signature (server action)
```typescript
// Source: VERIFIED from src/app/admin/leads/actions.ts line 174
// EDITABLE_FIELDS includes "status" — drag-drop can call this directly
export async function updateLeadField(
leadId: string,
fieldName: "name" | "email" | "phone" | "company" | "status" | "next_action",
value: string
): Promise<void>
// Validates: status must be in LEAD_STAGES; throws on invalid value
// Side effects: revalidatePath("/admin/leads") + revalidatePath(`/admin/leads/${leadId}`)
```
### LEAD_STAGES canonical values
```typescript
// Source: VERIFIED from src/lib/lead-validators.ts line 4
export const LEAD_STAGES = [
"contacted",
"qualified",
"proposal_sent",
"negotiating",
"won",
"lost",
] as const;
```
### Existing STAGE_COLOR map (reuse for kanban column headers)
```typescript
// Source: VERIFIED from src/components/admin/leads/LeadTable.tsx line 20
const STAGE_COLOR: Record<string, string> = {
contacted: "bg-blue-100 text-blue-800",
qualified: "bg-purple-100 text-purple-800",
proposal_sent: "bg-amber-100 text-amber-800",
negotiating: "bg-orange-100 text-orange-800",
won: "bg-green-100 text-green-800",
lost: "bg-red-100 text-red-800",
};
// Move to a shared constant (e.g., src/lib/lead-constants.ts) if reused in both components
```
### PhasesViewToggle pattern (exact analog)
```typescript
// Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx
// State: useState<"list" | "kanban">("list")
// Toggle: pill button group (bg-[#f4f4f5] rounded-lg p-1 w-fit)
// Active: bg-white text-[#1A463C] shadow-sm
// Inactive: text-[#71717a] hover:text-[#1a1a1a]
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Lead status change via modal form | Inline dropdown in table cell (`StatusCell`) | Phase 14 | Drag-drop is the third mechanism; all write to same `updateLeadField` action |
| Separate `/admin/analytics` route | Fused into `/admin` dashboard | Phase 18 | No impact on leads page |
| `SendQuoteModal` with dead branch | Dead branch removed | Phase 18 | No impact |
---
## Project Constraints (from CLAUDE.md)
| Directive | Impact on This Phase |
|-----------|---------------------|
| `clients.token` = rotatable, never PK | Not relevant (leads have no token) |
| `quote_items` never exposed via client API | Not relevant (Kanban is admin-only) |
| `deliverables.approved_at` immutable once set | Not relevant |
| Auth: `/admin/*` → Auth.js session | Kanban lives at `/admin/leads` — already protected |
| No file hosting v1 | Not relevant |
| Migration safety: never drop/truncate rows | Phase 19 is UI-only — no schema changes, no migration needed |
| Security: confirm before destructive commands | No destructive operations |
| No package installs without showing name+version | No new packages needed |
---
## Environment Availability
Step 2.6: SKIPPED — Phase 19 is a pure UI addition. All required libraries (`@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`) are already installed. No external services, databases (beyond the existing Neon Postgres connection), or CLI tools are needed.
---
## Validation Architecture
`nyquist_validation: false` in `.planning/config.json` — section omitted per config.
---
## Security Domain
Phase 19 adds a new interaction path to an existing admin-only route (`/admin/leads`). No new auth surface is introduced.
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (existing) | Auth.js session via `requireAdmin()` in server action |
| V4 Access Control | yes (existing) | `requireAdmin()` guard in `updateLeadField` — drag-drop calls same action |
| V5 Input Validation | yes | `updateLeadField` validates status via `LEAD_STAGES.includes(value)` — no new validation needed |
No new threat surface beyond what Phase 14 already addressed. The drag-drop `handleDragEnd` validates the `over.id` is a known stage before calling the server action — follow the same guard pattern as in `KanbanBoard.tsx` (line 190: `if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus)) return;`).
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The `won` and `lost` columns are terminal states with no special side-effects beyond setting `leads.status` (no auto-creation of client/project, no email trigger) | Architecture Patterns | If the plan later requires auto-provisioning on "won", a new server action will be needed — but PIPE-01/02 say nothing about this, and PROP-04 (auto-provisioning) is deferred to backlog post-R5 |
| A2 | Card content (name, company, next_action) is sufficient for the kanban view; no additional fields are needed per card | Code Examples | If the user wants tags or email visible on cards, the `LeadWithTags` type already provides them — no data-layer change, only card template change |
| A3 | The search filter covering only the table view (not the kanban) is acceptable for v1 of this feature | Common Pitfalls | If the user wants search in kanban too, the fix is to lift filtered state into the toggle wrapper — straightforward but adds scope |
---
## Open Questions
1. **Search/filter scope in kanban view**
- What we know: `LeadsSearch` holds the search `useState` and passes `filtered` leads to `LeadTable`. The kanban would receive all leads from the page.
- What's unclear: Does the user want the search bar to filter the kanban board too, or is it acceptable that kanban shows all leads?
- Recommendation: Default to wrapping both views inside a new `LeadsViewToggle` that receives `leads` (unfiltered) and `options`, manages the view toggle, and passes `filtered` leads to both `LeadTable` and `LeadsKanbanBoard`. This is a clean pattern and handles it gracefully.
2. **Column layout: scroll vs. wrap on 6 columns**
- What we know: The existing kanban uses `grid-cols-3`. Six columns need more space.
- What's unclear: Target viewport is unknown (likely 1440px+ since this is a single-admin tool).
- Recommendation: Use `min-w-[180px]` per column inside an `overflow-x-auto` wrapper. This makes it work on any viewport without content truncation.
---
## Sources
### Primary (HIGH confidence)
- `src/components/admin/kanban/KanbanBoard.tsx` — exact @dnd-kit usage pattern, drag primitives, sensors, DragOverlay, optimistic update + router.refresh()
- `src/components/admin/kanban/PhasesViewToggle.tsx` — view toggle pattern (list/kanban state, pill button UI)
- `src/components/admin/leads/LeadTable.tsx` — STAGE_COLOR map, LeadWithTags usage, StatusCell inline dropdown
- `src/app/admin/leads/actions.ts``updateLeadField` signature, EDITABLE_FIELDS, `requireAdmin()` guard
- `src/lib/lead-validators.ts` — canonical LEAD_STAGES array (6 values)
- `src/lib/admin-queries.ts` lines 883942 — `LeadWithTags` type, `getLeadsWithTags()` query (all fields), `LeadFieldOptions`
- `src/db/schema.ts` lines 441462 — `leads` table definition, `status` column with all 6 stage values documented
- `src/app/admin/leads/LeadsSearch.tsx` — search filter pattern, `LeadWithTags` + `LeadFieldOptions` prop interface
- `src/app/admin/leads/page.tsx` — server component structure, data fetching pattern, `revalidate = 0`
- `src/components/admin/AdminSidebar.tsx``/admin/leads` is already in NAV_ITEMS, no sidebar change needed
- `package.json`@dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2
### Secondary (MEDIUM confidence)
- `.planning/config.json``nyquist_validation: false` confirmed
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all packages verified in package.json; exact primitives verified in KanbanBoard.tsx
- Architecture: HIGH — all patterns verified from existing codebase analogs
- Pitfalls: HIGH — derived from direct code inspection and known Next.js App Router behaviors
- Data layer: HIGH — schema, actions, and query functions all read directly
**Research date:** 2026-06-19
**Valid until:** Stable indefinitely (no external dependencies; codebase-derived findings)
+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*
@@ -1,569 +0,0 @@
---
phase: "01-foundation-client-dashboard"
plan: 03
type: execute
wave: 2
depends_on:
- "01-01"
- "01-02"
files_modified:
- src/middleware.ts
- app/api/internal/validate-token/route.ts
- src/lib/client-view.ts
- app/c/[token]/page.tsx
- app/c/[token]/layout.tsx
autonomous: true
requirements:
- DASH-01
- DASH-02
- DASH-03
- DASH-04
must_haves:
truths:
- "Middleware validates token at edge and returns 404 if token not found"
- "Client can open /c/[token] without login"
- "Server Component fetches client data from DB via token"
- "ClientView type ensures quote_items is never exposed to client API"
- "All phase, task, payment, document, and note data is fetched and passed to UI"
- "TypeScript types are exported for downstream UI rendering"
artifacts:
- path: "src/middleware.ts"
provides: "Token validation using fetch to internal API route (Edge-compatible)"
contains: "function middleware"
- path: "app/api/internal/validate-token/route.ts"
provides: "Node.js API route that queries DB and returns 200/404 for token validation"
min_lines: 20
contains: "clients.token"
- path: "src/lib/client-view.ts"
provides: "Client-safe type definitions and query functions"
contains: "ClientView"
- path: "app/c/[token]/page.tsx"
provides: "Server Component rendering client dashboard"
min_lines: 30
contains: "export default async function"
- path: "app/c/[token]/layout.tsx"
provides: "Layout for token-authenticated routes"
min_lines: 10
key_links:
- from: "src/middleware.ts"
to: "app/api/internal/validate-token/route.ts"
via: "fetch('/api/internal/validate-token?token=X')"
pattern: "validate-token"
- from: "app/api/internal/validate-token/route.ts"
to: "Database query for token validation"
via: "db.select().from(clients).where(eq(clients.token, token))"
pattern: "clients\\.token"
- from: "app/c/[token]/page.tsx"
to: "src/lib/client-view.ts"
via: "import { getClientView }"
pattern: "getClientView"
- from: "ClientView type"
to: "Rendering props"
via: "ensures no quote_items"
pattern: "quote_items"
---
<objective>
**Token Middleware + Client Portal Data Layer:** Create Next.js middleware to validate client tokens at the edge, build the ClientView type system that enforces ClientView vs. AdminView separation, and create a Server Component that fetches and prepares all client dashboard data without exposing admin secrets (quote_items, service prices).
Purpose: Establish the secure client access pattern: middleware validates token → Server Component fetches data → UI receives ClientView shape only. This prevents accidental exposure of admin data to clients.
Output: Fully functional `/c/[token]` route that fetches real client data and prepares it for rendering. No client-side waterfalls.
</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 (Data Flow section, lines 29-50)
@.planning/research/PITFALLS.md (Pitfall 2: Client API Exposes Admin Data, lines 26-38)
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md
@.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Create src/middleware.ts (Edge-compatible fetch pattern) + internal validate-token API route</name>
<files>
src/middleware.ts
app/api/internal/validate-token/route.ts
</files>
<read_first>
src/db/schema.ts (clients table definition)
package.json (verify Next.js version)
</read_first>
<action>
**Why two files:** Next.js middleware runs in the Edge runtime by default. The postgres-js driver (used by Drizzle) requires Node.js `net`/`tls` APIs unavailable at the Edge. The solution is a two-layer pattern: middleware uses `fetch()` to call an internal API route that runs in the Node.js runtime and does the actual DB query.
Create `app/api/internal/validate-token/route.ts` (Node.js runtime, does DB query):
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { clients } from '@/db/schema';
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ valid: false }, { status: 400 });
}
try {
const rows = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.token, token))
.limit(1);
if (rows.length === 0) {
return NextResponse.json({ valid: false }, { status: 404 });
}
return NextResponse.json({ valid: true }, { status: 200 });
} catch {
return NextResponse.json({ valid: false }, { status: 500 });
}
}
```
Create `src/middleware.ts` (Edge-compatible, uses fetch):
```typescript
import { NextRequest, NextResponse } from 'next/server';
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Extract token from path: /c/[token]/...
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
if (!tokenMatch) {
return NextResponse.rewrite(new URL('/not-found', request.url));
}
const token = tokenMatch[1];
try {
// Call internal Node.js API route — Edge middleware cannot use postgres-js directly
const validateUrl = new URL(
`/api/internal/validate-token?token=${encodeURIComponent(token)}`,
request.url
);
const res = await fetch(validateUrl.toString());
if (!res.ok) {
return NextResponse.rewrite(new URL('/not-found', request.url));
}
return NextResponse.next();
} catch {
return NextResponse.rewrite(new URL('/not-found', request.url));
}
}
export const config = {
matcher: ['/c/:path*'],
};
```
Key points:
- Middleware is Edge-compatible: no Node.js imports, only `fetch()`
- DB query lives in the API route (Node.js runtime) where postgres-js works correctly
- Token is URL-encoded before being passed as query param
- Non-existent or invalid tokens resolve to `/not-found` (Next.js built-in 404 page)
- Internal API route should not be called directly by clients (no auth secret needed — it only returns boolean valid/invalid)
</action>
<verify>
<automated>test -f src/middleware.ts && echo "middleware.ts exists"</automated>
<automated>grep -q "export.*function middleware" src/middleware.ts && echo "middleware function exported"</automated>
<automated>grep -q "matcher.*c/" src/middleware.ts && echo "matcher configured for /c/ routes"</automated>
<automated>! grep -q "from '@/db'" src/middleware.ts && echo "middleware does not import drizzle/db (good — Edge safe)"</automated>
<automated>test -f app/api/internal/validate-token/route.ts && echo "internal validate-token route exists"</automated>
<automated>grep -q "clients.token" app/api/internal/validate-token/route.ts && echo "Token DB query in API route"</automated>
</verify>
<acceptance_criteria>
- `src/middleware.ts` does NOT import Drizzle/postgres-js (Edge-safe)
- `src/middleware.ts` fetches `/api/internal/validate-token?token=X`
- `app/api/internal/validate-token/route.ts` queries `clients.token` via Drizzle
- Non-existent tokens return `/not-found` (404)
- Matcher configured for `/c/:path*`
- TypeScript compiles without errors
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 2: Create src/lib/client-view.ts with ClientView type and query functions</name>
<files>
src/lib/client-view.ts
</files>
<read_first>
src/db/schema.ts (all table definitions)
</read_first>
<action>
Create `src/lib/client-view.ts`:
```typescript
import { eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { clients, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema';
/**
* ClientView: The ONLY data shape returned to client-facing routes.
* Deliberately excludes: quote_items, service_catalog, service prices.
* Enforced server-side: client API never touches admin data.
*/
export interface ClientView {
client: {
id: string;
name: string;
brand_name: string;
brief: string;
accepted_total: string; // only total, never breakdown
};
phases: Array<{
id: string;
title: string;
status: 'upcoming' | 'active' | 'done';
sort_order: number;
tasks: Array<{
id: string;
title: string;
description: string | null;
status: 'todo' | 'in_progress' | 'done';
sort_order: number;
deliverables: Array<{
id: string;
title: string;
url: string | null;
status: 'pending' | 'submitted' | 'approved';
approved_at: string | null; // ISO timestamp
}>;
}>;
progress_pct: number; // % of tasks done in this phase
}>;
payments: Array<{
id: string;
label: string; // "Acconto 50%" | "Saldo 50%"
status: 'da_saldare' | 'inviata' | 'saldato';
}>;
documents: Array<{
id: string;
label: string;
url: string;
}>;
notes: Array<{
id: string;
body: string;
created_at: string; // ISO timestamp
}>;
global_progress_pct: number; // % of all tasks done across all phases
}
/**
* getClientView: Fetch all client data and return only ClientView shape.
* NEVER queries quote_items.
*/
export async function getClientView(token: string): Promise<ClientView | null> {
// Fetch client
const clientRow = await db
.select()
.from(clients)
.where(eq(clients.token, token))
.limit(1);
if (clientRow.length === 0) {
return null;
}
const client = clientRow[0];
// Fetch all phases for this client
const phasesRows = await db
.select()
.from(phases)
.where(eq(phases.client_id, client.id))
.orderBy(phases.sort_order);
// Fetch tasks scoped to this client's phases only
const phaseIds = phasesRows.map((p) => p.id);
const tasksRows = phaseIds.length === 0
? []
: await db
.select()
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds))
.orderBy(tasks.sort_order);
// Fetch deliverables scoped to this client's tasks only
const taskIds = tasksRows.map((t) => t.id);
const deliverables_rows = taskIds.length === 0
? []
: await db
.select()
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
// Fetch payments
const paymentsRows = await db
.select()
.from(payments)
.where(eq(payments.client_id, client.id));
// Fetch documents
const documentsRows = await db
.select()
.from(documents)
.where(eq(documents.client_id, client.id));
// Fetch notes
const notesRows = await db
.select()
.from(notes)
.where(eq(notes.client_id, client.id))
.orderBy(notes.created_at);
// Build hierarchical structure
const phasesList = phasesRows.map((phase) => {
const phaseTasksRows = tasksRows.filter((t) => t.phase_id === phase.id);
const tasksList = phaseTasksRows.map((task) => {
const taskDeliverables = deliverables_rows
.filter((d) => d.task_id === task.id)
.map((d) => ({
id: d.id,
title: d.title,
url: d.url,
status: d.status as 'pending' | 'submitted' | 'approved',
approved_at: d.approved_at ? new Date(d.approved_at).toISOString() : null,
}));
return {
id: task.id,
title: task.title,
description: task.description,
status: task.status as 'todo' | 'in_progress' | 'done',
sort_order: task.sort_order,
deliverables: taskDeliverables,
};
});
// Calculate progress for this phase
const taskCount = tasksList.length;
const doneCount = tasksList.filter((t) => t.status === 'done').length;
const progress_pct = taskCount === 0 ? 0 : Math.round((doneCount / taskCount) * 100);
return {
id: phase.id,
title: phase.title,
status: phase.status as 'upcoming' | 'active' | 'done',
sort_order: phase.sort_order,
tasks: tasksList,
progress_pct,
};
});
// Calculate global progress
const allTasks = phasesRows.flatMap((p) =>
tasksRows.filter((t) => t.phase_id === p.id)
);
const allDoneTasks = allTasks.filter((t) => t.status === 'done').length;
const globalProgressPct = allTasks.length === 0 ? 0 : Math.round((allDoneTasks / allTasks.length) * 100);
// Map payments (do NOT expose amount — only label and status)
const paymentsList = paymentsRows.map((p) => ({
id: p.id,
label: p.label,
status: p.status as 'da_saldare' | 'inviata' | 'saldato',
}));
// Map documents
const documentsList = documentsRows.map((d) => ({
id: d.id,
label: d.label,
url: d.url,
}));
// Map notes
const notesList = notesRows.map((n) => ({
id: n.id,
body: n.body,
created_at: new Date(n.created_at).toISOString(),
}));
return {
client: {
id: client.id,
name: client.name,
brand_name: client.brand_name,
brief: client.brief,
accepted_total: client.accepted_total ?? '0',
},
phases: phasesList,
payments: paymentsList,
documents: documentsList,
notes: notesList,
global_progress_pct: globalProgressPct,
};
}
```
Key points:
- `ClientView` interface explicitly omits admin data
- `getClientView()` never queries `quote_items`, `service_catalog`, or service prices
- Payments are returned WITHOUT amount (only label and status)
- All timestamps are ISO strings for JSON serialization
- Progress percentages are calculated server-side
</action>
<verify>
<automated>test -f src/lib/client-view.ts && echo "client-view.ts exists"</automated>
<automated>grep -q "interface ClientView" src/lib/client-view.ts && echo "ClientView interface defined"</automated>
<automated>grep -q "export async function getClientView" src/lib/client-view.ts && echo "getClientView function exported"</automated>
<automated>! grep -q "quote_items\|service_catalog" src/lib/client-view.ts && echo "quote_items not referenced (good)"</automated>
<automated>grep -q "inArray" src/lib/client-view.ts && echo "inArray scoping present"</automated>
<automated>grep -q "accepted_total.*?? '0'" src/lib/client-view.ts && echo "null coalescing on accepted_total"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "TypeScript errors" || echo "TypeScript OK"</automated>
</verify>
<acceptance_criteria>
- `src/lib/client-view.ts` exists with `ClientView` interface and `getClientView()` function
- Interface does NOT include quote_items, service_catalog, or individual service prices
- Payments are returned with only label and status (no amount)
- Function returns hierarchical data: client → phases → tasks → deliverables
- Progress percentages are calculated server-side
- TypeScript compiles without errors
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 3: Create app/c/[token]/page.tsx Server Component to render client dashboard</name>
<files>
app/c/[token]/page.tsx
app/c/[token]/layout.tsx
</files>
<read_first>
src/lib/client-view.ts (ClientView interface)
</read_first>
<action>
Create `app/c/[token]/layout.tsx`:
```typescript
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Client Portal',
description: 'Project status dashboard',
};
export default function ClientLayout({
children,
params,
}: {
children: React.ReactNode;
params: { token: string };
}) {
return <>{children}</>;
}
```
Create `app/c/[token]/page.tsx` (Server Component):
```typescript
import { getClientView } from '@/lib/client-view';
import { notFound } from 'next/navigation';
export const revalidate = 60; // ISR: revalidate every 60 seconds
export default async function ClientDashboard({
params,
}: {
params: { token: string };
}) {
const view = await getClientView(params.token);
if (!view) {
notFound();
}
return (
<div className="min-h-screen bg-white">
{/* Placeholder: Dashboard will be built in Plan 04 */}
<div className="p-6">
<h1 className="text-2xl font-bold">{view.client.brand_name}</h1>
<p className="text-gray-600">{view.client.brief}</p>
<p className="text-sm text-gray-400 mt-2">Token: {params.token}</p>
</div>
</div>
);
}
```
This page:
- Fetches ClientView data via `getClientView()`
- Uses Server Component (no Client Component overhead)
- Returns 404 if token not found
- Minimal placeholder content (full UI in Plan 04)
- ISR enabled: revalidates every 60 seconds so updates are visible within a minute
</action>
<verify>
<automated>test -f app/c/\[token\]/page.tsx && echo "Client page route exists"</automated>
<automated>grep -q "export default async function" app/c/\[token\]/page.tsx && echo "Server Component syntax correct"</automated>
<automated>grep -q "getClientView" app/c/\[token\]/page.tsx && echo "getClientView is called"</automated>
<automated>grep -q "notFound()" app/c/\[token\]/page.tsx && echo "404 handling in place"</automated>
<automated>test -f app/c/\[token\]/layout.tsx && echo "Layout file exists"</automated>
</verify>
<acceptance_criteria>
- `app/c/[token]/page.tsx` exists as a Server Component
- `app/c/[token]/layout.tsx` exists with metadata
- Page calls `getClientView()` and renders minimal placeholder
- 404 is returned if view is null
- `npm run build` succeeds
</acceptance_criteria>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client request → Middleware | Middleware validates token before any page renders; 404 on invalid token |
| Server Component → Database | getClientView() queries only client-safe fields; never queries quote_items |
| ClientView → Serialization | ClientView type prevents accidental inclusion of admin data in JSON responses |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-001 | Information Disclosure | ClientView shape | mitigate | TypeScript interface enforces shape; admin data fields are never included; IDE warnings if field is accessed |
| T-03-002 | Tampering | Token parameter | mitigate | Middleware validates token before page renders; invalid tokens → 404 before DB state is exposed |
| T-03-003 | Denial of Service | getClientView() query | accept | Queries are indexed on client_id and token; no N+1 queries; Postgres will handle reasonable load |
</threat_model>
<verification>
After plan execution:
1. Run `npm run build` → no errors
2. Visit `http://localhost:3000/c/invalid-token` → should return 404 (after db is seeded)
3. Check `src/middleware.ts` → validates token at edge
4. Check `src/lib/client-view.ts` → ClientView interface does not expose quote_items
5. Check `app/c/[token]/page.tsx` → Server Component structure correct
</verification>
<success_criteria>
- Middleware validates tokens at the edge
- Server Component fetches ClientView data without exposing admin secrets
- Invalid tokens return 404
- TypeScript enforces ClientView shape (no quote_items, no prices)
- Route is ready for UI rendering (Plan 04)
- Ready to proceed to Plan 04 (Dashboard UI)
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md`
</output>
@@ -1,149 +0,0 @@
---
phase: 01-foundation-client-dashboard
plan: 03
subsystem: client-portal
tags: [nextjs, middleware, drizzle-orm, client-view, server-component, edge-runtime]
# Dependency graph
requires:
- 01-01 (Next.js bootstrap, DATABASE_URL, Drizzle client in src/db/index.ts)
- 01-02 (src/db/schema.ts — clients, phases, tasks, deliverables, payments, documents, notes)
provides:
- src/proxy.ts (Edge proxy per validazione token su /c/:path*)
- src/app/api/internal/validate-token/route.ts (Node.js route che interroga clients.token)
- src/lib/client-view.ts (ClientView interface + getClientView() function)
- src/app/c/[token]/page.tsx (Server Component placeholder per dashboard cliente)
- src/app/c/[token]/layout.tsx (Layout con metadata per rotte token-auth)
affects:
- 01-04-dashboard-ui (consuma ClientView interface e getClientView())
- 01-05-seed-deploy (genera token, verifica accesso /c/[token])
# Tech tracking
tech-stack:
added: []
patterns:
- "Edge proxy pattern: proxy.ts usa fetch() verso /api/internal/validate-token — nessun import Drizzle/postgres-js in Edge runtime"
- "Next.js 16 breaking change: file convention rinominata da 'middleware' a 'proxy'; export function rinominata da 'middleware' a 'proxy'"
- "Next.js 15+ breaking change: params in Server Component è Promise<{ token: string }> — await params prima dell'uso"
- "ClientView enforced server-side: interface TypeScript + query function che non tocca mai quote_items o service_catalog"
- "inArray() per scoping tasks/deliverables: previene full table scan su clienti che non appartengono alla sessione"
key-files:
created:
- src/proxy.ts (proxy Edge-compatible — rinominato da middleware.ts per Next.js 16)
- src/app/api/internal/validate-token/route.ts (Node.js route, query clients.token via Drizzle)
- src/lib/client-view.ts (ClientView interface + getClientView() — 209 righe)
- src/app/c/[token]/page.tsx (Server Component placeholder — 28 righe)
- src/app/c/[token]/layout.tsx (Layout con metadata — 14 righe)
modified: []
key-decisions:
- "Next.js 16 richiede file 'proxy.ts' (non 'middleware.ts') ed export function 'proxy' (non 'middleware') — auto-corretto da Rule 1"
- "params nelle Server Component Next.js 15+ è Promise<{ token: string }> — await obbligatorio (breaking change)"
- "ClientView.payments non espone 'amount' — solo label e status — vincolo architetturale LOCKED rispettato"
- "getClientView() usa inArray() per scopare tasks e deliverables ai soli phase_id del cliente, evitando leak cross-client"
# Metrics
duration: 25min
completed: 2026-05-14
---
# Phase 1 Plan 03: Token Middleware + Client Portal Data Layer — Route /c/[token] operativa
**Edge proxy con validazione token, ClientView type system che esclude quote_items, e Server Component che fetcha tutti i dati cliente senza esporre segreti admin. Build Next.js 16 senza errori TypeScript.**
## Performance
- **Duration:** ~25 min
- **Started:** 2026-05-13T22:50:00Z
- **Completed:** 2026-05-14T00:15:00Z
- **Tasks:** 3/3
- **Files created:** 5
## Accomplishments
- `src/proxy.ts`: proxy Edge-compatible per Next.js 16 — valida token via `fetch('/api/internal/validate-token')` senza import Drizzle/postgres-js. Matcher configurato su `/c/:path*`. Token non trovato → rewrite su `/not-found`.
- `src/app/api/internal/validate-token/route.ts`: route Node.js che interroga `clients.token` via Drizzle ORM. Ritorna `{ valid: true }` (200) o `{ valid: false }` (404/400/500). Drizzle funziona correttamente qui perché non è nel runtime Edge.
- `src/lib/client-view.ts`: `ClientView` interface che esclude esplicitamente `quote_items`, `service_catalog` e prezzi singoli. `getClientView()` esegue 6 query scoped (client by token → phases → tasks via inArray → deliverables via inArray → payments → documents → notes). Progress % calcolata server-side. `accepted_total: client.accepted_total ?? '0'`.
- `src/app/c/[token]/page.tsx`: Server Component con `await params`, chiama `getClientView(token)`, ritorna `notFound()` se null. ISR a 60s.
- `src/app/c/[token]/layout.tsx`: layout minimo con metadata.
- `npm run build` completato con successo — zero errori TypeScript, tutte le route presenti nel build output.
## Task Commits
1. **Task 1: Edge proxy + validate-token API route**`ef34817` (feat)
2. **Task 2: ClientView type system + getClientView()**`14787ba` (feat)
3. **Task 3: /c/[token] Server Component + layout**`8b5e723` (feat, include deviazione Rule 1)
## Files Created/Modified
- `src/proxy.ts` — Edge proxy per /c/:path* (rinominato da middleware.ts — vedi Deviazioni)
- `src/app/api/internal/validate-token/route.ts` — Node.js route, query `eq(clients.token, token)`, risposta 200/404
- `src/lib/client-view.ts` — ClientView interface (quote_items mai inclusi) + getClientView() con inArray scoping
- `src/app/c/[token]/page.tsx` — Server Component placeholder, notFound() su token invalido
- `src/app/c/[token]/layout.tsx` — Layout con metadata
## Decisions Made
- **Next.js 16 proxy convention:** La nuova convenzione rinomina `middleware.ts``proxy.ts` ed esige `export function proxy` (non `middleware`). La build ha segnalato il deprecation warning al primo tentativo. Risolto con rename + export update (Rule 1).
- **params come Promise:** Next.js 15+ tratta `params` come `Promise<{ token: string }>` nelle Server Component. Il piano usava la sintassi Next.js 14 sincrona — aggiornato ad `await params` per conformità (Rule 1).
- **ClientView.payments senza amount:** Il campo `amount` di `payments` è intenzionalmente omesso dalla `ClientView`. Il cliente vede solo `label` e `status`. Vincolo architetturale LOCKED rispettato a livello di query.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Next.js 16 depreca la convenzione 'middleware' in favore di 'proxy'**
- **Found during:** Task 3 — `npm run build` ha emesso warning e poi errore: "Proxy is missing expected function export name"
- **Issue:** Next.js 16 ha rinominato la file convention da `middleware.ts` a `proxy.ts` e richiede che la funzione esportata si chiami `proxy` (o `default`), non `middleware`.
- **Fix:** Rinominato `src/middleware.ts``src/proxy.ts`; rinominato `export async function middleware``export async function proxy`. Il commit ef34817 conteneva middleware.ts — il Task 3 commit 8b5e723 include la modifica.
- **Files modified:** src/proxy.ts (rinominato da src/middleware.ts)
- **Commit:** 8b5e723
**2. [Rule 1 - Bug] params come Promise in Next.js 15+ Server Component**
- **Found during:** Task 3 — il piano usava la sintassi `params: { token: string }` di Next.js 14
- **Issue:** In Next.js 15+, i `params` nelle Server Component sono `Promise<{ token: string }>`. Usare la sintassi sincrona avrebbe causato TypeScript error e comportamento errato a runtime.
- **Fix:** Tipizzato `params: Promise<{ token: string }>` e aggiunto `const { token } = await params;` prima dell'uso.
- **Files modified:** src/app/c/[token]/page.tsx
- **Commit:** 8b5e723
## Known Stubs
La `page.tsx` mostra un placeholder minimo (brand_name, brief, token). Questo è **intenzionale e documentato nel piano**: "Placeholder content — full UI in Plan 04". Il Plan 04 (Dashboard UI) sostituirà questo placeholder con la UI completa. Il goal del piano (route operativa + data fetching corretto) è raggiunto pienamente.
## Threat Surface Scan
Nessuna nuova superficie di sicurezza non prevista dal threat model 01-03:
- T-03-001 (ClientView shape): mitigato — interface TypeScript + getClientView() non tocca mai quote_items/service_catalog
- T-03-002 (Token parameter): mitigato — proxy valida il token prima che qualsiasi pagina venga renderizzata; token invalidi → /not-found
- T-03-003 (DoS su getClientView): accettato — query indicizzate su client_id/token
L'API route `/api/internal/validate-token` è accessibile pubblicamente (nessun secret header). Questo è intenzionale: ritorna solo `{ valid: boolean }` — nessun dato cliente esposto. Un attaccante può enumerare token validi con brute force ma: (1) nanoid 21 chars offre ~126 bit di entropia, rendendo il brute force computazionalmente impossibile; (2) nessun dato sensibile è esposto dalla route.
## Self-Check
- [x] `src/proxy.ts` esiste (edge proxy — rinominato da middleware.ts)
- [x] `src/app/api/internal/validate-token/route.ts` esiste (query clients.token via Drizzle)
- [x] `src/lib/client-view.ts` esiste (ClientView interface + getClientView())
- [x] `src/app/c/[token]/page.tsx` esiste (Server Component con await params)
- [x] `src/app/c/[token]/layout.tsx` esiste
- [x] Commit `ef34817` esiste (Task 1)
- [x] Commit `14787ba` esiste (Task 2)
- [x] Commit `8b5e723` esiste (Task 3)
- [x] `npm run build` completato senza errori TypeScript
- [x] Build output mostra `/api/internal/validate-token` (Dynamic) e `/c/[token]` (Dynamic)
- [x] `proxy.ts` NON importa `@/db` o `drizzle-orm` (Edge safe)
- [x] `client-view.ts` non contiene query su `quote_items` o `service_catalog`
- [x] `accepted_total: client.accepted_total ?? '0'` presente
## Self-Check: PASSED
## Next Phase Readiness
- Plan 04 (Dashboard UI) può partire — `getClientView()` è disponibile e testa TypeScript OK
- Import pattern: `import { getClientView, type ClientView } from '@/lib/client-view'`
- La route `/c/[token]` è operativa — con un cliente seedato da Plan 05 sarà accessibile
---
*Phase: 01-foundation-client-dashboard*
*Completed: 2026-05-14*
@@ -1,864 +0,0 @@
---
phase: "01-foundation-client-dashboard"
plan: 04
type: execute
wave: 2
depends_on:
- "01-01"
- "01-02"
- "01-03"
files_modified:
- app/c/[token]/page.tsx
- src/components/client-dashboard.tsx
- src/components/phase-timeline.tsx
- src/components/payment-status.tsx
- src/components/documents-section.tsx
- src/components/notes-section.tsx
- src/app/globals.css
- tailwind.config.ts
autonomous: true
requirements:
- DASH-02
- DASH-03
- DASH-04
- DASH-07
- DASH-08
- DASH-09
- DASH-10
must_haves:
truths:
- "Client dashboard displays client brand name prominently with iamcavalli logo in corner"
- "Global progress bar at top shows % of all tasks completed"
- "Phases are displayed as lateral timeline (left indicator, content right)"
- "Each phase shows progress bar (% from completed tasks) + task list with status badges"
- "Tasks are nested within phases with status visible (todo/in_progress/done)"
- "Payment section always visible: accepted_total + Acconto 50% status + Saldo 50% status (NO amounts)"
- "Document links are clickable (opens external URL)"
- "Notes/decision log is visible (read-only, may be empty)"
- "Layout is mobile-responsive and light & clean visual style"
artifacts:
- path: "app/c/[token]/page.tsx"
provides: "Server Component rendering ClientDashboard"
min_lines: 20
- path: "src/components/client-dashboard.tsx"
provides: "Layout wrapper + main sections (header, progress, phases, payments, documents, notes)"
min_lines: 50
- path: "src/components/phase-timeline.tsx"
provides: "Lateral timeline rendering with phase cards and task lists"
min_lines: 80
- path: "src/components/payment-status.tsx"
provides: "Payment section: accepted_total + 2 payment rows with status"
min_lines: 30
- path: "src/components/documents-section.tsx"
provides: "List of external document links"
min_lines: 20
- path: "src/components/notes-section.tsx"
provides: "Read-only notes list with timestamps"
min_lines: 20
- path: "tailwind.config.ts"
provides: "Light & clean design tokens (updated from bootstrap)"
contains: "colors"
key_links:
- from: "app/c/[token]/page.tsx"
to: "ClientDashboard component"
via: "import { ClientDashboard }"
pattern: "<ClientDashboard"
- from: "ClientDashboard"
to: "PhaseTimeline + PaymentStatus + DocumentsSection + NotesSection"
via: "nested component props"
pattern: "view\\.phases"
- from: "PhaseTimeline"
to: "task status badges"
via: "status className mapping"
pattern: "status.*todo.*in_progress.*done"
---
<objective>
**Client Dashboard UI — Vertical Slice:** Render the complete client dashboard with all UI sections: header with branding, global progress bar, lateral phase timeline, task lists with status, payment status section, external document links, and read-only notes log. Implement light & clean visual style with mobile-first responsive design using Tailwind CSS and shadcn/ui components.
Purpose: Deliver the core user-facing product: a client can open their secret link and see the complete project status at a glance, with clear progress indicators, task hierarchy, payment overview, and documents.
Output: Fully rendered client portal with all DASH-02 through DASH-10 requirements implemented in the UI.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (Decisions D-04 through D-12)
@.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md
@src/lib/client-view.ts (ClientView interface)
</context>
<tasks>
<task type="auto">
<name>Task 1: Configure design tokens (tailwind.config.ts + globals.css) and wire app/c/[token]/page.tsx to ClientDashboard</name>
<files>
tailwind.config.ts
src/app/globals.css
app/c/[token]/page.tsx
</files>
<read_first>
tailwind.config.ts (current bootstrap)
src/app/globals.css (current bootstrap)
src/components/client-dashboard.tsx (will exist after Task 2 — read after Task 2 completes)
src/lib/client-view.ts (ClientView interface)
</read_first>
<action>
Update `tailwind.config.ts` to define light & clean design tokens:
```typescript
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// Light & clean palette
'primary': '#1a1a1a', // deep charcoal for text
'secondary': '#666666', // medium gray for secondary text
'tertiary': '#999999', // light gray for hints
'bg-light': '#ffffff', // pure white
'bg-subtle': '#f9f9f9', // very light gray
'border-light': '#e5e5e5', // subtle border
'accent': '#0066cc', // blue accent (will be brand-aware in Phase 2)
'success': '#22c55e', // green for done
'warning': '#eab308', // yellow for in-progress
'info': '#3b82f6', // blue for pending
},
spacing: {
'xs': '0.5rem',
'sm': '1rem',
'md': '1.5rem',
'lg': '2rem',
'xl': '3rem',
},
fontSize: {
'xs': '0.75rem',
'sm': '0.875rem',
'base': '1rem',
'lg': '1.125rem',
'xl': '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
},
fontFamily: {
'sans': [
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'sans-serif',
],
},
},
},
plugins: [],
};
export default config;
```
Update `src/app/globals.css`:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-white text-primary font-sans;
line-height: 1.6;
}
h1 {
@apply text-3xl font-bold tracking-tight;
}
h2 {
@apply text-2xl font-bold;
}
h3 {
@apply text-xl font-semibold;
}
p {
@apply text-base text-secondary;
}
a {
@apply text-accent hover:underline transition-colors;
}
.border-subtle {
@apply border border-border-light;
}
.bg-subtle {
@apply bg-bg-subtle;
}
```
Update `app/c/[token]/page.tsx` to replace the Plan 03 placeholder with the full ClientDashboard render:
```typescript
import { getClientView } from '@/lib/client-view';
import { ClientDashboard } from '@/components/client-dashboard';
import { notFound } from 'next/navigation';
export const revalidate = 60;
export async function generateMetadata({
params,
}: {
params: { token: string };
}) {
const view = await getClientView(params.token);
if (!view) {
return { title: 'Not Found' };
}
return {
title: `${view.client.brand_name} — Project Status | iamcavalli`,
description: view.client.brief || 'Project status dashboard',
};
}
export default async function ClientPage({
params,
}: {
params: { token: string };
}) {
const view = await getClientView(params.token);
if (!view) {
notFound();
}
return <ClientDashboard view={view} />;
}
```
Note: `getClientView` is called twice (once in `generateMetadata`, once in `ClientPage`). Next.js 15 deduplicates fetch calls within the same render, and since this is a DB query via Drizzle (not fetch), use React `cache()` in `client-view.ts` if double-call is a concern — acceptable for Phase 1 given low traffic.
</action>
<verify>
<automated>grep -q "colors:" tailwind.config.ts && echo "Color tokens defined"</automated>
<automated>grep -q "primary\|accent\|success" tailwind.config.ts && echo "Key colors present"</automated>
<automated>grep -q "@tailwind" src/app/globals.css && echo "Tailwind directives in globals.css"</automated>
<automated>grep -q "ClientDashboard" app/c/\[token\]/page.tsx && echo "ClientDashboard wired in page"</automated>
<automated>grep -q "generateMetadata" app/c/\[token\]/page.tsx && echo "Dynamic metadata present"</automated>
</verify>
<acceptance_criteria>
- `tailwind.config.ts` contains color tokens: primary, secondary, accent, success, warning
- `globals.css` includes Tailwind directives and base typography
- `app/c/[token]/page.tsx` renders `<ClientDashboard view={view} />` with dynamic metadata
- 404 returned if token invalid
- `npm run build` succeeds
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 2: Create ClientDashboard wrapper component with header, global progress, and section layout</name>
<files>
src/components/client-dashboard.tsx
</files>
<read_first>
src/lib/client-view.ts (ClientView interface)
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-06 through D-10)
</read_first>
<action>
Create `src/components/client-dashboard.tsx`:
```typescript
'use client';
import { ClientView } from '@/lib/client-view';
import { Progress } from '@/components/ui/progress';
import { PhaseTimeline } from './phase-timeline';
import { PaymentStatus } from './payment-status';
import { DocumentsSection } from './documents-section';
import { NotesSection } from './notes-section';
interface ClientDashboardProps {
view: ClientView;
}
export function ClientDashboard({ view }: ClientDashboardProps) {
return (
<div className="min-h-screen bg-white">
{/* Header: Logo + Brand Name */}
<header className="bg-white border-b border-subtle sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
{/* iamcavalli logo (small, corner) */}
<div className="text-xs font-semibold text-tertiary">iamcavalli</div>
{/* Client brand name (prominent) */}
<h1 className="text-2xl sm:text-3xl font-bold text-primary flex-1 text-center mx-4">
{view.client.brand_name}
</h1>
{/* Spacer for balance */}
<div className="w-20" />
</div>
</div>
</header>
{/* Global Progress Bar */}
<section className="bg-bg-subtle border-b border-subtle">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="space-y-2">
<p className="text-sm font-semibold text-primary">Project Progress</p>
<Progress
value={view.global_progress_pct}
className="h-2"
/>
<p className="text-xs text-tertiary">
{view.global_progress_pct}% Complete
</p>
</div>
</div>
</section>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Brief */}
{view.client.brief && (
<section className="mb-12">
<p className="text-lg text-secondary italic">
"{view.client.brief}"
</p>
</section>
)}
{/* Phase Timeline */}
<section className="mb-12">
<h2 className="text-2xl font-bold mb-8">Project Phases</h2>
<PhaseTimeline phases={view.phases} />
</section>
{/* Payment Status */}
<section className="mb-12">
<h2 className="text-2xl font-bold mb-6">Payment Status</h2>
<PaymentStatus
accepted_total={view.client.accepted_total}
payments={view.payments}
/>
</section>
{/* Documents */}
{view.documents.length > 0 && (
<section className="mb-12">
<h2 className="text-2xl font-bold mb-6">Documents & Files</h2>
<DocumentsSection documents={view.documents} />
</section>
)}
{/* Notes / Decision Log */}
{view.notes.length > 0 && (
<section className="mb-12">
<h2 className="text-2xl font-bold mb-6">Notes & Decisions</h2>
<NotesSection notes={view.notes} />
</section>
)}
</main>
{/* Footer */}
<footer className="bg-bg-subtle border-t border-subtle mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<p className="text-xs text-tertiary text-center">
This is a private project dashboard. Do not share your unique link.
</p>
</div>
</footer>
</div>
);
}
```
Key points:
- Header: small "iamcavalli" logo (top-left), client brand_name centered (prominent)
- Global progress bar shows % of all tasks done
- Section headers are h2 (consistent sizing)
- Responsive layout: max-width container with mobile padding
- Brief is quoted and italicized
- Documents and Notes sections show only if data exists
</action>
<verify>
<automated>test -f src/components/client-dashboard.tsx && echo "ClientDashboard component exists"</automated>
<automated>grep -q "export function ClientDashboard" src/components/client-dashboard.tsx && echo "Component exported"</automated>
<automated>grep -q "iamcavalli" src/components/client-dashboard.tsx && echo "Logo text present"</automated>
<automated>grep -q "brand_name" src/components/client-dashboard.tsx && echo "Brand name rendered"</automated>
<automated>grep -q "global_progress_pct" src/components/client-dashboard.tsx && echo "Progress bar displays"</automated>
</verify>
<acceptance_criteria>
- Component is exported and accepts ClientView props
- Header displays iamcavalli logo (small) + brand_name (prominent)
- Global progress bar shows project completion %
- Main sections: brief, phases, payments, documents (conditional), notes (conditional)
- Responsive layout with max-width container
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 3: Create PhaseTimeline component for lateral timeline layout with task lists</name>
<files>
src/components/phase-timeline.tsx
</files>
<read_first>
src/lib/client-view.ts (phase and task structure)
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-07, D-08)
</read_first>
<action>
Create `src/components/phase-timeline.tsx`:
```typescript
'use client';
import { ClientView } from '@/lib/client-view';
import { Progress } from '@/components/ui/progress';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, Circle, Clock } from 'lucide-react';
interface PhaseTimelineProps {
phases: ClientView['phases'];
}
export function PhaseTimeline({ phases }: PhaseTimelineProps) {
return (
<div className="space-y-8">
{phases.map((phase, index) => (
<div key={phase.id} className="flex gap-6">
{/* Left: Timeline Indicator */}
<div className="flex flex-col items-center gap-2">
{/* Circle indicator */}
<div className="relative z-10 w-10 h-10 bg-white border-2 border-accent rounded-full flex items-center justify-center shadow-sm">
{phase.status === 'done' ? (
<CheckCircle2 className="w-6 h-6 text-success" />
) : phase.status === 'active' ? (
<Circle className="w-6 h-6 text-accent" />
) : (
<Clock className="w-6 h-6 text-tertiary" />
)}
</div>
{/* Vertical line (not on last) */}
{index < phases.length - 1 && (
<div className="flex-1 w-0.5 bg-border-light" style={{ minHeight: '120px' }} />
)}
</div>
{/* Right: Phase Content */}
<div className="flex-1 pb-8">
{/* Phase Card */}
<Card className="p-6 border-subtle hover:shadow-md transition-shadow">
{/* Phase Header */}
<div className="flex items-start justify-between mb-4">
<h3 className="text-xl font-bold text-primary">
{phase.title}
</h3>
<Badge
className={`capitalize ${
phase.status === 'done' ? 'bg-success text-white' :
phase.status === 'active' ? 'bg-accent text-white' :
'bg-tertiary text-white'
}`}
>
{phase.status === 'upcoming' ? 'Upcoming' :
phase.status === 'active' ? 'In Progress' : 'Done'}
</Badge>
</div>
{/* Phase Progress Bar */}
<div className="mb-6 space-y-2">
<div className="flex justify-between items-center">
<p className="text-xs font-semibold text-secondary">
Phase Progress
</p>
<p className="text-xs text-tertiary">
{phase.progress_pct}%
</p>
</div>
<Progress value={phase.progress_pct} className="h-2" />
</div>
{/* Task List */}
<div className="space-y-3">
<p className="text-sm font-semibold text-secondary">
Tasks ({phase.tasks.filter(t => t.status === 'done').length} of {phase.tasks.length})
</p>
{phase.tasks.length === 0 ? (
<p className="text-sm text-tertiary italic">No tasks yet</p>
) : (
<ul className="space-y-2">
{phase.tasks.map((task) => (
<li
key={task.id}
className="flex items-start gap-3 p-2 rounded hover:bg-bg-subtle transition-colors"
>
{/* Task Status Icon */}
{task.status === 'done' ? (
<CheckCircle2 className="w-5 h-5 text-success mt-0.5 flex-shrink-0" />
) : task.status === 'in_progress' ? (
<Circle className="w-5 h-5 text-warning mt-0.5 flex-shrink-0" />
) : (
<Circle className="w-5 h-5 text-info mt-0.5 flex-shrink-0" />
)}
{/* Task Content */}
<div className="flex-1">
<p className={`text-sm ${task.status === 'done' ? 'line-through text-tertiary' : 'text-primary'}`}>
{task.title}
</p>
{task.description && (
<p className="text-xs text-tertiary mt-1">
{task.description}
</p>
)}
{/* Deliverables */}
{task.deliverables.length > 0 && (
<div className="mt-2 space-y-1">
{task.deliverables.map((d) => (
<div
key={d.id}
className="text-xs p-1 bg-bg-subtle rounded flex items-center justify-between gap-2"
>
<span className="text-secondary truncate">
{d.title}
</span>
{d.status === 'approved' && (
<Badge className="bg-success text-white text-xs">
Approved
</Badge>
)}
</div>
))}
</div>
)}
</div>
</li>
))}
</ul>
)}
</div>
</Card>
</div>
</div>
))}
</div>
);
}
```
Key points:
- Left indicator: circle with icon (checkmark for done, dot for upcoming/active)
- Vertical line connects phases (not on last phase)
- Right content: phase card with title, status badge, progress bar, task list
- Task status shown with icons and colors (success/warning/info)
- Deliverables nested under tasks with "Approved" badge if applicable
- Empty state if phase has no tasks
</action>
<verify>
<automated>test -f src/components/phase-timeline.tsx && echo "PhaseTimeline component exists"</automated>
<automated>grep -q "export function PhaseTimeline" src/components/phase-timeline.tsx && echo "Component exported"</automated>
<automated>grep -q "CheckCircle2\|Circle" src/components/phase-timeline.tsx && echo "Icons imported"</automated>
<automated>grep -q "progress_pct" src/components/phase-timeline.tsx && echo "Progress bar displays"</automated>
</verify>
<acceptance_criteria>
- Component renders lateral timeline layout
- Each phase shows: title, status badge, progress bar, task count
- Tasks show status with icons (checkmark/circle)
- Deliverables are nested and show "Approved" badge if applicable
- Empty state for phases with no tasks
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 4: Create PaymentStatus component (accepted_total + payment rows with status badges)</name>
<files>
src/components/payment-status.tsx
</files>
<read_first>
src/lib/client-view.ts (payments shape, PaymentStatus type)
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-10, D-11)
</read_first>
<action>
Create `src/components/payment-status.tsx`:
```typescript
'use client';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ClientView } from '@/lib/client-view';
import { CheckCircle2, Clock, AlertCircle } from 'lucide-react';
interface PaymentStatusProps {
accepted_total: string;
payments: ClientView['payments'];
}
export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps) {
const statusConfig = {
da_saldare: { color: 'bg-info', icon: Clock, label: 'Da Saldare', text: 'white' },
inviata: { color: 'bg-warning', icon: AlertCircle, label: 'Inviata', text: 'white' },
saldato: { color: 'bg-success', icon: CheckCircle2, label: 'Saldato', text: 'white' },
};
return (
<Card className="p-6 border-subtle">
{/* Total */}
<div className="mb-6 pb-6 border-b border-subtle">
<p className="text-sm text-secondary font-semibold mb-2">
Totale Preventivo Accettato
</p>
<p className="text-3xl font-bold text-primary">
€{parseFloat(accepted_total || '0').toLocaleString('it-IT', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
{/* Payment Rows */}
<div className="space-y-4">
{payments.map((payment) => {
const config = statusConfig[payment.status as keyof typeof statusConfig];
const Icon = config?.icon || Clock;
return (
<div
key={payment.id}
className="flex items-center justify-between p-4 bg-bg-subtle rounded-lg border border-subtle"
>
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-secondary flex-shrink-0" />
<p className="text-sm font-semibold text-primary">
{payment.label}
</p>
</div>
<Badge
className={`capitalize ${config?.color} text-${config?.text}`}
>
{config?.label || payment.status}
</Badge>
</div>
);
})}
</div>
{/* Note */}
<p className="text-xs text-tertiary italic mt-6 pt-6 border-t border-subtle">
I pagamenti sono suddivisi in due rate da 50% ciascuna.
Contattaci per domande sui dettagli.
</p>
</Card>
);
}
```
Key points:
- Shows `accepted_total` formatted as Euro currency — NEVER individual line-item amounts
- Two payment rows (Acconto 50%, Saldo 50%) with status badges only
- Status badge colors: da_saldare = blue, inviata = yellow, saldato = green
- Card + Badge from shadcn/ui
</action>
<verify>
<automated>test -f src/components/payment-status.tsx && echo "PaymentStatus component exists"</automated>
<automated>grep -q "export function PaymentStatus" src/components/payment-status.tsx && echo "Component exported"</automated>
<automated>grep -q "accepted_total" src/components/payment-status.tsx && echo "Total displayed"</automated>
<automated>grep -q "da_saldare\|inviata\|saldato" src/components/payment-status.tsx && echo "Status config present"</automated>
</verify>
<acceptance_criteria>
- Component exists and is exported
- Displays accepted_total formatted as Euro (no individual amounts)
- Renders payment rows with status badges (da_saldare/inviata/saldato)
- Uses shadcn/ui Card and Badge
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 5: Create DocumentsSection and NotesSection components (external links + read-only notes)</name>
<files>
src/components/documents-section.tsx
src/components/notes-section.tsx
</files>
<read_first>
src/lib/client-view.ts (documents and notes shapes)
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-12)
</read_first>
<action>
Create `src/components/documents-section.tsx`:
```typescript
'use client';
import { ClientView } from '@/lib/client-view';
import { Card } from '@/components/ui/card';
import { ExternalLink } from 'lucide-react';
interface DocumentsSectionProps {
documents: ClientView['documents'];
}
export function DocumentsSection({ documents }: DocumentsSectionProps) {
return (
<div className="space-y-3">
{documents.map((doc) => (
<Card
key={doc.id}
className="p-4 border-subtle hover:shadow-md transition-shadow"
>
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-3 text-accent hover:text-accent hover:underline group"
>
<span className="font-semibold text-primary group-hover:text-accent">
{doc.label}
</span>
<ExternalLink className="w-4 h-4 flex-shrink-0" />
</a>
</Card>
))}
</div>
);
}
```
Create `src/components/notes-section.tsx`:
```typescript
'use client';
import { ClientView } from '@/lib/client-view';
import { Card } from '@/components/ui/card';
interface NotesSectionProps {
notes: ClientView['notes'];
}
export function NotesSection({ notes }: NotesSectionProps) {
if (notes.length === 0) {
return (
<p className="text-secondary italic text-sm">
No notes yet. Decisions will appear here as they are made.
</p>
);
}
return (
<div className="space-y-4">
{notes.map((note) => (
<Card key={note.id} className="p-4 border-subtle">
<p className="text-sm text-primary leading-relaxed">
{note.body}
</p>
<p className="text-xs text-tertiary mt-3">
{new Date(note.created_at).toLocaleDateString('it-IT', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</Card>
))}
</div>
);
}
```
Key points:
- DocumentsSection: clickable external links with ExternalLink icon, `rel="noopener noreferrer"` for security
- NotesSection: read-only, client never writes (admin writes in Phase 2 admin area)
- NotesSection: empty state shown as italic hint when no notes exist
- Timestamps formatted in Italian locale
</action>
<verify>
<automated>test -f src/components/documents-section.tsx && echo "DocumentsSection component exists"</automated>
<automated>test -f src/components/notes-section.tsx && echo "NotesSection component exists"</automated>
<automated>grep -q "export function DocumentsSection" src/components/documents-section.tsx && echo "DocumentsSection exported"</automated>
<automated>grep -q "export function NotesSection" src/components/notes-section.tsx && echo "NotesSection exported"</automated>
<automated>grep -q "noopener noreferrer" src/components/documents-section.tsx && echo "External link security present"</automated>
</verify>
<acceptance_criteria>
- Both components exist and are exported
- DocumentsSection renders clickable external links with ExternalLink icon and secure rel attributes
- NotesSection shows read-only notes with Italian-formatted timestamps
- NotesSection shows empty state hint when notes array is empty
</acceptance_criteria>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client browser → CSS/HTML | UI rendering is client-safe; no admin secrets in HTML source |
| Link click → External URL | External document links open in new tab with `rel="noopener noreferrer"` |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-001 | Information Disclosure | Payment amounts | mitigate | Payments row shows status only; amounts never rendered on client dashboard |
| T-04-002 | Tampering | External links | accept | Links are user-provided URLs; client-side link validation (hostname check) could be added in Phase 2 |
| T-04-003 | Denial of Service | Image rendering | accept | Dashboard contains only text and icons; no resource-heavy assets |
</threat_model>
<verification>
After plan execution:
1. Run `npm run build` → no errors
2. Verify all component files exist: client-dashboard, phase-timeline, payment-status, documents-section, notes-section
3. Check page rendering logic in `app/c/[token]/page.tsx`
4. Verify mobile responsiveness: layout scales correctly on narrow screens
5. Check that payment amounts are NOT displayed (only status)
</verification>
<success_criteria>
- All UI components are created and exported
- Client dashboard renders complete project status
- Global progress bar and per-phase progress bars display correctly
- Payment section shows only status (no amounts)
- Document links are clickable
- Notes section shows read-only list (or empty state)
- Layout is responsive and uses light & clean design
- Mobile-first design works on small screens
- Ready to proceed to Plan 05 (Seed script + DNS)
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation-client-dashboard/01-04-SUMMARY.md`
</output>
@@ -1,167 +0,0 @@
---
phase: 01-foundation-client-dashboard
plan: 04
subsystem: client-portal-ui
tags: [nextjs, tailwind-v4, shadcn-ui, server-components, client-dashboard, responsive]
requires:
- 01-01 (Next.js 16 bootstrap, Tailwind v4, shadcn/ui)
- 01-02 (schema DB: clients, phases, tasks, deliverables, payments, documents, notes)
- 01-03 (ClientView interface + getClientView(), route /c/[token] operativa)
provides:
- src/components/client-dashboard.tsx (layout wrapper completo)
- src/components/phase-timeline.tsx (timeline laterale con progress bar per fase)
- src/components/payment-status.tsx (stato pagamenti senza importi singoli)
- src/components/documents-section.tsx (link documenti esterni)
- src/components/notes-section.tsx (log decisioni read-only)
- src/app/globals.css (design token Tailwind v4 — palette light & clean)
- app/c/[token]/page.tsx (Server Component che renderizza ClientDashboard)
affects:
- 01-05-seed-deploy (la dashboard e' completa — il seed popola i dati, il deploy la espone)
tech-stack:
added: []
patterns:
- "Tailwind v4: design token via @theme inline in globals.css (NON tailwind.config.ts)"
- "Colori arbitrari inline con sintassi [#hex] — compatibile Tailwind v4"
- "SVG inline al posto di lucide-react — compatibilita' massima con bundle size ridotto"
- "Server Components puri per tutti i componenti dashboard (nessun 'use client')"
- "React.cache() in page.tsx per deduplicare getClientView() tra generateMetadata e render"
key-files:
created:
- src/components/client-dashboard.tsx (99 righe — wrapper con header, progress, sezioni)
- src/components/phase-timeline.tsx (201 righe — timeline laterale con task e deliverable)
- src/components/payment-status.tsx (98 righe — totale + righe stato, zero importi singoli)
- src/components/documents-section.tsx (75 righe — link esterni sicuri)
- src/components/notes-section.tsx (68 righe — log decisioni read-only)
modified:
- src/app/globals.css (token Tailwind v4: primary, secondary, tertiary, bg-subtle, border-light, accent, success, warning, info)
- src/app/c/[token]/page.tsx (import ClientDashboard, generateMetadata dinamico, React.cache)
key-decisions:
- "Tailwind v4 usa @theme in globals.css — tailwind.config.ts non esiste in questo progetto"
- "SVG inline invece di lucide-react — evita dipendenza da icone e garantisce compatibilita'"
- "Colori arbitrari [#hex] in classi Tailwind invece di classi custom — piu' esplicito e manutenibile"
- "Server Components puri — nessun 'use client' necessario per componenti read-only"
- "React.cache() per deduplicare le due chiamate getClientView in generateMetadata + ClientPage"
duration: 45min
completed: 2026-05-14
---
# Phase 1 Plan 04: Client Dashboard UI — Vertical Slice completo
**Tutti i componenti UI della dashboard cliente renderizzati come Server Components con design light & clean in Tailwind v4: header con logo iamcavalli + brand name cliente, progress bar globale, timeline laterale delle fasi con barre per fase e task list, sezione pagamenti con badge stato (zero importi singoli), link documenti esterni e log note read-only.**
## Performance
- **Duration:** ~45 min
- **Started:** 2026-05-14T19:35:00Z
- **Completed:** 2026-05-14T20:20:00Z
- **Tasks:** 5/5
- **Files creati:** 5
- **Files modificati:** 2
## Accomplishments
- **globals.css**: Token di design Tailwind v4 via `@theme inline` — palette light & clean con 9 variabili colore (primary, secondary, tertiary, bg-subtle, border-light, accent, success, warning, info). Adattamento critico: il progetto usa Tailwind v4 che non ha `tailwind.config.ts`.
- **app/c/[token]/page.tsx**: Server Component aggiornato con `ClientDashboard`, `generateMetadata` dinamico (titolo con brand_name), `React.cache()` per deduplicare le due chiamate a `getClientView`.
- **client-dashboard.tsx**: Layout wrapper completo. Header sticky con "iamcavalli" in angolo sinistro (xs, tracking-widest) e `brand_name` centrato e prominente (D-06). Progress bar globale con percentuale (D-09). Brief con accent bar sinistra. Sezioni ordinate: PhaseTimeline, PaymentStatus (sempre visibile — D-10), Documents e Notes (condizionali). Footer con avviso link privato.
- **phase-timeline.tsx**: Timeline laterale a due colonne (D-07). Colonna sinistra: cerchio con icona SVG per stato (checkmark verde per done, cerchio pieno blu per active, cerchio vuoto grigio per upcoming) + linea verticale tra fasi. Colonna destra: Card con badge stato, progress bar per fase con contatore "X di N task" (D-08), task list con icone stato e line-through per done. Deliverable annidati con badge "Approvato".
- **payment-status.tsx**: Card con `accepted_total` in EUR come unico importo visibile (vincolo LOCKED). Righe pagamento con dot colorato + badge stato semantico (blu=da_saldare, giallo=inviata, verde=saldato) — MAI importi singoli (T-04-001 mitigato, D-11 rispettato).
- **documents-section.tsx**: Link esterni con `target="_blank" rel="noopener noreferrer"` (T-04-002). Icone SVG inline per documento ed external link. Hover state con transizione colore accent.
- **notes-section.tsx**: Note read-only con timestamp in locale it-IT. Empty state informativo. Server Component puro (D-12: admin scrive in Phase 2, cliente legge).
- **npm run build**: completato senza errori TypeScript. 1 warning CSS da Lightning CSS optimizer (selettore con caratteri speciali) — noto, non bloccante, non dipendente dal nostro codice.
## Task Commits
1. **Task 1: Design tokens + wire page.tsx**`4e703d7`
2. **Task 2: ClientDashboard wrapper**`debd391`
3. **Task 3: PhaseTimeline**`5d5c8ea`
4. **Task 4: PaymentStatus**`a4e2de0`
5. **Task 5: DocumentsSection + NotesSection**`8602bfa`
## Files Created/Modified
- `src/app/globals.css`@theme con 9 token colore light & clean
- `src/app/c/[token]/page.tsx` — ClientDashboard + generateMetadata + React.cache
- `src/components/client-dashboard.tsx` — layout wrapper completo
- `src/components/phase-timeline.tsx` — timeline laterale con progress per fase
- `src/components/payment-status.tsx` — totale accettato + badge stato (nessun importo)
- `src/components/documents-section.tsx` — link esterni sicuri
- `src/components/notes-section.tsx` — note read-only con timestamp italiano
## Decisions Made
- **Tailwind v4 senza tailwind.config.ts:** Il piano originale assumeva Tailwind v3 con `tailwind.config.ts`. Il progetto usa Tailwind v4 che gestisce i token via `@theme inline` in globals.css. Adattamento automatico.
- **SVG inline invece di lucide-react:** Lucide-react v1.14 ha alcune icone con nomi diversi. Usare SVG inline elimina la dipendenza ed e' compatibile con Server Components senza `'use client'`.
- **Server Components puri:** I componenti sono tutti read-only e non usano hooks React — nessun `'use client'` necessario, ottimizzazione del bundle.
- **Colori arbitrari [#hex]:** Usare classi come `text-[#1a1a1a]` invece di classi custom — piu' esplicito, nessun conflitto con shadcn/ui che usa le proprie variabili CSS (`bg-card`, `text-muted-foreground`, etc.).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] tailwind.config.ts non esiste — Tailwind v4 usa @theme in globals.css**
- **Found during:** Task 1
- **Issue:** Il piano indicava di aggiornare `tailwind.config.ts` con i color token. Questo file non esiste perche' il progetto usa Tailwind v4, che utilizza CSS `@theme inline` in `globals.css` invece del file di configurazione JavaScript.
- **Fix:** Token definiti in `globals.css` via `@theme inline { --color-primary: #1a1a1a; ... }`. Tailwind v4 mappa automaticamente queste variabili come classi utilitarie.
- **Files modified:** src/app/globals.css
- **Commit:** 4e703d7
**2. [Rule 1 - Bug] lucide-react usato come 'use client' — rimpiazzato con SVG inline**
- **Found during:** Task 2/3 (design)
- **Issue:** Il piano importava `CheckCircle2`, `Circle`, `Clock`, `ExternalLink` da `lucide-react`. I componenti sono Server Components puri — aggiungere `'use client'` solo per le icone avrebbe spostato tutto il rendering lato client inutilmente.
- **Fix:** SVG inline (Heroicons style) al posto di lucide-react per tutti i componenti. Mantiene i componenti come Server Components puri.
- **Files modified:** phase-timeline.tsx, payment-status.tsx, documents-section.tsx
- **Commit:** 5d5c8ea, a4e2de0, 8602bfa
## Known Stubs
Nessuno stub. Tutti i componenti ricevono dati reali dalla `ClientView` e li renderizzano completamente. Gli empty state (no documenti, no note) sono stati implementati come stati legittimi — non stub.
## Threat Surface Scan
| Flag | File | Description |
|------|------|-------------|
| Verificato T-04-001 | payment-status.tsx | `amount` assente dalla query e dal componente — solo `accepted_total` e `status` per riga |
| Verificato T-04-002 | documents-section.tsx | `rel="noopener noreferrer"` su tutti i link esterni |
Nessuna nuova superficie di sicurezza non prevista dal threat model 01-04.
## Self-Check: PASSED
- [x] `src/app/globals.css` esiste con @theme e token colore
- [x] `src/app/c/[token]/page.tsx` renderizza ClientDashboard con generateMetadata
- [x] `src/components/client-dashboard.tsx` esiste (99 righe)
- [x] `src/components/phase-timeline.tsx` esiste (201 righe)
- [x] `src/components/payment-status.tsx` esiste (98 righe) — nessun campo `amount`
- [x] `src/components/documents-section.tsx` esiste (75 righe)
- [x] `src/components/notes-section.tsx` esiste (68 righe)
- [x] Commit `4e703d7` esiste (Task 1)
- [x] Commit `debd391` esiste (Task 2)
- [x] Commit `5d5c8ea` esiste (Task 3)
- [x] Commit `a4e2de0` esiste (Task 4)
- [x] Commit `8602bfa` esiste (Task 5)
- [x] `npm run build` completato senza errori TypeScript
- [x] `payment-status.tsx` non contiene il campo `amount`
- [x] `documents-section.tsx` contiene `rel="noopener noreferrer"`
## Next Phase Readiness
- Plan 05 (Seed script + DNS) puo' partire
- La dashboard e' pienamente funzionale: basta un cliente seedato per vederla operativa
- Il seed script deve inserire client, phases, tasks, deliverables, payments, documents, notes
- La route /c/[token] e' operativa dal Plan 03 — Plan 05 aggiunge il seed + configurazione DNS
---
*Phase: 01-foundation-client-dashboard*
*Completed: 2026-05-14*
@@ -1,567 +0,0 @@
---
phase: "01-foundation-client-dashboard"
plan: 05
type: execute
wave: 3
depends_on:
- "01-01"
- "01-02"
- "01-03"
- "01-04"
files_modified:
- scripts/seed.ts
- .env.local
autonomous: true
requirements:
- DASH-01
- DASH-02
- DASH-03
- DASH-04
- DASH-07
- DASH-08
- DASH-09
- DASH-10
must_haves:
truths:
- "Seed script exists and contains TypeScript seed logic"
- "Script inserts one complete test client with all related data (phases, tasks, deliverables, payments, documents, notes)"
- "Client token is generated via nanoid (21 chars, cryptographically secure)"
- "Seed script prints shareable URL to console: http://localhost:3000/c/[token]"
- "Script can be run via: npx tsx scripts/seed.ts"
- "DNS CNAME is configured: welcomeclient.iamcavalli.net → vercel DNS"
- "DNS propagation is verified (can be checked via `dig` or online tool)"
artifacts:
- path: "scripts/seed.ts"
provides: "Seed script that inserts first real client with all data"
min_lines: 100
contains: "import.*nanoid"
- path: ".env.local (updated)"
provides: "Updated with VERCEL_URL or custom domain setting"
contains: "DATABASE_URL"
key_links:
- from: "scripts/seed.ts"
to: "src/db/schema"
via: "drizzle db.insert()"
pattern: "db.insert\\("
- from: "nanoid token"
to: "client URL"
via: "http://localhost:3000/c/[token]"
pattern: "nanoid"
---
<objective>
**Seed Script + DNS Configuration:** Create a TypeScript seed script that populates the database with one complete test client (including phases, tasks, deliverables, payments, documents, and notes), generates a secret token via nanoid, and prints a shareable dashboard URL. Configure DNS CNAME for welcomeclient.iamcavalli.net to Vercel and verify propagation.
Purpose: Enable end-to-end testing with real data. One developer can run the seed script and immediately open a working client dashboard. DNS configuration allows the project to be accessed via the production domain.
Output: Executable seed script + verified DNS CNAME + shareable client link for testing Phase 1.
</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 (Data Model section)
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-13)
</context>
<tasks>
<task type="auto">
<name>Task 1: Create scripts/seed.ts to insert first real client with all data</name>
<files>
scripts/seed.ts
</files>
<read_first>
src/db/schema.ts (all table definitions)
src/db/index.ts (db client)
</read_first>
<action>
Create `scripts/seed.ts`:
```typescript
/**
* Seed Script — Inserts first test client with complete project data
* Run: npx tsx scripts/seed.ts
*/
import { db } from '@/db';
import {
clients,
phases,
tasks,
deliverables,
payments,
documents,
notes,
} from '@/db/schema';
import { nanoid } from 'nanoid';
async function seed() {
console.log('🌱 Seeding database...\n');
try {
// 1. Create client
const clientToken = nanoid();
const [client] = await db
.insert(clients)
.values({
id: nanoid(),
name: 'Test Client Inc.',
brand_name: 'TestBrand',
brief:
'A comprehensive personal branding overhaul, positioning our company as a premium consultancy in the digital transformation space.',
token: clientToken,
accepted_total: '5000.00',
created_at: new Date(),
})
.returning();
console.log(
'✓ Client created: ' + client.name + ' (ID: ' + client.id + ')'
);
// 2. Create phases
const [phase1, phase2, phase3] = await db
.insert(phases)
.values([
{
id: nanoid(),
client_id: client.id,
title: 'Discovery & Strategy',
sort_order: 1,
status: 'done',
},
{
id: nanoid(),
client_id: client.id,
title: 'Design & Messaging',
sort_order: 2,
status: 'active',
},
{
id: nanoid(),
client_id: client.id,
title: 'Implementation & Launch',
sort_order: 3,
status: 'upcoming',
},
])
.returning();
console.log('✓ Phases created (3 total)');
// 3. Create tasks
const [task1, task2, task3, task4, task5, task6] = await db
.insert(tasks)
.values([
{
id: nanoid(),
phase_id: phase1.id,
title: 'Stakeholder interviews',
description: 'In-depth conversations with leadership team',
sort_order: 1,
status: 'done',
},
{
id: nanoid(),
phase_id: phase1.id,
title: 'Competitive analysis',
description: 'Research top 10 competitors in the space',
sort_order: 2,
status: 'done',
},
{
id: nanoid(),
phase_id: phase2.id,
title: 'Brand positioning document',
description:
'Write and refine the core positioning statement',
sort_order: 1,
status: 'in_progress',
},
{
id: nanoid(),
phase_id: phase2.id,
title: 'Visual identity design',
description: 'Logo, color palette, typography',
sort_order: 2,
status: 'in_progress',
},
{
id: nanoid(),
phase_id: phase3.id,
title: 'Website build & launch',
description: 'Design and develop new company website',
sort_order: 1,
status: 'todo',
},
{
id: nanoid(),
phase_id: phase3.id,
title: 'Social media rollout',
description: 'Launch branded social media accounts',
sort_order: 2,
status: 'todo',
},
])
.returning();
console.log('✓ Tasks created (6 total)');
// 4. Create deliverables
await db
.insert(deliverables)
.values([
{
id: nanoid(),
task_id: task1.id,
title: 'Interview notes & synthesis',
url: 'https://docs.google.com/document/d/1example',
status: 'approved',
approved_at: new Date('2026-04-15'),
},
{
id: nanoid(),
task_id: task2.id,
title: 'Competitive landscape report',
url: 'https://docs.google.com/presentation/d/1example',
status: 'approved',
approved_at: new Date('2026-04-20'),
},
{
id: nanoid(),
task_id: task3.id,
title: 'Brand positioning document (draft)',
url: 'https://docs.google.com/document/d/2example',
status: 'submitted',
approved_at: null,
},
{
id: nanoid(),
task_id: task4.id,
title: 'Logo concepts (3 variations)',
url: 'https://www.figma.com/file/example',
status: 'pending',
approved_at: null,
},
])
.returning();
console.log('✓ Deliverables created (4 total)');
// 5. Create payments
await db
.insert(payments)
.values([
{
id: nanoid(),
client_id: client.id,
label: 'Acconto 50%',
amount: '2500.00',
status: 'saldato',
paid_at: new Date('2026-04-01'),
},
{
id: nanoid(),
client_id: client.id,
label: 'Saldo 50%',
amount: '2500.00',
status: 'inviata',
paid_at: null,
},
])
.returning();
console.log('✓ Payments created (2 total)');
// 6. Create documents
await db
.insert(documents)
.values([
{
id: nanoid(),
client_id: client.id,
label: 'Brand Guidelines PDF',
url: 'https://example.com/brand-guidelines.pdf',
created_at: new Date(),
},
{
id: nanoid(),
client_id: client.id,
label: 'Design Mockups Figma',
url: 'https://www.figma.com/file/example',
created_at: new Date(),
},
])
.returning();
console.log('✓ Documents created (2 total)');
// 7. Create notes
await db
.insert(notes)
.values([
{
id: nanoid(),
client_id: client.id,
body: 'Initial strategy session completed. Key insight: positioning needs to emphasize tech expertise and creative thinking balance.',
created_at: new Date('2026-04-10'),
},
{
id: nanoid(),
client_id: client.id,
body: 'Phase 1 approved. Moving forward with design phase. Stakeholders excited about direction.',
created_at: new Date('2026-04-22'),
},
])
.returning();
console.log('✓ Notes created (2 total)');
// Print shareable URL
console.log('\n✨ Seed complete!\n');
console.log('📎 Shareable client link:');
console.log(
` http://localhost:3000/c/${clientToken}\n`
);
console.log(
'This link is unique and secret. Send it to the client via Slack or email.\n'
);
} catch (error) {
console.error('❌ Seed failed:', error);
process.exit(1);
}
}
seed();
```
Key points:
- Uses nanoid for token generation (21 chars, cryptographically secure)
- Inserts complete hierarchical data: 1 client → 3 phases → 6 tasks → 4 deliverables + 2 payments + 2 documents + 2 notes
- Mix of statuses: phase 1 done, phase 2 active, phase 3 upcoming; tasks have various completion states
- Deliverables show different statuses: approved (with timestamp), submitted, pending
- Payments: one paid, one sent but unpaid
- Notes: 2 decision log entries
- Prints shareable URL to console
</action>
<verify>
<automated>test -f scripts/seed.ts && echo "Seed script exists"</automated>
<automated>grep -q "import.*nanoid" scripts/seed.ts && echo "nanoid imported"</automated>
<automated>grep -q "db.insert" scripts/seed.ts && echo "Insert statements present"</automated>
<automated>grep -q "clientToken" scripts/seed.ts && echo "Token generation present"</automated>
<automated>grep -q "http://localhost:3000/c/" scripts/seed.ts && echo "URL printed"</automated>
</verify>
<acceptance_criteria>
- `scripts/seed.ts` exists as TypeScript file
- Script imports nanoid and db client
- Creates one complete client with all related data (phases, tasks, deliverables, payments, documents, notes)
- Prints shareable URL to console
- Can be executed via `npx tsx scripts/seed.ts` without errors
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 2: Test seed script execution and verify data is inserted into database</name>
<files>
None (execution only)
</files>
<read_first>
scripts/seed.ts
.env.local
</read_first>
<action>
Run the seed script:
```
npx tsx scripts/seed.ts
```
Expected output:
```
🌱 Seeding database...
✓ Client created: Test Client Inc. (ID: xxx...)
✓ Phases created (3 total)
✓ Tasks created (6 total)
✓ Deliverables created (4 total)
✓ Payments created (2 total)
✓ Documents created (2 total)
✓ Notes created (2 total)
✨ Seed complete!
📎 Shareable client link:
http://localhost:3000/c/[token]
This link is unique and secret. Send it to the client via Slack or email.
```
If the script fails:
- Verify DATABASE_URL is set and correct
- Verify Postgres on Coolify is accessible
- Check that schema exists (run `npx drizzle-kit introspect` to confirm)
</action>
<verify>
<automated>npx tsx scripts/seed.ts 2>&1 | grep -q "Seed complete" && echo "Seed script succeeded" || echo "Seed script failed"</automated>
<automated>npx tsx scripts/seed.ts 2>&1 | grep -oE "http://localhost:3000/c/[a-zA-Z0-9_-]+" | head -1 > /tmp/client_url.txt && test -s /tmp/client_url.txt && echo "Client URL generated" || echo "Client URL not found"</automated>
</verify>
<acceptance_criteria>
- Seed script executes without errors
- Output shows all entity types created (client, phases, tasks, deliverables, payments, documents, notes)
- Shareable URL is printed to console
- Data is inserted into Postgres on Coolify
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 3: Test end-to-end: Open seeded client link in browser and verify dashboard renders</name>
<files>
None (verification only)
</files>
<read_first>
None
</read_first>
<action>
Start dev server:
```
npm run dev
```
Open the seeded client link in browser:
- Copy the URL from seed script output (e.g., http://localhost:3000/c/xyz123)
- Visit in browser
- Verify dashboard renders with:
- ✓ Client brand name displayed prominently
- ✓ iamcavalli logo in corner
- ✓ Global progress bar showing % completion
- ✓ All 3 phases visible with status badges (done/active/upcoming)
- ✓ Each phase shows progress bar and task count
- ✓ Tasks nested under phases with status icons
- ✓ Deliverables shown under tasks (with Approved badge if applicable)
- ✓ Payment section shows accepted_total (€5000.00) and 2 payment rows
- ✓ Payment amounts are NOT visible (only status: saldato, inviata)
- ✓ Document section shows clickable links
- ✓ Notes section shows decision log entries
Test edge cases:
- Invalid token (http://localhost:3000/c/invalid) → should return 404
- Page refresh → data should persist (no client-side state loss)
- Mobile view (use DevTools mobile emulator) → layout should be responsive
</action>
<verify>
<automated>curl -s http://localhost:3000/c/invalid | grep -q "404\|not found" && echo "Invalid token returns 404" || echo "404 check inconclusive"</automated>
</verify>
<acceptance_criteria>
- Seeded client link opens without errors
- Dashboard renders with client data
- All sections visible: header, progress, phases, tasks, deliverables, payments, documents, notes
- Invalid token returns 404
- Layout is responsive on mobile
</acceptance_criteria>
</task>
<task type="auto">
<name>Task 4: Configure DNS CNAME for welcomeclient.iamcavalli.net → Vercel DNS</name>
<files>
None (external DNS configuration)
</files>
<read_first>
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-03)
</read_first>
<action>
**DNS Configuration Steps:**
1. Log into your domain registrar (where iamcavalli.net is registered)
2. Navigate to DNS settings for iamcavalli.net
3. Create a new CNAME record:
- **Name:** welcomeclient
- **Type:** CNAME
- **Value:** cname.vercel-dns.com
- **TTL:** 3600 (or default)
4. Save the record
5. Verify propagation (may take 15 minutes to 2 hours):
```
dig welcomeclient.iamcavalli.net
```
You should see:
```
welcomeclient.iamcavalli.net. 3600 IN CNAME cname.vercel-dns.com.
```
Or use an online tool: https://mxtoolbox.com/cname.aspx
**Vercel Configuration:**
1. Go to Vercel dashboard → Project Settings → Domains
2. Add domain: `welcomeclient.iamcavalli.net`
3. Vercel will show the CNAME record to configure (should match above)
4. Click "Add" and wait for verification (usually immediate after DNS propagates)
**After DNS is live:**
- You can access the dashboard via https://welcomeclient.iamcavalli.net/c/[token]
- DNS is bidirectional: localhost:3000 still works for dev
</action>
<verify>
<automated>dig welcomeclient.iamcavalli.net +short 2>/dev/null | grep -q "vercel-dns.com" && echo "DNS CNAME configured" || echo "DNS CNAME not yet live"</automated>
</verify>
<acceptance_criteria>
- CNAME record is created at registrar: welcomeclient → cname.vercel-dns.com
- Vercel project has the domain added and verified
- `dig` shows the CNAME record pointing to Vercel DNS
- Domain is accessible via browser (may take time to propagate)
</acceptance_criteria>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client browser → Secret link | Token is in URL; HTTPS encrypts transit; never log token in server logs |
| Token generation | nanoid is cryptographically secure (126 bits entropy); non-enumerable |
| DNS configuration | CNAME points to Vercel; Vercel controls SSL/TLS for domain |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-001 | Information Disclosure | Token in seed output | mitigate | URL is printed to console; developer must not commit or share the seed output; regenerate token in Phase 2 if compromised |
| T-05-002 | Information Disclosure | HTTPS for domain | mitigate | Vercel automatically provisions SSL/TLS for custom domain; all traffic to welcomeclient.iamcavalli.net is encrypted |
| T-05-003 | Denial of Service | Seed script re-run | accept | Running seed script multiple times creates duplicate clients (same test data); acceptable for dev; Phase 2 adds admin UI to manage clients |
</threat_model>
<verification>
After plan execution:
1. Run `npx tsx scripts/seed.ts` → output shows "Seed complete!"
2. Copy the printed URL and visit in browser
3. Verify dashboard renders with seeded data
4. Test invalid token → 404
5. Verify DNS CNAME is live: `dig welcomeclient.iamcavalli.net`
6. (Optional) Visit https://welcomeclient.iamcavalli.net/c/[token] once DNS propagates
</verification>
<success_criteria>
- Seed script exists and inserts complete test data
- One client with 3 phases, 6 tasks, 4 deliverables, 2 payments, 2 documents, 2 notes
- Dashboard renders with seeded data via shareable link
- Invalid tokens return 404
- DNS CNAME is configured and verified
- Phase 1 is complete and ready for production (Phase 2 will add auth and CRUD)
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md`
Also update `.planning/ROADMAP.md` to mark Phase 1 complete and set up Phase 2 planning.
</output>
@@ -1,51 +0,0 @@
---
plan: 05
status: complete
commit: 073eec7
date: 2026-05-14
---
# Plan 01-05 Summary: Seed Script + DNS Configuration
## Tasks Completed
**Task 1: scripts/seed.ts** ✓
- Created seed script with 107 lines
- Inserts 1 client → 3 phases → 6 tasks → 4 deliverables + 2 payments + 2 documents + 2 notes
- Token generated via nanoid (21 chars, ~126 bits entropy)
- Prints shareable URL: `http://localhost:3000/c/[token]`
- Run via: `DATABASE_URL=<url> npx tsx scripts/seed.ts`
**Task 2: Seed execution verified** ✓
- Seed ran successfully against Hetzner Postgres
- Test client created: `Test Client Inc.` / `TestBrand`
- Shareable link: `http://localhost:3000/c/jXpPhUS-C6pCGAu5Va0Ct`
**Task 3: E2E dashboard test** ✓
- Valid token → HTTP 200 (production build: `next build` + `next start`)
- Invalid token → HTTP 404
- Dev server also fixed (see Bug Fix below)
**Task 4: DNS CNAME** — Pending user action
- Prerequisites not yet met: no Vercel project linked, no deployment
- Steps to complete (after Vercel deployment):
1. Run `vercel --prod` or connect via Vercel dashboard
2. Add domain `welcomeclient.iamcavalli.net` in Vercel Project Settings → Domains
3. At domain registrar for `iamcavalli.net`: add CNAME `welcomeclient → cname.vercel-dns.com`
4. Verify: `dig welcomeclient.iamcavalli.net +short` should return `cname.vercel-dns.com`
## Bug Fixed (outside plan scope)
**Tailwind v4 scanning `.01_projects/` directory**
- Root cause: Tailwind v4 auto-detects and scans ALL files in the repo root
- The `.01_projects/sparklingorbit` subdirectory contains a Python `.venv` with `markdown_it` library
- That library has a regex comment containing `[-:|]` (a regex character class)
- Tailwind interpreted `[-:|]` as an arbitrary CSS property class → generates invalid CSS `-: |`
- Dev server Turbopack was treating this as a fatal CSS parse error → 500 on all pages
- Fix: added `@source not` directives in `globals.css` to exclude adjacent projects
## Commits
- `073eec7` — feat(seed): add seed script + fix Tailwind scanning adjacent projects
</content>
</invoke>
@@ -1,126 +0,0 @@
# Phase 1: Foundation & Client Dashboard - Context
**Gathered:** 2026-05-13
**Status:** Ready for planning
<domain>
## Phase Boundary
Costruire il DB schema, il token API e la dashboard cliente read-only. Al termine di questa fase, un link segreto è condivisibile con un cliente reale che può aprirlo su mobile o desktop e vedere lo stato completo del suo progetto — senza login, senza admin, senza interazione. L'admin area (CRUD, auth, commenti, approvazioni) è Phase 2.
</domain>
<decisions>
## Implementation Decisions
### Database & Infrastruttura
- **D-01: Database su Coolify (Hetzner)** — Postgres istanza gestita da Coolify sul server Hetzner già pagato. Zero costo aggiuntivo. Neon è scartato in favore del self-hosting già disponibile.
- **D-02: ORM invariato** — Drizzle ORM con driver `postgres-js` (invece di `neon-http`). Schema e migrazioni identici, solo il driver di connessione cambia.
- **D-03: DNS in Phase 1** — Configurare `welcomeclient.iamcavalli.net` come CNAME verso Vercel nella Phase 1, non alla fine. Propagazione va verificata subito.
### Brand & Visual Design
- **D-04: Brand hardcoded in Phase 1** — Colori e logo iamcavalli fissi nel codice. La personalizzazione admin (tabella `brand_settings`, pannello colori/logo) è demandata a Phase 2.
- **D-05: Stile light & clean** — Sfondo chiaro, typography forte, layout professionale e leggibile su mobile.
- **D-06: Header dashboard** — Logo iamcavalli piccolo in un angolo (es. top-right o top-left), nome del brand cliente (`brand_name`) in primo piano e prominente. Non il nome del cliente, il nome del suo brand.
### Layout Fasi e Task
- **D-07: Timeline laterale per le fasi** — Indicatore temporale/progressione a sinistra, contenuto fase sulla destra. Trasmette senso di avanzamento sequenziale del progetto.
- **D-08: Barra progresso per fase** — In cima a ogni fase, una barra % calcolata dai task completati. Sotto la barra, lista dei task.
- **D-09: Barra progresso globale in cima** — Progress bar globale del progetto nella parte alta della dashboard, derivata dal totale dei task completati su tutti i task. Il cliente vede subito "sei al X%".
### Stato Pagamenti
- **D-10: Pagamenti sempre visibili** — Sezione pagamenti sempre in vista nella dashboard (non nascosta in accordion). Mostra: totale accettato + stato acconto 50% + stato saldo 50%.
- **D-11: Stati pagamento** — Tre stati per ogni payment row: `da_saldare` / `inviata` / `saldato`. Mai i prezzi singoli dei servizi — solo `accepted_total`.
### Storico Decisioni (DASH-10)
- **D-12: Admin scrive, cliente legge** — Le note/decisioni del log storico sono scritte solo dall'admin. Il cliente le vede in sola lettura nella sua dashboard. La UI per scrivere note è in Phase 2 (admin area). In Phase 1 il campo `body` è in schema e la visualizzazione lato cliente è già presente; sarà vuota finché Phase 2 non porta l'admin.
### Primo Cliente (Testing)
- **D-13: Seed script** — Uno script TypeScript (`scripts/seed.ts`) per inserire il primo cliente reale con dati completi (fasi, task, pagamenti, documenti). Eseguibile una volta con `npx tsx scripts/seed.ts`. Nessun form admin o SQL manuale necessario per Phase 1.
### Claude's Discretion
- Scelta del componente UI specifico per la timeline laterale (build custom vs. shadcn primitives)
- Struttura CSS delle card fasi e task (spaziatura, bordi, hover state)
- Schema colori specifico light & clean (bianco puro, grigi, quale accent color in attesa del brand panel)
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Progetto & Requisiti
- `.planning/PROJECT.md` — Contesto progetto, Core Value, decisioni chiave e vincoli
- `.planning/REQUIREMENTS.md` — REQ-IDs Phase 1: DASH-01 through DASH-04, DASH-07 through DASH-10
- `.planning/ROADMAP.md` — Success criteria Phase 1
### Ricerca tecnica
- `.planning/research/STACK.md` — Stack raccomandato (nota: database cambiato da Neon a Coolify Postgres)
- `.planning/research/ARCHITECTURE.md` — Data model completo, component boundaries, build order
- `.planning/research/PITFALLS.md` — Pitfall critici: token-as-PK, ClientView enforcement, data model day-one decisions
### Istruzioni progetto
- `CLAUDE.md` — Architectural constraints LOCKED (token separato dalla PK, accepted_total denormalizzato, approved_at immutabile, due path auth isolati)
No external specs beyond the above — requirements fully captured in decisions above.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- Nessun codice esistente — progetto greenfield.
### Established Patterns
- Next.js 15 App Router: Server Components per la dashboard read-only (zero client-side waterfalls per rendering dati)
- Drizzle ORM con `postgres-js` invece di `neon-http` (Coolify Postgres connection string via env var `DATABASE_URL`)
- nanoid per generazione token: `nanoid()` → 21 char, URL-safe, ~126 bit di entropia
### Integration Points
- Dashboard cliente: route `/c/[token]` — Middleware valida token → 404 se mancante → Server Component legge DB e renderizza
- Seed script: `scripts/seed.ts` — inserisce dati cliente reale, genera token, stampa URL condivisibile
- DNS: CNAME `welcomeclient.iamcavalli.net``cname.vercel-dns.com` (da configurare su domain registrar)
</code_context>
<specifics>
## Specific Ideas
- Il portale deve sembrare professionale e in linea con il brand iamcavalli — light & clean, non un SaaS generico
- Logo iamcavalli piccolo in corner + nome brand cliente prominente nell'header
- Progress bar globale del progetto in cima alla dashboard (percentuale)
- Timeline laterale per le fasi (non accordion, non card flat) — trasmette sequenzialità e avanzamento
- Barra progresso per singola fase, calcolata da task completati
- Personalizzazione colori/logo dal pannello admin è demandata a Phase 2 — in Phase 1 brand hardcoded
</specifics>
<deferred>
## Deferred Ideas
- **Brand customization panel** (colori background, testi, logo upload dall'admin) → Phase 2, da aggiungere come requisito nell'area admin
- **Three.js / animazioni 3D** → Non necessario per questo tipo di portale. UI curata con Tailwind è sufficiente.
- **Commenti e approvazioni cliente** → Phase 2 (DASH-05, DASH-06)
- **Auth admin** → Phase 2
</deferred>
---
*Phase: 1-Foundation & Client Dashboard*
*Context gathered: 2026-05-13*
@@ -1,128 +0,0 @@
# Phase 1: Foundation & Client Dashboard - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-05-13
**Phase:** 1 - Foundation & Client Dashboard
**Areas discussed:** Database, Three.js, Brand & visual design, Layout fasi e task, Primo cliente, Storico decisioni
---
## Database
| Option | Description | Selected |
|--------|-------------|----------|
| Postgres su Coolify | Zero costo extra — istanza Postgres su Hetzner già pagato | ✓ |
| Neon free tier | Postgres serverless gestito, 0.5 GB gratis, dipendenza esterna | |
| Oracle Cloud Free Tier | ARM 4vCPU 24GB RAM gratuito, richiede configurazione | |
**User's choice:** Postgres su Coolify (Hetzner)
**Notes:** L'utente ha già un server Hetzner con Coolify attivo (usato per SparklingOrbit). Preferisce zero dipendenze esterne a pagamento e mantenere i dati nella propria infrastruttura.
---
## Three.js
| Option | Description | Selected |
|--------|-------------|----------|
| Animazioni sottili | Effetti di sfondo leggeri (particelle, gradienti animati) | |
| Elemento hero visivo | Shape 3D animata nella parte alta della dashboard | |
| Ripensaci | UI curata con Tailwind è sufficiente, niente 3D | ✓ |
**User's choice:** Ripensaci — niente three.js
**Notes:** L'utente aveva menzionato three.js inizialmente ma dopo riflessione ha preferito concentrarsi su un'UI curata senza 3D.
---
## Brand & Visual Design
| Option | Description | Selected |
|--------|-------------|----------|
| Brand hardcoded in Phase 1 | Colori/logo iamcavalli fissi, personalizzazione in Phase 2 | ✓ |
| Già configurabile in Phase 1 | Tabella brand_settings e pannello già in Phase 1 | |
**Brand hardcoded:** ✓ — pannello personalizzazione rimandato a Phase 2
| Stile visual | Description | Selected |
|--------------|-------------|----------|
| Dark & premium | Sfondo scuro, accenti brillanti | |
| Light & clean | Sfondo chiaro, typography forte | ✓ |
| Decido dal pannello | Nessuna preferenza ora | |
**Stile:** Light & clean
| Header | Description | Selected |
|--------|-------------|----------|
| Solo logo + nome progetto | Logo iamcavalli + nome progetto | |
| Solo nome brand cliente | brand_name in grande, nessun logo | |
| Entrambi | Logo piccolo in corner + brand_name cliente in primo piano | ✓ |
**Header:** Logo iamcavalli piccolo in un angolo + nome brand cliente prominente
---
## Layout Fasi e Task
| Layout fasi | Description | Selected |
|-------------|-------------|----------|
| Accordion verticale | Fasi impilate, espandibili | |
| Card separate | Card per fase, task sempre visibili | |
| Timeline laterale | Indicatore temporale a sinistra, contenuto a destra | ✓ |
**Layout fasi:** Timeline laterale
| Task status | Description | Selected |
|-------------|-------------|----------|
| Badge colorato | Pallino/badge per stato | |
| Icona + testo | Icona con etichetta | |
| Barra progresso fase | Barra % per fase + lista task | ✓ |
**Task status:** Barra progresso in cima a ogni fase
| Avanzamento globale | Description | Selected |
|--------------------|-------------|----------|
| Sì, in cima | % completamento totale in cima alla dashboard | ✓ |
| No, solo per fase | Progresso solo per singola fase | |
| No progress bar | Solo stati task | |
**Progress bar globale:** Sì, in cima alla dashboard
---
## Primo Cliente
| Option | Description | Selected |
|--------|-------------|----------|
| Seed script | TypeScript script, `npx tsx scripts/seed.ts` | ✓ |
| Form admin minimale | Form no-auth per creare primo cliente | |
| SQL diretto Coolify | INSERT manuale via console Coolify | |
**User's choice:** Seed script
**Notes:** Lo script inserisce un cliente reale con fasi, task, pagamenti, documenti e stampa l'URL condivisibile.
---
## Storico Decisioni (DASH-10)
| Option | Description | Selected |
|--------|-------------|----------|
| Solo admin scrive | Admin aggiunge note, cliente legge in sola lettura | ✓ |
| Visibile solo in Phase 2 | UI mostrata solo con admin area | |
**User's choice:** Admin scrive, cliente legge
**Notes:** In Phase 1 lo schema è già presente e il cliente vede le note (sezione vuota inizialmente). La UI per aggiungere note arriva con l'area admin in Phase 2.
---
## Claude's Discretion
- Scelta componente UI specifico per timeline laterale (shadcn primitives vs. custom)
- CSS dettagliato delle card fasi e task (spaziatura, bordi, hover)
- Accent color provvisorio per Phase 1 (in attesa del brand panel in Phase 2)
## Deferred Ideas
- **Brand customization panel** (colori, logo upload dall'admin) → Phase 2
- **Three.js / animazioni 3D** → Scartato
- **Commenti e approvazioni** → Phase 2 (DASH-05, DASH-06)
@@ -1,302 +0,0 @@
# ClientHub — Walking Skeleton (Phase 1)
**Project:** ClientHub — Freelancer Client Portal
**Phase:** 01 — Foundation & Client Dashboard
**Date:** 2026-05-13
**Status:** Blueprint (decisions below are LOCKED for all subsequent phases)
---
## Project Architecture — Locked Decisions
This Walking Skeleton establishes the architectural foundation for all future phases. These decisions are **immutable** without explicit user approval.
### Core Stack
| Layer | Technology | Why | Locked? |
|-------|-----------|-----|---------|
| **Framework** | Next.js 15 (App Router, TypeScript, src/) | Server Components + Edge Middleware for performance; Vercel-native | ✅ YES |
| **Database** | Postgres on Coolify (Hetzner), via `postgres-js` driver | Self-hosted (no Neon/Supabase cost); persistent via external DB | ✅ YES |
| **ORM** | Drizzle ORM with postgres-js | Zero-cost serverless driver; schema-as-code migrations | ✅ YES |
| **UI** | Tailwind CSS v4 + shadcn/ui components | Utility-first, copied components, mobile-first | ✅ YES |
| **Auth (Admin)** | Auth.js v4 Credentials provider (Phase 2) | Single admin account, JWT cookie | ✅ YES |
| **Auth (Client)** | Custom Next.js Middleware + token validation | No session store needed; token in URL | ✅ YES |
| **Token Generation** | nanoid (21 chars) | Cryptographically secure, URL-safe, non-enumerable | ✅ YES |
| **Deployment** | Vercel (Hobby plan) + custom subdomain | Native Next.js; auto-SSL; single deploy command | ✅ YES |
### Data Model — Locked Entities
All tables below **must** exist and maintain these field definitions. Modifications require explicit approval.
```
clients
id UUID PK (stable, never changes)
name TEXT
brand_name TEXT
brief TEXT
token UUID UNIQUE ← SEPARATE from PK, rotatable
accepted_total NUMERIC ← denormalized, only price client sees
created_at TIMESTAMPTZ
phases
id UUID PK
client_id UUID FK → clients.id
title TEXT
sort_order INT
status TEXT (upcoming | active | done)
tasks
id UUID PK
phase_id UUID FK → phases.id
title TEXT
description TEXT
status TEXT (todo | in_progress | done)
sort_order INT
deliverables
id UUID PK
task_id UUID FK → tasks.id
title TEXT
url TEXT
status TEXT (pending | submitted | approved)
approved_at TIMESTAMPTZ ← immutable audit trail
comments
id UUID PK
entity_type TEXT (task | deliverable)
entity_id UUID
author TEXT (client | admin)
body TEXT
created_at TIMESTAMPTZ
payments
id UUID PK
client_id UUID FK → clients.id
label TEXT ("Acconto 50%" | "Saldo 50%")
amount NUMERIC
status TEXT (da_saldare | inviata | saldato)
paid_at TIMESTAMPTZ
documents
id UUID PK
client_id UUID FK → clients.id
label TEXT
url TEXT ← external links only, no file uploads
created_at TIMESTAMPTZ
notes
id UUID PK
client_id UUID FK → clients.id
body TEXT
created_at TIMESTAMPTZ
service_catalog
id UUID PK
name TEXT
description TEXT
unit_price NUMERIC
active BOOLEAN
quote_items
id UUID PK
client_id UUID FK → clients.id
service_id UUID FK → service_catalog.id
quantity NUMERIC
unit_price NUMERIC
subtotal NUMERIC
← NEVER exposed via client API
```
### Critical Design Principles — Locked
1. **`clients.token` is NOT the primary key.** Data is keyed by stable UUID `id`. Token is a separate, rotatable field. Rotation is a single UPDATE statement.
2. **Client API never exposes `quote_items`.** Server-side filtering enforces this; not a UI trick. The `accepted_total` field is the only price the client API returns.
3. **`deliverables.approved_at` is immutable.** Once set, it cannot be unset. Provides an audit trail for disputes.
4. **Two independent auth systems:**
- `/c/[token]/*` → Middleware validates token, 404 on miss
- `/admin/*` → Auth.js session check (Phase 2)
- No overlap; no shared session store
5. **No file hosting in v1.** Documents are external URLs only (Google Drive, PDFs, Figma links). File uploads → Phase 3+.
6. **No email in v1.** Deliverables are dashboard links, not email attachments. Email integration → Phase 2+.
### Directory Structure — Locked
```
IAMCAVALLI/
├── src/
│ ├── app/
│ │ ├── c/[token]/
│ │ │ ├── page.tsx ← Client dashboard route
│ │ │ └── layout.tsx
│ │ ├── admin/ ← Phase 2 (protected by middleware)
│ │ │ ├── page.tsx ← Admin dashboard
│ │ │ ├── clients/
│ │ │ │ ├── page.tsx
│ │ │ │ └── [id]/
│ │ │ ├── catalog/
│ │ │ └── ...
│ │ ├── layout.tsx
│ │ └── globals.css
│ ├── components/
│ │ ├── ui/ ← shadcn/ui components
│ │ ├── client-dashboard.tsx
│ │ ├── phase-timeline.tsx
│ │ ├── payment-status.tsx
│ │ ├── documents-section.tsx
│ │ ├── notes-section.tsx
│ │ └── ...
│ ├── db/
│ │ ├── schema.ts ← Drizzle schema (source of truth)
│ │ ├── migrations/ ← Generated by drizzle-kit
│ │ └── index.ts ← db client export
│ ├── lib/
│ │ ├── client-view.ts ← ClientView type + queries
│ │ ├── auth.ts ← Phase 2: Auth helpers
│ │ └── ...
│ └── middleware.ts ← Token validation at edge
├── scripts/
│ ├── seed.ts ← Insert first test client
│ └── ...
├── .env.local ← DATABASE_URL, secrets
├── drizzle.config.ts
├── next.config.ts
├── tailwind.config.ts
├── tsconfig.json
├── package.json
└── .planning/
├── ROADMAP.md
├── REQUIREMENTS.md
├── STATE.md
└── phases/
└── 01-foundation-client-dashboard/
├── 01-CONTEXT.md
├── 01-DISCUSSION-LOG.md
├── 01-01-PLAN.md
├── 01-02-PLAN.md
├── 01-03-PLAN.md
├── 01-04-PLAN.md
├── 01-05-PLAN.md
└── SKELETON.md
```
### Deployment — Locked
- **Host:** Vercel (Hobby plan, $0/month for Phase 1 scale)
- **Domain:** welcomeclient.iamcavalli.net (CNAME to Vercel DNS)
- **Database:** Postgres on Coolify (existing Hetzner server, Simone manages)
- **Environment:** DATABASE_URL injected via Vercel Secrets
- **SSL/TLS:** Vercel auto-provisioning for custom domain
### API Routes Structure (Phase 2+)
Routes created in Phase 2 will follow this pattern:
**Client-facing routes** (`/api/c/[token]/...`):
- No authentication library needed
- Middleware validates token
- Routes return ClientView shape only
**Admin routes** (`/api/admin/...`):
- Require Auth.js session
- Access full AdminView including quote_items
- CRUD operations on all entities
### UI Layer Principles — Locked
- **Light & clean visual style:** White backgrounds, strong typography, subtle gray accents
- **Mobile-first design:** Tailwind defaults ensure responsive behavior
- **Semantic HTML:** Proper heading hierarchy, accessible form controls
- **No client-side state management libraries:** Server Components + Server Actions for Phase 1-2
- **Progress visualization:** Global bar (top) + per-phase bars (sections) + task status badges
- **Brand consistency:** iamcavalli logo in corner, client brand_name prominent
### Security Assumptions — Locked
1. **Database credentials are secrets:** DATABASE_URL never logged, committed, or exposed
2. **Tokens are non-enumerable:** 21-character nanoid cannot be guessed
3. **Client API is isolated:** Admin data never leaks to `/c/[token]/*` routes
4. **Admin password** (Phase 2): env var `ADMIN_PASSWORD` protects `/admin/*` before Auth.js is added
5. **No PII in logs:** Payment amounts and tokens never logged to Vercel logs
---
## What This Skeleton Delivers
After Phase 1 execution:
✅ **Functional client portal:**
- One client can open their secret link on any device
- Dashboard shows project phases, tasks, status, payments, documents, decision log
- No login required; link is the secret
✅ **Production-ready infrastructure:**
- Database is live on Coolify Postgres
- Custom domain is verified and HTTPS-enabled
- Application is deployed on Vercel
- One-command deploy pipeline (`git push → Vercel auto-build`)
✅ **Developer-friendly codebase:**
- TypeScript with strict mode
- Drizzle ORM manages schema as code
- Git-tracked migrations (reproducible database state)
- One seed script to populate test data
- No manual SQL; no database browser required
✅ **Foundation for Phase 2:**
- Data model is stable and comprehensive
- Admin CRUD can be built without schema changes
- Auth.js integration point is clear
- Comments and approvals schema already exists
---
## Phase 1 → Phase 2 Contract
Phase 2 will extend this skeleton by:
1. **Admin authentication:** Middleware check + Auth.js session on `/admin/*` routes
2. **CRUD operations:** Forms and API routes to edit clients, phases, tasks, deliverables, payments
3. **Comments & approvals:** Client-facing UI for commenting and approving deliverables
4. **Admin workspace:** Dashboard to manage all clients with state summary and quick actions
5. **Payment management:** Update payment status, send payment reminders
**No schema changes required.** All Phase 2 features fit into the existing data model.
---
## Validation Checklist (End of Phase 1)
- [ ] Next.js 15 application compiles without TypeScript errors
- [ ] Database schema is live on Coolify Postgres (all 11 tables)
- [ ] Middleware validates tokens at edge
- [ ] Client portal route renders complete dashboard with seeded data
- [ ] Seed script inserts test client and prints shareable link
- [ ] DNS CNAME is live: welcomeclient.iamcavalli.net → Vercel
- [ ] Application is deployed on Vercel (accessible via https://welcomeclient.iamcavalli.net/)
- [ ] Invalid tokens return 404 (no information leakage)
- [ ] Payment amounts are NOT visible on client dashboard (only status)
- [ ] Mobile layout is responsive and readable
- [ ] All DASH-01 through DASH-10 requirements are satisfied (except DASH-05, DASH-06 which are Phase 2)
---
## Future Extensibility Notes
This skeleton is designed for:
- **Phase 2:** Admin CRUD + comments + approvals (no schema changes)
- **Phase 3:** Service catalog + quote builder (admin-only, client sees only total)
- **Phase 4 (v2):** Claude AI onboarding flow (optional; may defer indefinitely)
- **Beyond:** Multi-team support, real file uploads, email automation (major schema rework)
The current design is intentionally simple. Future phases should resist scope creep and maintain the "client sees only what they need" principle.
---
**Skeleton locked:** 2026-05-13
**Next checkpoint:** Phase 2 planning (`/gsd-plan-phase 2`)
@@ -1,481 +0,0 @@
---
phase: "02-admin-area-interactive-features"
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- package.json
- src/proxy.ts
- src/app/api/auth/[...nextauth]/route.ts
- src/app/admin/login/page.tsx
- src/app/admin/login/actions.ts
- src/lib/auth.ts
- .env.local
autonomous: true
requirements:
- ADMIN-01
- ADMIN-02
must_haves:
truths:
- "Admin can POST /admin/login with ADMIN_EMAIL + ADMIN_PASSWORD and receive a session JWT cookie"
- "Visiting /admin/* without a valid session redirects to /admin/login"
- "Visiting /c/[token]/* still validates token at edge (proxy.ts unchanged for client routes)"
- "Session is JWT-based (stateless) — no DB users table involved"
- "ADMIN_EMAIL and ADMIN_PASSWORD are read from env vars, never hardcoded"
artifacts:
- path: "src/lib/auth.ts"
provides: "NextAuth config — CredentialsProvider validating against ADMIN_EMAIL/ADMIN_PASSWORD env vars"
contains: "CredentialsProvider"
- path: "src/app/api/auth/[...nextauth]/route.ts"
provides: "NextAuth catch-all route handler"
contains: "NextAuth"
- path: "src/app/admin/login/page.tsx"
provides: "Admin login form UI (email + password, submit)"
min_lines: 30
- path: "src/proxy.ts"
provides: "Updated proxy: /c/* token validation + /admin/* session guard"
contains: "getToken"
key_links:
- from: "src/proxy.ts"
to: "src/app/api/auth/[...nextauth]/route.ts"
via: "getToken({ req, secret: process.env.NEXTAUTH_SECRET })"
pattern: "getToken"
- from: "src/app/admin/login/page.tsx"
to: "/api/auth/callback/credentials"
via: "signIn('credentials', { email, password })"
pattern: "signIn"
- from: "ADMIN_EMAIL + ADMIN_PASSWORD"
to: "CredentialsProvider authorize()"
via: "process.env.ADMIN_EMAIL"
pattern: "ADMIN_EMAIL"
---
<objective>
**Auth.js Admin Session + Proxy Guard:** Install next-auth@4, configure a CredentialsProvider that validates against ADMIN_EMAIL/ADMIN_PASSWORD env vars, wire the catch-all API route, build the login page, and extend the existing src/proxy.ts to guard /admin/* routes with a session check.
Purpose: Gate the entire admin area behind Auth.js JWT session before any admin UI is built. Two independent auth paths are enforced: /c/[token]/* uses edge token validation (unchanged from Phase 1), /admin/* uses getToken() from next-auth/jwt. No DB users table — single admin, env-var credentials only (per D-01, D-02, D-03, D-04).
Output: Working /admin/login page, session cookie on successful login, automatic redirect to /admin/login for unauthenticated access to any /admin/* route.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
@.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md
<interfaces>
<!-- Existing proxy from Phase 1 (src/proxy.ts) — EXTEND this file, do not create src/middleware.ts -->
<!-- Current structure: named export `proxy(request)` + config.matcher = ['/c/:path*'] -->
<!-- Next.js requires the export to be named `middleware` — rename proxy→middleware in this task -->
<!-- Phase 2 extends it to also handle /admin/:path* session guard using getToken() from next-auth/jwt -->
Current src/proxy.ts content:
```typescript
import { NextRequest, NextResponse } from 'next/server';
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Extract token from path: /c/[token]/...
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
if (!tokenMatch) {
return NextResponse.rewrite(new URL('/not-found', request.url));
}
const token = tokenMatch[1];
try {
const validateUrl = new URL(
`/api/internal/validate-token?token=${encodeURIComponent(token)}`,
request.url
);
const res = await fetch(validateUrl.toString());
if (!res.ok) {
return NextResponse.rewrite(new URL('/not-found', request.url));
}
return NextResponse.next();
} catch {
return NextResponse.rewrite(new URL('/not-found', request.url));
}
}
export const config = {
matcher: ['/c/:path*'],
};
```
Note: Next.js middleware MUST be exported as `middleware`, not `proxy`. The Phase 1 file uses `proxy` — this plan must rename it to `middleware` while extending it with /admin/* guard. No src/middleware.ts should ever be created.
From src/db/index.ts:
```typescript
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
export const db = drizzle(client);
```
From src/db/schema.ts (types needed in this plan):
```typescript
// No schema changes needed — no users table. Auth is env-var only.
export type Client = typeof clients.$inferSelect;
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Install next-auth@4, create src/lib/auth.ts and NextAuth catch-all route</name>
<files>
package.json
src/lib/auth.ts
src/app/api/auth/[...nextauth]/route.ts
.env.local
</files>
<action>
Install next-auth v4 (stable — v5 is still beta RC as of 2026-05-15, per D-01):
```
npm install next-auth@4
```
Add to .env.local (generate NEXTAUTH_SECRET with: `openssl rand -base64 32`):
```
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=<generated-32-byte-base64-string>
ADMIN_EMAIL=simone.cavalli.gestione@gmail.com
ADMIN_PASSWORD=<choose-a-strong-password>
```
Create `src/lib/auth.ts` — NextAuth config, no DB adapter (per D-03):
```typescript
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const adminEmail = process.env.ADMIN_EMAIL;
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
throw new Error("ADMIN_EMAIL and ADMIN_PASSWORD env vars must be set");
}
if (
credentials.email === adminEmail &&
credentials.password === adminPassword
) {
// Return minimal session user — no DB lookup needed
return { id: "admin", email: adminEmail, name: "Admin" };
}
return null; // null = unauthorized (NextAuth returns 401)
},
}),
],
session: {
strategy: "jwt", // stateless JWT — no DB session table (per D-03)
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: "/admin/login", // custom login page (per D-07)
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
(session.user as { id?: string }).id = token.id as string;
}
return session;
},
},
};
```
Create `src/app/api/auth/[...nextauth]/route.ts` — NextAuth catch-all:
```typescript
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
```
Note: next-auth@4 with App Router uses this export pattern. The handler handles
GET (session fetch, CSRF) and POST (sign in, sign out).
</action>
<verify>
<automated>grep -q '"next-auth"' package.json && echo "next-auth installed"</automated>
<automated>test -f src/lib/auth.ts && grep -q "CredentialsProvider" src/lib/auth.ts && echo "CredentialsProvider configured"</automated>
<automated>grep -q "strategy.*jwt" src/lib/auth.ts && echo "JWT session strategy set"</automated>
<automated>grep -q "ADMIN_EMAIL" src/lib/auth.ts && echo "ADMIN_EMAIL env var referenced"</automated>
<automated>test -f src/app/api/auth/\[...nextauth\]/route.ts && grep -q "NextAuth" src/app/api/auth/\[...nextauth\]/route.ts && echo "NextAuth route created"</automated>
<automated>grep -q "NEXTAUTH_SECRET" .env.local && echo "NEXTAUTH_SECRET in .env.local"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- next-auth@4 is in package.json
- src/lib/auth.ts exports authOptions with CredentialsProvider using env vars
- src/app/api/auth/[...nextauth]/route.ts exports GET and POST handlers
- NEXTAUTH_SECRET, ADMIN_EMAIL, ADMIN_PASSWORD are set in .env.local
- npm run build passes without errors
</done>
</task>
<task type="auto">
<name>Task 2: Extend src/proxy.ts to guard /admin/* with session check; create /admin/login page</name>
<files>
src/proxy.ts
src/app/admin/login/page.tsx
src/app/admin/login/actions.ts
</files>
<action>
**Replace** `src/proxy.ts` entirely. NEVER create src/middleware.ts — it would be a dead file ignored by Next.js since this project uses src/proxy.ts as the middleware entry point. The new file renames the export from `proxy` to `middleware` (required by Next.js) and adds the /admin/* guard alongside the existing /c/* token validation logic (per D-04):
```typescript
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// ── ADMIN GUARD ──────────────────────────────────────────────────────────
if (pathname.startsWith("/admin")) {
// Allow the login page and NextAuth API routes through without session check
if (
pathname === "/admin/login" ||
pathname.startsWith("/api/auth")
) {
return NextResponse.next();
}
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
if (!token) {
const loginUrl = new URL("/admin/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
// ── CLIENT TOKEN GUARD ───────────────────────────────────────────────────
if (pathname.startsWith("/c/")) {
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
if (!tokenMatch) {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
const clientToken = tokenMatch[1];
try {
const validateUrl = new URL(
`/api/internal/validate-token?token=${encodeURIComponent(clientToken)}`,
request.url
);
const res = await fetch(validateUrl.toString());
if (!res.ok) {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
return NextResponse.next();
} catch {
return NextResponse.rewrite(new URL("/not-found", request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*", "/c/:path*"],
};
```
Note: The export is renamed from `proxy` to `middleware`. This is the only correct Next.js middleware export name. The /c/* logic is preserved verbatim from Phase 1.
Create `src/app/admin/login/page.tsx` — login form as Client Component:
```typescript
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function AdminLoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/admin";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const result = await signIn("credentials", {
email,
password,
redirect: false, // handle redirect manually to show errors
});
if (result?.error) {
setError("Email o password non corretti.");
setLoading(false);
return;
}
router.replace(callbackUrl);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-xl">Admin — ClientHub</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-1">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Accesso in corso..." : "Accedi"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
```
Do NOT create src/app/admin/login/actions.ts — the login is handled
client-side via signIn(). No Server Action file is needed.
</action>
<verify>
<automated>test -f src/proxy.ts && grep -q "getToken" src/proxy.ts && echo "getToken imported in proxy.ts"</automated>
<automated>grep -q "export async function middleware" src/proxy.ts && echo "export named middleware (not proxy)"</automated>
<automated>grep -q '"/admin/:path\*"' src/proxy.ts && echo "admin matcher configured"</automated>
<automated>grep -q '"/c/:path\*"' src/proxy.ts && echo "client matcher still present"</automated>
<automated>grep -q "pathname === \"/admin/login\"" src/proxy.ts && echo "login page exempted from auth guard"</automated>
<automated>test -f src/app/admin/login/page.tsx && grep -q "signIn" src/app/admin/login/page.tsx && echo "login page uses signIn"</automated>
<automated>grep -q '"use client"' src/app/admin/login/page.tsx && echo "login page is Client Component"</automated>
<automated>test ! -f src/middleware.ts && echo "src/middleware.ts does NOT exist (correct)"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- src/proxy.ts guards /admin/* routes: unauthenticated requests redirect to /admin/login?callbackUrl=...
- Export renamed from proxy to middleware (required by Next.js)
- /admin/login and /api/auth/* are exempt from the session guard
- /c/:path* token validation is unchanged
- /admin/login page renders email+password form, calls signIn('credentials'), shows error on failure, redirects on success
- src/middleware.ts does NOT exist
- npm run build passes
- Manual verification: visiting http://localhost:3000/admin redirects to /admin/login; successful login redirects back to /admin
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → /admin/* | All admin routes gated by JWT session cookie; proxy.ts rejects unauthenticated requests before any page code runs |
| Login form → CredentialsProvider | Email + password transmitted over HTTPS; validated in server-side authorize() only |
| NEXTAUTH_SECRET → JWT signing | All session tokens are HMAC-signed; tampering is detectable |
| ADMIN_EMAIL/ADMIN_PASSWORD → env vars | Credentials never in source code; must be in .env.local and Vercel environment |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-01 | Spoofing | Admin login | mitigate | CredentialsProvider validates against env vars server-side; password never logged; JWT signed with NEXTAUTH_SECRET |
| T-02-02 | Tampering | JWT session cookie | mitigate | next-auth signs JWT with NEXTAUTH_SECRET (HMAC-SHA256); proxy.ts verifies signature on every /admin request via getToken() |
| T-02-03 | Information Disclosure | ADMIN_PASSWORD in env | mitigate | Stored only in .env.local (gitignored) and Vercel environment secrets; never returned in API responses |
| T-02-04 | Elevation of Privilege | /api/auth/* exemption | accept | NextAuth API routes are exempt from session guard by design; they perform their own CSRF and credential validation internally |
| T-02-05 | Denial of Service | Brute-force login | accept | Single admin, not a public product; no rate limiting in v1. If needed in v2, add next-auth rate limit middleware. |
</threat_model>
<verification>
After plan execution:
1. `npm run build` — no TypeScript errors
2. `npm run dev`, visit http://localhost:3000/admin → redirects to /admin/login
3. Submit wrong credentials → error message "Email o password non corretti." appears
4. Submit correct ADMIN_EMAIL + ADMIN_PASSWORD → redirects to /admin (200, even if page is blank)
5. Visit http://localhost:3000/c/any-token → still validates token (client path unchanged)
6. Visit http://localhost:3000/api/auth/session after login → returns `{ user: { email, id: "admin" } }`
7. Confirm src/middleware.ts does not exist in the repo
</verification>
<success_criteria>
- Admin can log in at /admin/login with env-var credentials and receive a JWT session cookie
- All /admin/* routes (except /admin/login and /api/auth/*) redirect unauthenticated visitors to /admin/login
- Client token route /c/:path* is unaffected
- No DB users table exists or is needed
- src/proxy.ts is the middleware file — src/middleware.ts never created
- npm run build passes cleanly
</success_criteria>
<output>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md`
</output>
@@ -1,92 +0,0 @@
---
phase: "02-admin-area-interactive-features"
plan: 01
subsystem: "auth"
tags: [next-auth, credentials, jwt, admin, session, middleware]
dependency_graph:
requires: []
provides: [admin-session-auth, admin-login-page, proxy-admin-guard]
affects: [src/proxy.ts, src/lib/auth.ts]
tech_stack:
added: [next-auth@4]
patterns: [CredentialsProvider, JWT session strategy, edge proxy guard]
key_files:
created:
- src/lib/auth.ts
- src/app/api/auth/[...nextauth]/route.ts
- src/app/admin/login/page.tsx
modified:
- src/proxy.ts
- package.json
- package-lock.json
- .env.local
decisions:
- "Keep proxy export named 'proxy' (not 'middleware') — Next.js 16 renamed the middleware concept back to proxy, breaking the plan's rename instruction"
- "Wrap useSearchParams() in Suspense boundary — required by Next.js App Router for static prerendering"
- "Single admin user, env-var credentials only — no DB users table (stateless JWT)"
metrics:
duration: "~15 minutes"
completed: "2026-05-15T08:42:35Z"
tasks_completed: 2
files_changed: 7
---
# Phase 02 Plan 01: Auth.js Admin Session + Proxy Guard Summary
Auth.js v4 CredentialsProvider with JWT sessions gates the entire /admin/* area using env-var credentials (no DB users table), with an edge proxy guard in src/proxy.ts that validates sessions via getToken() before any admin page code runs.
## What Was Built
### Task 1: next-auth@4 installation and auth config
- Installed `next-auth@4` (stable; v5 still RC as of 2026-05-15)
- Created `src/lib/auth.ts` — NextAuthOptions with CredentialsProvider reading `ADMIN_EMAIL` + `ADMIN_PASSWORD` from env vars; JWT session strategy (stateless, no DB adapter)
- Created `src/app/api/auth/[...nextauth]/route.ts` — NextAuth catch-all handler (GET + POST)
- Updated `.env.local` with `NEXTAUTH_URL`, `NEXTAUTH_SECRET` (32-byte base64), `ADMIN_EMAIL`, `ADMIN_PASSWORD`
### Task 2: Proxy guard and login page
- Extended `src/proxy.ts` with `/admin/*` session guard using `getToken()` from `next-auth/jwt`
- `/admin/login` and `/api/auth/*` exempted from the guard (pass-through)
- Unauthenticated `/admin/*` requests redirect to `/admin/login?callbackUrl=<original-path>`
- `/c/:path*` client token validation logic preserved verbatim from Phase 1
- matcher updated: `["/admin/:path*", "/c/:path*"]`
- Created `src/app/admin/login/page.tsx` — email+password Client Component with `signIn('credentials')`, inline error display ("Email o password non corretti."), redirect on success
## Commits
| Task | Commit | Description |
|------|--------|-------------|
| 1 | 5d363a6 | feat(02-01): install next-auth@4, configure CredentialsProvider auth |
| 2 | 69f8a7e | feat(02-01): extend proxy.ts with admin session guard, add login page |
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Next.js 16 proxy export name is 'proxy', not 'middleware'**
- **Found during:** Task 2 first build attempt
- **Issue:** The plan instructed renaming the export to `middleware` (Next.js 15 convention), but this project runs Next.js 16.2.6, which introduced the `proxy` concept and requires the function to be named `proxy`. The build failed with: "Proxy is missing expected function export name"
- **Fix:** Kept the export name as `proxy` — consistent with the existing Phase 1 file and Next.js 16 API
- **Files modified:** `src/proxy.ts`
**2. [Rule 1 - Bug] useSearchParams() requires Suspense boundary in App Router**
- **Found during:** Task 2 second build attempt
- **Issue:** `useSearchParams()` in a Client Component causes a build failure during static page generation without a Suspense boundary. Error: "useSearchParams() should be wrapped in a suspense boundary at page /admin/login"
- **Fix:** Extracted the form into `AdminLoginForm` component; wrapped it in `<Suspense>` inside the default export `AdminLoginPage`
- **Files modified:** `src/app/admin/login/page.tsx`
## Known Stubs
None — all implemented functionality is complete and functional.
## Threat Surface Scan
No new security surface beyond what was planned in the threat model:
- T-02-01: Mitigated — CredentialsProvider validates against env vars server-side
- T-02-02: Mitigated — JWT signed with NEXTAUTH_SECRET, verified via getToken() on every /admin request
- T-02-03: Mitigated — ADMIN_PASSWORD stored only in .env.local (gitignored) and Vercel secrets
- T-02-04: Accepted — /api/auth/* exempt by design, NextAuth handles its own CSRF
- T-02-05: Accepted — No rate limiting in v1
## Self-Check: PASSED
All created files exist on disk. Both task commits (5d363a6, 69f8a7e) verified in git log.
@@ -1,561 +0,0 @@
---
phase: "02-admin-area-interactive-features"
plan: 02
type: execute
wave: 2
depends_on:
- "02-01"
files_modified:
- src/app/admin/page.tsx
- src/app/admin/layout.tsx
- src/app/admin/clients/new/page.tsx
- src/app/admin/clients/new/actions.ts
- src/lib/admin-queries.ts
- src/components/admin/ClientRow.tsx
- src/components/admin/NavBar.tsx
autonomous: true
requirements:
- ADMIN-01
- ADMIN-02
must_haves:
truths:
- "Admin can see a list of all clients at /admin with name, brand, and payment status badges"
- "Admin can create a new client via /admin/clients/new form; on submit the client row + two payment rows are inserted and the secret link (token) is auto-generated"
- "After creating a client, admin is redirected to /admin (or /admin/clients/[id] for detail)"
- "The new client's shareable link /c/[token] is visible to the admin immediately after creation"
- "Payment status badges for Acconto and Saldo are visible in the client list row"
artifacts:
- path: "src/app/admin/page.tsx"
provides: "Admin client list — Server Component fetching all clients with payments"
contains: "export default async function"
- path: "src/app/admin/layout.tsx"
provides: "Admin layout with minimal NavBar (logo + Clienti link + logout button)"
contains: "NavBar"
- path: "src/app/admin/clients/new/page.tsx"
provides: "New client form page"
min_lines: 30
- path: "src/app/admin/clients/new/actions.ts"
provides: "Server Action: createClient() — inserts client + 2 payment rows"
contains: "createClient"
- path: "src/lib/admin-queries.ts"
provides: "Admin-side DB query functions (getAllClientsWithPayments)"
contains: "getAllClientsWithPayments"
key_links:
- from: "src/app/admin/page.tsx"
to: "src/lib/admin-queries.ts"
via: "getAllClientsWithPayments()"
pattern: "getAllClientsWithPayments"
- from: "src/app/admin/clients/new/page.tsx"
to: "src/app/admin/clients/new/actions.ts"
via: "createClient Server Action"
pattern: "createClient"
- from: "createClient action"
to: "clients + payments tables"
via: "db.insert(clients) + db.insert(payments) x2"
pattern: "db.insert"
---
<objective>
**Admin Client List + Create Client:** Build the admin home page (client list with payment badges) and the new client creation form. The create form auto-generates the nanoid secret token, inserts the client row, and creates two payment rows (Acconto 50% / Saldo 50%) in a single Server Action.
Purpose: Deliver the first end-to-end admin capability — admin can enter a client's details and immediately get a shareable /c/[token] link. Implements ADMIN-01 (client list with status) and the creation half of ADMIN-02 (per D-05 Server Actions, D-07 list→detail layout, D-09 minimal nav).
Output: /admin shows all clients with payment badges; /admin/clients/new creates a client and two payment stubs.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
@.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md
<interfaces>
<!-- Exact schema exports from src/db/schema.ts (do not re-read the file) -->
```typescript
export const clients = pgTable("clients", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
name: text("name").notNull(),
brand_name: text("brand_name").notNull(),
brief: text("brief").notNull(),
token: text("token").notNull().unique().$defaultFn(() => nanoid()),
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const payments = pgTable("payments", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
client_id: text("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 }),
});
export type Client = typeof clients.$inferSelect;
export type NewClient = typeof clients.$inferInsert;
export type Payment = typeof payments.$inferSelect;
```
From src/db/index.ts:
```typescript
export const db = drizzle(client); // drizzle-orm/postgres-js
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create src/lib/admin-queries.ts and admin layout + NavBar component</name>
<files>
src/lib/admin-queries.ts
src/app/admin/layout.tsx
src/components/admin/NavBar.tsx
</files>
<action>
Create `src/lib/admin-queries.ts` — all admin-side DB reads live here:
```typescript
import { db } from "@/db";
import { clients, payments } from "@/db/schema";
import { eq } from "drizzle-orm";
export type ClientWithPayments = {
id: string;
name: string;
brand_name: string;
token: string;
accepted_total: string;
created_at: Date;
payments: Array<{
id: string;
label: string;
status: string;
amount: string;
}>;
};
export async function getAllClientsWithPayments(): Promise<ClientWithPayments[]> {
const allClients = await db
.select()
.from(clients)
.orderBy(clients.created_at);
if (allClients.length === 0) return [];
const allPayments = await db
.select()
.from(payments);
return allClients.map((c) => ({
id: c.id,
name: c.name,
brand_name: c.brand_name,
token: c.token,
accepted_total: c.accepted_total ?? "0",
created_at: c.created_at,
payments: allPayments
.filter((p) => p.client_id === c.id)
.map((p) => ({
id: p.id,
label: p.label,
status: p.status,
amount: p.amount,
})),
}));
}
export async function getClientById(id: string) {
const rows = await db
.select()
.from(clients)
.where(eq(clients.id, id))
.limit(1);
return rows[0] ?? null;
}
```
Create `src/components/admin/NavBar.tsx` — minimal nav per D-09 (no sidebar):
```typescript
"use client";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
export function NavBar() {
return (
<nav className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-6">
<span className="font-semibold text-gray-900">ClientHub</span>
<Link
href="/admin"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
Clienti
</Link>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => signOut({ callbackUrl: "/admin/login" })}
className="text-sm text-gray-500"
>
Esci
</Button>
</nav>
);
}
```
Create `src/app/admin/layout.tsx` — wraps all /admin/* pages:
```typescript
import { NavBar } from "@/components/admin/NavBar";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<NavBar />
<main className="max-w-5xl mx-auto px-6 py-8">{children}</main>
</div>
);
}
```
</action>
<verify>
<automated>test -f src/lib/admin-queries.ts && grep -q "getAllClientsWithPayments" src/lib/admin-queries.ts && echo "admin-queries.ts created"</automated>
<automated>grep -q "getClientById" src/lib/admin-queries.ts && echo "getClientById exported"</automated>
<automated>test -f src/components/admin/NavBar.tsx && grep -q "signOut" src/components/admin/NavBar.tsx && echo "NavBar with logout"</automated>
<automated>test -f src/app/admin/layout.tsx && grep -q "NavBar" src/app/admin/layout.tsx && echo "Admin layout wraps NavBar"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- src/lib/admin-queries.ts exports getAllClientsWithPayments() and getClientById()
- NavBar renders with "Clienti" link and "Esci" button
- Admin layout wraps all /admin/* pages with NavBar + centered main content area
- npm run build passes
</done>
</task>
<task type="auto">
<name>Task 2: Build /admin client list page and /admin/clients/new create-client flow</name>
<files>
src/app/admin/page.tsx
src/components/admin/ClientRow.tsx
src/app/admin/clients/new/page.tsx
src/app/admin/clients/new/actions.ts
</files>
<action>
Create `src/components/admin/ClientRow.tsx` — single row in client list table:
```typescript
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import type { ClientWithPayments } from "@/lib/admin-queries";
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
da_saldare: { label: "Da saldare", variant: "destructive" },
inviata: { label: "Inviata", variant: "secondary" },
saldato: { label: "Saldato", variant: "default" },
};
export function ClientRow({ client }: { client: ClientWithPayments }) {
const acconto = client.payments.find((p) => p.label.includes("Acconto"));
const saldo = client.payments.find((p) => p.label.includes("Saldo"));
return (
<tr className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
<td className="py-3 px-4">
<Link
href={`/admin/clients/${client.id}`}
className="font-medium text-gray-900 hover:underline"
>
{client.name}
</Link>
<p className="text-xs text-gray-400">{client.brand_name}</p>
</td>
<td className="py-3 px-4 text-sm text-gray-600">
€ {parseFloat(client.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</td>
<td className="py-3 px-4">
{acconto && (
<Badge variant={statusConfig[acconto.status]?.variant ?? "outline"}>
Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
</Badge>
)}
</td>
<td className="py-3 px-4">
{saldo && (
<Badge variant={statusConfig[saldo.status]?.variant ?? "outline"}>
Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
</Badge>
)}
</td>
<td className="py-3 px-4">
<a
href={`/c/${client.token}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline font-mono"
>
/c/{client.token.slice(0, 10)}…
</a>
</td>
</tr>
);
}
```
Create `src/app/admin/page.tsx` — Server Component, no client state:
```typescript
import Link from "next/link";
import { getAllClientsWithPayments } from "@/lib/admin-queries";
import { ClientRow } from "@/components/admin/ClientRow";
import { Button } from "@/components/ui/button";
export const revalidate = 0; // always fresh — admin needs real-time data
export default async function AdminDashboard() {
const clients = await getAllClientsWithPayments();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Clienti</h1>
<Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button>
</div>
{clients.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<p>Nessun cliente ancora.</p>
<p className="mt-2">
<Link href="/admin/clients/new" className="text-blue-600 hover:underline">
Crea il primo cliente
</Link>
</p>
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left py-3 px-4 font-medium text-gray-600">Cliente</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Totale</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Acconto</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Saldo</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Link</th>
</tr>
</thead>
<tbody>
{clients.map((client) => (
<ClientRow key={client.id} client={client} />
))}
</tbody>
</table>
</div>
)}
</div>
);
}
```
Create `src/app/admin/clients/new/actions.ts` — Server Action (per D-05):
```typescript
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/db";
import { clients, payments } from "@/db/schema";
const createClientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Nome brand richiesto"),
brief: z.string().min(1, "Brief richiesto"),
});
export async function createClient(formData: FormData) {
const raw = {
name: formData.get("name") as string,
brand_name: formData.get("brand_name") as string,
brief: formData.get("brief") as string,
};
const parsed = createClientSchema.safeParse(raw);
if (!parsed.success) {
// In v1 return errors as thrown string — form displays validation inline
throw new Error(parsed.error.issues.map((i) => i.message).join(", "));
}
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
const [newClient] = await db
.insert(clients)
.values({
name: parsed.data.name,
brand_name: parsed.data.brand_name,
brief: parsed.data.brief,
})
.returning({ id: clients.id, token: clients.token });
// Always create two payment stubs per client — Acconto 50% and Saldo 50%
// Amounts default to 0 until admin sets accepted_total; admin updates separately
await db.insert(payments).values([
{
client_id: newClient.id,
label: "Acconto 50%",
amount: "0",
status: "da_saldare",
},
{
client_id: newClient.id,
label: "Saldo 50%",
amount: "0",
status: "da_saldare",
},
]);
revalidatePath("/admin");
redirect(`/admin/clients/${newClient.id}`);
}
```
Create `src/app/admin/clients/new/page.tsx` — form using the Server Action:
```typescript
import { createClient } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
export default function NewClientPage() {
return (
<div className="max-w-xl">
<div className="mb-6">
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
← Clienti
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Nuovo cliente</CardTitle>
</CardHeader>
<CardContent>
<form action={createClient} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">Nome cliente</Label>
<Input
id="name"
name="name"
type="text"
placeholder="es. Marco Rossi"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="brand_name">Nome brand</Label>
<Input
id="brand_name"
name="brand_name"
type="text"
placeholder="es. Rossi Studio"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="brief">Brief del progetto</Label>
<Textarea
id="brief"
name="brief"
placeholder="Descrizione del progetto e degli obiettivi..."
rows={5}
required
/>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit">Crea cliente</Button>
<Button variant="outline" asChild>
<Link href="/admin">Annulla</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
```
</action>
<verify>
<automated>test -f src/app/admin/page.tsx && grep -q "getAllClientsWithPayments" src/app/admin/page.tsx && echo "Admin page fetches clients"</automated>
<automated>grep -q "ClientRow" src/app/admin/page.tsx && echo "ClientRow used in table"</automated>
<automated>test -f src/app/admin/clients/new/actions.ts && grep -q '"use server"' src/app/admin/clients/new/actions.ts && echo "Server Action directive present"</automated>
<automated>grep -q "db.insert(clients)" src/app/admin/clients/new/actions.ts && echo "client insert present"</automated>
<automated>grep -q "Acconto 50%" src/app/admin/clients/new/actions.ts && grep -q "Saldo 50%" src/app/admin/clients/new/actions.ts && echo "both payment stubs inserted"</automated>
<automated>grep -q "createClientSchema" src/app/admin/clients/new/actions.ts && echo "Zod validation present"</automated>
<automated>test -f src/app/admin/clients/new/page.tsx && grep -q "action={createClient}" src/app/admin/clients/new/page.tsx && echo "form wired to Server Action"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- /admin shows table of clients with name, brand, totale, acconto badge, saldo badge, client link
- Empty state shows "Nessun cliente ancora" with link to create
- /admin/clients/new shows form with name, brand_name, brief fields
- Submitting the form inserts client row (token auto-generated by nanoid) + 2 payment stubs
- After creation, admin is redirected to /admin/clients/[id] (detail page — stub until Plan 03)
- npm run build passes
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin browser → Server Action | createClient() runs server-side; input validated with Zod before any DB write |
| Admin browser → /admin/* | Middleware session guard (02-01) prevents unauthenticated access to all admin pages and Server Actions |
| Client token → DB | Token generated server-side by nanoid(), never user-supplied; cannot be guessed |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-06 | Tampering | createClient Server Action | mitigate | Zod validates all input fields before DB insert; malformed input throws, no partial writes |
| T-02-07 | Information Disclosure | Client list page | mitigate | /admin/* protected by middleware session guard from 02-01; unauthenticated requests never reach this Server Component |
| T-02-08 | Tampering | token generation | mitigate | token is $defaultFn(() => nanoid()) — server-generated, cryptographically random, never derived from user input |
| T-02-09 | Information Disclosure | ClientRow renders full token | accept | Token is shown truncated in UI for usability; full token accessible via /c/[token] link only — acceptable since admin has session auth |
</threat_model>
<verification>
After plan execution:
1. `npm run build` — no errors
2. Log in as admin, visit /admin — client table renders (empty or with seeded data)
3. Click "+ Nuovo cliente" → /admin/clients/new loads with form
4. Submit form with valid data → redirects to /admin/clients/[id] (stub page acceptable at this point)
5. Return to /admin → new client appears in table with "Da saldare" badges for Acconto and Saldo
6. Click the /c/[token] link in the table → opens client dashboard (Phase 1 output)
</verification>
<success_criteria>
- /admin shows all clients with payment status badges; empty state handled gracefully
- New client can be created via form; token is auto-generated server-side
- Two payment stubs (Acconto 50% / Saldo 50%) are created automatically on client creation
- Admin can immediately share /c/[token] after creating a client
- npm run build passes cleanly
</success_criteria>
<output>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md`
</output>
@@ -1,144 +0,0 @@
---
phase: 02-admin-area-interactive-features
plan: "02"
subsystem: ui
tags: [nextjs, server-actions, drizzle, shadcn, zod, nanoid, tailwind]
# Dependency graph
requires:
- phase: 02-01
provides: Auth.js session guard protecting all /admin/* routes via middleware
provides:
- Admin client list at /admin with payment status badges (Acconto/Saldo per client)
- New client creation form at /admin/clients/new with Server Action
- Automatic payment stub creation (Acconto 50% + Saldo 50%) on client insert
- ClientWithPayments query type and getAllClientsWithPayments() utility
- Admin layout with NavBar (ClientHub logo, Clienti link, Esci logout button)
affects:
- 02-03
- 02-04
- client-detail page (Plan 03 will build /admin/clients/[id])
# Tech tracking
tech-stack:
added: []
patterns:
- "Server Components for admin data pages (no client state, no useEffect)"
- "Server Actions with Zod validation for form mutations (D-05)"
- "revalidatePath('/admin') + redirect after mutation"
- "nanoid token generated server-side via $defaultFn — never user-supplied"
- "Two-query fetch pattern: clients then payments, merged in-memory (avoids JOIN complexity)"
key-files:
created:
- src/lib/admin-queries.ts
- src/components/admin/NavBar.tsx
- src/app/admin/layout.tsx
- src/components/admin/ClientRow.tsx
- src/app/admin/page.tsx
- src/app/admin/clients/new/page.tsx
- src/app/admin/clients/new/actions.ts
modified: []
key-decisions:
- "Zod validates createClient input server-side before any DB write — malformed input throws cleanly with no partial inserts"
- "Payment stubs inserted immediately on client creation with amount=0 and status=da_saldare — amounts updated separately by admin"
- "Token is $defaultFn(() => nanoid()) — server-generated, never derived from user input, satisfies T-02-08"
- "Admin page uses revalidate=0 to always fetch fresh data — admin operations require real-time list"
- "Two-query pattern (clients + payments) merged in-memory chosen over Drizzle relations JOIN for simplicity at this scale"
patterns-established:
- "Server Action pattern: 'use server' + Zod schema + db.insert + revalidatePath + redirect"
- "ClientRow: presentational component receiving ClientWithPayments, no data fetching"
- "Badge variant mapping: da_saldare=destructive, inviata=secondary, saldato=default"
requirements-completed:
- ADMIN-01
- ADMIN-02
# Metrics
duration: 30min
completed: 2026-05-15
---
# Phase 02 Plan 02: Admin Client List + Create Client Summary
**Admin home shows all clients with Acconto/Saldo payment badges; new client form inserts client row + two payment stubs via Zod-validated Server Action with nanoid token auto-generation**
## Performance
- **Duration:** ~30 min
- **Started:** 2026-05-15
- **Completed:** 2026-05-15
- **Tasks:** 2 (Task 1 pre-committed, Task 2 executed in this run)
- **Files modified:** 7
## Accomplishments
- Admin dashboard at /admin renders all clients in a table with name, brand, totale, Acconto badge, Saldo badge, and truncated secret link
- Empty state renders gracefully with a link to create the first client
- /admin/clients/new form with nome, brand, brief fields wired to `createClient` Server Action
- `createClient` validates with Zod, inserts client row (token auto-generated by nanoid), inserts Acconto 50% + Saldo 50% payment stubs, revalidates /admin, redirects to /admin/clients/[id]
- Admin layout wraps all /admin/* pages with NavBar showing "ClientHub" + "Clienti" link + "Esci" logout button
## Task Commits
1. **Task 1: Create admin-queries.ts, NavBar, and admin layout** - `7029583` (feat)
2. **Task 2: Admin client list page and create-client flow** - `f77051a` (feat)
**Plan metadata:** (docs commit follows)
## Files Created/Modified
- `src/lib/admin-queries.ts` - `getAllClientsWithPayments()` and `getClientById()` query utilities; `ClientWithPayments` type
- `src/components/admin/NavBar.tsx` - Client component with logo, Clienti nav link, signOut button
- `src/app/admin/layout.tsx` - Layout wrapper applying NavBar to all /admin/* pages
- `src/components/admin/ClientRow.tsx` - Table row with payment status Badge components and secret link
- `src/app/admin/page.tsx` - Server Component fetching all clients; renders table or empty state
- `src/app/admin/clients/new/page.tsx` - Form page (Server Component) wired to createClient action
- `src/app/admin/clients/new/actions.ts` - Server Action: Zod validation + db.insert(clients) + db.insert(payments) x2
## Decisions Made
- Zod validates createClient input before any DB operation — malformed input throws a clean error with no partial writes
- Payment stubs created with `amount: "0"` — amounts set later by admin via separate update flow (Plan 03)
- `revalidate = 0` on /admin page ensures admin always sees fresh data after mutations
- Token is entirely server-generated (`$defaultFn(() => nanoid())`) — user cannot supply or influence it (T-02-08 mitigated)
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None. Build passed cleanly (existing CSS warning is pre-existing and unrelated to this plan).
## Known Stubs
- `/admin/clients/[id]` — redirect destination after client creation. The route does not exist yet; Next.js will render a 404 until Plan 03 builds the client detail page. This is explicitly noted in the plan as acceptable at this stage ("stub until Plan 03").
## Threat Surface Scan
All threats in the plan's threat register are addressed:
| Threat ID | Disposition | Status |
|-----------|-------------|--------|
| T-02-06 | mitigate | Zod validates createClient before any DB write |
| T-02-07 | mitigate | /admin/* protected by 02-01 middleware session guard |
| T-02-08 | mitigate | Token is `$defaultFn(() => nanoid())`, never user-supplied |
| T-02-09 | accept | Token shown truncated in UI; full token only via /c/[token] link |
No new security surfaces introduced beyond the threat model.
## Next Phase Readiness
- /admin client list and /admin/clients/new are fully functional end-to-end
- createClient Server Action is the canonical pattern for future admin mutations
- Plan 03 can build /admin/clients/[id] detail page using `getClientById()` already exported from admin-queries.ts
- Payment stub amounts need an update flow (Plan 03 or later)
---
*Phase: 02-admin-area-interactive-features*
*Completed: 2026-05-15*
@@ -1,825 +0,0 @@
---
phase: "02-admin-area-interactive-features"
plan: 03
type: execute
wave: 3
depends_on:
- "02-02"
files_modified:
- src/app/admin/clients/[id]/page.tsx
- src/app/admin/clients/[id]/actions.ts
- src/components/admin/tabs/PhasesTab.tsx
- src/components/admin/tabs/PaymentsTab.tsx
- src/components/admin/tabs/DocumentsTab.tsx
- src/components/admin/tabs/CommentsTab.tsx
- src/lib/admin-queries.ts
autonomous: true
requirements:
- ADMIN-02
must_haves:
truths:
- "Admin can open /admin/clients/[id] and see all client data in tabs: Panoramica, Fasi & Task, Documenti, Pagamenti, Commenti"
- "Admin can add a phase to a client, add a task to a phase, and change task status — all via Server Actions"
- "Admin can add a document (label + URL) and delete it"
- "Admin can change the payment status (da_saldare / inviata / saldato) and update the accepted_total on the client row"
- "Admin can see all comments left by the client (read-only in this tab) and post a reply as 'admin'"
artifacts:
- path: "src/app/admin/clients/[id]/page.tsx"
provides: "Client workspace with tabbed layout using @radix-ui/react-tabs"
contains: "Tabs"
- path: "src/app/admin/clients/[id]/actions.ts"
provides: "Server Actions: addPhase, addTask, updateTaskStatus, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment"
contains: "addPhase"
- path: "src/components/admin/tabs/PhasesTab.tsx"
provides: "Fasi & Task tab — list phases with tasks, add-phase form, add-task form, task status selector"
min_lines: 60
- path: "src/components/admin/tabs/PaymentsTab.tsx"
provides: "Pagamenti tab — accepted_total field + two payment rows with status selects"
min_lines: 40
key_links:
- from: "src/app/admin/clients/[id]/page.tsx"
to: "src/lib/admin-queries.ts"
via: "getClientFullDetail(id)"
pattern: "getClientFullDetail"
- from: "PhasesTab, PaymentsTab, DocumentsTab"
to: "src/app/admin/clients/[id]/actions.ts"
via: "Server Actions bound to form action={}"
pattern: "action={"
- from: "updatePaymentStatus / updateAcceptedTotal"
to: "payments / clients tables"
via: "db.update().set().where()"
pattern: "db.update"
---
<objective>
**Admin Client Workspace (tabs):** Build the full /admin/clients/[id] detail page with Radix Tabs. Each tab covers one concern: Panoramica (overview), Fasi & Task (add phases/tasks, update status), Documenti (add/delete document links), Pagamenti (update payment status + accepted_total), Commenti (read client comments, post admin reply). All mutations use Server Actions (per D-05). Tabs use @radix-ui/react-tabs + shadcn tabs component (per D-08).
Purpose: Deliver ADMIN-02 — complete management of every client's data from a single authenticated workspace.
Output: Admin can fully manage a client's project lifecycle without leaving the detail page.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
@.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md
<interfaces>
<!-- From src/db/schema.ts — all types relevant to this plan -->
```typescript
export type Client = typeof clients.$inferSelect;
export type Phase = typeof phases.$inferSelect;
export type Task = typeof tasks.$inferSelect;
export type Deliverable = typeof deliverables.$inferSelect;
export type Comment = typeof comments.$inferSelect;
export type Payment = typeof payments.$inferSelect;
export type Document = typeof documents.$inferSelect;
export type Note = typeof notes.$inferSelect;
// phases columns: id, client_id, title, sort_order, status (upcoming|active|done)
// tasks columns: id, phase_id, title, description, status (todo|in_progress|done), sort_order
// comments columns: id, entity_type (task|deliverable), entity_id, author (client|admin), body, created_at
// payments columns: id, client_id, label, amount, status (da_saldare|inviata|saldato), paid_at
// documents columns: id, client_id, label, url, created_at
```
<!-- From src/lib/admin-queries.ts (02-02 output) -->
```typescript
export async function getClientById(id: string): Promise<Client | null>;
```
<!-- New function to add to admin-queries.ts in this plan -->
<!-- getClientFullDetail(id) must return client + phases + tasks + deliverables + payments + documents + notes + comments -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Install @radix-ui/react-tabs + shadcn tabs; add getClientFullDetail() to admin-queries; create Server Actions</name>
<files>
package.json
src/components/ui/tabs.tsx
src/lib/admin-queries.ts
src/app/admin/clients/[id]/actions.ts
</files>
<action>
Install Radix tabs and add shadcn tabs component (per D-08):
```
npx shadcn@latest add tabs
```
This installs @radix-ui/react-tabs and creates src/components/ui/tabs.tsx.
Extend `src/lib/admin-queries.ts` — add getClientFullDetail() below existing functions.
Read the current file first to append without overwriting.
Add this function:
```typescript
import { clients, phases, tasks, deliverables, comments, payments, documents, notes } from "@/db/schema";
import { eq, inArray, asc } from "drizzle-orm";
export type ClientFullDetail = {
client: Client;
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
payments: Payment[];
documents: Document[];
notes: Note[];
comments: Comment[];
};
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
const clientRows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
if (clientRows.length === 0) return null;
const client = clientRows[0];
const phasesRows = await db
.select()
.from(phases)
.where(eq(phases.client_id, id))
.orderBy(asc(phases.sort_order));
const phaseIds = phasesRows.map((p) => p.id);
const tasksRows = phaseIds.length === 0
? []
: await db.select().from(tasks).where(inArray(tasks.phase_id, phaseIds)).orderBy(asc(tasks.sort_order));
const taskIds = tasksRows.map((t) => t.id);
const deliverablesRows = taskIds.length === 0
? []
: await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds));
const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, id));
const documentsRows = await db.select().from(documents).where(eq(documents.client_id, id)).orderBy(asc(documents.created_at));
const notesRows = await db.select().from(notes).where(eq(notes.client_id, id)).orderBy(asc(notes.created_at));
// Fetch all comments for this client's tasks and deliverables
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
const commentsRows = allEntityIds.length === 0
? []
: await db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at));
const phasesWithTasks = phasesRows.map((phase) => {
const phaseTasks = tasksRows
.filter((t) => t.phase_id === phase.id)
.map((task) => ({
...task,
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
}));
return { ...phase, tasks: phaseTasks };
});
return {
client,
phases: phasesWithTasks,
payments: paymentsRows,
documents: documentsRows,
notes: notesRows,
comments: commentsRows,
};
}
```
Create `src/app/admin/clients/[id]/actions.ts` — all mutations for the workspace:
```typescript
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { phases, tasks, deliverables, documents, payments, clients, comments } from "@/db/schema";
import { eq } from "drizzle-orm";
import { z } from "zod";
// ── PHASES ────────────────────────────────────────────────────────────────
export async function addPhase(clientId: string, formData: FormData) {
const title = (formData.get("title") as string)?.trim();
if (!title) throw new Error("Titolo fase richiesto");
// Determine next sort_order
const existingPhases = await db.select({ sort_order: phases.sort_order })
.from(phases).where(eq(phases.client_id, clientId));
const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1);
await db.insert(phases).values({
client_id: clientId,
title,
sort_order: maxOrder + 1,
status: "upcoming",
});
revalidatePath(`/admin/clients/${clientId}`);
}
export async function updatePhaseStatus(phaseId: string, clientId: string, status: string) {
const allowed = ["upcoming", "active", "done"];
if (!allowed.includes(status)) throw new Error("Stato non valido");
await db.update(phases).set({ status }).where(eq(phases.id, phaseId));
revalidatePath(`/admin/clients/${clientId}`);
}
// ── TASKS ─────────────────────────────────────────────────────────────────
export async function addTask(phaseId: string, clientId: string, formData: FormData) {
const title = (formData.get("title") as string)?.trim();
if (!title) throw new Error("Titolo task richiesto");
const existingTasks = await db.select({ sort_order: tasks.sort_order })
.from(tasks).where(eq(tasks.phase_id, phaseId));
const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.sort_order), -1);
await db.insert(tasks).values({
phase_id: phaseId,
title,
description: (formData.get("description") as string)?.trim() || null,
sort_order: maxOrder + 1,
status: "todo",
});
revalidatePath(`/admin/clients/${clientId}`);
}
export async function updateTaskStatus(taskId: string, clientId: string, status: string) {
const allowed = ["todo", "in_progress", "done"];
if (!allowed.includes(status)) throw new Error("Stato non valido");
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
revalidatePath(`/admin/clients/${clientId}`);
}
// ── DELIVERABLES ──────────────────────────────────────────────────────────
export async function addDeliverable(taskId: string, clientId: string, formData: FormData) {
const title = (formData.get("title") as string)?.trim();
const url = (formData.get("url") as string)?.trim() || null;
if (!title) throw new Error("Titolo deliverable richiesto");
await db.insert(deliverables).values({ task_id: taskId, title, url, status: "pending" });
revalidatePath(`/admin/clients/${clientId}`);
}
// ── DOCUMENTS ─────────────────────────────────────────────────────────────
const docSchema = z.object({
label: z.string().min(1),
url: z.string().url("URL non valido"),
});
export async function addDocument(clientId: string, formData: FormData) {
const parsed = docSchema.safeParse({
label: formData.get("label"),
url: formData.get("url"),
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(documents).values({ client_id: clientId, ...parsed.data });
revalidatePath(`/admin/clients/${clientId}`);
}
export async function deleteDocument(documentId: string, clientId: string) {
await db.delete(documents).where(eq(documents.id, documentId));
revalidatePath(`/admin/clients/${clientId}`);
}
// ── PAYMENTS ──────────────────────────────────────────────────────────────
export async function updatePaymentStatus(paymentId: string, clientId: string, status: string) {
const allowed = ["da_saldare", "inviata", "saldato"];
if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
const paid_at = status === "saldato" ? new Date() : null;
await db.update(payments).set({ status, paid_at }).where(eq(payments.id, paymentId));
revalidatePath(`/admin/clients/${clientId}`);
}
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
const raw = (formData.get("accepted_total") as string)?.trim();
const val = parseFloat(raw);
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
// Update accepted_total on client row
await db.update(clients).set({ accepted_total: raw }).where(eq(clients.id, clientId));
// Update payment amounts to 50% each
const half = (val / 2).toFixed(2);
const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, clientId));
for (const p of paymentsRows) {
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
}
revalidatePath(`/admin/clients/${clientId}`);
}
// ── COMMENTS (admin reply) ────────────────────────────────────────────────
export async function postAdminComment(clientId: string, formData: FormData) {
const entity = formData.get("entity") as string;
const body = (formData.get("body") as string)?.trim();
if (!body || !entity) throw new Error("Dati mancanti");
const [entity_type, entity_id] = entity.split(":");
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
if (!["task", "deliverable"].includes(entity_type)) throw new Error("entity_type non valido");
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
revalidatePath(`/admin/clients/${clientId}`);
}
```
</action>
<verify>
<automated>test -f src/components/ui/tabs.tsx && echo "shadcn tabs component installed"</automated>
<automated>grep -q "getClientFullDetail" src/lib/admin-queries.ts && echo "getClientFullDetail added to admin-queries"</automated>
<automated>test -f src/app/admin/clients/\[id\]/actions.ts && grep -q '"use server"' src/app/admin/clients/\[id\]/actions.ts && echo "actions.ts is Server Action file"</automated>
<automated>grep -q "addPhase\|addTask\|updatePaymentStatus\|updateAcceptedTotal\|postAdminComment" src/app/admin/clients/\[id\]/actions.ts && echo "all major actions present"</automated>
<automated>grep -q "revalidatePath" src/app/admin/clients/\[id\]/actions.ts && echo "revalidatePath called in actions"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- src/components/ui/tabs.tsx exists (shadcn tabs installed)
- getClientFullDetail(id) added to admin-queries.ts, returns all nested client data
- actions.ts contains addPhase, addTask, updateTaskStatus, addDeliverable, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment — all with revalidatePath
- npm run build passes
</done>
</task>
<task type="auto">
<name>Task 2: Build /admin/clients/[id] detail page with all four tab components</name>
<files>
src/app/admin/clients/[id]/page.tsx
src/components/admin/tabs/PhasesTab.tsx
src/components/admin/tabs/PaymentsTab.tsx
src/components/admin/tabs/DocumentsTab.tsx
src/components/admin/tabs/CommentsTab.tsx
</files>
<action>
Create `src/app/admin/clients/[id]/page.tsx` — Server Component, tab container:
```typescript
import { notFound } from "next/navigation";
import { getClientFullDetail } from "@/lib/admin-queries";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
import Link from "next/link";
export const revalidate = 0;
export default async function ClientDetailPage({
params,
}: {
params: { id: string };
}) {
const detail = await getClientFullDetail(params.id);
if (!detail) notFound();
const { client, phases, payments, documents, notes, comments } = detail;
return (
<div>
<div className="mb-4">
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
← Clienti
</Link>
</div>
<div className="mb-6 flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{client.name}</h1>
<p className="text-sm text-gray-500">{client.brand_name}</p>
</div>
<a
href={`/c/${client.token}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline font-mono bg-blue-50 px-2 py-1 rounded"
>
Link cliente →
</a>
</div>
<Tabs defaultValue="phases" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="phases">Fasi &amp; Task</TabsTrigger>
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
<TabsTrigger value="documents">Documenti</TabsTrigger>
<TabsTrigger value="comments">Commenti</TabsTrigger>
</TabsList>
<TabsContent value="phases">
<PhasesTab phases={phases} clientId={client.id} />
</TabsContent>
<TabsContent value="payments">
<PaymentsTab
payments={payments}
acceptedTotal={client.accepted_total ?? "0"}
clientId={client.id}
/>
</TabsContent>
<TabsContent value="documents">
<DocumentsTab documents={documents} clientId={client.id} />
</TabsContent>
<TabsContent value="comments">
<CommentsTab comments={comments} phases={phases} clientId={client.id} />
</TabsContent>
</Tabs>
</div>
);
}
```
Create `src/components/admin/tabs/PhasesTab.tsx`:
```typescript
import { addPhase, addTask, updateTaskStatus, updatePhaseStatus } from "@/app/admin/clients/[id]/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { ClientFullDetail } from "@/lib/admin-queries";
type Props = {
phases: ClientFullDetail["phases"];
clientId: string;
};
const taskStatusOptions = [
{ value: "todo", label: "Da fare" },
{ value: "in_progress", label: "In corso" },
{ value: "done", label: "Fatto" },
];
const phaseStatusOptions = [
{ value: "upcoming", label: "In arrivo" },
{ value: "active", label: "Attiva" },
{ value: "done", label: "Completata" },
];
export function PhasesTab({ phases, clientId }: Props) {
return (
<div className="space-y-6">
{/* Add phase form */}
<form
action={async (fd) => { "use server"; await addPhase(clientId, fd); }}
className="flex gap-2"
>
<Input name="title" placeholder="Nome nuova fase..." className="max-w-xs" required />
<Button type="submit" variant="outline" size="sm">+ Fase</Button>
</form>
{/* Phases list */}
{phases.length === 0 && (
<p className="text-sm text-gray-400">Nessuna fase ancora.</p>
)}
{phases.map((phase) => (
<div key={phase.id} className="border border-gray-200 rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
<form
action={async (fd) => {
"use server";
await updatePhaseStatus(phase.id, clientId, fd.get("status") as string);
}}
className="flex items-center gap-2"
>
<select
name="status"
defaultValue={phase.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
>
{phaseStatusOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Button type="submit" variant="ghost" size="sm" className="text-xs">Salva</Button>
</form>
</div>
{/* Tasks */}
<div className="space-y-2 mb-3">
{phase.tasks.map((task) => (
<div key={task.id} className="flex items-center justify-between pl-3 border-l-2 border-gray-100">
<span className="text-sm text-gray-800">{task.title}</span>
<form
action={async (fd) => {
"use server";
await updateTaskStatus(task.id, clientId, fd.get("status") as string);
}}
className="flex items-center gap-1"
>
<select
name="status"
defaultValue={task.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
>
{taskStatusOptions.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Button type="submit" variant="ghost" size="sm" className="text-xs px-1">✓</Button>
</form>
</div>
))}
</div>
{/* Add task form */}
<form
action={async (fd) => { "use server"; await addTask(phase.id, clientId, fd); }}
className="flex gap-2 mt-2"
>
<Input name="title" placeholder="Nuovo task..." className="text-sm max-w-xs" required />
<Button type="submit" variant="ghost" size="sm" className="text-xs">+ Task</Button>
</form>
</div>
))}
</div>
);
}
```
Create `src/components/admin/tabs/PaymentsTab.tsx`:
```typescript
import { updatePaymentStatus, updateAcceptedTotal } from "@/app/admin/clients/[id]/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Payment } from "@/db/schema";
type Props = {
payments: Payment[];
acceptedTotal: string;
clientId: string;
};
const statusLabels: Record<string, string> = {
da_saldare: "Da saldare",
inviata: "Inviata",
saldato: "Saldato",
};
export function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
return (
<div className="space-y-6 max-w-md">
{/* Accepted total */}
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
<form
action={async (fd) => { "use server"; await updateAcceptedTotal(clientId, fd); }}
className="flex items-end gap-3"
>
<div className="space-y-1 flex-1">
<Label htmlFor="accepted_total">Importo (€)</Label>
<Input
id="accepted_total"
name="accepted_total"
type="number"
step="0.01"
min="0"
defaultValue={acceptedTotal}
className="max-w-xs"
/>
</div>
<Button type="submit" size="sm">Salva</Button>
</form>
<p className="text-xs text-gray-400 mt-2">
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
</p>
</div>
{/* Payment rows */}
{payments.map((p) => (
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium text-gray-900">{p.label}</h3>
<span className="text-sm text-gray-600">
€ {parseFloat(p.amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
</div>
<form
action={async (fd) => {
"use server";
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
}}
className="flex items-center gap-2"
>
<select
name="status"
defaultValue={p.status}
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
>
{Object.entries(statusLabels).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
<Button type="submit" size="sm" variant="outline">Aggiorna</Button>
</form>
</div>
))}
</div>
);
}
```
Create `src/components/admin/tabs/DocumentsTab.tsx`:
```typescript
import { addDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Document } from "@/db/schema";
type Props = { documents: Document[]; clientId: string };
export function DocumentsTab({ documents, clientId }: Props) {
return (
<div className="space-y-6 max-w-lg">
<form
action={async (fd) => { "use server"; await addDocument(clientId, fd); }}
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
>
<h3 className="font-medium text-gray-900">Aggiungi documento</h3>
<div className="space-y-1">
<Label htmlFor="doc-label">Nome / etichetta</Label>
<Input id="doc-label" name="label" placeholder="es. Brief progetto" required />
</div>
<div className="space-y-1">
<Label htmlFor="doc-url">URL (Google Drive, PDF...)</Label>
<Input id="doc-url" name="url" type="url" placeholder="https://drive.google.com/..." required />
</div>
<Button type="submit" size="sm">Aggiungi</Button>
</form>
{documents.length === 0 && (
<p className="text-sm text-gray-400">Nessun documento ancora.</p>
)}
<div className="space-y-2">
{documents.map((doc) => (
<div key={doc.id} className="flex items-center justify-between bg-white border border-gray-200 rounded-lg px-4 py-3">
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
{doc.label}
</a>
<form action={async () => { "use server"; await deleteDocument(doc.id, clientId); }}>
<Button type="submit" variant="ghost" size="sm" className="text-red-500 hover:text-red-700 text-xs">
Rimuovi
</Button>
</form>
</div>
))}
</div>
</div>
);
}
```
Create `src/components/admin/tabs/CommentsTab.tsx`:
```typescript
import { postAdminComment } from "@/app/admin/clients/[id]/actions";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { Comment } from "@/db/schema";
import type { ClientFullDetail } from "@/lib/admin-queries";
type Props = {
comments: Comment[];
phases: ClientFullDetail["phases"];
clientId: string;
};
export function CommentsTab({ comments, phases, clientId }: Props) {
// Build entity label map for display
const entityLabels: Record<string, string> = {};
for (const phase of phases) {
for (const task of phase.tasks) {
entityLabels[task.id] = `Task: ${task.title}`;
for (const d of task.deliverables) {
entityLabels[d.id] = `Deliverable: ${d.title}`;
}
}
}
// Build list of entities the admin can reply on
const entities: Array<{ id: string; type: string; label: string }> = [];
for (const phase of phases) {
for (const task of phase.tasks) {
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
for (const d of task.deliverables) {
entities.push({ id: d.id, type: "deliverable", label: `Deliverable: ${d.title}` });
}
}
}
return (
<div className="space-y-6 max-w-lg">
{/* Comment list */}
{comments.length === 0 && (
<p className="text-sm text-gray-400">Nessun commento ancora.</p>
)}
<div className="space-y-3">
{comments.map((c) => (
<div key={c.id} className={`flex gap-3 ${c.author === "admin" ? "flex-row-reverse" : ""}`}>
<div
className={`rounded-lg px-3 py-2 text-sm max-w-xs ${
c.author === "admin"
? "bg-gray-900 text-white"
: "bg-white border border-gray-200 text-gray-800"
}`}
>
<p className="text-xs font-medium mb-1 opacity-60">
{c.author === "admin" ? "iamcavalli" : "Cliente"} — {entityLabels[c.entity_id] ?? c.entity_id}
</p>
<p>{c.body}</p>
</div>
</div>
))}
</div>
{/* Admin reply form */}
{entities.length > 0 && (
<form
action={async (fd) => { "use server"; await postAdminComment(clientId, fd); }}
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
>
<h3 className="font-medium text-gray-900 text-sm">Rispondi come admin</h3>
<select name="entity" className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white" required>
{entities.map((e) => (
<option key={e.id} value={`${e.type}:${e.id}`}>{e.label}</option>
))}
</select>
<Textarea name="body" placeholder="Scrivi un commento..." rows={3} required />
<Button type="submit" size="sm">Invia risposta</Button>
</form>
)}
</div>
);
}
```
</action>
<verify>
<automated>test -f src/app/admin/clients/\[id\]/page.tsx && grep -q "Tabs" src/app/admin/clients/\[id\]/page.tsx && echo "Tabs imported in detail page"</automated>
<automated>grep -q "getClientFullDetail" src/app/admin/clients/\[id\]/page.tsx && echo "getClientFullDetail called"</automated>
<automated>test -f src/components/admin/tabs/PhasesTab.tsx && echo "PhasesTab exists"</automated>
<automated>test -f src/components/admin/tabs/PaymentsTab.tsx && echo "PaymentsTab exists"</automated>
<automated>test -f src/components/admin/tabs/DocumentsTab.tsx && echo "DocumentsTab exists"</automated>
<automated>test -f src/components/admin/tabs/CommentsTab.tsx && echo "CommentsTab exists"</automated>
<automated>grep -q "updateAcceptedTotal" src/components/admin/tabs/PaymentsTab.tsx && echo "Payment total update wired"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- /admin/clients/[id] renders with Radix Tabs: Fasi & Task, Pagamenti, Documenti, Commenti
- PhasesTab: shows phases with task lists; add phase form and add task form work; task status updates work
- PaymentsTab: accepted_total editable; payment status selects update and set paid_at on saldato
- DocumentsTab: add document (label + URL) and delete document work
- CommentsTab: displays all comments chronologically; admin can post reply on any task/deliverable
- npm run build passes cleanly
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin browser → Server Actions | All mutations server-side; session guard in middleware ensures only authenticated admin reaches these |
| Server Actions → DB | Input validated with Zod or allowlist checks before any write |
| approved_at field | Not touched by any admin action in this plan — immutability enforced by omission |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-10 | Tampering | updateTaskStatus / updatePaymentStatus | mitigate | Server-side allowlist check on status value before db.update(); invalid values throw before any write |
| T-02-11 | Tampering | deleteDocument | mitigate | Admin-only route protected by middleware session; no client can call deleteDocument without a valid JWT |
| T-02-12 | Information Disclosure | getClientFullDetail fetches comments | accept | Comments are fetched only by admin in this plan; client reads own comments only via client-facing API (Plan 04) |
| T-02-13 | Tampering | postAdminComment entity_type parsing | mitigate | entity_type parsed from "type:id" composite value; only "task" and "deliverable" are valid; invalid type rejected in action |
| T-02-14 | Elevation of Privilege | Server Action inline "use server" | mitigate | Inline "use server" directives in RSC props are Next.js 15 pattern; each closure captures clientId from Server Component scope, preventing cross-client pollution |
</threat_model>
<verification>
After plan execution:
1. `npm run build` — no errors
2. Log in, open /admin/clients/[id] → tabs render
3. Add a phase → appears in Fasi & Task tab after submit
4. Add a task to the phase → appears nested under phase
5. Change task status → badge updates
6. Set accepted_total to 1000 → both payments show 500.00
7. Change payment status to "saldato" → status updates
8. Add a document with URL → appears in list; delete it → removed
9. If comments exist (from Phase 1 seed), they appear in Commenti tab
</verification>
<success_criteria>
- Admin can fully manage client phases, tasks, documents, and payments from the detail page
- All mutations use Server Actions with revalidatePath — no client-side fetch or state
- accepted_total update correctly sets both payment amounts to 50% each
- Payment status "saldato" sets paid_at timestamp
- Tab layout renders correctly with @radix-ui/react-tabs
- npm run build passes cleanly
</success_criteria>
<output>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md`
</output>
@@ -1,117 +0,0 @@
---
phase: "02-admin-area-interactive-features"
plan: "03"
subsystem: "admin-workspace"
tags: [admin, tabs, server-actions, phases, tasks, payments, documents, comments]
dependency_graph:
requires: ["02-02"]
provides: ["admin-client-workspace", "getClientFullDetail", "client-detail-mutations"]
affects: ["admin-area", "client-data-management"]
tech_stack:
added:
- "@radix-ui/react-tabs ^1.1.13 — tab primitive for client workspace"
- "shadcn/ui tabs component — src/components/ui/tabs.tsx"
patterns:
- "Inline Server Action closures in RSC props (action={async (fd) => { 'use server'; ... }})"
- "getClientFullDetail() — waterfall DB query assembling full client data in one call"
- "Server-side allowlist validation on all status mutations before db.update()"
key_files:
created:
- src/app/admin/clients/[id]/page.tsx
- src/app/admin/clients/[id]/actions.ts
- src/components/admin/tabs/PhasesTab.tsx
- src/components/admin/tabs/PaymentsTab.tsx
- src/components/admin/tabs/DocumentsTab.tsx
- src/components/admin/tabs/CommentsTab.tsx
- src/components/ui/tabs.tsx
modified:
- src/lib/admin-queries.ts
- package.json
- package-lock.json
decisions:
- "Inline Server Action closures capture clientId/phaseId/taskId from RSC scope — no cross-client pollution (T-02-14)"
- "approved_at immutability enforced by omission in addDeliverable — field not set in insert, never updated"
- "quote_items never queried in getClientFullDetail — accepted_total is the only price surface returned"
- "params in Next.js 16 App Router must be awaited (Promise<{ id: string }>) — applied as deviation fix"
metrics:
duration_minutes: 25
completed_date: "2026-05-15"
tasks_completed: 2
tasks_total: 2
files_created: 7
files_modified: 3
---
# Phase 2 Plan 03: Admin Client Workspace (Tabs) Summary
**One-liner:** Full-featured admin client workspace with Radix Tabs covering phases/tasks, payments, documents, and comments — all mutations via inline Server Actions with server-side validation.
## What Was Built
The `/admin/clients/[id]` route delivers a complete project management workspace for the admin. Four tabs cover every concern of a client's lifecycle:
- **Fasi & Task** — Add phases and nested tasks, update phase/task status with select dropdowns
- **Pagamenti** — Edit `accepted_total` (auto-splits to 50% per payment row), update payment status (sets `paid_at` on saldato)
- **Documenti** — Add document links (label + external URL) and delete them
- **Commenti** — Read all client/admin comments chronologically; post admin replies against any task or deliverable
All mutations are Server Actions in `src/app/admin/clients/[id]/actions.ts`. The page calls `getClientFullDetail()` which assembles client + phases + tasks + deliverables + payments + documents + notes + comments in a single waterfall query sequence.
## Tasks Completed
| Task | Name | Commit | Key Files |
|------|------|--------|-----------|
| 1 | Install tabs, add getClientFullDetail, create Server Actions | 7733566 | package.json, admin-queries.ts, [id]/actions.ts, ui/tabs.tsx |
| 2 | Build detail page and all four tab components | 59a46d3 | [id]/page.tsx, PhasesTab, PaymentsTab, DocumentsTab, CommentsTab |
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Next.js 16 params must be awaited**
- **Found during:** Task 2
- **Issue:** In Next.js 16 (project uses 16.2.6), dynamic route `params` is a `Promise<{ id: string }>` not a plain object. The plan's template used `params.id` directly which would fail at runtime.
- **Fix:** Changed page signature to `params: Promise<{ id: string }>` and added `const { id } = await params;` before use.
- **Files modified:** src/app/admin/clients/[id]/page.tsx
**2. [Rule 2 - Missing critical functionality] Explicit TypeScript types on Server Action closure params**
- **Found during:** Task 2
- **Issue:** Inline closures `async (fd) => { "use server"; ... }` lacked explicit `FormData` type annotation, which could cause TypeScript inference issues.
- **Fix:** Added explicit `: FormData` type annotation on all closure parameters.
- **Files modified:** PhasesTab.tsx, PaymentsTab.tsx, DocumentsTab.tsx, CommentsTab.tsx
## Architecture Constraints Respected
- `clients.token` — Read-only in this plan. Never used as primary key.
- `quote_items` — Not queried anywhere in this plan. `accepted_total` is the only price value exposed.
- `deliverables.approved_at` — Enforced by omission: `addDeliverable` inserts `status: "pending"` with no `approved_at` field.
- Two independent auth paths — Admin workspace is under `/admin/*`, protected by middleware session from Phase 2 Plan 01.
## Security Notes (Threat Register Mitigations Applied)
- **T-02-10:** `updateTaskStatus` and `updatePaymentStatus` both validate status against an allowlist before any `db.update()` call.
- **T-02-11:** `deleteDocument` is only reachable through admin-protected routes; no client can reach it without a valid Auth.js session.
- **T-02-13:** `postAdminComment` parses the composite `"type:id"` entity value and validates `entity_type` is exactly `"task"` or `"deliverable"` before insert.
- **T-02-14:** Inline closures capture `clientId`, `phaseId`, `taskId` from the Server Component's own scope, preventing cross-client data pollution.
## Known Stubs
None — all data is live from the database via `getClientFullDetail()`.
## Threat Flags
None — no new network endpoints, auth paths, or schema changes introduced beyond what the plan's threat model covers.
## Self-Check
- [x] src/app/admin/clients/[id]/page.tsx exists
- [x] src/app/admin/clients/[id]/actions.ts exists
- [x] src/components/admin/tabs/PhasesTab.tsx exists (153 lines > 60 min)
- [x] src/components/admin/tabs/PaymentsTab.tsx exists (96 lines > 40 min)
- [x] src/components/admin/tabs/DocumentsTab.tsx exists
- [x] src/components/admin/tabs/CommentsTab.tsx exists
- [x] Commit 7733566 exists (Task 1)
- [x] Commit 59a46d3 exists (Task 2)
- [x] npm run build passes cleanly
## Self-Check: PASSED
@@ -1,661 +0,0 @@
---
phase: "02-admin-area-interactive-features"
plan: 04
type: execute
wave: 4
depends_on:
- "02-03"
files_modified:
- src/app/api/client/approve/route.ts
- src/app/api/client/comment/route.ts
- src/app/c/[token]/page.tsx
- src/components/client/ApproveButton.tsx
- src/components/client/CommentForm.tsx
- src/components/client/CommentList.tsx
autonomous: true
requirements:
- DASH-05
- DASH-06
must_haves:
truths:
- "Client can click 'Approva' on a deliverable and the approved_at timestamp is set immutably in DB"
- "The Approva button is hidden once approved_at is set — the approved state shows a timestamp instead"
- "Client can submit a comment on a task or deliverable; it appears in the list on reload"
- "Comment author is 'client'; admin comments show as 'iamcavalli', client comments show as 'Tu'"
- "Both API routes validate the client token from the request body against the DB before writing"
- "quote_items is never queried or returned by either API route"
artifacts:
- path: "src/app/api/client/approve/route.ts"
provides: "POST — validates client token, sets deliverable status=approved + approved_at=now() if not already approved"
contains: "approved_at"
- path: "src/app/api/client/comment/route.ts"
provides: "POST — validates client token, inserts comment with author='client'"
contains: "author.*client"
- path: "src/components/client/ApproveButton.tsx"
provides: "Client Component: Approva button that POSTs to /api/client/approve and refreshes the page"
contains: "useRouter"
- path: "src/components/client/CommentForm.tsx"
provides: "Client Component: textarea + submit that POSTs to /api/client/comment"
contains: "api/client/comment"
- path: "src/app/c/[token]/page.tsx"
provides: "Updated client dashboard wiring ApproveButton and CommentForm into deliverable/task sections"
contains: "ApproveButton"
key_links:
- from: "ApproveButton"
to: "POST /api/client/approve"
via: "fetch('/api/client/approve', { body: JSON.stringify({ token, deliverableId }) })"
pattern: "api/client/approve"
- from: "POST /api/client/approve"
to: "deliverables table"
via: "db.update(deliverables).set({ status: 'approved', approved_at: new Date() })"
pattern: "approved_at"
- from: "CommentForm"
to: "POST /api/client/comment"
via: "fetch('/api/client/comment', { body: JSON.stringify({ token, entity_type, entity_id, body }) })"
pattern: "api/client/comment"
- from: "POST /api/client/comment"
to: "comments table"
via: "db.insert(comments).values({ author: 'client', ... })"
pattern: "author.*client"
---
<objective>
**Client Interactions — Approvals + Comments:** Add two API routes for client-side mutations (per D-06 — not Server Actions, because the client has no admin session), then update the client dashboard UI to render ApproveButton on pending/submitted deliverables and CommentForm + CommentList on every task and deliverable. Token is validated server-side in each API route against the clients table before any write.
Purpose: Deliver DASH-05 (deliverable approval with immutable approved_at) and DASH-06 (inline comments). The approved_at immutability rule from CLAUDE.md is enforced in the API route: if approved_at is already set, the request is a no-op (returns 200 but does not overwrite).
Output: Clients can approve deliverables and leave comments from their dashboard; admin sees both in the workspace (Plan 03 CommentsTab).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
@.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md
<interfaces>
<!-- From src/db/schema.ts — types used in this plan -->
```typescript
export const deliverables = pgTable("deliverables", {
id: text("id").primaryKey(),
task_id: text("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 once set
});
export const comments = pgTable("comments", {
id: text("id").primaryKey(),
entity_type: text("entity_type").notNull(), // task | deliverable
entity_id: text("entity_id").notNull(),
author: text("author").notNull(), // client | admin
body: text("body").notNull(),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const clients = pgTable("clients", {
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
// ... other fields
});
export type Deliverable = typeof deliverables.$inferSelect;
export type Comment = typeof comments.$inferSelect;
```
<!-- From src/lib/client-view.ts (Phase 1) — ClientView shape for reference -->
```typescript
export interface ClientView {
client: { id: string; name: string; brand_name: string; brief: string; accepted_total: string; };
phases: Array<{
id: string; title: string; status: string; sort_order: number; progress_pct: number;
tasks: Array<{
id: string; title: string; description: string | null; status: string; sort_order: number;
deliverables: Array<{
id: string; title: string; url: string | null;
status: 'pending' | 'submitted' | 'approved';
approved_at: string | null;
}>;
}>;
}>;
payments: Array<{ id: string; label: string; status: string; }>;
documents: Array<{ id: string; label: string; url: string; }>;
notes: Array<{ id: string; body: string; created_at: string; }>;
global_progress_pct: number;
}
```
<!-- ClientView does NOT include comments — they are fetched separately in the page -->
<!-- The page must fetch comments independently using db query on entity_ids from the view -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create POST /api/client/approve and POST /api/client/comment API routes</name>
<files>
src/app/api/client/approve/route.ts
src/app/api/client/comment/route.ts
</files>
<action>
Both routes validate the client's token against the DB before any mutation (per D-06).
Token comes from the request body JSON. Neither route uses Auth.js — client has no session.
Neither route ever queries quote_items (per CLAUDE.md architecture constraint).
Create `src/app/api/client/approve/route.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
import { eq, and } from "drizzle-orm";
import { db } from "@/db";
import { clients, deliverables, tasks, phases } from "@/db/schema";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token, deliverableId } = body as { token?: string; deliverableId?: string };
if (!token || !deliverableId) {
return NextResponse.json({ error: "token e deliverableId richiesti" }, { status: 400 });
}
// Validate token — find the client
const clientRows = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.token, token))
.limit(1);
if (clientRows.length === 0) {
return NextResponse.json({ error: "Token non valido" }, { status: 404 });
}
const clientId = clientRows[0].id;
// Verify deliverable belongs to this client (prevents cross-client approval)
// deliverable → task → phase → client
const ownershipCheck = await db
.select({ deliverable_id: deliverables.id, approved_at: deliverables.approved_at })
.from(deliverables)
.innerJoin(tasks, eq(deliverables.task_id, tasks.id))
.innerJoin(phases, and(eq(tasks.phase_id, phases.id), eq(phases.client_id, clientId)))
.where(eq(deliverables.id, deliverableId))
.limit(1);
if (ownershipCheck.length === 0) {
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
}
// IMMUTABILITY RULE (CLAUDE.md): if approved_at is already set, this is a no-op
if (ownershipCheck[0].approved_at !== null) {
return NextResponse.json({ approved: true, message: "Già approvato" }, { status: 200 });
}
// Set approved — approved_at is immutable once set, client cannot unset it
await db
.update(deliverables)
.set({ status: "approved", approved_at: new Date() })
.where(eq(deliverables.id, deliverableId));
return NextResponse.json({ approved: true }, { status: 200 });
} catch (err) {
console.error("/api/client/approve error:", err);
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
}
}
```
Create `src/app/api/client/comment/route.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
import { eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { clients, comments, tasks, phases, deliverables } from "@/db/schema";
const commentSchema = z.object({
token: z.string().min(1),
entity_type: z.enum(["task", "deliverable"]),
entity_id: z.string().min(1),
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = commentSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0].message },
{ status: 400 }
);
}
const { token, entity_type, entity_id, body: commentBody } = parsed.data;
// Validate token
const clientRows = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.token, token))
.limit(1);
if (clientRows.length === 0) {
return NextResponse.json({ error: "Token non valido" }, { status: 404 });
}
const clientId = clientRows[0].id;
// Verify entity belongs to this client (prevent cross-client comment injection)
if (entity_type === "task") {
const phasesForClient = await db
.select({ id: phases.id })
.from(phases)
.where(eq(phases.client_id, clientId));
const phaseIds = phasesForClient.map((p) => p.id);
if (phaseIds.length === 0) {
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
}
const taskCheck = await db
.select({ id: tasks.id })
.from(tasks)
.where(
inArray(tasks.phase_id, phaseIds)
)
.then((rows) => rows.find((r) => r.id === entity_id));
if (!taskCheck) {
return NextResponse.json({ error: "Task non trovato" }, { status: 404 });
}
} else {
// deliverable — verify via task → phase → client chain
const phasesForClient = await db
.select({ id: phases.id })
.from(phases)
.where(eq(phases.client_id, clientId));
const phaseIds = phasesForClient.map((p) => p.id);
if (phaseIds.length === 0) {
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
}
const taskIds = await db
.select({ id: tasks.id })
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds))
.then((rows) => rows.map((r) => r.id));
if (taskIds.length === 0) {
return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 });
}
const delivCheck = await db
.select({ id: deliverables.id })
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds))
.then((rows) => rows.find((r) => r.id === entity_id));
if (!delivCheck) {
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
}
}
await db.insert(comments).values({
entity_type,
entity_id,
author: "client",
body: commentBody,
});
return NextResponse.json({ success: true }, { status: 201 });
} catch (err) {
console.error("/api/client/comment error:", err);
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
}
}
```
</action>
<verify>
<automated>test -f src/app/api/client/approve/route.ts && echo "approve route exists"</automated>
<automated>grep -q "approved_at.*null" src/app/api/client/approve/route.ts && echo "immutability check present"</automated>
<automated>grep -q "phases.client_id.*clientId\|clientId.*phases.client_id" src/app/api/client/approve/route.ts && echo "ownership verification present"</automated>
<automated>test -f src/app/api/client/comment/route.ts && echo "comment route exists"</automated>
<automated>grep -q "author.*client" src/app/api/client/comment/route.ts && echo "author set to client"</automated>
<automated>grep -v '^#' src/app/api/client/approve/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in approve route"</automated>
<automated>grep -v '^#' src/app/api/client/comment/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in comment route"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- POST /api/client/approve: validates token, verifies deliverable ownership via phase→client chain, sets status=approved + approved_at=now() only if approved_at is currently null
- POST /api/client/comment: validates token, validates entity ownership, inserts comment with author='client'
- Both routes return 404 on invalid token or missing entity
- Neither route references quote_items
- npm run build passes
</done>
</task>
<task type="auto">
<name>Task 2: Build ApproveButton + CommentForm/List Client Components; wire into client dashboard page</name>
<files>
src/components/client/ApproveButton.tsx
src/components/client/CommentForm.tsx
src/components/client/CommentList.tsx
src/app/c/[token]/page.tsx
</files>
<action>
Create `src/components/client/ApproveButton.tsx` — Client Component (per D-10, no confirm modal):
```typescript
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type Props = {
deliverableId: string;
token: string;
approvedAt: string | null; // ISO timestamp or null
};
export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Already approved — show immutable confirmation, no button
if (approvedAt) {
const date = new Date(approvedAt).toLocaleDateString("it-IT", {
day: "2-digit",
month: "long",
year: "numeric",
});
return (
<span className="text-xs text-green-700 bg-green-50 border border-green-200 px-2 py-1 rounded">
Approvato il {date}
</span>
);
}
async function handleApprove() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/client/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, deliverableId }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Errore durante l'approvazione");
return;
}
router.refresh(); // Re-fetch Server Component data — approved_at now set
} catch {
setError("Errore di rete");
} finally {
setLoading(false);
}
}
return (
<div>
<Button
size="sm"
variant="outline"
onClick={handleApprove}
disabled={loading}
className="text-xs text-green-700 border-green-300 hover:bg-green-50"
>
{loading ? "Approvazione..." : "Approva"}
</Button>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
);
}
```
Create `src/components/client/CommentList.tsx` — pure presentational:
```typescript
import type { Comment } from "@/db/schema";
type Props = { comments: Comment[] };
export function CommentList({ comments }: Props) {
if (comments.length === 0) return null;
return (
<div className="mt-3 space-y-2">
{comments.map((c) => (
<div
key={c.id}
className={`flex gap-2 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
>
<div
className={`rounded-lg px-3 py-2 text-xs max-w-xs ${
c.author === "admin"
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-800"
}`}
>
<p className="font-medium mb-0.5 opacity-60">
{c.author === "admin" ? "iamcavalli" : "Tu"}
</p>
<p>{c.body}</p>
</div>
</div>
))}
</div>
);
}
```
Create `src/components/client/CommentForm.tsx` — Client Component (per D-11):
```typescript
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
type Props = {
token: string;
entityType: "task" | "deliverable";
entityId: string;
};
export function CommentForm({ token, entityType, entityId }: Props) {
const router = useRouter();
const [body, setBody] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!body.trim()) return;
setLoading(true);
setError(null);
try {
const res = await fetch("/api/client/comment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, entity_type: entityType, entity_id: entityId, body }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Errore durante l'invio");
return;
}
setBody("");
router.refresh(); // Re-fetch Server Component to show new comment
} catch {
setError("Errore di rete");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="mt-3 flex gap-2">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Lascia un commento..."
rows={2}
className="text-sm resize-none flex-1"
/>
<div className="flex flex-col justify-end">
<Button
type="submit"
size="sm"
disabled={loading || !body.trim()}
className="text-xs"
>
{loading ? "Invio..." : "Invia"}
</Button>
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</form>
);
}
```
Update `src/app/c/[token]/page.tsx` — extend the existing Phase 1 dashboard to:
1. Fetch comments for all task/deliverable ids in this client's data
2. Render ApproveButton on each deliverable (pending or submitted)
3. Render CommentList + CommentForm below each task and deliverable
Read the existing page first, then extend it. The page must remain a Server Component.
ApproveButton and CommentForm are Client Components embedded within it.
Key additions to the existing page (add these imports and sections):
```typescript
// New imports to add:
import { ApproveButton } from "@/components/client/ApproveButton";
import { CommentForm } from "@/components/client/CommentForm";
import { CommentList } from "@/components/client/CommentList";
import { db } from "@/db";
import { comments } from "@/db/schema";
import { inArray } from "drizzle-orm";
// After getClientView(), fetch comments:
const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id));
const allDeliverableIds = view.phases.flatMap((p) =>
p.tasks.flatMap((t) => t.deliverables.map((d) => d.id))
);
const allEntityIds = [...allTaskIds, ...allDeliverableIds];
const allComments = allEntityIds.length > 0
? await db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
: [];
// Helper to get comments for a specific entity:
const commentsFor = (entityId: string) =>
allComments.filter((c) => c.entity_id === entityId);
```
Within the task/deliverable render loop, add below each deliverable:
```typescript
// Within deliverable rendering:
<ApproveButton
deliverableId={deliverable.id}
token={params.token}
approvedAt={deliverable.approved_at}
/>
<CommentList comments={commentsFor(deliverable.id)} />
<CommentForm token={params.token} entityType="deliverable" entityId={deliverable.id} />
```
And below each task:
```typescript
// Within task rendering (after deliverables):
<CommentList comments={commentsFor(task.id)} />
<CommentForm token={params.token} entityType="task" entityId={task.id} />
```
The page must read the full Phase 1 client dashboard before modifying it.
Preserve all existing Phase 1 UI sections. Only add the interactive elements.
</action>
<verify>
<automated>test -f src/components/client/ApproveButton.tsx && grep -q '"use client"' src/components/client/ApproveButton.tsx && echo "ApproveButton is Client Component"</automated>
<automated>grep -q "router.refresh" src/components/client/ApproveButton.tsx && echo "router.refresh on approval"</automated>
<automated>grep -q "approvedAt.*null" src/components/client/ApproveButton.tsx && echo "approved_at check present in button"</automated>
<automated>test -f src/components/client/CommentForm.tsx && grep -q '"use client"' src/components/client/CommentForm.tsx && echo "CommentForm is Client Component"</automated>
<automated>grep -q "api/client/comment" src/components/client/CommentForm.tsx && echo "CommentForm posts to correct route"</automated>
<automated>test -f src/components/client/CommentList.tsx && grep -q "iamcavalli" src/components/client/CommentList.tsx && echo "admin author label present"</automated>
<automated>grep -q "ApproveButton" src/app/c/\[token\]/page.tsx && echo "ApproveButton imported in dashboard page"</automated>
<automated>grep -q "CommentForm" src/app/c/\[token\]/page.tsx && echo "CommentForm imported in dashboard page"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- ApproveButton renders on deliverables with approved_at=null; shows immutable "Approvato il [date]" once set
- CommentForm posts to /api/client/comment and calls router.refresh() on success
- CommentList shows client comments as "Tu", admin comments as "iamcavalli"
- Client dashboard page fetches comments server-side and renders all three components inline
- Phase 1 existing UI is preserved — only interactive elements are added
- npm run build passes cleanly
- Manual verification: approve a deliverable → refreshed page shows date badge; submit comment → refreshed page shows comment in list
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client browser → POST /api/client/approve | Unauthenticated client route; token in request body is the only credential |
| Client browser → POST /api/client/comment | Same — token in body is the only credential |
| Token → client ownership | Each API route validates token → client, then verifies entity belongs to that client via DB join |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-15 | Spoofing | /api/client/approve token validation | mitigate | Token validated server-side via DB lookup before any mutation; expired or rotated tokens return 404 |
| T-02-16 | Elevation of Privilege | Cross-client approval | mitigate | Ownership check: deliverable → task → phase → client_id match enforced via innerJoin before update; a client cannot approve another client's deliverable |
| T-02-17 | Tampering | approved_at immutability | mitigate | API route checks approved_at !== null before running UPDATE; once set, the field cannot be overwritten via this route — enforced at application layer |
| T-02-18 | Tampering | Comment injection across clients | mitigate | entity ownership verified via DB join (task/deliverable → phase → client_id) before insert; client can only comment on their own entities |
| T-02-19 | Information Disclosure | CommentList renders all comments | accept | Comments are scoped to entity_ids belonging to the validated client; server-side filtering before rendering |
| T-02-20 | Denial of Service | POST /api/client/comment body length | mitigate | Zod schema enforces max 2000 chars on body; requests exceeding this return 400 |
</threat_model>
<verification>
After plan execution:
1. `npm run build` — no errors
2. Open client dashboard at /c/[valid-token]
3. Locate a deliverable with status=pending or status=submitted → "Approva" button visible
4. Click Approva → page refreshes → button replaced with "Approvato il [date]"
5. Refresh page again → approval still shows (persisted in DB)
6. In admin workspace → CommentsTab shows the approved deliverable's state
7. Open CommentForm under a task → type a message → click Invia
8. Page refreshes → comment appears as "Tu" in the list
9. In admin workspace → CommentsTab shows the comment with author "Cliente"
10. Test invalid token: POST /api/client/approve with wrong token → 404
</verification>
<success_criteria>
- Client can approve deliverables; approved_at is set once and immutable (CLAUDE.md constraint enforced)
- Client can submit comments on tasks and deliverables
- Both API routes validate token and verify entity ownership before writing
- CommentList shows author correctly: "Tu" for client, "iamcavalli" for admin
- Phase 1 client dashboard UI is fully preserved; interactive elements are additive
- npm run build passes cleanly
</success_criteria>
<output>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-04-SUMMARY.md`
</output>

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