Compare commits
90 Commits
v2.2
..
817a8cd5d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 817a8cd5d1 | |||
| bec7e039d7 | |||
| 4403b39e63 | |||
| fe767899b9 | |||
| 15b01e3e05 | |||
| 8b54f48afd | |||
| 2e9bd2ab60 | |||
| c3d2afa61f | |||
| 94f4a54248 | |||
| 0d7186ba03 | |||
| ac74a81a72 | |||
| 53f1758f52 | |||
| d14f95c80d | |||
| 44be190631 | |||
| 7e58031528 | |||
| 4666fc4058 | |||
| c9855d58ce | |||
| 4e3907d382 | |||
| 1fa8e1ab5e | |||
| b49d4bfaaa | |||
| df4671236a | |||
| 330749a883 | |||
| 9a57e450fc | |||
| 5547e555bd | |||
| 3fcb10dac6 | |||
| 97cc6460a0 | |||
| 19ed377214 | |||
| d6d3be00f4 | |||
| 8f2b3255ab | |||
| 1115bb2265 | |||
| d6e95ef66a | |||
| a9358da96f | |||
| 4b135ce67f | |||
| 571f58bff8 | |||
| 8000d562dc | |||
| 24213e7251 | |||
| 08b0a60bae | |||
| 31237da11c | |||
| ee47f35e97 | |||
| 0aad9caf46 | |||
| f7eb7eec23 | |||
| 187550fedf | |||
| 09a5b1ff4f | |||
| 5177a3700a | |||
| d57b0f3e04 | |||
| 27da969963 | |||
| c9b5cd7451 | |||
| 8158038145 | |||
| b27b9d07ac | |||
| 94b3b2f766 | |||
| fb6ab92fd0 | |||
| bcff4aad48 | |||
| e2bd1d95ed | |||
| dd2d148457 | |||
| d444bd6064 | |||
| dd4ae42542 | |||
| c29fab8975 | |||
| 1fc0650dcf | |||
| c1ff5a7e67 | |||
| a1d8d18902 | |||
| c1cc13a99a | |||
| a20a9de2d7 | |||
| 08aadc1d97 | |||
| e5fa07bba3 | |||
| c110689b6a | |||
| 11870e15d3 | |||
| 6d5e04bf9c | |||
| 26d752892a | |||
| 9eb2d45c67 | |||
| 86e1499e8f | |||
| 43cb7e7469 | |||
| add2176a6b | |||
| 9abe1fe4bb | |||
| ae355c33a6 | |||
| fc766ca1ee | |||
| 0b8a7b3809 | |||
| e10d1f70cb | |||
| 64030afef3 | |||
| 4b28f254ba | |||
| 186bb9ea19 | |||
| 988f6f425a | |||
| f98828f75e | |||
| 1824cb643f | |||
| f5f90cd643 | |||
| e1b3e8c3d5 | |||
| e80c95f838 | |||
| 320827e13a | |||
| ba3e824157 | |||
| 19a7ffb6a6 | |||
| bc9051c899 |
@@ -0,0 +1,77 @@
|
||||
# `.claude/` — la cartella di configurazione di ClientHub
|
||||
|
||||
Questo file si carica quando si lavora **dentro `.claude/`**. Spiega cosa va dove, e basta.
|
||||
|
||||
**Per il progetto comanda [`../CLAUDE.md`](../CLAUDE.md)**: stack, vincoli LOCKED, procedura
|
||||
di deploy, accesso al DB, design system. Qui non si duplica niente di quello — una seconda
|
||||
copia è il modo più veloce per averne due che si contraddicono.
|
||||
|
||||
## Cosa va in ogni cartella
|
||||
|
||||
| Cartella | Cosa ci va | Cosa **non** ci va |
|
||||
|---|---|---|
|
||||
| `rules/` | Regole caricate per contesto. Oggi: `memory-discipline.md`, importata da `../CLAUDE.md` | Documentazione di feature — quella sta in `STATUS.md` |
|
||||
| `skills/` | **Solo** skill specifiche di ClientHub: `preventivo/`, `audit/` | Le skill globali (`/seo-audit`, `/copywriting`, `/docx`…) — stanno in `~/.claude/skills/` |
|
||||
| `agents/` | **Solo** agenti specifici di ClientHub. Oggi vuota: quello dell'audit nasce col motore | I 18 agenti globali di `~/.claude/agents/` |
|
||||
| `hooks/` | Script eseguibili richiamati da `settings.json` | Logica di prodotto |
|
||||
| `plans/` | I piani delle milestone, versionati | Piani usa-e-getta di una singola sessione |
|
||||
| `commands/`, `workflows/`, `projects/` | Vuote per ora, sono lo scheletro | — |
|
||||
| `memory/` | Appunti di lavoro versionati nel repo | **La memoria persistente. Non è qui** → vedi sotto |
|
||||
| `worktrees/` | Generata dagli strumenti | — |
|
||||
|
||||
### Agenti e skill globali non si copiano qui
|
||||
|
||||
Regola già fissata in [`../../CLAUDE.md`](../../CLAUDE.md): agenti e skill che valgono per
|
||||
tutti i progetti vivono in `~/.claude/` e si invocano da qualunque cartella. Copiarne uno qui
|
||||
crea due file destinati a divergere, e il primo a cambiare vince a caso.
|
||||
|
||||
In locale ci va solo ciò che **senza questo repo non ha senso**: le due skill qui sotto.
|
||||
|
||||
### `.claude/memory/` ≠ memoria persistente
|
||||
|
||||
Due posti diversi con lo stesso nome, e confonderli fa perdere il lavoro:
|
||||
|
||||
- **`.claude/memory/`** (questa cartella) — appunti versionati nel repo, li vede chiunque
|
||||
faccia clone.
|
||||
- **`~/.claude/projects/-Users-simonecavalli-Vault-IAMCAVALLI-hub/memory/`** — la memoria
|
||||
persistente vera, un file per fatto più `MEMORY.md` come indice. Sta fuori dal repo, non
|
||||
si committa, e viene iniettata in automatico a inizio sessione.
|
||||
|
||||
Ci va quello che **non si deduce dal repo**: perché una decisione è stata presa, un vincolo
|
||||
operativo, una cosa provata che non funziona. Regole complete in
|
||||
[`rules/memory-discipline.md`](rules/memory-discipline.md).
|
||||
|
||||
## Le skill del progetto
|
||||
|
||||
- **`/preventivo`** — l'attrezzo per lavorare sulla pipeline che genera i preventivi
|
||||
(`src/lib/proposal/`) senza romperla. Non contiene un prompt di generazione: quello vive
|
||||
in `agent.ts` ed è l'unico.
|
||||
- **`/audit`** — fa girare le cinque fonti di `src/lib/audit/sources/` su un URL e dice cosa
|
||||
è stato **misurato** e cosa no. Le fonti sono in produzione ma inerti: nessuna route le
|
||||
chiama ancora.
|
||||
|
||||
## Gli hook attivi
|
||||
|
||||
Tutti e tre in [`settings.json`](settings.json). Si provano a mano prima di fidarsi.
|
||||
|
||||
| Hook | Quando | Cosa fa |
|
||||
|---|---|---|
|
||||
| Promemoria memoria | `Stop` | Se `src/` o `.planning/` hanno modifiche non committate, ricorda di aggiornare `STATE.md`. Non blocca |
|
||||
| [`guardia-migration.sh`](hooks/guardia-migration.sh) | `PreToolUse` su Write/Edit in `src/db/migrations/` | **Blocca** l'SQL che cancella dati dalle entità protette (`clients`, `projects`, `payments`, `phases`). È il vincolo Data Safety LOCKED fatto rispettare dalla macchina |
|
||||
| [`guardia-token.sh`](hooks/guardia-token.sh) | `PostToolUse` su `.tsx`/`.css` | **Avvisa** se compaiono classi Tailwind grezze o hex letterali. Non blocca: le eccezioni sanzionate esistono e stanno nella whitelist dello script |
|
||||
|
||||
Per provarli senza passare da Claude:
|
||||
|
||||
```bash
|
||||
echo '{"tool_input":{"file_path":"src/db/migrations/9999_x.sql","content":"DROP TABLE payments;"}}' \
|
||||
| .claude/hooks/guardia-migration.sh; echo "exit=$?" # atteso: 2
|
||||
|
||||
echo '{"tool_input":{"file_path":"src/x.tsx","content":"<div className=\"bg-slate-100\"/>"}}' \
|
||||
| .claude/hooks/guardia-token.sh; echo "exit=$?" # atteso: 0 + avviso
|
||||
```
|
||||
|
||||
## I piani
|
||||
|
||||
`plans/` contiene i piani della milestone v2.5, portati dentro il repo il 2026-08-26 perché
|
||||
stavano solo in `~/.claude/plans/` e `STATE.md` avvertiva che senza quelli la milestone non
|
||||
era ricostruibile. Dettaglio in [`plans/README.md`](plans/README.md).
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse su Write/Edit — vincolo Data Safety (LOCKED) di ../../CLAUDE.md.
|
||||
#
|
||||
# Blocca la scrittura di una migration che cancella dati dalle entita' protette.
|
||||
# Non e' una prova: e' una rete. Un DROP scritto in modo esotico puo' passare —
|
||||
# la revisione a occhio dell'SQL resta obbligatoria.
|
||||
#
|
||||
# exit 0 = passa · exit 2 = bloccato, il messaggio su stderr torna a Claude.
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# Fuori da src/db/migrations/ questo hook non ha voce in capitolo.
|
||||
case "$FILE" in
|
||||
*src/db/migrations/*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Write porta `content`, Edit porta `new_string`. MultiEdit porta un array.
|
||||
SQL=$(printf '%s' "$INPUT" | jq -r '
|
||||
[ .tool_input.content?,
|
||||
.tool_input.new_string?,
|
||||
(.tool_input.edits? // [] | .[].new_string?)
|
||||
] | map(select(. != null)) | join("\n")
|
||||
')
|
||||
[ -z "$SQL" ] && exit 0
|
||||
|
||||
PROTETTE='clients|projects|payments|phases'
|
||||
|
||||
# Via i commenti, tutto minuscolo, una riga per statement: cosi' "DROP" e il nome
|
||||
# della tabella devono stare nella STESSA istruzione per far scattare il blocco.
|
||||
PULITO=$(printf '%s' "$SQL" \
|
||||
| sed -E 's/--.*$//' \
|
||||
| tr '\n' ' ' \
|
||||
| sed -E 's;/\*[^*]*\*+([^/*][^*]*\*+)*/; ;g' \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| tr ';' '\n')
|
||||
|
||||
COLPEVOLI=$(printf '%s\n' "$PULITO" \
|
||||
| grep -E 'drop[[:space:]]+table|drop[[:space:]]+column|truncate|delete[[:space:]]+from' \
|
||||
| grep -E "\\b($PROTETTE)\\b" || true)
|
||||
|
||||
if [ -n "$COLPEVOLI" ]; then
|
||||
{
|
||||
echo "BLOCCATO — Data Safety (LOCKED, ../CLAUDE.md)."
|
||||
echo
|
||||
echo "Questa migration cancella dati da un'entita' protetta (clients, projects,"
|
||||
echo "payments, phases). Le migration sono additive: si aggiungono colonne e"
|
||||
echo "tabelle, non si tolgono righe."
|
||||
echo
|
||||
echo "Istruzioni incriminate:"
|
||||
printf '%s\n' "$COLPEVOLI" | sed 's/^[[:space:]]*/ · /'
|
||||
echo
|
||||
echo "Se la rimozione serve davvero, e' una decisione da confermare a voce con"
|
||||
echo "l'utente prima di scriverla — non da aggirare qui."
|
||||
} >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse su .tsx/.css — regola cardinale del design system:
|
||||
# solo token semantici (bg-card, text-muted-foreground, border-border).
|
||||
#
|
||||
# AVVISA, non blocca: le eccezioni sanzionate esistono (vedi WHITELIST) e il
|
||||
# debito storico e' gia' di ~450 occorrenze. Bloccare renderebbe l'hook un
|
||||
# ostacolo da disattivare invece di un promemoria da leggere.
|
||||
#
|
||||
# exit 0 sempre. Il messaggio su stderr arriva a Claude come contesto.
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
case "$FILE" in
|
||||
*.tsx|*.css) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Eccezioni sanzionate da ../CLAUDE.md § Design System:
|
||||
# - StatusBadge: i colori di stato usano la palette con variante dark: esplicita
|
||||
# - AdminShell: il verde brand della sidebar
|
||||
# - mailer.ts / .html: l'HTML delle email non puo' usare variabili CSS
|
||||
# - globals.css: e' il posto dove i token vengono *definiti*
|
||||
# - design-reference/: i mock precedono la regola, si traducono non si copiano
|
||||
case "$FILE" in
|
||||
*StatusBadge*|*AdminShell*|*mailer*|*globals.css|*design-reference/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
TESTO=$(printf '%s' "$INPUT" | jq -r '
|
||||
[ .tool_input.content?,
|
||||
.tool_input.new_string?,
|
||||
(.tool_input.edits? // [] | .[].new_string?)
|
||||
] | map(select(. != null)) | join("\n")
|
||||
')
|
||||
[ -z "$TESTO" ] && exit 0
|
||||
|
||||
PALETTE='slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'
|
||||
GREZZE=$(printf '%s\n' "$TESTO" \
|
||||
| grep -oE "\\b(bg|text|border|ring|from|to|via|fill|stroke|divide|outline|shadow|accent|decoration|placeholder)-($PALETTE)-[0-9]{2,3}\\b" \
|
||||
| sort -u | head -12 || true)
|
||||
HEX=$(printf '%s\n' "$TESTO" \
|
||||
| grep -oE '#[0-9a-fA-F]{3,8}\b' | sort -u | head -6 || true)
|
||||
|
||||
if [ -n "$GREZZE" ] || [ -n "$HEX" ]; then
|
||||
{
|
||||
echo "AVVISO design system — token semantici, non palette grezza."
|
||||
echo "File: $FILE"
|
||||
[ -n "$GREZZE" ] && { echo " classi grezze: $(printf '%s ' $GREZZE)"; }
|
||||
[ -n "$HEX" ] && { echo " hex letterali: $(printf '%s ' $HEX)"; }
|
||||
echo
|
||||
echo "Usa bg-card / text-muted-foreground / border-border: e' quello che fa"
|
||||
echo "funzionare chiaro e scuro sul solo toggle della classe .dark."
|
||||
echo "Riferimento: design-reference/DESIGN-SYSTEM.md"
|
||||
echo "Eccezione sanzionata? Aggiungi il file alla WHITELIST di questo hook."
|
||||
} >&2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,17 @@
|
||||
# Piani
|
||||
|
||||
I piani delle milestone, **dentro il repo**. Ci sono arrivati il 2026-08-26: prima
|
||||
stavano solo in `~/.claude/plans/`, con nomi generati a caso, e `STATE.md` avvertiva
|
||||
che senza quei file v2.5 non era ricostruibile. Un piano che vive solo sul portatile
|
||||
di chi l'ha scritto non e' documentazione, e' un ricordo.
|
||||
|
||||
| File | Cosa contiene | Origine |
|
||||
|---|---|---|
|
||||
| `v2.5-audit-documento.md` | Il documento di audit: tre livelli (Radiografia / Prima-Dopo / Rotta) come configurazioni di un unico documento su `/audit/[slug]` | `dovremmo-fare-una-cosa-woolly-puddle.md` |
|
||||
| `v2.5-audit-motore.md` | Il motore: raccolta in parallelo, quattro sub-agent, sintetizzatore. Il vincolo che regge tutto — **un numero entra solo se misurato** | `vorrei-solo-farti-capire-radiant-valley.md` |
|
||||
| `v2.5-modifiche-hub.md` | I blocchi A/B/C delle modifiche all'hub chieste il 2026-08-18 | `sei-arrivato-qua-search-recursive-kettle.md` |
|
||||
|
||||
Sono **piani, non stato**: dicono cosa era stato deciso di fare, non cosa e' fatto.
|
||||
Per quello ci sono `STATUS.md` (narrativa) e `.planning/STATE.md` (digest).
|
||||
Passati al setaccio per credenziali prima del commit: dentro compaiono nomi di
|
||||
variabili d'ambiente, mai i loro valori.
|
||||
@@ -0,0 +1,413 @@
|
||||
# Audit — documento di restituzione
|
||||
|
||||
## Context
|
||||
|
||||
iamcavalli vende un servizio di analisi sito in tre livelli (La Radiografia / Il Prima-Dopo / La Rotta). Il deliverable è un documento di restituzione presentato in una call da 40 minuti. Oggi si fa a mano fuori dall'hub.
|
||||
|
||||
Il servizio si chiama **audit** → rotta `/audit/[slug]`. Il nome del livello acquistato compare **solo in copertina**.
|
||||
|
||||
```
|
||||
acquisto Whop ──┐
|
||||
├─► audit creato ─► intake dati ─► agent: analisi profonda
|
||||
creazione manuale ┘ │
|
||||
▼
|
||||
call (fissata a mano) ◄── consegna ◄── revisione + redesign (manuale)
|
||||
```
|
||||
|
||||
Il redesign lo prepari tu mentre gli agent lavorano. **Deve essere possibile creare un cliente e far partire l'audit in manuale, senza acquisto.**
|
||||
|
||||
Volume: ~50 audit/anno. Overhead non-analitico sotto i 10 minuti per audit. Regola guida: *tu scrivi solo analisi e redesign, tutto il resto si popola*.
|
||||
|
||||
### Documento canonico
|
||||
|
||||
La **Spec V1** (blocchi, copy fisso, campi) è il documento canonico. L'Excel *Ecommerce Growthlist* è **rubrica interna del motore di analisi**, non struttura del documento — vedi §4.
|
||||
|
||||
### Verdetto di fattibilità
|
||||
|
||||
Fattibile. Tre pezzi da costruire da zero: **motore di analisi**, **PDF**, **hosting immagini**. Il resto ha precedenti diretti in casa.
|
||||
|
||||
### Conflitti risolti a favore del progetto
|
||||
|
||||
| Spec V1 | Qui | Perché |
|
||||
|---|---|---|
|
||||
| Supabase | Neon + Drizzle | Lo stack è quello |
|
||||
| `uuid` | `text` + `nanoid()` | Convenzione di tutte le tabelle in `schema.ts` |
|
||||
| PDF serverless headless | Print CSS | Deploy Docker su Coolify, non serverless. Stesso risultato, zero infra |
|
||||
| `/r/[slug]?k=[token]`, slug = nome cliente | `/audit/[slug]`, slug nanoid nel path | **Sicurezza** (sotto) |
|
||||
|
||||
**Sullo slug.** `teckell-2026` è indovinabile: la segretezza si sposterebbe tutta sul token in query string, e i parametri di query finiscono nei log d'accesso e nei referrer molto più facilmente di un path. Il `Referrer-Policy: strict-origin-when-cross-origin` già presente mitiga in parte, ma la convenzione collaudata qui è **nanoid non indovinabile nel path, niente query** (`/preventivo/[slug]`, `/quote/[token]`). Un audit nomina un'azienda reale e ne elenca le debolezze: è il contenuto più sensibile che il portale pubblicherà.
|
||||
|
||||
**Due correzioni ad altre premesse:**
|
||||
|
||||
1. **I `gsd-audit-*` non c'entrano.** Verificato: `gsd-audit-milestone` verifica una milestone GSD, `gsd-audit-uat` raccoglie i test in sospeso, `gsd-audit-fix` sistema i finding nel *tuo* codebase. Auditano il progetto, non il sito del cliente. Motore custom; il precedente è `src/lib/proposal/agent.ts`.
|
||||
2. **Il deck dei preventivi non è riusabile come layout.** `ProposalDeck.tsx:151` monta solo la slide corrente e `:133` imposta `body.overflow = "hidden"`: stamparlo produce una pagina sola. Il documento è **a scorrimento**. Si riusa schema e macchina a stati, non il guscio visivo.
|
||||
|
||||
### Decisioni prese
|
||||
|
||||
| Punto | Decisione |
|
||||
|---|---|
|
||||
| Motore | In-app, background, polling dall'admin (§5) |
|
||||
| "La direzione" (blocco 8) | Manuale, foglio bianco, **con il materiale grezzo a fianco** |
|
||||
| PDF | Print CSS — stesso DOM, vincolo "una sola fonte" per costruzione |
|
||||
| Immagini | Volume persistente. **Modifica il vincolo LOCKED #5** (§9) |
|
||||
| Redesign | Immagini caricate **e** link Figma, con ruoli distinti (§6) |
|
||||
| Miglioramento nel tempo | Template versionato: tocca gli audit futuri, mai i consegnati (§3) |
|
||||
| Copertina | Titolo = nome del livello acquistato |
|
||||
| Scadenza | Nessuna. Depubblicazione manuale reversibile |
|
||||
| Tracking | Prima apertura, ultima apertura, conteggio |
|
||||
|
||||
---
|
||||
|
||||
## 1 · Spike sul motore — si parte da qui
|
||||
|
||||
È la parte più incerta e quella su cui si regge tutto il resto. Se la qualità dell'analisi non regge, meglio scoprirlo prima di costruirci sopra schema, editor e documento.
|
||||
|
||||
**Non tocca il database, non tocca l'hub.** Uno script isolato, `scripts/spike-audit.ts`, sul sito di un cliente attuale, che stampa l'output grezzo.
|
||||
|
||||
Deve dimostrare che:
|
||||
- Il fetch delle pagine chiave e l'estrazione del testo reggono su un sito reale
|
||||
- La verifica delle voci di checklist è **affidabile e ripetibile** (§4)
|
||||
- I problemi sono **concreti, non generici** — "il messaggio non è chiaro" non vale niente, "l'headline non nomina il destinatario" sì
|
||||
- La conseguenza per il business è specifica
|
||||
|
||||
Da riusare subito: la protezione da prompt injection di `src/lib/proposal/agent.ts:32-35` (blocco SICUREZZA che dichiara il contenuto come dati, non istruzioni) e `:49` (neutralizzazione dei tag di chiusura). Con HTML scrapato da un sito esterno serve **più** che con le trascrizioni: è contenuto di terzi e può contenere istruzioni ostili.
|
||||
|
||||
> Il resto si esegue **dopo** esito positivo dello spike.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Il documento
|
||||
|
||||
Un solo documento ben progettato; i tre livelli sono **configurazioni** di quello.
|
||||
|
||||
| # | Blocco | Radiografia | Prima/Dopo | Rotta |
|
||||
|---|---|:--:|:--:|:--:|
|
||||
| 1 | Copertina | ● | ● | ● |
|
||||
| 2 | Sintesi | ● | ● | ● |
|
||||
| 2b | **Cosa funziona già** | ● | ● | ● |
|
||||
| 3 | Stato di fatto | ● | ● | ● |
|
||||
| 4 | I problemi, per impatto | ● | ● | ● |
|
||||
| 5 | Analisi per area | ● | ● | ● |
|
||||
| 6 | Il redesign | — | ● | ● |
|
||||
| 6b | **Cosa il redesign non risolve** | — | ● | ● |
|
||||
| 7 | Le ottimizzazioni | — | — | ● |
|
||||
| 8 | La direzione | ● | ● | ● |
|
||||
| 9 | Come si prosegue | ● | ● | ● |
|
||||
|
||||
I blocchi non pertinenti **non esistono nel DOM**, non sono nascosti via CSS.
|
||||
|
||||
Il **copy fisso** di ogni blocco è quello della Spec V1, riportato integralmente in `template/v1.ts` (§3). Nel blocco 9 le *fasi già completate* si derivano dal livello acquistato, e il credito riconosciuto è `importo_pagato`.
|
||||
|
||||
**I due blocchi 2b e 6b sono aggiunte del prototipo Giojello, non della Spec V1**, e vanno tenute:
|
||||
- *Cosa funziona già* costruisce credibilità prima di criticare, e dichiara cosa non va toccato
|
||||
- *Cosa il redesign non risolve* è onesto e commercialmente più efficace della vendita: apre al progetto completo senza promettere
|
||||
|
||||
Campi: `punti_forza` (lista) e `redesign_limiti` (testo).
|
||||
|
||||
### Il prototipo Giojello è il riferimento del template v1
|
||||
|
||||
Il file HTML prodotto per Giojello **non è il documento: è la fonte di `template/v1.ts`** — copy, tipografia, gerarchia. Va spacchettato in componenti alimentati dal record. Quattro difetti da NON portarsi dietro:
|
||||
|
||||
1. **Contenuto cablato nel markup** (699€, 8,0s, 500€, le date). Viola il vincolo "un record → una pagina → un PDF": tutto viene dal DB
|
||||
2. **Google Fonts via `<link>`** — la CSP del progetto è `font-src 'self' data:` e `style-src 'self' 'unsafe-inline'`: verrebbe **bloccato** e il documento cadrebbe sui font di sistema. **Font self-hostati** (Instrument Serif / Inter / IBM Plex Mono, se si conferma quel trio in luogo di Plus Jakarta Sans — è una deroga consapevole a DESIGN-SYSTEM.md, giustificabile perché è un documento pubblico, non la UI admin)
|
||||
3. **Print CSS di sei righe.** Nasconde la legenda interna ma **non i colori che la legenda spiega**: i bordi e i testi rossi/blu dei blocchi di lavorazione finiscono stampati senza più nulla che li spieghi. I marcatori di lavorazione non devono esistere nel documento consegnato
|
||||
4. **Colore come unico portatore di informazione nelle metriche** — `3,4 s` rosso e `1,0 s` verde diventano identici in scala di grigi. Serve un secondo canale (glifo, peso, etichetta)
|
||||
|
||||
⚠️ **Tassonomia degli impatti.** Il prototipo usa cinque valori ("medio-alto", "basso-medio"…), lo schema ne prevede tre. Con cinque l'ordinamento automatico su tre non funziona. Decisione: si resta a **tre valori**, e la sfumatura sta nell'ordine dentro il gruppo (`sort_order`).
|
||||
|
||||
### Regola di collocazione: finding vs analisi
|
||||
|
||||
Il prototipo v2 ha aggiunto una sezione discorsiva "Interfaccia" con otto paragrafi che contengono almeno quattro **veri finding** — contrasto insufficiente sulla CTA della hero, riflesso che mangia il 40% dell'immagine prodotto, titoli troncati, e soprattutto il carrello (spedizione assicurata 39,90€ contro 6,13€ con la non assicurata preselezionata). Quest'ultimo è da primi cinque ed era in coda a una sezione discorsiva.
|
||||
|
||||
**Regola per il motore e per l'editor:** se una cosa ha un impatto e una conseguenza, è un finding — va nel blocco 4, prende un numero e viene ordinata. Il blocco 5 resta discorsivo e resta a **tre aree** (struttura / messaggio / conversione). Nessuna quarta area.
|
||||
|
||||
Motivo: il documento promette "non un elenco di quaranta punti", e una seconda lista non ordinata dopo quella ordinata annulla la promessa. La forza del documento è la **selezione**.
|
||||
|
||||
### Disciplina sui numeri — vincolo del motore
|
||||
|
||||
Confronto con un audit parallelo dello stesso sito prodotto da un altro modello: conteneva "~65% abbandono stimato" e "+60% velocità mobile immediata". **Nessuno dei due è misurato**: sono congetture presentate come rilevazioni.
|
||||
|
||||
Il prototipo Claude è disciplinato su questo — dice "una parte importante del traffico" proprio perché non può quantificarla. **Quella disciplina va imposta nel prompt di sistema del motore**: un numero compare nel documento solo se proviene da una rilevazione (PageSpeed, conteggio DOM, peso pagina, dati del sito). Mai stime di conversione o di guadagno percentuale. In un deliverable premium basta un cliente che verifichi per bruciare tutta la credibilità.
|
||||
|
||||
Voce di verifica: nessun numero nel documento consegnato è privo di fonte in `audit_runs.raw`.
|
||||
|
||||
### Contributi da assorbire dall'audit parallelo
|
||||
|
||||
- **La misura/taglia nella griglia prodotti** — su pezzi unici rigenerati è criterio di scelta primario e non compare da nessuna parte. Si aggancia al finding sull'unicità e apre a un servizio ("messa a misura disponibile"). Va nella checklist profilo `ecommerce` come voce
|
||||
- **Conteggio dei nodi DOM** — quantifica il problema di peso meglio dei MB. Da aggiungere alle rilevazioni di `fetch.ts`
|
||||
- **Stime in giornate** — alimentano la colonna *Impegno* del blocco 7
|
||||
|
||||
### Estensione opzionale — allegato tecnico (NON in fase 1)
|
||||
|
||||
Il blocco 9 promette *"il documento resta tuo e puoi darlo a chiunque lavorerà sul sito"*, ma consegna prosa da imprenditore a uno sviluppatore.
|
||||
|
||||
Un **allegato tecnico generato dallo stesso record** — stessi finding, registro da sviluppatore, con selettori, file coinvolti e stime — renderebbe quella promessa molto più preziosa. È una seconda vista sugli stessi dati, quindi non viola il vincolo della fonte unica e non richiede contenuto aggiuntivo. Da valutare dopo il primo audit consegnato.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Versionamento del template
|
||||
|
||||
Requisito: migliorare il documento deve toccare gli audit **successivi**, mai quelli già consegnati.
|
||||
|
||||
Precedente in casa: `proposals.content` congela profilo e prezzi alla generazione, così modificarli dopo non retro-cambia un preventivo pubblicato.
|
||||
|
||||
Copy fisso e configurazione dei blocchi vivono in **moduli TS versionati**, non nel database:
|
||||
|
||||
```
|
||||
src/lib/audit/template/
|
||||
v1.ts copy fisso di ogni blocco + ordine + mappa livello→blocchi
|
||||
index.ts registry versione→modulo + LATEST_TEMPLATE_VERSION
|
||||
```
|
||||
|
||||
- `audits.template_version` impostata alla versione corrente **alla creazione**
|
||||
- Un audit **consegnato resta congelato** sulla sua versione, per sempre
|
||||
- Migliorare il documento = aggiungere `v2.ts` e alzare `LATEST_TEMPLATE_VERSION`. Nessuna migration, nessun backfill, storia completa in git
|
||||
- Le **bozze** si portano all'ultima versione con un bottone esplicito, mai in automatico
|
||||
|
||||
---
|
||||
|
||||
## 4 · La checklist come rubrica del motore
|
||||
|
||||
L'Excel ha 264 voci su 7 step di funnel, con scoring ICE e peso di importanza per sezione. Serve al **motore**, non al documento.
|
||||
|
||||
**Perché è preziosa:** ogni voce è un'asserzione binaria e verificabile ("Il checkout consente l'acquisto come ospite"). Verificare 264 affermazioni falsificabili è molto più affidabile che chiedere a un modello "analizza questo sito": trasforma l'analisi da generativa a **verificativa**, controllabile voce per voce. E lo scoring ICE risolve gratis l'ordinamento del blocco 4 e le priorità del blocco 7.
|
||||
|
||||
**Tre vincoli emersi dalla lettura del file:**
|
||||
|
||||
1. **È al 73% ecommerce.** Generale (50) e Homepage (21) valgono per qualsiasi sito; Categoria, Scheda Prodotto, Carrello, Checkout, Ringraziamento — **193 voci su 264** — presuppongono un carrello. Servono **due profili**: `ecommerce` e `servizi`. Il secondo va scritto, non esiste nel file.
|
||||
2. **Misura la conformità, non l'adeguatezza.** Un sito può fare 250/264 e continuare a descrivere un'azienda che non esiste più. Nessuna voce chiede se il posizionamento dichiarato corrisponde a quello che vendi oggi, o se le prove sono della fascia di cliente giusta. La checklist è il **pavimento** (guasti meccanici, li trova l'agent); lo scarto strategico è il **soffitto** e resta analisi tua. Se il documento diventa il rendering della checklist, torna a sembrare un audit automatico gratuito.
|
||||
3. **Molte voci sono tattiche da ecommerce a volume** — scarsità, urgenza, countdown, popup di social proof. Su un brand premium **danneggiano**: abbassano il segnale di prezzo mentre tu vendi il contrario. Ogni voce porta quindi un campo `registro` (`volume` / `premium` / `neutro`) e l'audit di un brand premium non propone mai i trigger da discount.
|
||||
|
||||
⚠️ Note tecniche sul file: il foglio *Algoritmo* ha errori **`#REF!`**, e la colonna *Facilità* è **1.0 su tutte le righe** — non compilata, quindi lo Score attuale è di fatto solo Impatto × Confidenza. Da sistemare prima di seminarne i default.
|
||||
|
||||
Il foglio *Best Ecommerce List* (88 siti per settore) alimenta confronti concreti: "il tuo checkout chiede 11 campi, i riferimenti del settore ne chiedono 6".
|
||||
|
||||
**Nel documento** la checklist non compare come elenco. Il blocco 3 mostra al massimo il grado di conformità **per step di funnel** (una barra per step), accanto alle metriche di performance. I problemi del blocco 4 sono una **selezione curata** — voci non conformi ad alto impatto più lo scarto strategico — non il dump delle non conformità.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Migration `0017_audits.sql`
|
||||
|
||||
Additiva e idempotente. Template di stile: `src/db/migrations/0016_retainer_lifecycle.sql` (header in italiano che spiega il *perché*, `ADD COLUMN IF NOT EXISTS`, `CHECK ... NOT VALID` dentro `DO $$ ... pg_constraint`, `CREATE INDEX IF NOT EXISTS`).
|
||||
|
||||
Convenzioni da `src/db/schema.ts`: id `text` con `$defaultFn(() => nanoid())`, **nessun `pgEnum`** (text + CHECK in SQL + tupla `as const` in TS + Zod nell'action), timestamp `withTimezone: true`, `updated_at` bumpato a mano nell'action.
|
||||
|
||||
**`audits`** — colonne scalari, non jsonb: non c'è snapshot da congelare (ci pensa `template_version`) e l'editor mappa 1:1.
|
||||
|
||||
- Identità: `id`, `slug` unique nanoid, `lead_id` → `leads` SET NULL, `client_id` → `clients` SET NULL (nullable entrambi, come `proposals`)
|
||||
- Config: `livello` CHECK `('radiografia','prima_dopo','rotta')`, `template_version`, `profilo` CHECK `('ecommerce','servizi')`, `cliente_nome`, `cliente_referente`, `sito_url`, `importo_pagato numeric(10,2)`, `data_consegna date`
|
||||
- Origine: `origin` CHECK `('manuale','whop')` default `'manuale'`, `external_ref` — predispone Whop senza costruirlo
|
||||
- Stato: `state` CHECK `('draft','published')` default `'draft'` (etichettati *Bozza* / *Consegnata* nella UI), `published_at`
|
||||
- Tracking: `first_viewed_at`, `last_viewed_at`, `view_count integer default 0`
|
||||
- Rilevazioni (blocco 3): `perf_mobile`, `perf_desktop`, `lcp numeric(6,2)`, `cls numeric(5,3)`, `inp`, `pagine_indicizzate`, `screenshot_desktop_url`, `screenshot_mobile_url`, `measured_at`
|
||||
- Contenuto (blocchi 2/2b/5/8): `sintesi`, `punti_forza jsonb` (lista, blocco 2b), `analisi_struttura`, `analisi_messaggio`, `analisi_conversione`, `direzione`
|
||||
- Redesign (blocchi 6/6b): `redesign_sezione`, `redesign_prima_url`, `redesign_dopo_url`, `redesign_razionale`, `redesign_limiti`, `redesign_figma_url`
|
||||
- Intake: `intake jsonb` — i dati che il cliente condivide, forma ancora da definire
|
||||
|
||||
**Tutti i campi di contenuto sono nullable.** È ciò che rende possibile "si salva sempre, anche a metà": la validazione di completezza scatta solo alla consegna.
|
||||
|
||||
**`audit_findings`** (blocco 4) — `audit_id` CASCADE, `titolo`, `impatto` CHECK `('alto','medio','basso')`, `area` CHECK `('struttura','messaggio','conversione','performance')`, `descrizione`, `conseguenza`, `screenshot_url`, `sort_order integer default 0`, `origin` CHECK `('agent','manuale')`
|
||||
|
||||
**`audit_optimizations`** (blocco 7) — `audit_id` CASCADE, `intervento`, `priorita` CHECK `('alta','media','bassa')`, `motivazione`, `impegno`, `sort_order integer default 0`
|
||||
|
||||
**`checklist_items`** (rubrica, versionata come il template) — `profilo`, `step`, `focus`, `testo`, `impatto_default`, `confidenza_default`, `registro` CHECK `('volume','premium','neutro')`, `sort_order`
|
||||
|
||||
**`audit_checklist_results`** — `audit_id` CASCADE, `item_id`, `esito` CHECK `('conforme','non_conforme','non_rilevante')`, `note`, `evidenza`, `origin` CHECK `('agent','manuale')`
|
||||
|
||||
**`audit_runs`** (§6) — `audit_id` CASCADE, `status` CHECK `('queued','running','done','error')`, `step`, `started_at`, `finished_at`, `heartbeat_at`, `error`, `raw jsonb` (output grezzo: materiale per il blocco 8)
|
||||
|
||||
Indici: unique su `slug`; index su `client_id`, `lead_id`, `(audit_id, sort_order)` per le figlie, `(audit_id, started_at desc)` per le run, `(audit_id, item_id)` unique per i risultati.
|
||||
|
||||
**Ordinamento automatico** (blocchi 4 e 7): la query ordina per rango di impatto/priorità con `sort_order` come spareggio *dentro* il gruppo. Tu non ordini niente.
|
||||
|
||||
> ⚠️ **Checkpoint bloccante.** Migration applicata in produzione **prima** di pushare il codice schema-dipendente (regola CLAUDE.md "Ordering"), via SSH/docker-exec. Aggiornare `src/db/schema.ts` a mano in parallelo — `drizzle-kit generate` è rotto.
|
||||
|
||||
---
|
||||
|
||||
## 6 · Motore di analisi
|
||||
|
||||
`src/lib/audit/` modellato su `src/lib/proposal/` ma multi-step:
|
||||
|
||||
```
|
||||
src/lib/audit/
|
||||
pipeline.ts funzione pura: (auditId) => risultato. Non conosce chi la chiama
|
||||
agent.ts chiamate Anthropic, una per step di funnel
|
||||
schema.ts validazione Zod dell'output
|
||||
fetch.ts recupero pagine + estrazione testo + PageSpeed
|
||||
```
|
||||
|
||||
**Passi:** recupero pagine chiave → metriche PageSpeed → verifica delle voci di checklist del profilo → sintesi dei problemi con impatto+area+descrizione+conseguenza.
|
||||
|
||||
Tutto l'output è **bozza**: finisce in `audit_checklist_results` e in `audit_findings` con `origin='agent'`. Tu rivedi e correggi prima di consegnare. Si automatizza il meccanico, **il giudizio resta tuo**.
|
||||
|
||||
**Il blocco 8 "La direzione" resta manuale, foglio bianco.** L'editor mostra a lato il materiale grezzo — problemi ad alto impatto e temi ricorrenti, da `audit_runs.raw`. Nessun testo proposto, nessun template. È il blocco che giustifica il prezzo: se diventa formula, il cliente lo sente.
|
||||
|
||||
**Esecuzione: in-app, background, con polling.**
|
||||
- "Avvia analisi" → riga `audit_runs` con `status='queued'`, lancia la pipeline
|
||||
- L'admin fa polling e mostra `step` corrente e avanzamento
|
||||
- Progresso scritto a ogni passo: un'interruzione non perde il lavoro fatto
|
||||
|
||||
> ⚠️ **Rischio da mettere in conto.** Con `output: "standalone"` su singolo container Coolify, un redeploy **uccide un job in corso** e lascia una riga bloccata su `running`. Mitigazione: `heartbeat_at` aggiornato a ogni passo, le run senza heartbeat da N minuti vanno in `error`, e "Rilancia" riparte dall'ultimo passo completato. Non è un sistema a code: è deliberatamente il minimo che regge 50 audit/anno.
|
||||
|
||||
`pipeline.ts` è una **funzione pura riusabile**: oggi la chiama il bottone, domani il webhook Whop. Nessuna riscrittura.
|
||||
|
||||
---
|
||||
|
||||
## 7 · Storage immagini e redesign
|
||||
|
||||
Servono: 2 screenshot home, N screenshot dei problemi, 2 immagini redesign. Con URL esterni incollati a mano il budget dei 10 minuti non regge, e un URL morto uccide il documento del cliente.
|
||||
|
||||
**Volume persistente, non servizio esterno.** Nessuna dipendenza npm nuova, nessun account terzo, nessuna credenziale.
|
||||
|
||||
- Volume Coolify montato su `/app/uploads`
|
||||
- Upload via **server action** che riceve `File` da `FormData`. Niente upload diretto dal browser: la CSP ha `connect-src 'self'`, un POST verso un host esterno sarebbe bloccato — passare dal nostro origin è l'unica strada e va bene così
|
||||
- Validazione: whitelist MIME `image/png|jpeg|webp`, max 5MB, nome file `nanoid()` (mai l'originale)
|
||||
- Lettura via `src/app/api/uploads/[...path]/route.ts` con guardia sul path traversal e `Cache-Control` lungo
|
||||
- CSP invariata: serviamo dal nostro origin, coperto da `img-src 'self'`
|
||||
|
||||
> ⚠️ **Checkpoint bloccante.** Volume creato in Coolify prima del deploy, altrimenti gli upload si perdono a ogni redeploy.
|
||||
|
||||
Nuovo componente `ImageUploadField` — in tutto `src/` non c'è un solo `<input type="file">`.
|
||||
|
||||
**Il redesign usa entrambi i formati, con ruoli distinti:**
|
||||
- **Immagini caricate** — rappresentazione canonica, quelle che si vedono nello slider e **le uniche che finiscono nel PDF**
|
||||
- **Link Figma** — opzionale, "apri il redesign interattivo", nuova scheda
|
||||
|
||||
Perché non embeddare Figma: la CSP ha `default-src 'self'` senza `frame-src`, quindi l'iframe sarebbe bloccato; e un iframe **in stampa non produce nulla**, rompendo il vincolo "una sola fonte" proprio sul blocco che vale di più.
|
||||
|
||||
---
|
||||
|
||||
## 8 · Pagina pubblica e PDF
|
||||
|
||||
`/audit/[slug]`, modellata su `src/app/preventivo/[slug]/page.tsx`: `export const revalidate = 0`, fetch by slug, `notFound()` se assente.
|
||||
|
||||
**Differenze deliberate dal preventivo:**
|
||||
- **Documento a scorrimento.** Nessun `h-screen`, nessun `overflow-hidden`, nessuna manipolazione di `body.style`
|
||||
- **Rate limit**: aggiungere `/audit` al matcher di `src/proxy.ts` riusando `src/lib/rate-limit.ts`. Il preventivo non ce l'ha — non ereditare quell'omissione
|
||||
- **noindex**: `X-Robots-Tag: noindex, nofollow` in `next.config.ts` più `metadata.robots`
|
||||
- **Anteprima admin**: con `state === 'draft'` mostrare comunque il documento se `getServerSession` restituisce una sessione admin, altrimenti il placeholder. Non tocca LOCKED #4, che riguarda `/client/*`
|
||||
- **Tracking**: alla prima render non-admin aggiornare `first_viewed_at` / `last_viewed_at` / `view_count`
|
||||
|
||||
Nuovi componenti in `src/components/public/audit/`, uno per blocco, server components salvo slider e bottone stampa. **Non riusare** le 20 sezioni di `public/proposal/sections/`: sono saldate a `ProposalContent` con copy hard-coded.
|
||||
|
||||
Da riusare: **`RichText.tsx`** per ogni testo libero (obbligatorio su output del modello — mai `dangerouslySetInnerHTML`), e le convenzioni visive esistenti (eyebrow `text-xs font-mono tracking-widest uppercase`, headline `text-5xl font-light`, card `border border-border rounded-xl p-8`).
|
||||
|
||||
⚠️ Il deck preventivi è light-only (`bg-white` hard-coded in `ProposalDeck.tsx:139,141,156`, `PricingSection.tsx:46`) e viola la regola dei token in due punti (`TimelineSection.tsx:5-9`, `ClosingSection.tsx:20,34`). **Non replicare quei difetti**: solo token semantici, dual light/dark.
|
||||
|
||||
**Leggibilità in B/N**: il livello di impatto non può dipendere dal colore. Etichetta + glifo — `●●● alto` / `●●○ medio` / `●○○ basso`.
|
||||
|
||||
### PDF via print CSS
|
||||
|
||||
Stessa pagina, stesso DOM, stesso record: il vincolo "una sola fonte" è soddisfatto per costruzione.
|
||||
|
||||
In `src/app/globals.css` — oggi **zero regole print** in 218 righe — un blocco `@media print`:
|
||||
- Palette chiara forzata, `print-color-adjust: exact` dove serve
|
||||
- `break-inside: avoid` su ogni card problema e riga della tabella; `break-before: page` sui blocchi maggiori
|
||||
- Nascosti: bottone stampa, nav, controllo dello slider
|
||||
- **Slider prima/dopo**: a schermo interattivo, in stampa due immagini impilate con etichetta. Tecnica: entrambe le `<img>` **sempre** nel DOM, clippate in overlay via CSS a schermo; in `@media print` si toglie il clip e si impila. Nessun ramo JS, nessuna divergenza possibile
|
||||
- Bottone "Scarica PDF" sticky in alto a destra = client island con `window.print()`
|
||||
|
||||
---
|
||||
|
||||
## 9 · Admin
|
||||
|
||||
Mutazioni come Server Actions colocate in `actions.ts` (niente REST per l'admin).
|
||||
|
||||
- `src/app/admin/audit/page.tsx` — lista. Riusare `PageHeader`, `SearchInput`, badge di stato
|
||||
- `src/app/admin/audit/nuovo/` — **creazione manuale**: cliente o lead, livello, profilo, URL, importo → redirect all'editor
|
||||
- `src/app/admin/audit/[id]/edit/page.tsx` — RSC sottile + client component
|
||||
- `src/components/admin/audit/AuditEditor.tsx`
|
||||
- `src/app/admin/audit/actions.ts`
|
||||
- Voce in `AdminSidebar.tsx`
|
||||
|
||||
**Pattern editor: `saveOfferEditor` + `OfferEditorClient`** (`src/app/admin/offers/actions.ts:177-274`, `src/components/admin/offers/OfferEditorClient.tsx`) — unico precedente di salvataggio a payload intero con array di figli:
|
||||
|
||||
- Zod annidato con `z.array(findingSchema)` / `z.array(optimizationSchema)`, `id` opzionale sui figli
|
||||
- Nell'action: update degli scalari, loop sui figli con **upsert per presenza di `id`**, delete delle righe sparite, `sort_order: index` assegnato server-side
|
||||
- Client: array in `useState`, update immutabili via `.map`, `startTransition` + try/catch → `setSaveError`
|
||||
- ⚠️ **Il re-sync degli id dopo il primo salvataggio** (`OfferEditorClient.tsx:93-98`): senza, i figli appena creati vengono re-inseriti invece che aggiornati al salvataggio successivo. È il bug più facile da introdurre qui
|
||||
|
||||
**Aggiungi / rimuovi / riordina righe**: non esiste nel codebase — `sort_order` oggi è write-once. `@dnd-kit/sortable` è già in `package.json` e usato in `KanbanBoard.tsx`: riusarlo, con l'indice dell'array che diventa `sort_order` al salvataggio.
|
||||
|
||||
**Campi condizionali**: redesign e ottimizzazioni non si renderizzano se il livello non li prevede.
|
||||
|
||||
**Bozza**: niente autosave (non esiste nel codebase, complessità sproporzionata). "Salva bozza" esplicito + avviso all'uscita con modifiche pendenti. Tutti i campi nullable. La **validazione di completezza scatta solo su "Consegna"**, sui soli blocchi pertinenti.
|
||||
|
||||
**Consegna / Ritira**: transizioni guardate `WHERE id = ? AND state = ?` come `publishProposal` (`src/app/admin/preventivi/actions.ts:117-127`). Entrambe reversibili.
|
||||
|
||||
**Automatismi** (budget 10 minuti): credito = `importo_pagato`, fasi già completate derivate dal livello, date da `published_at`/`measured_at`, ordinamento dei blocchi 4 e 7, analisi draftata dagli agent.
|
||||
|
||||
---
|
||||
|
||||
## 10 · Ingresso: manuale ora, Whop dopo
|
||||
|
||||
Fase 1 = **solo creazione manuale**, che resta comunque un requisito permanente.
|
||||
|
||||
Predisposto senza costruirlo: `origin` + `external_ref` sulla tabella, e `pipeline.ts` chiamabile da un webhook. L'intake del cliente è **ancora da definire** — la colonna `intake jsonb` accoglie qualunque forma prenderà, senza migration aggiuntive.
|
||||
|
||||
---
|
||||
|
||||
## 11 · Modifica al vincolo LOCKED #5 — richiede approvazione
|
||||
|
||||
`CLAUDE.md` oggi: `5. No file hosting v1 — documenti come URL esterni`
|
||||
|
||||
Nuova versione proposta:
|
||||
|
||||
```
|
||||
5. No file hosting per i documenti — restano URL esterni.
|
||||
Deroga (Phase 27, 2026-08-16): le immagini dell'audit (screenshot e
|
||||
redesign) sono caricate su volume persistente e servite da
|
||||
/api/uploads/[...path], con whitelist MIME e limite di dimensione.
|
||||
Non estendere l'upload ad altre entità senza modificare questo vincolo.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12 · GSD e memoria di progetto
|
||||
|
||||
**Non c'è niente da costruire**: state file, roadmap e checkpoint automatico esistono già. `.planning/STATE.md` è il digest che comanda i comandi `/gsd-*`, `.planning/ROADMAP.md` traccia la posizione, `execute-plan.md` aggiorna STATE.md **dopo ogni piano**, e `.claude/rules/memory-discipline.md` è la regola scritta.
|
||||
|
||||
Due difetti reali:
|
||||
|
||||
1. **`.planning/config.json` non ha la chiave `hooks`.** Verificato: entrambi gli hook globali (`gsd-session-state.sh`, `gsd-phase-boundary.sh`) escono a vuoto senza `"hooks": { "community": true }`. Aggiungerla riattiva l'iniezione di STATE.md all'avvio sessione e il promemoria a ogni scrittura in `.planning/`. Una riga.
|
||||
2. **`gsd-sdk` e `gsd-tools` non sono nel PATH** (verificato: `not found`). Le mutazioni automatiche di STATE.md in `execute-plan.md` non hanno backend e ricadono su edit manuali — è il motivo per cui le fasi 13 e 26 hanno SUMMARY ricostruiti a posteriori.
|
||||
|
||||
L'hook `Stop` in `.claude/settings.json` è solo advisory (`echo`) e guarda solo `src` e `.planning`: non nota modifiche a `STATUS.md` o `CLAUDE.md`.
|
||||
|
||||
**Inquadramento**: nuovo milestone **v2.5 — Audit** via `/gsd-new-milestone`. Numerazione progressiva e mai riusata (l'ultima è 26 ⇒ si parte da **27**), come impone `.planning/PROJECT.md`.
|
||||
|
||||
Da aggiornare a fine lavoro: `.planning/STATE.md`, `.planning/ROADMAP.md`, `STATUS.md`, `CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 13 · File toccati
|
||||
|
||||
**Nuovi** — `scripts/spike-audit.ts` · `src/db/migrations/0017_audits.sql` · `src/lib/audit/{pipeline,agent,schema,fetch}.ts` · `src/lib/audit/template/{v1,index}.ts` · `src/lib/audit-queries.ts` · `src/lib/audit-view.ts` · `src/app/audit/[slug]/page.tsx` · `src/components/public/audit/*` · `src/app/admin/audit/**` · `src/components/admin/audit/AuditEditor.tsx` · `src/components/ui/ImageUploadField.tsx` · `src/app/api/uploads/[...path]/route.ts`
|
||||
|
||||
**Modificati** — `src/db/schema.ts` · `src/proxy.ts` · `next.config.ts` · `src/app/globals.css` · `src/components/admin/AdminSidebar.tsx` · `CLAUDE.md` · `.planning/config.json` · `Dockerfile` + volume Coolify
|
||||
|
||||
---
|
||||
|
||||
## 14 · Verifica
|
||||
|
||||
Non esiste test suite (nessun vitest/jest/playwright, nessuno script `test`). **`npm run build` è la verifica di riferimento** — fa il typecheck. `npm run lint` è `eslint` nudo.
|
||||
|
||||
**Fase 1 (spike)** — gira su un sito reale, i problemi sono concreti e verificabili, la valutazione delle voci di checklist è ripetibile. È un giudizio tuo, non un test automatico.
|
||||
|
||||
**Dopo:**
|
||||
1. `npm run build` — typecheck e compilazione
|
||||
2. Migration applicata in prod **prima** del push; verificare che le sei tabelle esistano
|
||||
3. Un audit di prova per ciascuno dei tre livelli, su entrambi i profili: i blocchi condizionali compaiono e spariscono correttamente
|
||||
4. Salvare a metà, ricaricare, nulla si perde. Salvare due volte di fila: i figli si aggiornano e **non si duplicano** (test del re-sync degli id)
|
||||
5. Riordinare i problemi, salvare, ricaricare: l'ordine regge
|
||||
6. Avviare un'analisi e **riavviare il container a metà**: la run va in `error` e "Rilancia" riparte senza perdere i passi completati
|
||||
7. Upload di un'immagine → redeploy → l'immagine è ancora lì (prova del volume)
|
||||
8. Slug consegnato in incognito: si apre. Bozza in incognito: placeholder. Bozza da admin loggato: si vede
|
||||
9. `X-Robots-Tag: noindex, nofollow` presente nella risposta
|
||||
10. **Stampa**: Cmd+P sul documento consegnato → interruzioni di pagina corrette, slider impilato in due immagini, nessun marcatore di lavorazione (bordi/testi colorati) sopravvissuto, e in **scala di grigi** sia i livelli di impatto sia i valori delle metriche restano distinguibili
|
||||
10b. **Rete disattivata dopo il primo caricamento**: i font restano quelli giusti (prova che sono self-hostati e che nessuna risorsa esterna è rimasta)
|
||||
11. Il tracking apertura si incrementa da visitatore e **non** dalla preview admin
|
||||
12. Consegnato un audit su `v1`, alzare `LATEST_TEMPLATE_VERSION` a `v2`: il consegnato continua a rendere `v1`, uno nuovo nasce `v2`
|
||||
13. Un audit su brand premium **non** propone mai voci con `registro='volume'`
|
||||
|
||||
⚠️ Playwright non funziona contro `npm run dev` (la CSP blocca `eval`, i client component non si idratano). Per E2E usare il build di produzione.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Audit — motore multi-agente, rilevazioni e tracciamento
|
||||
|
||||
## Context
|
||||
|
||||
Il piano approvato il 2026-08-16 (`dovremmo-fare-una-cosa-woolly-puddle.md`) resta in vigore per struttura del documento, versionamento del template, rubrica checklist, editor admin, pagina pubblica e print CSS. **Questo file ne sostituisce tre parti** — §6 (motore), §7 (immagini) e il tracciamento in §5/§8 — e non tocca il resto.
|
||||
|
||||
Cosa è successo da allora:
|
||||
|
||||
- **Lo spike è girato su giojello.com** e ha prodotto un audit di buona qualità. Ma `psi: {}` — le rilevazioni PageSpeed sono fallite tutte per quota anonima esaurita. Quell'audit è stato scritto **con zero misurazioni**, solo HTML statico.
|
||||
- **Il 52% della checklist non è verificabile da HTML statico** (204 voci → 107 non verificabili; lo step `generale` 40 su 50). Barra di ricerca, widget carrello, hover, animazioni, banner cookie esistono solo a runtime.
|
||||
- **Il VPS non può ospitare Chromium**: 1.492 MB di RAM disponibili su 3.819, 736 MB già in swap, 2 vCPU, con Coolify/n8n/Gitea/Vaultwarden sopra. Il disco invece è libero (21 GB su 38). Immagine `node:20-alpine`, non supportata da Playwright.
|
||||
- **Il processo bersaglio è: incolli un URL, il motore fa ricerche incrociate con le API gratuite, esce un documento pronto da inoltrare.** Tu carichi a mano solo due immagini: hero prima e hero dopo, JPG ≤ 0,5 MB.
|
||||
|
||||
### Decisioni
|
||||
|
||||
| Punto | Decisione | Data |
|
||||
|---|---|---|
|
||||
| Renderer headless | **Nessuno, da nessuna parte.** Motore 100% in-app, self-service | 2026-08-18 |
|
||||
| Buco a runtime | Coperto da audit Lighthouse completi + screenshot renderizzato letto in visione + CrUX + Wayback | 2026-08-18 |
|
||||
| Immagini manuali | Solo le due del redesign, JPG ≤ 0,5 MB | 2026-08-18 |
|
||||
| Screenshot stato di fatto | Automatici, da PageSpeed | 2026-08-18 |
|
||||
| Tracciamento | Registro delle singole visite + evento stampa | 2026-08-18 |
|
||||
|
||||
**Due decisioni del 2026-08-17 sono superate da questa revisione** e vanno corrette prima di eseguire (§8): il renderer locale sul Mac e la frase "la copia master è la cartella di cattura locale" scritta in `CLAUDE.md` — non esiste più nessuna cattura locale.
|
||||
|
||||
---
|
||||
|
||||
## 1 · Prerequisito bloccante — chiave PageSpeed e verifica dell'ipotesi
|
||||
|
||||
Tutto il resto poggia su un'ipotesi **non ancora verificata**: la quota anonima risponde `429` e senza chiave non si prova. Primo passo, prima di scrivere codice:
|
||||
|
||||
1. Creare un progetto Google Cloud, abilitare *PageSpeed Insights API* e *Chrome UX Report API*, generare una chiave (gratuita, nessuna carta). In `.env.local` e in Coolify come `PAGESPEED_API_KEY`.
|
||||
2. Una chiamata a mano, e **verificare che esistano davvero**:
|
||||
- `lighthouseResult.fullPageScreenshot.screenshot.data` → `data:image/jpeg;base64,…` a pagina intera
|
||||
- `lighthouseResult.audits` → l'insieme completo (~150 voci), non i 10 scalari che lo spike estrae oggi
|
||||
|
||||
**Se lo screenshot a pagina intera non c'è**, ripiego su `lighthouseResult.audits["final-screenshot"]` (solo viewport, quindi di fatto l'hero — che è comunque il blocco più importante). **Se non c'è nemmeno quello**, l'analisi visiva esce dalla fase 1 e gli screenshot dello stato di fatto tornano manuali: cambia §5 di questo piano, non il resto.
|
||||
|
||||
> ⚠️ Non costruire il sub-agent visivo prima di aver visto quel campo con i tuoi occhi.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Le fonti — "tutte le api gratuite che servono"
|
||||
|
||||
| Fonte | Cosa dà | Chiave | Alimenta |
|
||||
|---|---|---|---|
|
||||
| **PageSpeed Insights v5** (mobile + desktop) | ~150 audit Lighthouse, punteggi, screenshot renderizzato a pagina intera | sì, gratuita | blocco 3, finding tecnici, sub-agent visivo |
|
||||
| **CrUX API** | LCP/INP/CLS **p75 di utenti reali**, per origin e per URL | stessa chiave | blocco 3 — la distinzione lab/campo |
|
||||
| **Wayback CDX** | storico degli snapshot, diff del testo hero rispetto a 1/3/5 anni fa | no | la tesi "l'azienda è cresciuta, il sito no" |
|
||||
| **RDAP** (`rdap.org`) | età del dominio | no | contesto in blocco 3 |
|
||||
| **Mozilla HTTP Observatory** | voto sugli header di sicurezza | no | segnali di fiducia |
|
||||
| **Fetch diretto** | `robots.txt`, `sitemap.xml` (→ `pagine_indicizzate`), JSON-LD, `hreflang`, impronta della piattaforma | no | blocco 3, finding tecnici |
|
||||
| **Le pagine del sito** | già nello spike: home + fino a 3 pagine per profilo | no | verifica checklist |
|
||||
|
||||
**Perché questo elenco e non altro:** ognuna di queste è una **rilevazione**, non una stima. È esattamente ciò che impone la disciplina sui numeri del piano approvato — un numero entra nel documento solo se misurato. CrUX in particolare porta dati di utenti veri, la cosa più difendibile che si possa scrivere in un audit.
|
||||
|
||||
**Il guadagno più grande è già in casa e viene buttato via:** lo spike chiama PageSpeed e ne estrae 10 numeri. Gli audit Lighthouse coprono da soli una fetta consistente del 52% non verificabile — `color-contrast`, `tap-targets`, `font-size`, `image-alt`, `link-text`, `crawlable-anchors`, `structured-data`, `unsized-images`, `errors-in-console`, `viewport`, `canonical`. Sono verifiche fatte **sul DOM renderizzato**, cioè proprio quello che l'HTML statico non vede.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Il motore
|
||||
|
||||
`src/lib/audit/`, modellato su `src/lib/proposal/` ma multi-step e con fan-out:
|
||||
|
||||
```
|
||||
src/lib/audit/
|
||||
pipeline.ts orchestratore: (auditId) => risultato. Non sa chi lo chiama
|
||||
sources/ raccolta dati, nessun LLM
|
||||
fetch.ts pagine + estrazione testo (dallo spike, quasi invariato)
|
||||
pagespeed.ts PSI: audit completi + screenshot + estrazione scalari
|
||||
crux.ts dati di campo
|
||||
history.ts Wayback CDX + diff
|
||||
signals.ts RDAP, header, robots/sitemap/JSON-LD
|
||||
agents/ un file per sub-agent, ognuno con prompt e schema Zod propri
|
||||
checklist.ts verificatore, Sonnet, batch da 12 per step di funnel
|
||||
visual.ts analisi visiva, Opus con input immagine
|
||||
history.ts scarto fra il sito di allora e l'azienda di oggi, Sonnet
|
||||
technical.ts audit Lighthouse + header + dati strutturati, Sonnet
|
||||
synthesis.ts sintetizzatore, Opus
|
||||
schema.ts Zod per ogni output di sub-agent + per la sintesi
|
||||
```
|
||||
|
||||
**Sequenza.** Raccolta in parallelo (I/O di rete puro) → quattro sub-agent in parallelo, ognuno sul proprio materiale → sintetizzatore che riceve i quattro output e **incrocia**.
|
||||
|
||||
**Cosa vuol dire "incrociate", in concreto.** I sub-agent producono osservazioni, non finding. È il sintetizzatore che le fonde:
|
||||
|
||||
> checklist: "nessun segnale di fiducia sulla scheda prodotto" + tecnico: "nessun dato strutturato `Product`/`Review`" + storico: "il testo dell'hero non cambia dal 2021" + visivo: "le recensioni sono sotto tre schermate di scroll"
|
||||
> → **un solo finding**, con quattro evidenze indipendenti, invece di quattro finding deboli.
|
||||
|
||||
Questo serve la regola di selezione del piano approvato: il documento promette "non un elenco di quaranta punti", e la forza sta nella selezione. Massimo 10 problemi, ordinati per impatto.
|
||||
|
||||
**Tre cose che lo spike non fa e vanno fatte:**
|
||||
|
||||
1. **Validazione Zod di ogni output di modello.** Lo spike fa `as Record<string, unknown>` e stampa. `proposal/schema.ts` è il precedente: `safeParse`, fallimento duro, nessun loop di riparazione.
|
||||
2. **Concorrenza limitata e retry.** Fan-out sì, ma con un tetto (4 chiamate contemporanee) e un retry con backoff sulle 429/529 di Anthropic. Lo spike non ha né l'uno né l'altro.
|
||||
3. **Heartbeat a ogni passo.** `audit_runs.heartbeat_at`; una run senza battito da N minuti va in `error`, "Rilancia" riparte dall'ultimo passo completato. È la mitigazione già prevista per il redeploy che uccide il job.
|
||||
|
||||
**Da riusare invariati dallo spike:** i blocchi `SICUREZZA` e `NUMERI`, la funzione `fence()` che neutralizza i tag di chiusura, e l'estrazione del primo blocco `text` con guardia su `stop_reason === "max_tokens"` (più robusta di `content[0]` in `proposal/agent.ts`).
|
||||
|
||||
**Impronta sul VPS:** I/O di rete e JSON. Nessun processo pesante, nessun Chromium. Il picco è la dimensione dello screenshot in memoria prima dell'invio ad Anthropic — vedi §7.
|
||||
|
||||
---
|
||||
|
||||
## 4 · Schema — differenze rispetto a §5 del piano approvato
|
||||
|
||||
La migration resta `0017_audits.sql`, additiva e idempotente, applicata in prod **prima** del push. Rispetto all'elenco già approvato:
|
||||
|
||||
**Nuova tabella `audit_visits`** — il registro delle singole visite:
|
||||
`id`, `audit_id` CASCADE, `occurred_at` default now, `event` CHECK `('view','print')`, `referrer`, `user_agent`, `ip_hash`. Indice su `(audit_id, occurred_at desc)`.
|
||||
|
||||
L'IP **non si salva in chiaro**: SHA-256 di `ip + NEXTAUTH_SECRET` via Web Crypto, come già si fa per il digest del gate admin in `src/lib/admin-gate.ts`. Serve a distinguere due aperture dello stesso lettore da due lettori diversi, non a identificare qualcuno.
|
||||
|
||||
**Campi aggiunti su `audits`** — separare laboratorio e campo, perché è la distinzione che rende credibile il blocco 3:
|
||||
`lcp_field`, `inp_field`, `cls_field` (numeric, da CrUX, nullable — un sito senza traffico sufficiente non ha dati di campo, ed è un'informazione anch'essa).
|
||||
|
||||
**Restano come da piano approvato** le colonne di roll-up `first_viewed_at` / `last_viewed_at` / `view_count`: sono la lettura veloce per la lista admin, aggiornate insieme alla riga di `audit_visits`.
|
||||
|
||||
**`screenshot_desktop_url` / `screenshot_mobile_url`** ora li scrive la pipeline, non tu.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Immagini
|
||||
|
||||
**Due upload manuali per audit**, non di più: `redesign_prima_url` e `redesign_dopo_url`. JPG, limite **512 KB**. Il componente `ImageUploadField` accetta `image/jpeg|png|webp` — non ha senso rifiutare un PNG per principio — ma il limite di dimensione è quello.
|
||||
|
||||
**Due file scritti dalla pipeline**: gli screenshot PageSpeed mobile e desktop, decodificati da base64 e salvati come JPG sullo stesso volume.
|
||||
|
||||
Quattro file per audit, circa 1,5 MB. Il volume Coolify su `/app/uploads` e la lettura via `src/app/api/uploads/[...path]/route.ts` con guardia sul path traversal restano come da §7 del piano approvato — con un margine molto più comodo di quanto si era dimensionato.
|
||||
|
||||
> ⚠️ **Checkpoint bloccante.** Volume creato in Coolify **prima** del deploy, altrimenti gli upload si perdono a ogni redeploy.
|
||||
|
||||
---
|
||||
|
||||
## 6 · Tracciamento
|
||||
|
||||
Un'isola client `<AuditVisitTracker>` montata nella pagina pubblica chiama una server action fire-and-forget:
|
||||
|
||||
- **all'mount** → evento `view`
|
||||
- **su `window.print()`** e sull'evento `beforeprint` → evento `print`
|
||||
|
||||
La server action, lato server, chiama `getServerSession`: **se c'è una sessione admin non scrive niente.** È così che l'anteprima admin non inquina i numeri — requisito già scritto nella verifica del piano approvato (test 11).
|
||||
|
||||
Perché un'isola client e non una scrittura nel render RSC: la pagina ha `revalidate = 0` e quindi ri-esegue a ogni richiesta, ma scrivere sul DB dentro un render è un anti-pattern Next e conterebbe anche i prefetch e i bot. La convenzione del progetto è comunque "le mutazioni sono Server Actions".
|
||||
|
||||
Lato admin: il registro visite compare nell'editor dell'audit, sotto forma di elenco cronologico.
|
||||
|
||||
---
|
||||
|
||||
## 7 · Rischi da mettere in conto
|
||||
|
||||
- **Lo screenshot a pagina intera può essere altissimo** (una home lunga arriva a 10-15.000 px). Va ridimensionato prima di mandarlo a Opus, o mandato a fette. Non risolto: da misurare sul primo audit vero.
|
||||
- **Latenza PageSpeed**: 30-60 s per strategia, due strategie. Il job in background lo regge, ma incide sul tempo totale — stimare il totale della pipeline dopo il primo giro completo, non prima.
|
||||
- **Wayback e Observatory sono lenti e ballerini.** Ogni fonte deve fallire in modo non fatale, come già fa PageSpeed nello spike (`psi[s] = null` e si prosegue).
|
||||
- **CrUX non risponde per i siti a basso traffico.** Non è un errore: è un dato. Il documento deve saperlo dire ("non ci sono abbastanza visitatori perché Google raccolga dati di campo") invece di lasciare un buco.
|
||||
- **La ripetibilità è già stata vista traballare**: lo step `generale` ha dato 0 non conformi nel giro completo e 2 in quello isolato, a parità di sito e voci. Con il fan-out il rischio non diminuisce. Da tenere sotto osservazione al primo audit di prova.
|
||||
|
||||
---
|
||||
|
||||
## 8 · Da correggere prima di eseguire
|
||||
|
||||
Due cose scritte ieri sono ora sbagliate:
|
||||
|
||||
1. **`CLAUDE.md`, deroga a LOCKED #5** — dice "la copia master è la cartella di cattura locale". Non esiste più nessuna cattura locale. Sostituire con la formulazione di §11 del piano approvato, che è già corretta e non parla di copie locali.
|
||||
2. **`.planning/STATE.md`** — la decisione "il renderer headless gira in LOCALE sul Mac" va riscritta come "nessun renderer headless: il buco a runtime è coperto dagli audit Lighthouse e dallo screenshot PageSpeed". Il *perché* il VPS non regge Chromium resta valido e va tenuto: è la ragione per cui l'opzione non tornerà.
|
||||
|
||||
Stessa correzione nel file di memoria persistente `project_clienthub_vps_no_headless.md`.
|
||||
|
||||
---
|
||||
|
||||
## 9 · File toccati
|
||||
|
||||
**Nuovi** — `src/db/migrations/0017_audits.sql` · `src/lib/audit/pipeline.ts` · `src/lib/audit/sources/{fetch,pagespeed,crux,history,signals}.ts` · `src/lib/audit/agents/{checklist,visual,history,technical,synthesis}.ts` · `src/lib/audit/schema.ts` · `src/lib/audit/template/{v1,index}.ts` · `src/lib/audit-queries.ts` · `src/app/audit/[slug]/page.tsx` · `src/components/public/audit/*` (uno per blocco, più `AuditVisitTracker`) · `src/app/admin/audit/**` · `src/components/admin/audit/AuditEditor.tsx` · `src/components/ui/ImageUploadField.tsx` · `src/app/api/uploads/[...path]/route.ts`
|
||||
|
||||
**Modificati** — `src/db/schema.ts` · `src/proxy.ts` (aggiungere `/audit` al matcher, con `rateLimit`) · `next.config.ts` (`X-Robots-Tag: noindex, nofollow`) · `src/app/globals.css` (blocco `@media print`) · `src/components/admin/AdminSidebar.tsx` · `CLAUDE.md` · `.planning/STATE.md` · Dockerfile + volume Coolify
|
||||
|
||||
**Riusati senza modifiche** — `src/lib/rate-limit.ts` · `src/components/public/proposal/RichText.tsx` · `src/lib/admin-gate.ts` (per l'hash) · `src/app/admin/offers/actions.ts` come modello di editor a payload intero · `@dnd-kit/sortable` per il riordino
|
||||
|
||||
---
|
||||
|
||||
## 10 · Verifica
|
||||
|
||||
Non c'è test suite: **`npm run build` è la verifica di riferimento** (fa il typecheck).
|
||||
|
||||
**Prima di tutto** — la chiave PageSpeed funziona e `fullPageScreenshot` esiste davvero (§1). Se non esiste, fermarsi e rivedere §5.
|
||||
|
||||
Poi, nell'ordine:
|
||||
|
||||
1. `npm run build` pulito
|
||||
2. Migration applicata in prod **prima** del push; le sette tabelle esistono
|
||||
3. Un audit completo su giojello.com: **confrontare la quota di non verificabili con il 52% dello spike**. È la misura che dice se l'approccio senza Chromium ha funzionato
|
||||
4. Ogni numero nel documento consegnato ha una fonte rintracciabile in `audit_runs.raw`. Nessuna percentuale inventata
|
||||
5. Un sito senza dati CrUX: il documento lo dice, non lascia un buco
|
||||
6. Riavviare il container a metà analisi: la run va in `error`, "Rilancia" riparte senza rifare i passi completati
|
||||
7. Upload di un'immagine → redeploy → l'immagine è ancora lì (prova del volume)
|
||||
8. Aprire il documento consegnato in incognito: compare una riga in `audit_visits`. Aprirlo da admin loggato: **non** compare. Premere "Scarica PDF": compare una riga `print`
|
||||
9. Cmd+P sul documento: interruzioni di pagina corrette, slider impilato in due immagini, nessun marcatore di lavorazione sopravvissuto, e in **scala di grigi** impatti e metriche restano distinguibili
|
||||
10. Rete disattivata dopo il primo caricamento: i font restano quelli giusti (prova che sono self-hostati)
|
||||
11. I test 3, 4, 5, 8, 12 e 13 del piano approvato — blocchi condizionali per livello, salvataggio a metà, re-sync degli id dei figli, riordino, congelamento del template, e nessuna voce `registro='volume'` su un brand premium
|
||||
|
||||
⚠️ Playwright non funziona contro `npm run dev` (la CSP blocca `eval`). Vale solo se un giorno servirà un E2E sul portale: usare il build di produzione.
|
||||
@@ -0,0 +1,319 @@
|
||||
# Modifiche all'hub — dashboard, progetti, pipeline
|
||||
|
||||
## Contesto
|
||||
|
||||
Il 2026-08-18 hai elencato una serie di modifiche all'hub divise per sezione
|
||||
(dashboard, progetti, pipeline). La sessione si è fermata sul limite settimanale
|
||||
subito dopo aver lanciato l'esplorazione, e quel lavoro non è mai stato ripreso.
|
||||
Questo piano lo raccoglie.
|
||||
|
||||
Il punto di partenza è che **quasi tutto quello che chiedi è calcolabile con i
|
||||
dati che l'hub già ha**: le categorie di offerta esistono, le fasi e i task hanno
|
||||
uno stato, i pagamenti hanno una data di incasso, le conversazioni sono già
|
||||
aggregate. Serve poco schema nuovo — una sola migration additiva — e molta
|
||||
query + UI.
|
||||
|
||||
L'eccezione è l'ingresso da Whop per gli audit: dipende dal motore di analisi
|
||||
della v2.5, che è fermo alle sole fonti. Hai scelto di fare **prima le modifiche
|
||||
hub e poi il motore**, quindi quel blocco resta documentato ma non costruito.
|
||||
|
||||
### Decisioni prese
|
||||
|
||||
| Domanda | Scelta |
|
||||
|---|---|
|
||||
| Sequenza | Hub prima, motore audit dopo |
|
||||
| Fonte "Audit" negli analytics | È la tua **Entry Offer** — si legge da `offer_macros.category`, come Signature e Retainer |
|
||||
| Data di consegna attesa | **Derivata** da offerta + durata, con **override manuale** opzionale |
|
||||
| Ingresso lead | TidyCal + form sito — vedi il blocco C, dove c'è un vincolo che cambia le carte |
|
||||
|
||||
---
|
||||
|
||||
## Blocco A — Progetti
|
||||
|
||||
Il blocco a rischio più basso e a resa più immediata. Da fare per primo.
|
||||
|
||||
### A1 · Togliere il tab Commenti e il timer dalla lista
|
||||
|
||||
Due rimozioni chieste esplicitamente.
|
||||
|
||||
- **Tab "Commenti"** in [page.tsx:75](src/app/admin/projects/[id]/page.tsx#L75) e
|
||||
[page.tsx:118-120](src/app/admin/projects/[id]/page.tsx#L118-L120), più l'import a riga 8.
|
||||
**Non si perde niente**: ho verificato che `buildEntityMap()` in
|
||||
[conversations-queries.ts:57](src/lib/conversations-queries.ts#L57) cammina
|
||||
clienti → progetti → fasi → task → deliverable e raccoglie *tutti* i commenti
|
||||
con la loro etichetta d'entità. `/admin/conversazioni` è un sovrainsieme
|
||||
stretto di quel tab. Il campo `comments` in `ProjectFullDetail` diventa morto:
|
||||
va tolto anche da [admin-queries.ts:567](src/lib/admin-queries.ts#L567) e dalla query.
|
||||
- **Colonna "Timer"** dalla lista progetti: `<TimerCell>` in
|
||||
[ProjectRow.tsx:85](src/components/admin/ProjectRow.tsx#L85) e l'intestazione in
|
||||
[projects/page.tsx:47](src/app/admin/projects/page.tsx#L47). Il timer resta dove
|
||||
ha senso, dentro il progetto.
|
||||
|
||||
### A2 · Mini-dashboard nel singolo progetto
|
||||
|
||||
Una striscia sopra i tab, prima di `<Tabs>` in
|
||||
[projects/[id]/page.tsx:69](src/app/admin/projects/[id]/page.tsx#L69). **Nessuna
|
||||
query nuova**: `getProjectFullDetail` restituisce già tutto.
|
||||
|
||||
**Finance** — incassato / contrattualizzato / residuo, da `payments` +
|
||||
`offersAcceptedTotal`; ore tracciate e €/h reale da `totalTrackedSeconds` e
|
||||
`targetHourlyRate` (già passato alla pagina a riga 24).
|
||||
|
||||
**Avanzamento** — task `done` su totale, con barra. Stessa aritmetica del
|
||||
`progress_pct` già usato per le fasi.
|
||||
|
||||
Riusa il pattern `MetricCard` di [admin/page.tsx:18](src/app/admin/page.tsx#L18):
|
||||
va estratto in `src/components/admin/MetricCard.tsx` e importato da entrambe.
|
||||
Scrivere la striscia **a token semantici** (`bg-card`, `text-muted-foreground`) —
|
||||
questa route è il cluster peggiore di DEBT-01 (~182 occorrenze di palette raw) e
|
||||
non va peggiorata.
|
||||
|
||||
### A3 · Timer per task e fase
|
||||
|
||||
Oggi `time_entries` ha solo `project_id`
|
||||
([schema.ts:245](src/db/schema.ts#L245)). Servono due colonne nullable.
|
||||
|
||||
- **Migration `0018`** (dettagli in fondo): `phase_id`, `task_id` su
|
||||
`time_entries`, entrambe `ON DELETE SET NULL`. Cancellare un task **non deve**
|
||||
cancellare il tempo tracciato: è storico fatturabile, l'entry ricade a livello
|
||||
progetto. `project_id` resta obbligatoria, quindi ogni entry è sempre
|
||||
attribuita.
|
||||
- **Server actions** in [timer-actions.ts](src/app/admin/timer-actions.ts):
|
||||
`startTimer` prende `phaseId`/`taskId` opzionali; stessa cosa per
|
||||
`addManualTimeEntry`. La regola "un solo timer attivo alla volta" (righe 19-38)
|
||||
resta com'è — vale globalmente, non per task.
|
||||
- **UI**: `<TimerCell>` su ogni riga task dentro
|
||||
[PhasesTab.tsx](src/components/admin/tabs/PhasesTab.tsx), con il totale della
|
||||
fase come somma dei suoi task. `TimerTab` continua a mostrare il totale progetto.
|
||||
|
||||
---
|
||||
|
||||
## Blocco B — Dashboard
|
||||
|
||||
### B1 · Analytics per linea di prodotto
|
||||
|
||||
Tre righe — **Entry (Audit), Signature, Retainer** — lette da
|
||||
`offer_macros.category`, che è già una taxonomy editabile da
|
||||
`/admin/impostazioni` ([taxonomy.ts:37](src/lib/taxonomy.ts#L37)). In produzione
|
||||
oggi ci sono esattamente quelle tre categorie.
|
||||
|
||||
Per categoria:
|
||||
|
||||
- **Iniziati questo mese** — `count(project_offers)` con `start_date` nel mese corrente.
|
||||
- **Totale anno** — `count` + somma `accepted_total` con `start_date` nell'anno.
|
||||
- **Incassato** — `payments` in stato `saldato` con `paid_at` nell'anno.
|
||||
|
||||
L'incasso ha un problema di attribuzione da risolvere esplicitamente: i pagamenti
|
||||
stanno sul **progetto**, non sull'offerta. La regola:
|
||||
|
||||
1. progetto con **una** offerta → tutto l'incasso va a quella categoria;
|
||||
2. progetto con **più** offerte → ripartito in proporzione ai rispettivi `accepted_total`;
|
||||
3. progetto **senza** offerta → riga separata **"Senza offerta"**, mostrata a video.
|
||||
|
||||
Il terzo caso non va nascosto. Oggi in prod ci sono 5 progetti e 2 righe in
|
||||
`project_offers`: la maggior parte dell'incassato finirebbe lì, e vederlo è il
|
||||
modo per accorgersene.
|
||||
|
||||
Nuovo modulo `src/lib/product-analytics.ts` (non gonfiare `analytics-queries.ts`),
|
||||
nuovo componente `src/components/admin/dashboard/ProductBreakdown.tsx`.
|
||||
|
||||
> **Nota da verificare al primo giro:** l'unica Entry Offer in produzione si
|
||||
> chiama *"Sblocca Business"*. Se l'audit è un prodotto diverso, va creata la sua
|
||||
> `offer_macro` con categoria `Entry Offer` — è configurazione, non codice.
|
||||
|
||||
### B2 · Timeline delle consegne
|
||||
|
||||
Riguarda i progetti che **si consegnano** — Entry e Signature. I retainer sono
|
||||
continuativi e non hanno una consegna: restano fuori, filtrando su
|
||||
`offer_macros.offer_type = 'una_tantum'`.
|
||||
|
||||
Per ogni progetto non archiviato:
|
||||
|
||||
- **Consegna attesa** = `projects.due_date` se valorizzata, altrimenti
|
||||
`project_offers.start_date + offer_micros.duration_months`. La colonna
|
||||
`due_date` arriva con la migration 0018 ed è l'override manuale che hai chiesto.
|
||||
- **% completamento** = task `done` / task totali.
|
||||
- **% tempo trascorso** = `(oggi − inizio) / (consegna − inizio)`.
|
||||
- **Semaforo** — soglia ±10 punti: `in linea` dentro la banda, `in ritardo` se il
|
||||
completamento è sotto, `in anticipo` se è sopra. Un progetto oltre la data di
|
||||
consegna e non completo è `in ritardo` a prescindere.
|
||||
|
||||
I progetti senza scadenza calcolabile vanno elencati sotto come **"senza
|
||||
scadenza"** invece di sparire — altrimenti un progetto senza offerta assegnata
|
||||
diventa invisibile proprio nella vista che dovrebbe segnalarlo.
|
||||
|
||||
Query in `src/lib/delivery-queries.ts`, componente
|
||||
`src/components/admin/dashboard/DeliveryTimeline.tsx`. `StatusBadge` per il
|
||||
semaforo, Geist Mono per date e percentuali.
|
||||
|
||||
### B3 · Inbox
|
||||
|
||||
**Questo esiste già.** [MessagesWidget.tsx](src/components/admin/dashboard/MessagesWidget.tsx)
|
||||
mostra i clienti con messaggi non letti, l'anteprima dell'ultimo messaggio e il
|
||||
link "Rispondi →" verso `/admin/conversazioni`. È il widget stretto in colonna
|
||||
1/3, in fondo: probabilmente non l'hai visto perché sta sotto la piega.
|
||||
|
||||
Quindi non si costruisce, si promuove:
|
||||
|
||||
- fascia **a piena larghezza in cima** alla dashboard, sopra i KPI, e solo quando
|
||||
c'è almeno un non letto;
|
||||
- 6 righe invece di 4, con **data relativa** e **etichetta dell'entità**
|
||||
(`lastMessageAt` e `lastEntityLabel` sono già in `ConversationSummary`,
|
||||
[conversations-queries.ts:8](src/lib/conversations-queries.ts#L8) — oggi non
|
||||
vengono usati);
|
||||
- anteprima più lunga del messaggio.
|
||||
|
||||
Nuovo layout in [admin/page.tsx](src/app/admin/page.tsx): Inbox → KPI →
|
||||
Timeline consegne → Prodotti → il resto.
|
||||
|
||||
---
|
||||
|
||||
## Blocco C — Pipeline: ingresso lead
|
||||
|
||||
### Il vincolo che cambia le carte
|
||||
|
||||
**TidyCal non ha webhook.** È scritto nella loro FAQ: nessun supporto nativo, e
|
||||
la strada suggerita è passare da Zapier / Make / Pabbly. Quello che TidyCal *ha*
|
||||
è una REST API con OAuth 2.0 e **Personal Access Token disponibile su tutti i
|
||||
piani** (si crea da `tidycal.com/integrations/oauth`), con endpoint sulle
|
||||
prenotazioni.
|
||||
|
||||
Elementor Pro, invece, ha un'azione **Webhook** nativa in "Actions After Submit":
|
||||
si incolla un URL e lui fa POST dei campi del form. Se invece andate su Astro,
|
||||
il POST lo scrivete voi. In entrambi i casi è una chiamata in ingresso.
|
||||
|
||||
Da qui la forma della soluzione: **un endpoint solo, sia per il form che per
|
||||
TidyCal**, e per TidyCal un pezzo in più che va a prendersi le prenotazioni.
|
||||
|
||||
### C1 · Endpoint di ingresso — `POST /api/webhooks/lead`
|
||||
|
||||
Un solo endpoint, indipendente da chi chiama.
|
||||
|
||||
- **Guardia**: header `x-webhook-secret` confrontato con una env nuova
|
||||
(`LEAD_WEBHOOK_SECRET`), stesso pattern di
|
||||
[validate-slug/route.ts:7](src/app/api/internal/validate-slug/route.ts#L7) —
|
||||
ma **senza** la scorciatoia "se non è configurato passa": questa route è
|
||||
esposta a internet, secret assente deve voler dire 403.
|
||||
- **Rate limit** con `rateLimit()` da [rate-limit.ts](src/lib/rate-limit.ts),
|
||||
bucket per IP. `src/proxy.ts` **non intercetta** `/api/*` (matcher a riga 120),
|
||||
quindi la difesa è tutta dentro la route.
|
||||
- **Payload** validato con Zod: `name` obbligatorio, `email`/`phone`/`company`/
|
||||
`notes` opzionali, più un `source` libero. Mappatura campi in un modulo a
|
||||
parte così aggiungere una sorgente non tocca la route.
|
||||
- **Effetto**: crea un `lead` in stato `contacted` riusando le funzioni di
|
||||
[lead-service.ts](src/lib/lead-service.ts). Se l'email esiste già, aggiorna
|
||||
`last_contact_date` invece di duplicare.
|
||||
|
||||
Questo è tutto ciò che serve per Elementor, ed è pronto in anticipo per Astro:
|
||||
la decisione Elementor-vs-Astro **non blocca niente**, perché il contratto è
|
||||
un POST JSON in entrambi i casi.
|
||||
|
||||
### C2 · TidyCal — sincronizzazione a polling
|
||||
|
||||
Senza webhook, l'unico modo pulito senza terze parti è andare a leggere.
|
||||
|
||||
- `src/lib/tidycal.ts` — client con Personal Access Token in
|
||||
`TIDYCAL_ACCESS_TOKEN`, che chiede le prenotazioni create dall'ultimo giro.
|
||||
- `GET /api/internal/tidycal-sync` — protetta da `INTERNAL_SECRET`, come le due
|
||||
internal esistenti. Per ogni prenotazione nuova crea un lead con nome, email e
|
||||
la data della call in `next_action_date`, e registra un'`activity` di tipo
|
||||
`meeting`. Idempotente sull'id TidyCal, così rilanciarla non duplica.
|
||||
- **Schedulazione**: Coolify ha gli Scheduled Tasks. Un `curl` ogni 15 minuti
|
||||
verso quella route. Non serve infrastruttura cron nell'app — e infatti nel
|
||||
repo non ce n'è.
|
||||
|
||||
**Prima di scrivere il client va guardata la documentazione vera dell'API**, che
|
||||
è dietro login (`tidycal.com/integrations` → API Keys) e non è indicizzata: forma
|
||||
esatta dell'endpoint prenotazioni, filtro per data, paginazione. È il primo passo
|
||||
del blocco, non un dettaglio: vale la regola già scritta in memoria — *ogni
|
||||
estrazione da API di terzi va vista funzionare, non dedotta dai docs*.
|
||||
|
||||
Se l'API risultasse inadatta, il ripiego è Zapier/Make che POSTa su C1, che a
|
||||
quel punto esiste già.
|
||||
|
||||
### C3 · "Alleggerire l'hub"
|
||||
|
||||
Questa richiesta non ha ancora un perimetro. Non la pianifico al buio: quando i
|
||||
blocchi A e B sono in produzione ti porto l'elenco di cosa è davvero poco usato
|
||||
(`/admin/quotes` e `/admin/preventivi` convivono, `service_catalog` e
|
||||
`offer_services` sono legacy da DEBT-02) e decidi tu cosa togliere. Cancellare
|
||||
route è irreversibile e va fatto guardando, non indovinando.
|
||||
|
||||
---
|
||||
|
||||
## Blocco D — Whop → audit *(rinviato, dipende dal motore)*
|
||||
|
||||
L'ingresso Whop è già predisposto nello schema: `audits.origin` accetta
|
||||
`'whop'` e `audits.external_ref` tiene il riferimento esterno
|
||||
([schema.ts:688-690](src/db/schema.ts#L688-L690)).
|
||||
|
||||
Ma "partono gli agent e fanno il lavoro" oggi **non è possibile**: in
|
||||
`src/lib/audit/` ci sono solo le cinque fonti; agent, sintetizzatore e pipeline
|
||||
(AUD-06 → AUD-11) non sono scritti. Costruire il webhook adesso significa
|
||||
costruire un innesco che non innesca niente.
|
||||
|
||||
Quando il motore c'è, il blocco è: `POST /api/webhooks/whop` con verifica firma →
|
||||
crea cliente + progetto con la Entry Offer assegnata (così l'audit compare
|
||||
automaticamente in B1 e B2) → crea l'`audit` con `origin='whop'` → accoda la run.
|
||||
|
||||
---
|
||||
|
||||
## Migration 0018 — additiva
|
||||
|
||||
`src/db/migrations/0018_timer_scope_and_due_date.sql`, in parallelo alle stesse
|
||||
modifiche in [schema.ts](src/db/schema.ts) (drizzle-kit generate è rotto, i due
|
||||
file si tengono in pari a mano).
|
||||
|
||||
```
|
||||
ALTER TABLE time_entries ADD COLUMN phase_id text REFERENCES phases(id) ON DELETE SET NULL;
|
||||
ALTER TABLE time_entries ADD COLUMN task_id text REFERENCES tasks(id) ON DELETE SET NULL;
|
||||
CREATE INDEX time_entries_phase_idx ON time_entries(phase_id);
|
||||
CREATE INDEX time_entries_task_idx ON time_entries(task_id);
|
||||
ALTER TABLE projects ADD COLUMN due_date timestamptz;
|
||||
```
|
||||
|
||||
Solo `ADD COLUMN` e `CREATE INDEX`: nessun DROP, nessun TRUNCATE, nessuna colonna
|
||||
rimossa — conforme a Data Safety (LOCKED). Le righe esistenti di `time_entries`
|
||||
restano valide con le due colonne a NULL, cioè "tempo di progetto".
|
||||
|
||||
**Ordine di applicazione**, come da CLAUDE.md: prima a produzione via
|
||||
`ssh root@178.104.27.55` + `docker exec`, **poi** il push del codice che le usa.
|
||||
|
||||
---
|
||||
|
||||
## Ordine di esecuzione
|
||||
|
||||
1. **A1** — le due rimozioni (nessuna dipendenza, effetto immediato)
|
||||
2. **Migration 0018** in produzione
|
||||
3. **A3** timer su task/fase · **A2** mini-dashboard progetto
|
||||
4. **B3** inbox (piccola) → **B1** analytics prodotto → **B2** timeline consegne
|
||||
5. **C1** endpoint lead → **C2** TidyCal, dopo aver letto l'API vera
|
||||
6. **C3** e **D** dopo, con le informazioni che oggi non abbiamo
|
||||
|
||||
Commit per unità logica, come negli ultimi quattro.
|
||||
|
||||
## Verifica
|
||||
|
||||
Non c'è suite di test in questo progetto: **`npm run build` è la verifica di
|
||||
riferimento** (fa il typecheck), più `npm run lint`.
|
||||
|
||||
Controlli manuali, in ordine:
|
||||
|
||||
- **A1** — il progetto non mostra più il tab Commenti; gli stessi commenti si
|
||||
vedono ancora in `/admin/conversazioni` con la loro etichetta d'entità; la
|
||||
lista progetti non ha più la colonna Timer.
|
||||
- **A3** — timer avviato su un task: `time_entries` ha `task_id` valorizzato; il
|
||||
totale della fase è la somma dei suoi task; il totale progetto non cambia.
|
||||
Poi cancellare quel task e verificare che **l'entry sopravviva** con
|
||||
`task_id` a NULL.
|
||||
- **A2 / B1 / B2** — confrontare ogni numero a video con la stessa query lanciata
|
||||
a mano su prod via `psql`. Su un dataset di 5 progetti si controllano tutti a
|
||||
occhio; è l'unico momento in cui questo è ancora possibile.
|
||||
- **B2** — mettere una `due_date` manuale su un progetto e verificare che vinca
|
||||
sulla data derivata dall'offerta.
|
||||
- **C1** — `curl` con secret giusto (lead creato), con secret sbagliato (403),
|
||||
senza secret (403), e ripetuto per verificare che non duplichi.
|
||||
- Dual light/dark su ogni schermata nuova, e nessuna classe palette raw.
|
||||
|
||||
Il portale cliente non va toccato da nessuna di queste modifiche: `client-view.ts`
|
||||
non si tocca, quindi il vincolo LOCKED #2 non è in gioco.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Regola: la memoria di progetto si aggiorna, sempre
|
||||
|
||||
`.planning/STATE.md` è la fonte di verità su **dove siamo**. È rimasto fermo dal 2026-06-21 al 2026-07-28 mentre venivano chiusi un audit di sicurezza, una riorganizzazione della cartella e mezzo design system: chi riapriva il progetto leggeva uno stato falso. Questa regola esiste per impedire che si ripeta.
|
||||
|
||||
## Quando aggiornare
|
||||
|
||||
Dopo **ogni** unità di lavoro conclusa — una fase, una migration applicata, un fix deployato, una decisione presa che cambia la rotta. Non a fine milestone: a fine cosa.
|
||||
|
||||
## Cosa scrivere in `.planning/STATE.md`
|
||||
|
||||
- **Frontmatter**: `last_updated` (ISO, data reale), `last_activity`, `status`, `progress`.
|
||||
- **Current Position**: fase, stato, e soprattutto **se qualcosa blocca**.
|
||||
- **Blocchi**: marcati `[BLOCCANTE]`, con *cosa* manca e *chi/cosa* lo sblocca. Un blocco non scritto è un blocco che si riscopre a caro prezzo.
|
||||
- **Lezioni**: quando un approccio si rivela sbagliato, scrivere *perché* falliva, non solo cosa si è fatto al suo posto. Serve a non riprovarci fra due mesi.
|
||||
- **Date assolute**, mai "ieri" o "la settimana scorsa".
|
||||
|
||||
## Cosa scrivere nella memoria persistente
|
||||
|
||||
`~/.claude/projects/-Users-simonecavalli-Vault-IAMCAVALLI-hub/memory/` — un file per fatto, più la riga di indice in `MEMORY.md`.
|
||||
|
||||
⚠️ **La chiave finisce in `-hub`.** Quella senza suffisso (`…-Vault-IAMCAVALLI/memory/`) è la memoria del *workspace*, un altro posto con altri file: scriverci un fatto di ClientHub significa non ritrovarlo più, perché a inizio sessione qui viene iniettata solo quella con `-hub`. Da non confondere nemmeno con `.claude/memory/`, che è versionata nel repo — la distinzione sta in [`../CLAUDE.md`](../CLAUDE.md).
|
||||
|
||||
Ci va quello che **non si deduce dal repo**: decisioni e il loro perché, vincoli operativi, cose che sono state provate e non funzionano. Non ci va quello che il codice già dice: struttura, cronologia dei fix, contenuto di `CLAUDE.md`.
|
||||
|
||||
Se un fatto in memoria diventa falso, **correggerlo o cancellarlo**. Una memoria sbagliata è peggio di una memoria assente.
|
||||
|
||||
## Cosa NON fare
|
||||
|
||||
- Non scrivere "completato" per lavoro che compila ma non è stato verificato. Distinguere sempre *scritto* / *testato* / *in produzione* — sono tre stati diversi e confonderli è il modo più veloce per deployare un disastro.
|
||||
- Non lasciare `STATE.md` a raccontare la milestone precedente.
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"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": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guardia-migration.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guardia-token.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "git -C \"$CLAUDE_PROJECT_DIR\" diff --quiet HEAD -- src .planning 2>/dev/null || echo 'PROMEMORIA memory-discipline: ci sono modifiche non committate in src/ o .planning/. Prima di chiudere, aggiorna .planning/STATE.md (last_updated, Current Position, blocchi marcati [BLOCCANTE]) e la memoria persistente se e cambiata una decisione. Regola: .claude/rules/memory-discipline.md'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: audit
|
||||
description: Far girare le fonti di rilevazione dell'audit sito di ClientHub su un URL e capire cosa e' stato misurato e cosa no. Da usare quando si lavora al motore audit v2.5, quando serve una rilevazione su un sito reale, quando si vuole sapere se una fonte risponde, o prima di scrivere qualsiasi pezzo del documento di audit.
|
||||
---
|
||||
|
||||
# Audit — le rilevazioni, e cosa vale come misura
|
||||
|
||||
Il motore v2.5 **non e' scritto**. Quello che esiste, e che questa skill mette in moto,
|
||||
sono le fonti di rilevazione: nessun LLM, nessun database, nessun browser headless.
|
||||
|
||||
## Due attrezzi, due domande diverse
|
||||
|
||||
| Comando | Cosa fa | Quando |
|
||||
|---|---|---|
|
||||
| `npx tsx scripts/audit-fonti.ts <url>` | Le **cinque fonti** di `src/lib/audit/sources/` — pagine, CrUX, PageSpeed, storico Wayback, segnali. Puramente meccanico | «Cosa si riesce a misurare su questo sito?» |
|
||||
| `npx tsx scripts/spike-audit.ts <url>` | Verifica le **264 voci di checklist** con Sonnet 5, poi sintetizza con Opus 5. Volutamente **isolato** da `src/`: non importa nulla, non tocca il DB | «Cosa c'e' che non va, in parole?» |
|
||||
|
||||
Opzioni comuni: `--profilo=servizi|ecommerce`, `--no-psi` (salta PageSpeed, che da solo
|
||||
vale meta' del tempo). Riferimento misurato il 2026-08-26 su `giojello.com`: cinque fonti
|
||||
su cinque, **42,7 s** in tutto, PageSpeed 34,2 s. Se una fonte tace, **quella e' la notizia** —
|
||||
va riportata, non aggirata.
|
||||
|
||||
`PAGESPEED_API_KEY` viene letta dall'ambiente o da `.env.local`. Senza, PageSpeed e CrUX
|
||||
rispondono a vuoto **senza errore**: lo script lo dice in testa, leggerlo.
|
||||
|
||||
Il grezzo finisce in `audit-fonti-<dominio>.json` (gitignorato). E' li' che ogni numero del
|
||||
documento dovra' essere rintracciabile.
|
||||
|
||||
## Le tre regole che il motore erediterà
|
||||
|
||||
Non sono preferenze. Sono gia' costate, e stanno per esteso in `STATUS.md` § Lezioni operative.
|
||||
|
||||
1. **Un numero entra solo se e' stato misurato.** Rintracciabile nel grezzo, e nel motore in
|
||||
`audit_runs.raw`. Nessun numero dedotto, arrotondato o ricordato.
|
||||
|
||||
2. **Laboratorio e campo non si fondono — la differenza *e'* il risultato.** Su giojello.com
|
||||
Lighthouse dava `server-response-time` **7 ms** e CrUX TTFB p75 **3.553 ms con l'1% nel
|
||||
verde**: il server risponde in fretta al datacenter Google e lento a tutti gli altri. Due
|
||||
numeri con lo stesso nome verrebbero fusi in uno solo — per questo il campo si chiama
|
||||
`risposta_server_ms` e non `ttfb_ms`.
|
||||
|
||||
3. **Un punteggio va sempre con la sua data.** Performance mobile 52, poi 64 sullo stesso sito
|
||||
mezz'ora dopo. Mai presentato come una costante del sito.
|
||||
|
||||
E una regola di metodo che vale per ogni fonte nuova: **ogni estrazione da un'API di terzi va
|
||||
vista funzionare, non dedotta dai docs.** Scrivendo `sources/`, due estrazioni prese dalla
|
||||
documentazione hanno restituito valori vuoti *senza errore* — `largest-contentful-paint-element`
|
||||
non esiste piu' e `configSettings.screenEmulation` non esiste affatto nelle risposte pubbliche.
|
||||
Trovate solo perche' le fonti sono state fatte girare su un sito vero.
|
||||
|
||||
## Perche' niente browser headless
|
||||
|
||||
Misurato il 2026-08-17 sul VPS: **1,5 GB di RAM liberi su 3,8**, Chromium ne prende 500 MB–1 GB
|
||||
→ OOM kill sui servizi con dati reali. Verificato il 2026-08-18 che non serve comunque: 153 voci
|
||||
si verificano sul DOM renderizzato che PageSpeed restituisce, e lo screenshot buono e'
|
||||
`final-screenshot`, non `fullPageScreenshot`.
|
||||
|
||||
## Lo stato di v2.5, per non ripartire dal posto sbagliato
|
||||
|
||||
**In pausa dal 2026-08-19** per scelta: prima le modifiche all'hub, poi il motore.
|
||||
|
||||
| Pezzo | Dove | Stato |
|
||||
|---|---|---|
|
||||
| Schema, 7 tabelle + rubrica 264 voci | `0017_audits.sql`, `checklist_items` | in produzione |
|
||||
| Le cinque fonti | `src/lib/audit/sources/` | **in prod ma inerti**: nessuna route le chiama |
|
||||
| Agent, sintetizzatore, pipeline, editor, pagina | `src/lib/audit/`, `src/app/{admin/audit,audit}` | **da scrivere** |
|
||||
|
||||
I piani stanno in [`../../plans/`](../../plans/): `v2.5-audit-documento.md` (i tre livelli
|
||||
Radiografia / Prima-Dopo / Rotta come configurazioni di **un unico** documento) e
|
||||
`v2.5-audit-motore.md` (raccolta in parallelo → quattro sub-agent → sintetizzatore che
|
||||
**incrocia** le osservazioni in massimo 10 finding).
|
||||
|
||||
## L'agente, quando lo progetteremo
|
||||
|
||||
Va in `.claude/agents/audit-*.md`, non qui. Non esiste ancora e non va abbozzato: il design
|
||||
dei quattro sub-agent cambiera' scrivendo il motore. Quando nascera' eredita le tre regole
|
||||
qui sopra — sono il perimetro, non il contorno.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: preventivo
|
||||
description: Lavorare sulla pipeline che genera i preventivi di ClientHub (src/lib/proposal/) senza romperla. Da usare quando si modifica il prompt, lo schema Zod, il montaggio o la resa di un preventivo, quando una generazione fallisce con "Contenuto AI non valido", quando una sezione del documento esce vuota, o prima di pubblicare una proposta a un cliente.
|
||||
---
|
||||
|
||||
# Preventivo — la catena e come non spezzarla
|
||||
|
||||
Il preventivo lo genera **l'app**, da `/admin/preventivi/genera`. Questa skill non contiene
|
||||
un prompt di generazione e non ne va aggiunto uno: il prompt vive in `agent.ts` ed e' l'unico.
|
||||
Un secondo prompt qui resterebbe indietro rispetto a quello vero senza che nessuno se ne accorga.
|
||||
|
||||
## La catena: quattro file che devono dire la stessa cosa
|
||||
|
||||
```
|
||||
src/lib/proposal/agent.ts il prompt chiede N campi ─┐
|
||||
src/lib/proposal/schema.ts lo Zod pretende quegli N campi │ se non
|
||||
src/lib/proposal/assemble.ts li impacchetta con prezzi+profilo │ coincidono,
|
||||
src/components/public/proposal/ ProposalDeck + sections/ li resa ─┘ si rompe
|
||||
```
|
||||
|
||||
**Si tocca il prompt, si riapre lo schema. Sempre.** I due fallimenti hanno forma diversa e
|
||||
solo uno si vede subito:
|
||||
|
||||
| Cosa hai fatto | Cosa succede |
|
||||
|---|---|
|
||||
| Campo aggiunto allo **schema**, non al prompt | `ProposalContentSchema.safeParse` fallisce → **ogni** generazione muore con «Contenuto AI non valido». Rumoroso, si scopre subito |
|
||||
| Campo aggiunto al **prompt**, non allo schema | Zod lo scarta in silenzio. Il documento esce senza quella parte, e te ne accorgi davanti al cliente |
|
||||
| Campo nello schema, nessuna `<Section>` che lo legge | Generazione verde, sezione assente. Il piu' subdolo: niente segnala l'errore |
|
||||
|
||||
Quando aggiungi un campo, il giro completo e' **quattro file**: prompt in `buildUserPrompt`,
|
||||
schema Zod, eventuale passaggio in `assembleProposal`, e la sezione in
|
||||
`src/components/public/proposal/sections/` piu' la riga in `ProposalDeck.tsx` che la monta.
|
||||
I vincoli di cardinalita' stanno nello schema (`.min(3).max(5)` sui problemi, `.length(5)` sui
|
||||
nodi del diagramma, `.min(4).max(10)` sulla matrice): se li cambi li' e non nel prompt, il
|
||||
modello continua a produrre il numero vecchio e Zod lo rifiuta.
|
||||
|
||||
## Prezzi: cosa puo' vedere il cliente
|
||||
|
||||
Vincolo **LOCKED #2** (`../../CLAUDE.md`): al cliente vanno i totali, mai le righe di prezzo.
|
||||
|
||||
`PricingSection.tsx` renderizza `tier.publicPrice ?? tier.servicesTotal` e il **nome** dei
|
||||
servizi — corretto. Ma `assembleProposal` mette in `content.offer.tiers[].services[].unitPrice`
|
||||
anche il prezzo unitario, e `ProposalDeck` e' `"use client"`: riceve l'intero `proposal` come
|
||||
prop da un server component, quindi **tutto** l'oggetto finisce serializzato nel payload RSC
|
||||
della pagina, renderizzato o no.
|
||||
|
||||
E' la stessa trappola del gate OTP annotata in `STATUS.md` — *sparire a schermo non e' sparire*.
|
||||
Prima di toccare la pagina pubblica, e prima di mandare un preventivo a un cliente che potrebbe
|
||||
aprire il sorgente:
|
||||
|
||||
```bash
|
||||
curl -s https://<host>/preventivo/<slug> > /tmp/p.html
|
||||
LC_ALL=C grep -c unitPrice /tmp/p.html # atteso a regime: 0
|
||||
```
|
||||
|
||||
Se e' > 0, la correzione non e' nascondere la sezione: e' una proiezione client-safe in
|
||||
`assemble.ts` o al confine del componente, come gia' fa `src/lib/client-view.ts` per il portale.
|
||||
|
||||
## Prima di pubblicare
|
||||
|
||||
1. **Preflight sui dati finti** — `./check-profilo.sh` da questa cartella. `profile.ts` e' uno
|
||||
**snapshot**: quello che c'e' dentro al momento della generazione finisce in
|
||||
`proposals.content` e ci resta anche se poi correggi il file.
|
||||
2. **Le citazioni sono verbatim o non sono.** Il prompt lo impone; verificarlo a campione contro
|
||||
il transcript e' il controllo che nessuna macchina fa al posto tuo. Una citazione inventata
|
||||
in un preventivo e' peggio di un preventivo senza citazioni.
|
||||
3. **Copy** — italiano, registro di `../../../../brand/voce.md`, e **nessun numero che non stia
|
||||
in `../../../../brand/prove.md` marcato divulgabile** (`../../../../.claude/rules/lingua-e-tono.md`).
|
||||
⚠️ `brand/` sta **fuori dal repo hub**: su un clone senza il workspace questo passo si salta
|
||||
dichiarandolo, non si finge di averlo fatto.
|
||||
4. **Stato** — `draft` non e' visibile (la pagina risponde «non ancora disponibile»).
|
||||
Pubblicare = `publishProposal` in `src/app/admin/preventivi/actions.ts`.
|
||||
|
||||
## Come si prova davvero
|
||||
|
||||
- `npm run build` e' **la verifica di riferimento**: non esiste test suite, e il build fa il
|
||||
typecheck. Verde qui significa «i tipi tornano», non «il documento e' giusto».
|
||||
- Per vedere l'output serve una **generazione vera** dall'admin, con un'offerta e almeno un
|
||||
transcript. In locale oggi **non si puo'**: `.env.local` non autentica piu' contro il DB
|
||||
(dal 2026-08-21, vedi `STATUS.md`). Si guarda in produzione.
|
||||
- Un preventivo si legge a schermo prima di mandarlo. «Buildato» non e' «funziona», e
|
||||
«generato» non e' «verificato».
|
||||
|
||||
## Note sulla chiamata al modello
|
||||
|
||||
`agent.ts` usa `max_tokens: 8192` per uno schema che chiede fino a 5 problemi + 5 soluzioni +
|
||||
matrice. Se la risposta viene troncata, il fallimento **non** dice «troncata»: dice
|
||||
«L'AI ha prodotto JSON non valido», e manda a cercare nel posto sbagliato. Se capita,
|
||||
controllare `message.stop_reason === "max_tokens"` prima di dare la colpa al parsing.
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preflight: src/lib/proposal/profile.ts contiene dati placeholder?
|
||||
#
|
||||
# profile.ts e' uno SNAPSHOT: finisce dentro proposals.content al momento della
|
||||
# generazione e ci resta. Un placeholder spedito una volta non si corregge piu'
|
||||
# modificando il file — va rigenerato il preventivo.
|
||||
#
|
||||
# exit 0 = pulito · exit 1 = placeholder trovati
|
||||
set -uo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
|
||||
F="$ROOT/src/lib/proposal/profile.ts"
|
||||
[ -f "$F" ] || { echo "profile.ts non trovato in $F" >&2; exit 1; }
|
||||
|
||||
# Ogni riga: etichetta|regex. Sono i segni concreti di "da riempire".
|
||||
PATTERNS=(
|
||||
'testimonianza segnaposto|→ Aggiungere'
|
||||
'nome cliente finto|"Cliente [0-9]"'
|
||||
'ruolo generico|role: "Professione"'
|
||||
'campo da compilare|// → '
|
||||
'dominio sbagliato (il brand e iamcavalli.net)|iamcavalli\.com'
|
||||
)
|
||||
|
||||
TROVATI=0
|
||||
for p in "${PATTERNS[@]}"; do
|
||||
ETICHETTA="${p%%|*}"; RE="${p#*|}"
|
||||
N=$(grep -cE "$RE" "$F" || true)
|
||||
if [ "$N" -gt 0 ]; then
|
||||
printf ' ✗ %-48s %s occorrenz%s\n' "$ETICHETTA" "$N" "$([ "$N" = 1 ] && echo a || echo e)"
|
||||
TROVATI=$((TROVATI + N))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$TROVATI" -gt 0 ]; then
|
||||
cat >&2 <<'MSG'
|
||||
|
||||
BLOCCO — profile.ts spedisce dati placeholder in ogni preventivo generato.
|
||||
|
||||
Non si riempiono a occhio: i numeri divulgabili stanno in brand/prove.md, che oggi
|
||||
e' ancora `stato: scheletro-intervista`. Finche' non sono verificati, la mossa giusta
|
||||
non e' inventare — e' non renderizzare il blocco.
|
||||
|
||||
Regola: ../../../.claude/rules/lingua-e-tono.md — «un numero affermato con sicurezza e
|
||||
mai verificato e' il modo piu' veloce per bruciare la credibilita' che il tono costruisce».
|
||||
MSG
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ✓ profile.ts pulito"
|
||||
@@ -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
|
||||
|
||||
+12
@@ -24,6 +24,12 @@
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# cache del plugin impeccable (globale), si rigenera
|
||||
.impeccable/
|
||||
|
||||
# cartelle di lavoro lasciate dal plugin claude-security
|
||||
/CLAUDE-SECURITY-*/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
@@ -40,3 +46,9 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Output degli spike audit (dati di siti di clienti, non vanno committati)
|
||||
spike-audit-*.json
|
||||
|
||||
# Grezzo delle rilevazioni audit (npx tsx scripts/audit-fonti.ts)
|
||||
audit-fonti-*.json
|
||||
|
||||
@@ -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.*
|
||||
@@ -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).
|
||||
@@ -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 23–25, shipped 2026-07-29)
|
||||
|
||||
**Phases completed:** 3 fasi (23–25) · 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 18–22, shipped 2026-06-20)
|
||||
|
||||
**Phases completed:** 5 phases (18–22) · 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 7–10, completato 2026-06-13)
|
||||
|
||||
+30
-19
@@ -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 (0000–0010); `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 1–6, v2.0 7–10, v2.1 11–17 (13/15/16/17 mai eseguite), v2.2 18–22, v2.3 23–25, 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 18–22)*
|
||||
*Last updated: 2026-08-08 — v2.3 archiviata, v2.4 Post-vendita corrente (Phase 13 + 26 in produzione)*
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# 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)
|
||||
- [x] **HUB-14**: Rifiniture dall'uso reale del pannello — rinomina di un valore di tassonomia con propagazione alle fasi dei progetti, stato task "In revisione", tab pagamenti con ordine stabile e importi a mano, riordino task per trascinamento. *In prod 2026-08-20, migration `0019`.*
|
||||
- [x] **HUB-15**: Portale cliente — barra di avanzamento compatta e a tutta larghezza, card offerta senza accordion, "Valore dell'offerta" con override admin (`project_offers.offer_value_override`). *In prod 2026-08-21, migration `0020`. Serviva perché la somma dei prezzi di catalogo mostrava €20.250 su offerte vendute a 7.000 e 5.500.*
|
||||
- [ ] **HUB-16**: Guardare a mano HUB-14 e HUB-15 in produzione. `updateOfferValueOverride` **è provata** (Caruso Speaker ha un override a 7.500 messo dal pannello); restano da cliccare `reorderTasks`, `updatePaymentField`, `clearPaymentOverride` e il drag, e dal 2026-08-21 la verifica locale contro i dati veri non è più possibile
|
||||
|
||||
## Backlog (ereditato, nessuno in corso)
|
||||
|
||||
- [ ] **SEND-01 / SEND-02** — Invio del link `/preventivo/[slug]` via email. Il mailer è già in produzione dalla v2.3: manca il pulsante e l'action. *Rinviati il 2026-07-28.*
|
||||
- [ ] **PROP-03** — Stripe Payment Link sul deck pubblico del preventivo.
|
||||
- [ ] **PROP-04** — Auto-provisioning cliente / progetto / fasi al passaggio del lead a "Vinto".
|
||||
- [ ] **RET-06** — Canoni mensili tracciabili. **Serve una tabella nuova**: `payments` è protetta dai vincoli di Data Safety.
|
||||
- [ ] **OFFER-14** — Sezioni analitiche stile Notion sull'offerta.
|
||||
- [ ] **ARCH-01** — Split del modulo "compartimento stagno" in un deploy separato. *Solo se cresce.*
|
||||
- [ ] **DEBT-01** — Debito design: ~40 file, ~450 occorrenze di palette raw/hex al posto dei token. Cluster in `/admin/projects/[id]` (~182), `/admin/offers/[id]/edit` (~79), `/admin/clients/[id]` (~59), `/quote/[token]` (~48, ed è rivolto al cliente), `ChatPanel` (37), `ui/dialog.tsx`. *Misurato il 2026-08-08.*
|
||||
- [ ] **DEBT-02** — Tabelle legacy `service_catalog` / `offer_services` / `offer_micro_services`; `createService` / `serviceSchema` dead code.
|
||||
|
||||
## Rinviati esplicitamente da v2.5
|
||||
|
||||
- **Allegato tecnico** — seconda vista sugli stessi finding con registro da sviluppatore (selettori, file, stime). Renderebbe vera la promessa del blocco 9 *"il documento resta tuo e puoi darlo a chiunque lavorerà sul sito"*. Da valutare **dopo il primo audit consegnato**.
|
||||
- **Affettare lo screenshot a pagina intera** — richiede `sharp`, da verificare su `node:20-alpine`. Non serve in fase 1: `final-screenshot` (250×498) è leggibile.
|
||||
- **Ingresso via webhook Whop** — schema predisposto, costruzione in fase 2.
|
||||
|
||||
## Aperto, non un requisito
|
||||
|
||||
**`.env.local` da riallineare a Coolify.** `ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la
|
||||
password del DB** sono stale, e l'host che il file dichiara (`178.104.27.55:5432`) è chiuso
|
||||
dal firewall — il DB vero è su `127.0.0.1:54321` dietro tunnel. Finché resta così **una
|
||||
modifica al portale si verifica solo in produzione**: build, migration e query di controllo,
|
||||
poi occhio umano sul sito. Recuperare la password viva dal container è bloccato dal
|
||||
classifier dei permessi e non va aggirato: la sblocca l'utente copiando le variabili da
|
||||
Coolify. Le migration non ne soffrono (`docker exec` non usa quelle credenziali).
|
||||
|
||||
**Whitelist del portale vuota per 3 clienti su 4.** La migration 0015 ha seedato solo
|
||||
`mario@test.it`. Protocollo Estetico, Caruso Speaker e Teckell hanno whitelist vuota e
|
||||
finché lo è **il loro portale non è accessibile**. Si popola da `/admin/clients/<id>` →
|
||||
"Accessi al portale", poi va reinviato il link.
|
||||
|
||||
## Fuori scope
|
||||
|
||||
- Tabella utenti / multi-admin: l'auth resta una singola credenziale da env.
|
||||
- File hosting per i documenti del portale cliente: restano URL esterni (LOCKED #5, non emendato per quelli).
|
||||
+70
-16
@@ -4,9 +4,11 @@
|
||||
|
||||
- ✅ **v1.0 Client Portal & Offer System** — Phases 1–6 (shipped 2026-06-10) — [archive](milestones/v1.0-ROADMAP.md)
|
||||
- ✅ **v2.0 Business Operations Suite** — Phases 7–10 (shipped 2026-06-13) — [archive](milestones/v2.0-ROADMAP.md)
|
||||
- ✅ **v2.1 Offer Studio + CRM** — Phases 11–14 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 18–22 (shipped 2026-06-20) — [archive](milestones/v2.2-ROADMAP.md)
|
||||
- 📋 **v2.3** — prossima milestone (in definizione)
|
||||
- ✅ **v2.3 Email & Accesso** — Phases 23–25 (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 27–30 — documento di restituzione del servizio di analisi sito
|
||||
|
||||
## Phases
|
||||
|
||||
@@ -14,10 +16,10 @@
|
||||
<summary>✅ v1.0 + v2.0 + v2.1 (Phases 1–17) — SHIPPED / CHIUSE</summary>
|
||||
|
||||
Vedi archivi:
|
||||
|
||||
- `milestones/v1.0-ROADMAP.md` — Phases 1–6
|
||||
- `milestones/v2.0-ROADMAP.md` — Phases 7–10
|
||||
- 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 23–25) — 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 27–30) · *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 28–30 sono **derivati dalle sezioni §7/§8/§9 del piano approvato**, non ancora
|
||||
> passati da `/gsd-plan-phase`. La numerazione riprende da 27 perché 15–17 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 (15–17) sono fasi abbandonate o ri-scopate, non fasi mancanti.
|
||||
|
||||
| Phase | Milestone | Plans | Status | Completed |
|
||||
|-------|-----------|-------|--------|-----------|
|
||||
| 1–6. Foundation → UX Overhaul | v1.0 | 24/24 | ✅ Done | 2026-06-10 |
|
||||
| 7–10. 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 |
|
||||
| 8–10. 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 | — |
|
||||
| 16–17. Proposal AI originale | v2.1 | — | ❌ Ri-scopata in v2.2 | — |
|
||||
| 16–17. 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à).*
|
||||
|
||||
|
||||
+68
-81
@@ -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, rifiniture, chat a canali (0021), modifica messaggi + firma (0022), stati task e date pagamenti (0023) in prod; C2 (TidyCal) bloccato sulle credenziali API. Nulla del portale e' stato visto a schermo: .env.local non autentica piu', serve ?preview=1 in prod. Il riquadro 'Prossimo pagamento' non compare finche' nessuna rata ha una due_date."
|
||||
last_updated: "2026-08-26T16:10:00.000Z"
|
||||
last_activity: 2026-08-26 -- architettura .claude di hub: skill /preventivo e /audit, due hook di guardia, piani portati nel repo
|
||||
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 — tassonomie, tab pagamenti, riordino task | ✅ in prod 2026-08-20 (`0019`) |
|
||||
| Portale cliente — stepper compatto/full-width, card offerta | ✅ in prod 2026-08-21 (`0020`); override provato su Caruso Speaker |
|
||||
| Chat — canali, modifica messaggi, firma admin | ✅ in prod 2026-08-21 (`0021`, `0022`); **mai provata a mano**; menzioni rimandate, manca l'attribuzione |
|
||||
| Portale — stati task (forma, pill, legenda, «Cancellata») + date dei pagamenti | ✅ in prod 2026-08-22 (`2e9bd2a`, `8b54f48`, `fe76789`, migration `0023`); **mai visto a schermo**, nessuna `due_date` ancora inserita |
|
||||
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
|
||||
|
||||
### 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
|
||||
|
||||
## Performance Metrics
|
||||
I piani della milestone sono **nel repo dal 2026-08-26**: `.claude/plans/v2.5-*.md`.
|
||||
|
||||
**Velocity:**
|
||||
| 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** |
|
||||
|
||||
- Total plans completed: 7 (v2.1)
|
||||
- Average duration: —
|
||||
- Total execution time: —
|
||||
## Come funziona il motore
|
||||
|
||||
**By Phase:**
|
||||
|
||||
| Phase | Plans | Total | Avg/Plan |
|
||||
|-------|-------|-------|----------|
|
||||
| Phase 11 P01 | 25min | 2 tasks | 6 files |
|
||||
| 11 | 4 | - | - |
|
||||
| 14 | 3 | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
- Last 5 plans: —
|
||||
- Trend: —
|
||||
|
||||
*Updated after each plan completion*
|
||||
| Phase 11 P02 | 12min | 2 tasks | 2 files |
|
||||
| Phase 11 P03 | 9min | 2 tasks | 2 files |
|
||||
| Phase 11 P04 | 12min | 2 tasks | 4 files |
|
||||
Raccolta in parallelo (nessun LLM, nessun browser headless) → quattro sub-agent →
|
||||
sintetizzatore che **incrocia** le osservazioni in massimo 10 finding. Vincolo che
|
||||
regge tutto: **un numero entra solo se misurato**, rintracciabile in `audit_runs.raw`.
|
||||
Passo per passo in `STATUS.md` e in `.claude/plans/v2.5-audit-motore.md`.
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
Decisions are logged in PROJECT.md Key Decisions table.
|
||||
Recent decisions affecting current work:
|
||||
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
|
||||
|
||||
- **[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-22] Un task cancellato esce dai denominatori, non dalla lista** — resta visibile barrato (il cliente ha letto quella voce e deve capire che fine ha fatto) ma non conta, via un solo `countsTowardProgress()` in `task-status.ts`: contarlo terrebbe la fase sotto il 100% per sempre, contarlo come fatto racconterebbe una consegna mai avvenuta. Se in una fase restano solo cancellati torna «Da iniziare»: degenere, ma «Completata» mentirebbe.
|
||||
- **[2026-08-22] Le date dei pagamenti sì, gli importi per riga no** — LOCKED #2 parla di cifre, non di date. Salvate a **mezzogiorno UTC** (a mezzanotte il giorno civile a Roma è già quello dopo) e contate sui giorni civili a Roma in `src/lib/payment-dates.ts`, lo stesso modulo del futuro promemoria email: mail e portale non devono contraddirsi su quanti giorni mancano.
|
||||
- **[2026-08-20] Gli importi scritti a mano non si ricalcolano** — `amount_locked` esclude la riga da `rescalePayments`, e lo scarto fra somma rate e totale si dichiara invece di aggiustarlo. Il backfill dell'ordine rate va per `percent DESC`, non per `ctid`: 3 progetti su 5 erano già scombinati e l'ordine fisico avrebbe fissato l'errore.
|
||||
- **[2026-08-20] Rinominare una fase rinomina anche le fasi dei progetti** — non c'è FK fra tassonomia e `phases`: `importOfferIntoProject` riconosce una fase solo dal titolo (`offer_phase_id` non viene mai popolata). Senza propagazione, il re-import di un'offerta crea una fase duplicata accanto a quella vecchia. È l'unico rename che scrive fuori dal catalogo, quindi l'unico con conferma.
|
||||
- **[2026-08-19] Prima l'hub, poi il motore** — le modifiche all'hub sono indipendenti e a basso rischio, il motore no. Il Whop → audit resta ultimo perché dipende dal motore.
|
||||
- **[2026-08-19] L'incassato non attribuibile si mostra, non si spalma** — i pagamenti stanno sul progetto, non sull'offerta. Un progetto senza offerta finisce in una riga "Senza offerta" separata: spalmarlo darebbe un totale che quadra e righe che mentono.
|
||||
- **[2026-08-19] Il tempo lavorato sopravvive alla cancellazione del task** — `ON DELETE SET NULL`, mai cascade: con cascade, ripulire una fase abbasserebbe in silenzio il fatturato tracciato.
|
||||
- **[2026-08-18] Audit:** design system dell'area admin; nessun renderer headless (il VPS non regge Chromium); laboratorio ≠ campo, quindi nomi distinti per Lighthouse e CrUX; la checklist alimenta il **motore**, non il documento. Per esteso in `STATUS.md`.
|
||||
|
||||
### Blockers/Concerns
|
||||
|
||||
- **Migrations (sempre valido)**: ogni fase con schema (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 del template v1 non ha fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi da recuperare prima di Phase 30.
|
||||
- **Audit, da vedere sul campo:** il caso "zero dati CrUX" (test 5) e quanto del 52% non verificabile da HTML statico recuperi Lighthouse (test 3). **Whitelist portale vuota per 3 clienti su 4** — si popola da `/admin/clients/<id>`.
|
||||
- **`.env.local` NON è allineato a Coolify**: `ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la password del DB** sono stale, e l'host che scrive (`178.104.27.55:5432`) è chiuso — il DB vero è su `127.0.0.1:54321` dietro tunnel SSH. Estrarre la password viva dal container è **bloccato dal classifier** e non va aggirato. Rendere in locale contro i dati veri **oggi non si può** (2026-08-21); sblocca: l'utente riallinea il file alle variabili di Coolify. Le migration non ne soffrono, e resta valido il resto della procedura: **ogni fase con schema applica la migration a prod prima del push del codice**.
|
||||
- **Debito design (DEBT-01)** — ~40 file, ~450 occorrenze. Dettaglio in `STATUS.md`.
|
||||
|
||||
## Deferred Items
|
||||
|
||||
Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
| Category | Item | Status | Deferred At |
|
||||
|----------|------|--------|-------------|
|
||||
| v2 | 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-26
|
||||
Stopped at: **stato task «Cancellata» + date dei pagamenti nel portale** (`fe76789`, migration `0023` applicata a prod prima del push). Un task tolto dal lavoro ora ha dove stare: **X nel cerchio, titolo barrato**, pill «Cancellata» — l'unico stato chiuso con la pill, perché «Fatto» e «Cancellata» sono entrambi barrati e scambiarli significa credere consegnato ciò che non esiste. Esce da **tutti** i denominatori via `countsTowardProgress()`. Nel Kanban cliente la colonna compare solo se piena (mai nascosta se ha dentro qualcosa); nell'admin c'è sempre, ed è così che si cancella un task. Lato pagamenti: `payments.due_date` (nullable, più indice parziale per il futuro promemoria email), riquadro «Prossimo pagamento» con conto alla rovescia in parole e rosso se scaduto, «Scade il…» / «Pagato il…» su ogni riga, **zero importi**. Admin: campo Scadenza per rata, «Incassato nel mese» → «Incassato il» (giorno; le analytics raggruppano per mese e non se ne accorgono). Build, typecheck e lint verdi.
|
||||
**2026-08-26 — architettura `.claude/`**: skill `/preventivo` e `/audit`, due hook di guardia, piani nel repo. Nessun tocco al prodotto. Le due cose trovate e non risolte sul preventivo → `STATUS.md`.
|
||||
|
||||
Next: (1) verificare in prod con `?preview=1` **entrambe** le cose: stati task su **Caruso Speaker, fase «3 - Esecuzione»** (l'unica con «In corso» e «In revisione» insieme, 4 + 2 su 11) e box pagamenti — ma prima **inserire una scadenza** dal tab Pagamenti, altrimenti il riquadro non compare per definizione; (2) due `paid_at` storici valgono il primo del mese (2026-03-01, 2026-01-01, scritti dal vecchio selettore a mese) e il cliente ora li legge come «Pagato il 1 mar 2026»: correggerli se il giorno vero era un altro; (3) provare la chat in prod: modificare un messaggio admin e vederlo cambiare da solo entro ~20s senza duplicarsi; (4) sbloccare TidyCal con token o documentazione; (5) `LEAD_WEBHOOK_SECRET` su Coolify, senza cui `/api/webhooks/lead` risponde 403 a tutti; (6) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`.
|
||||
Resume file: None
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Archivio milestone v2.1 — Offer Studio + CRM
|
||||
|
||||
**Fasi previste:** 11–17 · **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 11–15) prima di Proposal AI (fasi 16–17): 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) |
|
||||
| 16–17 | Proposal AI (impianto originale) | — | ♻️ Ri-scopate in v2.2 (fasi 21–22) |
|
||||
|
||||
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 (1280–1440px). 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 883–942 — `LeadWithTags` type, `getLeadsWithTags()` query (all fields), `LeadFieldOptions`
|
||||
- `src/db/schema.ts` lines 441–462 — `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)
|
||||
@@ -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*
|
||||
@@ -0,0 +1,99 @@
|
||||
# Archivio milestone v2.3 — Email & Accesso
|
||||
|
||||
**Fasi:** 23–25 · **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`.
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user