Compare commits
57 Commits
v2.2
...
571f58bff8
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,28 @@
|
||||
# Regola: la memoria di progetto si aggiorna, sempre
|
||||
|
||||
`.planning/STATE.md` è la fonte di verità su **dove siamo**. È rimasto fermo dal 2026-06-21 al 2026-07-28 mentre venivano chiusi un audit di sicurezza, una riorganizzazione della cartella e mezzo design system: chi riapriva il progetto leggeva uno stato falso. Questa regola esiste per impedire che si ripeta.
|
||||
|
||||
## Quando aggiornare
|
||||
|
||||
Dopo **ogni** unità di lavoro conclusa — una fase, una migration applicata, un fix deployato, una decisione presa che cambia la rotta. Non a fine milestone: a fine cosa.
|
||||
|
||||
## Cosa scrivere in `.planning/STATE.md`
|
||||
|
||||
- **Frontmatter**: `last_updated` (ISO, data reale), `last_activity`, `status`, `progress`.
|
||||
- **Current Position**: fase, stato, e soprattutto **se qualcosa blocca**.
|
||||
- **Blocchi**: marcati `[BLOCCANTE]`, con *cosa* manca e *chi/cosa* lo sblocca. Un blocco non scritto è un blocco che si riscopre a caro prezzo.
|
||||
- **Lezioni**: quando un approccio si rivela sbagliato, scrivere *perché* falliva, non solo cosa si è fatto al suo posto. Serve a non riprovarci fra due mesi.
|
||||
- **Date assolute**, mai "ieri" o "la settimana scorsa".
|
||||
|
||||
## Cosa scrivere nella memoria persistente
|
||||
|
||||
`~/.claude/projects/-Users-simonecavalli-Vault-IAMCAVALLI/memory/` — un file per fatto, più la riga di indice in `MEMORY.md`.
|
||||
|
||||
Ci va quello che **non si deduce dal repo**: decisioni e il loro perché, vincoli operativi, cose che sono state provate e non funzionano. Non ci va quello che il codice già dice: struttura, cronologia dei fix, contenuto di `CLAUDE.md`.
|
||||
|
||||
Se un fatto in memoria diventa falso, **correggerlo o cancellarlo**. Una memoria sbagliata è peggio di una memoria assente.
|
||||
|
||||
## Cosa NON fare
|
||||
|
||||
- Non scrivere "completato" per lavoro che compila ma non è stato verificato. Distinguere sempre *scritto* / *testato* / *in produzione* — sono tre stati diversi e confonderli è il modo più veloce per deployare un disastro.
|
||||
- Non lasciare `STATE.md` a raccontare la milestone precedente.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__plugin_claude-mem_mcp-search__get_observations",
|
||||
"Bash(rtk tsc *)",
|
||||
"Bash(rtk git *)",
|
||||
"Bash(rtk grep *)",
|
||||
"Read(//Users/simonecavalli/.claude/get-shit-done/references/**)",
|
||||
"Skill(gsd-execute-phase)",
|
||||
"Skill(gsd-execute-phase:*)",
|
||||
"Skill(gsd-plan-phase)",
|
||||
"Skill(gsd-plan-phase:*)",
|
||||
"Skill(gsd-progress)",
|
||||
"Skill(gsd-progress:*)",
|
||||
"Skill(gsd-complete-milestone)",
|
||||
"Skill(gsd-complete-milestone:*)",
|
||||
"Read(//Users/simonecavalli/Downloads/**)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "git -C \"$CLAUDE_PROJECT_DIR\" diff --quiet HEAD -- src .planning 2>/dev/null || echo 'PROMEMORIA memory-discipline: ci sono modifiche non committate in src/ o .planning/. Prima di chiudere, aggiorna .planning/STATE.md (last_updated, Current Position, blocchi marcati [BLOCCANTE]) e la memoria persistente se e cambiata una decisione. Regola: .claude/rules/memory-discipline.md'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"impeccable@impeccable": true
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"impeccable": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "pbakaus/impeccable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,8 @@ 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>
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# cache del plugin impeccable (globale), si rigenera
|
||||
.impeccable/
|
||||
|
||||
# cartelle di lavoro lasciate dal plugin claude-security
|
||||
/CLAUDE-SECURITY-*/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
@@ -40,3 +46,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Output degli spike audit (dati di siti di clienti, non vanno committati)
|
||||
spike-audit-*.json
|
||||
|
||||
@@ -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,97 @@
|
||||
# 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.
|
||||
|
||||
## Backlog (ereditato, nessuno in corso)
|
||||
|
||||
- [ ] **SEND-01 / SEND-02** — Invio del link `/preventivo/[slug]` via email. Il mailer è già in produzione dalla v2.3: manca il pulsante e l'action. *Rinviati il 2026-07-28.*
|
||||
- [ ] **PROP-03** — Stripe Payment Link sul deck pubblico del preventivo.
|
||||
- [ ] **PROP-04** — Auto-provisioning cliente / progetto / fasi al passaggio del lead a "Vinto".
|
||||
- [ ] **RET-06** — Canoni mensili tracciabili. **Serve una tabella nuova**: `payments` è protetta dai vincoli di Data Safety.
|
||||
- [ ] **OFFER-14** — Sezioni analitiche stile Notion sull'offerta.
|
||||
- [ ] **ARCH-01** — Split del modulo "compartimento stagno" in un deploy separato. *Solo se cresce.*
|
||||
- [ ] **DEBT-01** — Debito design: ~40 file, ~450 occorrenze di palette raw/hex al posto dei token. Cluster in `/admin/projects/[id]` (~182), `/admin/offers/[id]/edit` (~79), `/admin/clients/[id]` (~59), `/quote/[token]` (~48, ed è rivolto al cliente), `ChatPanel` (37), `ui/dialog.tsx`. *Misurato il 2026-08-08.*
|
||||
- [ ] **DEBT-02** — Tabelle legacy `service_catalog` / `offer_services` / `offer_micro_services`; `createService` / `serviceSchema` dead code.
|
||||
|
||||
## Rinviati esplicitamente da v2.5
|
||||
|
||||
- **Allegato tecnico** — seconda vista sugli stessi finding con registro da sviluppatore (selettori, file, stime). Renderebbe vera la promessa del blocco 9 *"il documento resta tuo e puoi darlo a chiunque lavorerà sul sito"*. Da valutare **dopo il primo audit consegnato**.
|
||||
- **Affettare lo screenshot a pagina intera** — richiede `sharp`, da verificare su `node:20-alpine`. Non serve in fase 1: `final-screenshot` (250×498) è leggibile.
|
||||
- **Ingresso via webhook Whop** — schema predisposto, costruzione in fase 2.
|
||||
|
||||
## Aperto, non un requisito
|
||||
|
||||
**Whitelist del portale vuota per 3 clienti su 4.** La migration 0015 ha seedato solo
|
||||
`mario@test.it`. Protocollo Estetico, Caruso Speaker e Teckell hanno whitelist vuota e
|
||||
finché lo è **il loro portale non è accessibile**. Si popola da `/admin/clients/<id>` →
|
||||
"Accessi al portale", poi va reinviato il link.
|
||||
|
||||
## Fuori scope
|
||||
|
||||
- Tabella utenti / multi-admin: l'auth resta una singola credenziale da env.
|
||||
- File hosting per i documenti del portale cliente: restano URL esterni (LOCKED #5, non emendato per quelli).
|
||||
+70
-16
@@ -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à).*
|
||||
|
||||
|
||||
+65
-79
@@ -1,113 +1,99 @@
|
||||
---
|
||||
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: "Phase 27 a metà: schema in prod, fonti scritte e provate sul campo. Prossimo: src/lib/audit/schema.ts + agents/."
|
||||
last_updated: "2026-08-19T13:20:00.000Z"
|
||||
last_activity: 2026-08-19 -- v2.5 pushata su main e deployata (8000d56); motore ancora non richiamato da nessuna route
|
||||
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:** milestone **v2.5 "Audit"** —
|
||||
il servizio di analisi sito diventa un documento privato su `/audit/[slug]`.
|
||||
|
||||
## 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)
|
||||
Phase: 27 of 30 — Motore di analisi
|
||||
Plan: schema e fonti fatti; agent, sintetizzatore e pipeline da scrivere
|
||||
Status: nessun bloccante — prossimo passo `src/lib/audit/schema.ts` + `agents/`
|
||||
Last activity: 2026-08-18 — `src/lib/audit/sources/` scritto e provato su giojello.com
|
||||
|
||||
Progress (v2.2): [████░░░░░░] 40% — 2/5 fasi complete
|
||||
Progress: [███░░░░░░░] 25%
|
||||
|
||||
### Fasi completate (v2.1, storico)
|
||||
## Dove sta cosa
|
||||
|
||||
Phase 11 (catalogo), Phase 12 (offer editor), Phase 14 (CRM Attio) — consegnate e in prod. 55 servizi reali + tag offerta caricati.
|
||||
| Cosa | Dove | Stato |
|
||||
|---|---|---|
|
||||
| Schema audit (7 tabelle) | `src/db/migrations/0017_audits.sql` + `src/db/schema.ts` | **in produzione** |
|
||||
| Rubrica del motore, 264 voci | tabella `checklist_items`, sorgente `scripts/data/checklist.json` | **in produzione** |
|
||||
| Fonti del motore (5 moduli) | `src/lib/audit/sources/` | **in produzione ma inerte**: deployato, nessuna route lo chiama |
|
||||
| Agent, sintetizzatore, pipeline | `src/lib/audit/{schema,agents,pipeline}.ts` | **da scrivere** |
|
||||
| Editor admin e pagina pubblica | `src/app/admin/audit/`, `src/app/audit/[slug]/` | **da scrivere** |
|
||||
| L'unico audit prodotto finora | `spike-audit-giojello.com.json` (root, **gitignorato**) | spike del 2026-08-16, **zero rilevazioni** |
|
||||
| I due piani della milestone | `~/.claude/plans/` — `…woolly-puddle.md` (documento) + `…radiant-valley.md` (motore) | **fuori dal repo** |
|
||||
|
||||
## Come funziona il motore
|
||||
|
||||
Si incolla un URL. Nessun browser headless da nessuna parte.
|
||||
|
||||
1. **Raccolta in parallelo** (`sources/`, nessun LLM): PageSpeed, CrUX, Wayback, RDAP, robots/sitemap/JSON-LD, header. Ogni fonte fallisce in modo non fatale e dice *perché*.
|
||||
2. **Quattro sub-agent in parallelo** (`agents/`): checklist, visivo, storico, tecnico. Producono osservazioni, non finding.
|
||||
3. **Sintetizzatore** che le **incrocia**: quattro osservazioni deboli su temi diversi diventano un finding solo con quattro evidenze indipendenti. Massimo 10, per impatto.
|
||||
4. **Editor admin** per rifinire, poi consegna su `/audit/[slug]`.
|
||||
|
||||
Vincolo che regge tutto: **un numero entra solo se misurato**, e dev'essere rintracciabile in `audit_runs.raw`.
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 7 (v2.1)
|
||||
- Average duration: —
|
||||
- Total execution time: —
|
||||
|
||||
**By Phase:**
|
||||
|
||||
| Phase | Plans | Total | Avg/Plan |
|
||||
|-------|-------|-------|----------|
|
||||
| Phase 11 P01 | 25min | 2 tasks | 6 files |
|
||||
| 11 | 4 | - | - |
|
||||
| 14 | 3 | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
- Last 5 plans: —
|
||||
- Trend: —
|
||||
|
||||
*Updated after each plan completion*
|
||||
| Phase 11 P02 | 12min | 2 tasks | 2 files |
|
||||
| Phase 11 P03 | 9min | 2 tasks | 2 files |
|
||||
| Phase 11 P04 | 12min | 2 tasks | 4 files |
|
||||
**Velocity:** 21 plans completati (v2.1–v2.4). Phase 27: spike ~1h, schema ~1h, fonti ~2h.
|
||||
|
||||
## 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)
|
||||
- **[2026-08-18] Il documento usa il design system dell'area admin** — token semantici, Plus Jakarta Sans, Geist Mono per metriche e date, `StatusBadge` per gli impatti. Sostituisce la deroga tipografica del piano; i font sono già self-hostati da `next/font/google`, quindi la CSP è soddisfatta senza lavoro.
|
||||
- **[2026-08-18] Nessun renderer headless** — PageSpeed dà 153 audit sul DOM renderizzato, cioè le osservazioni visive che prima richiedevano screenshot a mano.
|
||||
- **[2026-08-18] Laboratorio ≠ campo, e la differenza è il risultato** — Lighthouse dà 7 ms di risposta server, CrUX dà TTFB p75 3.553 ms. Da qui il nome `risposta_server_ms`: con lo stesso nome il sintetizzatore li tratterebbe come un numero solo.
|
||||
- **[2026-08-17] Il VPS non regge Chromium** — RAM, non disco. È la ragione per cui l'opzione headless non torna.
|
||||
- **[2026-08-16] La checklist alimenta il MOTORE, non il documento** — se diventa il rendering della checklist, torna a sembrare un audit automatico gratuito.
|
||||
- **[2026-08-16] Immagini** — le due del redesign le carica l'utente, le due dello stato di fatto le scrive la pipeline. Emenda LOCKED #5 solo per gli asset di audit.
|
||||
|
||||
### Pending Todos
|
||||
|
||||
[From .planning/todos/pending/ — ideas captured during sessions]
|
||||
|
||||
None yet.
|
||||
### Pending Todos — nessuno (`.planning/todos/` non esiste)
|
||||
|
||||
### 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.
|
||||
- **Il copy fisso del template v1 non ha una fonte nel repo** — il prototipo Giojello non c'è. Lo *stile* ora viene dal design system, ma testi e gerarchia dei blocchi vanno recuperati prima di Phase 30.
|
||||
- **Il caso "zero dati CrUX" non è ancora stato visto** su un sito vero (test 5 del piano).
|
||||
- **Il 52% della checklist non è verificabile da HTML statico** — quanto ne recuperino gli audit Lighthouse non è ancora misurato (test 3 del piano).
|
||||
- **Whitelist portale vuota per 3 clienti su 4** — si popola da `/admin/clients/<id>`.
|
||||
- **`.env.local` punta al DB di PRODUZIONE**, non allineato a Coolify per `ADMIN_PASSWORD` / `NEXTAUTH_SECRET`.
|
||||
- **Ogni fase con schema**: migration applicata a prod **prima** del push del codice.
|
||||
- **Debito design (DEBT-01)** — ~40 file, ~450 occorrenze. Dettaglio in `STATUS.md`.
|
||||
|
||||
## Deferred Items
|
||||
|
||||
Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
| Category | Item | Status | Deferred At |
|
||||
|----------|------|--------|-------------|
|
||||
| v2 | OFFER-14 — Sezioni analitiche stile Notion (psicologia/rating/performance) | Backlog | v2.1 kickoff |
|
||||
| v2 | AUTH-OTP-01 — Accesso dashboard cliente via OTP email | Design ready, deferred | v2.1 kickoff |
|
||||
| v2 | ARCH-01 — Split modulo "compartimento stagno" in deploy separato | Backlog (only if module grows) | v2.1 kickoff |
|
||||
Vedi `REQUIREMENTS.md` § Backlog e § Rinviati esplicitamente 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-18
|
||||
Stopped at: `src/lib/audit/sources/` (5 moduli) scritto e **provato sul campo su giojello.com** — giro completo 73 s, tutte le fonti hanno risposto. Build e lint puliti.
|
||||
Next: (1) `src/lib/audit/schema.ts` + `agents/` con validazione Zod dura; (2) `pipeline.ts` con heartbeat su `audit_runs`; (3) volume Coolify prima del deploy; (4) editor e pagina.
|
||||
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>
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
phase: 01-foundation-client-dashboard
|
||||
plan: 03
|
||||
subsystem: client-portal
|
||||
tags: [nextjs, middleware, drizzle-orm, client-view, server-component, edge-runtime]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- 01-01 (Next.js bootstrap, DATABASE_URL, Drizzle client in src/db/index.ts)
|
||||
- 01-02 (src/db/schema.ts — clients, phases, tasks, deliverables, payments, documents, notes)
|
||||
provides:
|
||||
- src/proxy.ts (Edge proxy per validazione token su /c/:path*)
|
||||
- src/app/api/internal/validate-token/route.ts (Node.js route che interroga clients.token)
|
||||
- src/lib/client-view.ts (ClientView interface + getClientView() function)
|
||||
- src/app/c/[token]/page.tsx (Server Component placeholder per dashboard cliente)
|
||||
- src/app/c/[token]/layout.tsx (Layout con metadata per rotte token-auth)
|
||||
affects:
|
||||
- 01-04-dashboard-ui (consuma ClientView interface e getClientView())
|
||||
- 01-05-seed-deploy (genera token, verifica accesso /c/[token])
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Edge proxy pattern: proxy.ts usa fetch() verso /api/internal/validate-token — nessun import Drizzle/postgres-js in Edge runtime"
|
||||
- "Next.js 16 breaking change: file convention rinominata da 'middleware' a 'proxy'; export function rinominata da 'middleware' a 'proxy'"
|
||||
- "Next.js 15+ breaking change: params in Server Component è Promise<{ token: string }> — await params prima dell'uso"
|
||||
- "ClientView enforced server-side: interface TypeScript + query function che non tocca mai quote_items o service_catalog"
|
||||
- "inArray() per scoping tasks/deliverables: previene full table scan su clienti che non appartengono alla sessione"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/proxy.ts (proxy Edge-compatible — rinominato da middleware.ts per Next.js 16)
|
||||
- src/app/api/internal/validate-token/route.ts (Node.js route, query clients.token via Drizzle)
|
||||
- src/lib/client-view.ts (ClientView interface + getClientView() — 209 righe)
|
||||
- src/app/c/[token]/page.tsx (Server Component placeholder — 28 righe)
|
||||
- src/app/c/[token]/layout.tsx (Layout con metadata — 14 righe)
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Next.js 16 richiede file 'proxy.ts' (non 'middleware.ts') ed export function 'proxy' (non 'middleware') — auto-corretto da Rule 1"
|
||||
- "params nelle Server Component Next.js 15+ è Promise<{ token: string }> — await obbligatorio (breaking change)"
|
||||
- "ClientView.payments non espone 'amount' — solo label e status — vincolo architetturale LOCKED rispettato"
|
||||
- "getClientView() usa inArray() per scopare tasks e deliverables ai soli phase_id del cliente, evitando leak cross-client"
|
||||
|
||||
# Metrics
|
||||
duration: 25min
|
||||
completed: 2026-05-14
|
||||
---
|
||||
|
||||
# Phase 1 Plan 03: Token Middleware + Client Portal Data Layer — Route /c/[token] operativa
|
||||
|
||||
**Edge proxy con validazione token, ClientView type system che esclude quote_items, e Server Component che fetcha tutti i dati cliente senza esporre segreti admin. Build Next.js 16 senza errori TypeScript.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~25 min
|
||||
- **Started:** 2026-05-13T22:50:00Z
|
||||
- **Completed:** 2026-05-14T00:15:00Z
|
||||
- **Tasks:** 3/3
|
||||
- **Files created:** 5
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `src/proxy.ts`: proxy Edge-compatible per Next.js 16 — valida token via `fetch('/api/internal/validate-token')` senza import Drizzle/postgres-js. Matcher configurato su `/c/:path*`. Token non trovato → rewrite su `/not-found`.
|
||||
- `src/app/api/internal/validate-token/route.ts`: route Node.js che interroga `clients.token` via Drizzle ORM. Ritorna `{ valid: true }` (200) o `{ valid: false }` (404/400/500). Drizzle funziona correttamente qui perché non è nel runtime Edge.
|
||||
- `src/lib/client-view.ts`: `ClientView` interface che esclude esplicitamente `quote_items`, `service_catalog` e prezzi singoli. `getClientView()` esegue 6 query scoped (client by token → phases → tasks via inArray → deliverables via inArray → payments → documents → notes). Progress % calcolata server-side. `accepted_total: client.accepted_total ?? '0'`.
|
||||
- `src/app/c/[token]/page.tsx`: Server Component con `await params`, chiama `getClientView(token)`, ritorna `notFound()` se null. ISR a 60s.
|
||||
- `src/app/c/[token]/layout.tsx`: layout minimo con metadata.
|
||||
- `npm run build` completato con successo — zero errori TypeScript, tutte le route presenti nel build output.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Edge proxy + validate-token API route** — `ef34817` (feat)
|
||||
2. **Task 2: ClientView type system + getClientView()** — `14787ba` (feat)
|
||||
3. **Task 3: /c/[token] Server Component + layout** — `8b5e723` (feat, include deviazione Rule 1)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/proxy.ts` — Edge proxy per /c/:path* (rinominato da middleware.ts — vedi Deviazioni)
|
||||
- `src/app/api/internal/validate-token/route.ts` — Node.js route, query `eq(clients.token, token)`, risposta 200/404
|
||||
- `src/lib/client-view.ts` — ClientView interface (quote_items mai inclusi) + getClientView() con inArray scoping
|
||||
- `src/app/c/[token]/page.tsx` — Server Component placeholder, notFound() su token invalido
|
||||
- `src/app/c/[token]/layout.tsx` — Layout con metadata
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Next.js 16 proxy convention:** La nuova convenzione rinomina `middleware.ts` → `proxy.ts` ed esige `export function proxy` (non `middleware`). La build ha segnalato il deprecation warning al primo tentativo. Risolto con rename + export update (Rule 1).
|
||||
- **params come Promise:** Next.js 15+ tratta `params` come `Promise<{ token: string }>` nelle Server Component. Il piano usava la sintassi Next.js 14 sincrona — aggiornato ad `await params` per conformità (Rule 1).
|
||||
- **ClientView.payments senza amount:** Il campo `amount` di `payments` è intenzionalmente omesso dalla `ClientView`. Il cliente vede solo `label` e `status`. Vincolo architetturale LOCKED rispettato a livello di query.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Next.js 16 depreca la convenzione 'middleware' in favore di 'proxy'**
|
||||
- **Found during:** Task 3 — `npm run build` ha emesso warning e poi errore: "Proxy is missing expected function export name"
|
||||
- **Issue:** Next.js 16 ha rinominato la file convention da `middleware.ts` a `proxy.ts` e richiede che la funzione esportata si chiami `proxy` (o `default`), non `middleware`.
|
||||
- **Fix:** Rinominato `src/middleware.ts` → `src/proxy.ts`; rinominato `export async function middleware` → `export async function proxy`. Il commit ef34817 conteneva middleware.ts — il Task 3 commit 8b5e723 include la modifica.
|
||||
- **Files modified:** src/proxy.ts (rinominato da src/middleware.ts)
|
||||
- **Commit:** 8b5e723
|
||||
|
||||
**2. [Rule 1 - Bug] params come Promise in Next.js 15+ Server Component**
|
||||
- **Found during:** Task 3 — il piano usava la sintassi `params: { token: string }` di Next.js 14
|
||||
- **Issue:** In Next.js 15+, i `params` nelle Server Component sono `Promise<{ token: string }>`. Usare la sintassi sincrona avrebbe causato TypeScript error e comportamento errato a runtime.
|
||||
- **Fix:** Tipizzato `params: Promise<{ token: string }>` e aggiunto `const { token } = await params;` prima dell'uso.
|
||||
- **Files modified:** src/app/c/[token]/page.tsx
|
||||
- **Commit:** 8b5e723
|
||||
|
||||
## Known Stubs
|
||||
|
||||
La `page.tsx` mostra un placeholder minimo (brand_name, brief, token). Questo è **intenzionale e documentato nel piano**: "Placeholder content — full UI in Plan 04". Il Plan 04 (Dashboard UI) sostituirà questo placeholder con la UI completa. Il goal del piano (route operativa + data fetching corretto) è raggiunto pienamente.
|
||||
|
||||
## Threat Surface Scan
|
||||
|
||||
Nessuna nuova superficie di sicurezza non prevista dal threat model 01-03:
|
||||
- T-03-001 (ClientView shape): mitigato — interface TypeScript + getClientView() non tocca mai quote_items/service_catalog
|
||||
- T-03-002 (Token parameter): mitigato — proxy valida il token prima che qualsiasi pagina venga renderizzata; token invalidi → /not-found
|
||||
- T-03-003 (DoS su getClientView): accettato — query indicizzate su client_id/token
|
||||
|
||||
L'API route `/api/internal/validate-token` è accessibile pubblicamente (nessun secret header). Questo è intenzionale: ritorna solo `{ valid: boolean }` — nessun dato cliente esposto. Un attaccante può enumerare token validi con brute force ma: (1) nanoid 21 chars offre ~126 bit di entropia, rendendo il brute force computazionalmente impossibile; (2) nessun dato sensibile è esposto dalla route.
|
||||
|
||||
## Self-Check
|
||||
|
||||
- [x] `src/proxy.ts` esiste (edge proxy — rinominato da middleware.ts)
|
||||
- [x] `src/app/api/internal/validate-token/route.ts` esiste (query clients.token via Drizzle)
|
||||
- [x] `src/lib/client-view.ts` esiste (ClientView interface + getClientView())
|
||||
- [x] `src/app/c/[token]/page.tsx` esiste (Server Component con await params)
|
||||
- [x] `src/app/c/[token]/layout.tsx` esiste
|
||||
- [x] Commit `ef34817` esiste (Task 1)
|
||||
- [x] Commit `14787ba` esiste (Task 2)
|
||||
- [x] Commit `8b5e723` esiste (Task 3)
|
||||
- [x] `npm run build` completato senza errori TypeScript
|
||||
- [x] Build output mostra `/api/internal/validate-token` (Dynamic) e `/c/[token]` (Dynamic)
|
||||
- [x] `proxy.ts` NON importa `@/db` o `drizzle-orm` (Edge safe)
|
||||
- [x] `client-view.ts` non contiene query su `quote_items` o `service_catalog`
|
||||
- [x] `accepted_total: client.accepted_total ?? '0'` presente
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 (Dashboard UI) può partire — `getClientView()` è disponibile e testa TypeScript OK
|
||||
- Import pattern: `import { getClientView, type ClientView } from '@/lib/client-view'`
|
||||
- La route `/c/[token]` è operativa — con un cliente seedato da Plan 05 sarà accessibile
|
||||
|
||||
---
|
||||
*Phase: 01-foundation-client-dashboard*
|
||||
*Completed: 2026-05-14*
|
||||
@@ -1,864 +0,0 @@
|
||||
---
|
||||
phase: "01-foundation-client-dashboard"
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-01"
|
||||
- "01-02"
|
||||
- "01-03"
|
||||
files_modified:
|
||||
- app/c/[token]/page.tsx
|
||||
- src/components/client-dashboard.tsx
|
||||
- src/components/phase-timeline.tsx
|
||||
- src/components/payment-status.tsx
|
||||
- src/components/documents-section.tsx
|
||||
- src/components/notes-section.tsx
|
||||
- src/app/globals.css
|
||||
- tailwind.config.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DASH-02
|
||||
- DASH-03
|
||||
- DASH-04
|
||||
- DASH-07
|
||||
- DASH-08
|
||||
- DASH-09
|
||||
- DASH-10
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Client dashboard displays client brand name prominently with iamcavalli logo in corner"
|
||||
- "Global progress bar at top shows % of all tasks completed"
|
||||
- "Phases are displayed as lateral timeline (left indicator, content right)"
|
||||
- "Each phase shows progress bar (% from completed tasks) + task list with status badges"
|
||||
- "Tasks are nested within phases with status visible (todo/in_progress/done)"
|
||||
- "Payment section always visible: accepted_total + Acconto 50% status + Saldo 50% status (NO amounts)"
|
||||
- "Document links are clickable (opens external URL)"
|
||||
- "Notes/decision log is visible (read-only, may be empty)"
|
||||
- "Layout is mobile-responsive and light & clean visual style"
|
||||
artifacts:
|
||||
- path: "app/c/[token]/page.tsx"
|
||||
provides: "Server Component rendering ClientDashboard"
|
||||
min_lines: 20
|
||||
- path: "src/components/client-dashboard.tsx"
|
||||
provides: "Layout wrapper + main sections (header, progress, phases, payments, documents, notes)"
|
||||
min_lines: 50
|
||||
- path: "src/components/phase-timeline.tsx"
|
||||
provides: "Lateral timeline rendering with phase cards and task lists"
|
||||
min_lines: 80
|
||||
- path: "src/components/payment-status.tsx"
|
||||
provides: "Payment section: accepted_total + 2 payment rows with status"
|
||||
min_lines: 30
|
||||
- path: "src/components/documents-section.tsx"
|
||||
provides: "List of external document links"
|
||||
min_lines: 20
|
||||
- path: "src/components/notes-section.tsx"
|
||||
provides: "Read-only notes list with timestamps"
|
||||
min_lines: 20
|
||||
- path: "tailwind.config.ts"
|
||||
provides: "Light & clean design tokens (updated from bootstrap)"
|
||||
contains: "colors"
|
||||
key_links:
|
||||
- from: "app/c/[token]/page.tsx"
|
||||
to: "ClientDashboard component"
|
||||
via: "import { ClientDashboard }"
|
||||
pattern: "<ClientDashboard"
|
||||
- from: "ClientDashboard"
|
||||
to: "PhaseTimeline + PaymentStatus + DocumentsSection + NotesSection"
|
||||
via: "nested component props"
|
||||
pattern: "view\\.phases"
|
||||
- from: "PhaseTimeline"
|
||||
to: "task status badges"
|
||||
via: "status className mapping"
|
||||
pattern: "status.*todo.*in_progress.*done"
|
||||
|
||||
---
|
||||
|
||||
<objective>
|
||||
**Client Dashboard UI — Vertical Slice:** Render the complete client dashboard with all UI sections: header with branding, global progress bar, lateral phase timeline, task lists with status, payment status section, external document links, and read-only notes log. Implement light & clean visual style with mobile-first responsive design using Tailwind CSS and shadcn/ui components.
|
||||
|
||||
Purpose: Deliver the core user-facing product: a client can open their secret link and see the complete project status at a glance, with clear progress indicators, task hierarchy, payment overview, and documents.
|
||||
|
||||
Output: Fully rendered client portal with all DASH-02 through DASH-10 requirements implemented in the UI.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (Decisions D-04 through D-12)
|
||||
@.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md
|
||||
@src/lib/client-view.ts (ClientView interface)
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Configure design tokens (tailwind.config.ts + globals.css) and wire app/c/[token]/page.tsx to ClientDashboard</name>
|
||||
<files>
|
||||
tailwind.config.ts
|
||||
src/app/globals.css
|
||||
app/c/[token]/page.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
tailwind.config.ts (current bootstrap)
|
||||
src/app/globals.css (current bootstrap)
|
||||
src/components/client-dashboard.tsx (will exist after Task 2 — read after Task 2 completes)
|
||||
src/lib/client-view.ts (ClientView interface)
|
||||
</read_first>
|
||||
<action>
|
||||
Update `tailwind.config.ts` to define light & clean design tokens:
|
||||
|
||||
```typescript
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Light & clean palette
|
||||
'primary': '#1a1a1a', // deep charcoal for text
|
||||
'secondary': '#666666', // medium gray for secondary text
|
||||
'tertiary': '#999999', // light gray for hints
|
||||
'bg-light': '#ffffff', // pure white
|
||||
'bg-subtle': '#f9f9f9', // very light gray
|
||||
'border-light': '#e5e5e5', // subtle border
|
||||
'accent': '#0066cc', // blue accent (will be brand-aware in Phase 2)
|
||||
'success': '#22c55e', // green for done
|
||||
'warning': '#eab308', // yellow for in-progress
|
||||
'info': '#3b82f6', // blue for pending
|
||||
},
|
||||
spacing: {
|
||||
'xs': '0.5rem',
|
||||
'sm': '1rem',
|
||||
'md': '1.5rem',
|
||||
'lg': '2rem',
|
||||
'xl': '3rem',
|
||||
},
|
||||
fontSize: {
|
||||
'xs': '0.75rem',
|
||||
'sm': '0.875rem',
|
||||
'base': '1rem',
|
||||
'lg': '1.125rem',
|
||||
'xl': '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.875rem',
|
||||
},
|
||||
fontFamily: {
|
||||
'sans': [
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'sans-serif',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
Update `src/app/globals.css`:
|
||||
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-primary font-sans;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl font-bold tracking-tight;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl font-semibold;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply text-base text-secondary;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-accent hover:underline transition-colors;
|
||||
}
|
||||
|
||||
.border-subtle {
|
||||
@apply border border-border-light;
|
||||
}
|
||||
|
||||
.bg-subtle {
|
||||
@apply bg-bg-subtle;
|
||||
}
|
||||
```
|
||||
|
||||
Update `app/c/[token]/page.tsx` to replace the Plan 03 placeholder with the full ClientDashboard render:
|
||||
|
||||
```typescript
|
||||
import { getClientView } from '@/lib/client-view';
|
||||
import { ClientDashboard } from '@/components/client-dashboard';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { token: string };
|
||||
}) {
|
||||
const view = await getClientView(params.token);
|
||||
|
||||
if (!view) {
|
||||
return { title: 'Not Found' };
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${view.client.brand_name} — Project Status | iamcavalli`,
|
||||
description: view.client.brief || 'Project status dashboard',
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ClientPage({
|
||||
params,
|
||||
}: {
|
||||
params: { token: string };
|
||||
}) {
|
||||
const view = await getClientView(params.token);
|
||||
|
||||
if (!view) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <ClientDashboard view={view} />;
|
||||
}
|
||||
```
|
||||
|
||||
Note: `getClientView` is called twice (once in `generateMetadata`, once in `ClientPage`). Next.js 15 deduplicates fetch calls within the same render, and since this is a DB query via Drizzle (not fetch), use React `cache()` in `client-view.ts` if double-call is a concern — acceptable for Phase 1 given low traffic.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "colors:" tailwind.config.ts && echo "Color tokens defined"</automated>
|
||||
<automated>grep -q "primary\|accent\|success" tailwind.config.ts && echo "Key colors present"</automated>
|
||||
<automated>grep -q "@tailwind" src/app/globals.css && echo "Tailwind directives in globals.css"</automated>
|
||||
<automated>grep -q "ClientDashboard" app/c/\[token\]/page.tsx && echo "ClientDashboard wired in page"</automated>
|
||||
<automated>grep -q "generateMetadata" app/c/\[token\]/page.tsx && echo "Dynamic metadata present"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `tailwind.config.ts` contains color tokens: primary, secondary, accent, success, warning
|
||||
- `globals.css` includes Tailwind directives and base typography
|
||||
- `app/c/[token]/page.tsx` renders `<ClientDashboard view={view} />` with dynamic metadata
|
||||
- 404 returned if token invalid
|
||||
- `npm run build` succeeds
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create ClientDashboard wrapper component with header, global progress, and section layout</name>
|
||||
<files>
|
||||
src/components/client-dashboard.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
src/lib/client-view.ts (ClientView interface)
|
||||
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-06 through D-10)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/components/client-dashboard.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { ClientView } from '@/lib/client-view';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { PhaseTimeline } from './phase-timeline';
|
||||
import { PaymentStatus } from './payment-status';
|
||||
import { DocumentsSection } from './documents-section';
|
||||
import { NotesSection } from './notes-section';
|
||||
|
||||
interface ClientDashboardProps {
|
||||
view: ClientView;
|
||||
}
|
||||
|
||||
export function ClientDashboard({ view }: ClientDashboardProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header: Logo + Brand Name */}
|
||||
<header className="bg-white border-b border-subtle sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* iamcavalli logo (small, corner) */}
|
||||
<div className="text-xs font-semibold text-tertiary">iamcavalli</div>
|
||||
|
||||
{/* Client brand name (prominent) */}
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-primary flex-1 text-center mx-4">
|
||||
{view.client.brand_name}
|
||||
</h1>
|
||||
|
||||
{/* Spacer for balance */}
|
||||
<div className="w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Global Progress Bar */}
|
||||
<section className="bg-bg-subtle border-b border-subtle">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-primary">Project Progress</p>
|
||||
<Progress
|
||||
value={view.global_progress_pct}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-xs text-tertiary">
|
||||
{view.global_progress_pct}% Complete
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Brief */}
|
||||
{view.client.brief && (
|
||||
<section className="mb-12">
|
||||
<p className="text-lg text-secondary italic">
|
||||
"{view.client.brief}"
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Phase Timeline */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold mb-8">Project Phases</h2>
|
||||
<PhaseTimeline phases={view.phases} />
|
||||
</section>
|
||||
|
||||
{/* Payment Status */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold mb-6">Payment Status</h2>
|
||||
<PaymentStatus
|
||||
accepted_total={view.client.accepted_total}
|
||||
payments={view.payments}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Documents */}
|
||||
{view.documents.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold mb-6">Documents & Files</h2>
|
||||
<DocumentsSection documents={view.documents} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Notes / Decision Log */}
|
||||
{view.notes.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold mb-6">Notes & Decisions</h2>
|
||||
<NotesSection notes={view.notes} />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-bg-subtle border-t border-subtle mt-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<p className="text-xs text-tertiary text-center">
|
||||
This is a private project dashboard. Do not share your unique link.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Header: small "iamcavalli" logo (top-left), client brand_name centered (prominent)
|
||||
- Global progress bar shows % of all tasks done
|
||||
- Section headers are h2 (consistent sizing)
|
||||
- Responsive layout: max-width container with mobile padding
|
||||
- Brief is quoted and italicized
|
||||
- Documents and Notes sections show only if data exists
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/components/client-dashboard.tsx && echo "ClientDashboard component exists"</automated>
|
||||
<automated>grep -q "export function ClientDashboard" src/components/client-dashboard.tsx && echo "Component exported"</automated>
|
||||
<automated>grep -q "iamcavalli" src/components/client-dashboard.tsx && echo "Logo text present"</automated>
|
||||
<automated>grep -q "brand_name" src/components/client-dashboard.tsx && echo "Brand name rendered"</automated>
|
||||
<automated>grep -q "global_progress_pct" src/components/client-dashboard.tsx && echo "Progress bar displays"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Component is exported and accepts ClientView props
|
||||
- Header displays iamcavalli logo (small) + brand_name (prominent)
|
||||
- Global progress bar shows project completion %
|
||||
- Main sections: brief, phases, payments, documents (conditional), notes (conditional)
|
||||
- Responsive layout with max-width container
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create PhaseTimeline component for lateral timeline layout with task lists</name>
|
||||
<files>
|
||||
src/components/phase-timeline.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
src/lib/client-view.ts (phase and task structure)
|
||||
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-07, D-08)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/components/phase-timeline.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { ClientView } from '@/lib/client-view';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, Circle, Clock } from 'lucide-react';
|
||||
|
||||
interface PhaseTimelineProps {
|
||||
phases: ClientView['phases'];
|
||||
}
|
||||
|
||||
export function PhaseTimeline({ phases }: PhaseTimelineProps) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{phases.map((phase, index) => (
|
||||
<div key={phase.id} className="flex gap-6">
|
||||
{/* Left: Timeline Indicator */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Circle indicator */}
|
||||
<div className="relative z-10 w-10 h-10 bg-white border-2 border-accent rounded-full flex items-center justify-center shadow-sm">
|
||||
{phase.status === 'done' ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-success" />
|
||||
) : phase.status === 'active' ? (
|
||||
<Circle className="w-6 h-6 text-accent" />
|
||||
) : (
|
||||
<Clock className="w-6 h-6 text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vertical line (not on last) */}
|
||||
{index < phases.length - 1 && (
|
||||
<div className="flex-1 w-0.5 bg-border-light" style={{ minHeight: '120px' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Phase Content */}
|
||||
<div className="flex-1 pb-8">
|
||||
{/* Phase Card */}
|
||||
<Card className="p-6 border-subtle hover:shadow-md transition-shadow">
|
||||
{/* Phase Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-primary">
|
||||
{phase.title}
|
||||
</h3>
|
||||
<Badge
|
||||
className={`capitalize ${
|
||||
phase.status === 'done' ? 'bg-success text-white' :
|
||||
phase.status === 'active' ? 'bg-accent text-white' :
|
||||
'bg-tertiary text-white'
|
||||
}`}
|
||||
>
|
||||
{phase.status === 'upcoming' ? 'Upcoming' :
|
||||
phase.status === 'active' ? 'In Progress' : 'Done'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Phase Progress Bar */}
|
||||
<div className="mb-6 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs font-semibold text-secondary">
|
||||
Phase Progress
|
||||
</p>
|
||||
<p className="text-xs text-tertiary">
|
||||
{phase.progress_pct}%
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={phase.progress_pct} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold text-secondary">
|
||||
Tasks ({phase.tasks.filter(t => t.status === 'done').length} of {phase.tasks.length})
|
||||
</p>
|
||||
{phase.tasks.length === 0 ? (
|
||||
<p className="text-sm text-tertiary italic">No tasks yet</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{phase.tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex items-start gap-3 p-2 rounded hover:bg-bg-subtle transition-colors"
|
||||
>
|
||||
{/* Task Status Icon */}
|
||||
{task.status === 'done' ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-success mt-0.5 flex-shrink-0" />
|
||||
) : task.status === 'in_progress' ? (
|
||||
<Circle className="w-5 h-5 text-warning mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-info mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Task Content */}
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm ${task.status === 'done' ? 'line-through text-tertiary' : 'text-primary'}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.description && (
|
||||
<p className="text-xs text-tertiary mt-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Deliverables */}
|
||||
{task.deliverables.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{task.deliverables.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="text-xs p-1 bg-bg-subtle rounded flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="text-secondary truncate">
|
||||
{d.title}
|
||||
</span>
|
||||
{d.status === 'approved' && (
|
||||
<Badge className="bg-success text-white text-xs">
|
||||
Approved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Left indicator: circle with icon (checkmark for done, dot for upcoming/active)
|
||||
- Vertical line connects phases (not on last phase)
|
||||
- Right content: phase card with title, status badge, progress bar, task list
|
||||
- Task status shown with icons and colors (success/warning/info)
|
||||
- Deliverables nested under tasks with "Approved" badge if applicable
|
||||
- Empty state if phase has no tasks
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/components/phase-timeline.tsx && echo "PhaseTimeline component exists"</automated>
|
||||
<automated>grep -q "export function PhaseTimeline" src/components/phase-timeline.tsx && echo "Component exported"</automated>
|
||||
<automated>grep -q "CheckCircle2\|Circle" src/components/phase-timeline.tsx && echo "Icons imported"</automated>
|
||||
<automated>grep -q "progress_pct" src/components/phase-timeline.tsx && echo "Progress bar displays"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Component renders lateral timeline layout
|
||||
- Each phase shows: title, status badge, progress bar, task count
|
||||
- Tasks show status with icons (checkmark/circle)
|
||||
- Deliverables are nested and show "Approved" badge if applicable
|
||||
- Empty state for phases with no tasks
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Create PaymentStatus component (accepted_total + payment rows with status badges)</name>
|
||||
<files>
|
||||
src/components/payment-status.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
src/lib/client-view.ts (payments shape, PaymentStatus type)
|
||||
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-10, D-11)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/components/payment-status.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ClientView } from '@/lib/client-view';
|
||||
import { CheckCircle2, Clock, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface PaymentStatusProps {
|
||||
accepted_total: string;
|
||||
payments: ClientView['payments'];
|
||||
}
|
||||
|
||||
export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps) {
|
||||
const statusConfig = {
|
||||
da_saldare: { color: 'bg-info', icon: Clock, label: 'Da Saldare', text: 'white' },
|
||||
inviata: { color: 'bg-warning', icon: AlertCircle, label: 'Inviata', text: 'white' },
|
||||
saldato: { color: 'bg-success', icon: CheckCircle2, label: 'Saldato', text: 'white' },
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6 border-subtle">
|
||||
{/* Total */}
|
||||
<div className="mb-6 pb-6 border-b border-subtle">
|
||||
<p className="text-sm text-secondary font-semibold mb-2">
|
||||
Totale Preventivo Accettato
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-primary">
|
||||
€{parseFloat(accepted_total || '0').toLocaleString('it-IT', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Payment Rows */}
|
||||
<div className="space-y-4">
|
||||
{payments.map((payment) => {
|
||||
const config = statusConfig[payment.status as keyof typeof statusConfig];
|
||||
const Icon = config?.icon || Clock;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={payment.id}
|
||||
className="flex items-center justify-between p-4 bg-bg-subtle rounded-lg border border-subtle"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="w-5 h-5 text-secondary flex-shrink-0" />
|
||||
<p className="text-sm font-semibold text-primary">
|
||||
{payment.label}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
className={`capitalize ${config?.color} text-${config?.text}`}
|
||||
>
|
||||
{config?.label || payment.status}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<p className="text-xs text-tertiary italic mt-6 pt-6 border-t border-subtle">
|
||||
I pagamenti sono suddivisi in due rate da 50% ciascuna.
|
||||
Contattaci per domande sui dettagli.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Shows `accepted_total` formatted as Euro currency — NEVER individual line-item amounts
|
||||
- Two payment rows (Acconto 50%, Saldo 50%) with status badges only
|
||||
- Status badge colors: da_saldare = blue, inviata = yellow, saldato = green
|
||||
- Card + Badge from shadcn/ui
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/components/payment-status.tsx && echo "PaymentStatus component exists"</automated>
|
||||
<automated>grep -q "export function PaymentStatus" src/components/payment-status.tsx && echo "Component exported"</automated>
|
||||
<automated>grep -q "accepted_total" src/components/payment-status.tsx && echo "Total displayed"</automated>
|
||||
<automated>grep -q "da_saldare\|inviata\|saldato" src/components/payment-status.tsx && echo "Status config present"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Component exists and is exported
|
||||
- Displays accepted_total formatted as Euro (no individual amounts)
|
||||
- Renders payment rows with status badges (da_saldare/inviata/saldato)
|
||||
- Uses shadcn/ui Card and Badge
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Create DocumentsSection and NotesSection components (external links + read-only notes)</name>
|
||||
<files>
|
||||
src/components/documents-section.tsx
|
||||
src/components/notes-section.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
src/lib/client-view.ts (documents and notes shapes)
|
||||
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-12)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/components/documents-section.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { ClientView } from '@/lib/client-view';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
interface DocumentsSectionProps {
|
||||
documents: ClientView['documents'];
|
||||
}
|
||||
|
||||
export function DocumentsSection({ documents }: DocumentsSectionProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{documents.map((doc) => (
|
||||
<Card
|
||||
key={doc.id}
|
||||
className="p-4 border-subtle hover:shadow-md transition-shadow"
|
||||
>
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between gap-3 text-accent hover:text-accent hover:underline group"
|
||||
>
|
||||
<span className="font-semibold text-primary group-hover:text-accent">
|
||||
{doc.label}
|
||||
</span>
|
||||
<ExternalLink className="w-4 h-4 flex-shrink-0" />
|
||||
</a>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/components/notes-section.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { ClientView } from '@/lib/client-view';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
interface NotesSectionProps {
|
||||
notes: ClientView['notes'];
|
||||
}
|
||||
|
||||
export function NotesSection({ notes }: NotesSectionProps) {
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<p className="text-secondary italic text-sm">
|
||||
No notes yet. Decisions will appear here as they are made.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{notes.map((note) => (
|
||||
<Card key={note.id} className="p-4 border-subtle">
|
||||
<p className="text-sm text-primary leading-relaxed">
|
||||
{note.body}
|
||||
</p>
|
||||
<p className="text-xs text-tertiary mt-3">
|
||||
{new Date(note.created_at).toLocaleDateString('it-IT', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- DocumentsSection: clickable external links with ExternalLink icon, `rel="noopener noreferrer"` for security
|
||||
- NotesSection: read-only, client never writes (admin writes in Phase 2 admin area)
|
||||
- NotesSection: empty state shown as italic hint when no notes exist
|
||||
- Timestamps formatted in Italian locale
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/components/documents-section.tsx && echo "DocumentsSection component exists"</automated>
|
||||
<automated>test -f src/components/notes-section.tsx && echo "NotesSection component exists"</automated>
|
||||
<automated>grep -q "export function DocumentsSection" src/components/documents-section.tsx && echo "DocumentsSection exported"</automated>
|
||||
<automated>grep -q "export function NotesSection" src/components/notes-section.tsx && echo "NotesSection exported"</automated>
|
||||
<automated>grep -q "noopener noreferrer" src/components/documents-section.tsx && echo "External link security present"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Both components exist and are exported
|
||||
- DocumentsSection renders clickable external links with ExternalLink icon and secure rel attributes
|
||||
- NotesSection shows read-only notes with Italian-formatted timestamps
|
||||
- NotesSection shows empty state hint when notes array is empty
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Client browser → CSS/HTML | UI rendering is client-safe; no admin secrets in HTML source |
|
||||
| Link click → External URL | External document links open in new tab with `rel="noopener noreferrer"` |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-001 | Information Disclosure | Payment amounts | mitigate | Payments row shows status only; amounts never rendered on client dashboard |
|
||||
| T-04-002 | Tampering | External links | accept | Links are user-provided URLs; client-side link validation (hostname check) could be added in Phase 2 |
|
||||
| T-04-003 | Denial of Service | Image rendering | accept | Dashboard contains only text and icons; no resource-heavy assets |
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After plan execution:
|
||||
1. Run `npm run build` → no errors
|
||||
2. Verify all component files exist: client-dashboard, phase-timeline, payment-status, documents-section, notes-section
|
||||
3. Check page rendering logic in `app/c/[token]/page.tsx`
|
||||
4. Verify mobile responsiveness: layout scales correctly on narrow screens
|
||||
5. Check that payment amounts are NOT displayed (only status)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All UI components are created and exported
|
||||
- Client dashboard renders complete project status
|
||||
- Global progress bar and per-phase progress bars display correctly
|
||||
- Payment section shows only status (no amounts)
|
||||
- Document links are clickable
|
||||
- Notes section shows read-only list (or empty state)
|
||||
- Layout is responsive and uses light & clean design
|
||||
- Mobile-first design works on small screens
|
||||
- Ready to proceed to Plan 05 (Seed script + DNS)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation-client-dashboard/01-04-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,167 +0,0 @@
|
||||
---
|
||||
phase: 01-foundation-client-dashboard
|
||||
plan: 04
|
||||
subsystem: client-portal-ui
|
||||
tags: [nextjs, tailwind-v4, shadcn-ui, server-components, client-dashboard, responsive]
|
||||
|
||||
requires:
|
||||
- 01-01 (Next.js 16 bootstrap, Tailwind v4, shadcn/ui)
|
||||
- 01-02 (schema DB: clients, phases, tasks, deliverables, payments, documents, notes)
|
||||
- 01-03 (ClientView interface + getClientView(), route /c/[token] operativa)
|
||||
provides:
|
||||
- src/components/client-dashboard.tsx (layout wrapper completo)
|
||||
- src/components/phase-timeline.tsx (timeline laterale con progress bar per fase)
|
||||
- src/components/payment-status.tsx (stato pagamenti senza importi singoli)
|
||||
- src/components/documents-section.tsx (link documenti esterni)
|
||||
- src/components/notes-section.tsx (log decisioni read-only)
|
||||
- src/app/globals.css (design token Tailwind v4 — palette light & clean)
|
||||
- app/c/[token]/page.tsx (Server Component che renderizza ClientDashboard)
|
||||
affects:
|
||||
- 01-05-seed-deploy (la dashboard e' completa — il seed popola i dati, il deploy la espone)
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Tailwind v4: design token via @theme inline in globals.css (NON tailwind.config.ts)"
|
||||
- "Colori arbitrari inline con sintassi [#hex] — compatibile Tailwind v4"
|
||||
- "SVG inline al posto di lucide-react — compatibilita' massima con bundle size ridotto"
|
||||
- "Server Components puri per tutti i componenti dashboard (nessun 'use client')"
|
||||
- "React.cache() in page.tsx per deduplicare getClientView() tra generateMetadata e render"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/components/client-dashboard.tsx (99 righe — wrapper con header, progress, sezioni)
|
||||
- src/components/phase-timeline.tsx (201 righe — timeline laterale con task e deliverable)
|
||||
- src/components/payment-status.tsx (98 righe — totale + righe stato, zero importi singoli)
|
||||
- src/components/documents-section.tsx (75 righe — link esterni sicuri)
|
||||
- src/components/notes-section.tsx (68 righe — log decisioni read-only)
|
||||
modified:
|
||||
- src/app/globals.css (token Tailwind v4: primary, secondary, tertiary, bg-subtle, border-light, accent, success, warning, info)
|
||||
- src/app/c/[token]/page.tsx (import ClientDashboard, generateMetadata dinamico, React.cache)
|
||||
|
||||
key-decisions:
|
||||
- "Tailwind v4 usa @theme in globals.css — tailwind.config.ts non esiste in questo progetto"
|
||||
- "SVG inline invece di lucide-react — evita dipendenza da icone e garantisce compatibilita'"
|
||||
- "Colori arbitrari [#hex] in classi Tailwind invece di classi custom — piu' esplicito e manutenibile"
|
||||
- "Server Components puri — nessun 'use client' necessario per componenti read-only"
|
||||
- "React.cache() per deduplicare le due chiamate getClientView in generateMetadata + ClientPage"
|
||||
|
||||
duration: 45min
|
||||
completed: 2026-05-14
|
||||
---
|
||||
|
||||
# Phase 1 Plan 04: Client Dashboard UI — Vertical Slice completo
|
||||
|
||||
**Tutti i componenti UI della dashboard cliente renderizzati come Server Components con design light & clean in Tailwind v4: header con logo iamcavalli + brand name cliente, progress bar globale, timeline laterale delle fasi con barre per fase e task list, sezione pagamenti con badge stato (zero importi singoli), link documenti esterni e log note read-only.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~45 min
|
||||
- **Started:** 2026-05-14T19:35:00Z
|
||||
- **Completed:** 2026-05-14T20:20:00Z
|
||||
- **Tasks:** 5/5
|
||||
- **Files creati:** 5
|
||||
- **Files modificati:** 2
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **globals.css**: Token di design Tailwind v4 via `@theme inline` — palette light & clean con 9 variabili colore (primary, secondary, tertiary, bg-subtle, border-light, accent, success, warning, info). Adattamento critico: il progetto usa Tailwind v4 che non ha `tailwind.config.ts`.
|
||||
|
||||
- **app/c/[token]/page.tsx**: Server Component aggiornato con `ClientDashboard`, `generateMetadata` dinamico (titolo con brand_name), `React.cache()` per deduplicare le due chiamate a `getClientView`.
|
||||
|
||||
- **client-dashboard.tsx**: Layout wrapper completo. Header sticky con "iamcavalli" in angolo sinistro (xs, tracking-widest) e `brand_name` centrato e prominente (D-06). Progress bar globale con percentuale (D-09). Brief con accent bar sinistra. Sezioni ordinate: PhaseTimeline, PaymentStatus (sempre visibile — D-10), Documents e Notes (condizionali). Footer con avviso link privato.
|
||||
|
||||
- **phase-timeline.tsx**: Timeline laterale a due colonne (D-07). Colonna sinistra: cerchio con icona SVG per stato (checkmark verde per done, cerchio pieno blu per active, cerchio vuoto grigio per upcoming) + linea verticale tra fasi. Colonna destra: Card con badge stato, progress bar per fase con contatore "X di N task" (D-08), task list con icone stato e line-through per done. Deliverable annidati con badge "Approvato".
|
||||
|
||||
- **payment-status.tsx**: Card con `accepted_total` in EUR come unico importo visibile (vincolo LOCKED). Righe pagamento con dot colorato + badge stato semantico (blu=da_saldare, giallo=inviata, verde=saldato) — MAI importi singoli (T-04-001 mitigato, D-11 rispettato).
|
||||
|
||||
- **documents-section.tsx**: Link esterni con `target="_blank" rel="noopener noreferrer"` (T-04-002). Icone SVG inline per documento ed external link. Hover state con transizione colore accent.
|
||||
|
||||
- **notes-section.tsx**: Note read-only con timestamp in locale it-IT. Empty state informativo. Server Component puro (D-12: admin scrive in Phase 2, cliente legge).
|
||||
|
||||
- **npm run build**: completato senza errori TypeScript. 1 warning CSS da Lightning CSS optimizer (selettore con caratteri speciali) — noto, non bloccante, non dipendente dal nostro codice.
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Design tokens + wire page.tsx** — `4e703d7`
|
||||
2. **Task 2: ClientDashboard wrapper** — `debd391`
|
||||
3. **Task 3: PhaseTimeline** — `5d5c8ea`
|
||||
4. **Task 4: PaymentStatus** — `a4e2de0`
|
||||
5. **Task 5: DocumentsSection + NotesSection** — `8602bfa`
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/app/globals.css` — @theme con 9 token colore light & clean
|
||||
- `src/app/c/[token]/page.tsx` — ClientDashboard + generateMetadata + React.cache
|
||||
- `src/components/client-dashboard.tsx` — layout wrapper completo
|
||||
- `src/components/phase-timeline.tsx` — timeline laterale con progress per fase
|
||||
- `src/components/payment-status.tsx` — totale accettato + badge stato (nessun importo)
|
||||
- `src/components/documents-section.tsx` — link esterni sicuri
|
||||
- `src/components/notes-section.tsx` — note read-only con timestamp italiano
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Tailwind v4 senza tailwind.config.ts:** Il piano originale assumeva Tailwind v3 con `tailwind.config.ts`. Il progetto usa Tailwind v4 che gestisce i token via `@theme inline` in globals.css. Adattamento automatico.
|
||||
- **SVG inline invece di lucide-react:** Lucide-react v1.14 ha alcune icone con nomi diversi. Usare SVG inline elimina la dipendenza ed e' compatibile con Server Components senza `'use client'`.
|
||||
- **Server Components puri:** I componenti sono tutti read-only e non usano hooks React — nessun `'use client'` necessario, ottimizzazione del bundle.
|
||||
- **Colori arbitrari [#hex]:** Usare classi come `text-[#1a1a1a]` invece di classi custom — piu' esplicito, nessun conflitto con shadcn/ui che usa le proprie variabili CSS (`bg-card`, `text-muted-foreground`, etc.).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] tailwind.config.ts non esiste — Tailwind v4 usa @theme in globals.css**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** Il piano indicava di aggiornare `tailwind.config.ts` con i color token. Questo file non esiste perche' il progetto usa Tailwind v4, che utilizza CSS `@theme inline` in `globals.css` invece del file di configurazione JavaScript.
|
||||
- **Fix:** Token definiti in `globals.css` via `@theme inline { --color-primary: #1a1a1a; ... }`. Tailwind v4 mappa automaticamente queste variabili come classi utilitarie.
|
||||
- **Files modified:** src/app/globals.css
|
||||
- **Commit:** 4e703d7
|
||||
|
||||
**2. [Rule 1 - Bug] lucide-react usato come 'use client' — rimpiazzato con SVG inline**
|
||||
- **Found during:** Task 2/3 (design)
|
||||
- **Issue:** Il piano importava `CheckCircle2`, `Circle`, `Clock`, `ExternalLink` da `lucide-react`. I componenti sono Server Components puri — aggiungere `'use client'` solo per le icone avrebbe spostato tutto il rendering lato client inutilmente.
|
||||
- **Fix:** SVG inline (Heroicons style) al posto di lucide-react per tutti i componenti. Mantiene i componenti come Server Components puri.
|
||||
- **Files modified:** phase-timeline.tsx, payment-status.tsx, documents-section.tsx
|
||||
- **Commit:** 5d5c8ea, a4e2de0, 8602bfa
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Nessuno stub. Tutti i componenti ricevono dati reali dalla `ClientView` e li renderizzano completamente. Gli empty state (no documenti, no note) sono stati implementati come stati legittimi — non stub.
|
||||
|
||||
## Threat Surface Scan
|
||||
|
||||
| Flag | File | Description |
|
||||
|------|------|-------------|
|
||||
| Verificato T-04-001 | payment-status.tsx | `amount` assente dalla query e dal componente — solo `accepted_total` e `status` per riga |
|
||||
| Verificato T-04-002 | documents-section.tsx | `rel="noopener noreferrer"` su tutti i link esterni |
|
||||
|
||||
Nessuna nuova superficie di sicurezza non prevista dal threat model 01-04.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- [x] `src/app/globals.css` esiste con @theme e token colore
|
||||
- [x] `src/app/c/[token]/page.tsx` renderizza ClientDashboard con generateMetadata
|
||||
- [x] `src/components/client-dashboard.tsx` esiste (99 righe)
|
||||
- [x] `src/components/phase-timeline.tsx` esiste (201 righe)
|
||||
- [x] `src/components/payment-status.tsx` esiste (98 righe) — nessun campo `amount`
|
||||
- [x] `src/components/documents-section.tsx` esiste (75 righe)
|
||||
- [x] `src/components/notes-section.tsx` esiste (68 righe)
|
||||
- [x] Commit `4e703d7` esiste (Task 1)
|
||||
- [x] Commit `debd391` esiste (Task 2)
|
||||
- [x] Commit `5d5c8ea` esiste (Task 3)
|
||||
- [x] Commit `a4e2de0` esiste (Task 4)
|
||||
- [x] Commit `8602bfa` esiste (Task 5)
|
||||
- [x] `npm run build` completato senza errori TypeScript
|
||||
- [x] `payment-status.tsx` non contiene il campo `amount`
|
||||
- [x] `documents-section.tsx` contiene `rel="noopener noreferrer"`
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 05 (Seed script + DNS) puo' partire
|
||||
- La dashboard e' pienamente funzionale: basta un cliente seedato per vederla operativa
|
||||
- Il seed script deve inserire client, phases, tasks, deliverables, payments, documents, notes
|
||||
- La route /c/[token] e' operativa dal Plan 03 — Plan 05 aggiunge il seed + configurazione DNS
|
||||
|
||||
---
|
||||
*Phase: 01-foundation-client-dashboard*
|
||||
*Completed: 2026-05-14*
|
||||
@@ -1,567 +0,0 @@
|
||||
---
|
||||
phase: "01-foundation-client-dashboard"
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "01-01"
|
||||
- "01-02"
|
||||
- "01-03"
|
||||
- "01-04"
|
||||
files_modified:
|
||||
- scripts/seed.ts
|
||||
- .env.local
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DASH-01
|
||||
- DASH-02
|
||||
- DASH-03
|
||||
- DASH-04
|
||||
- DASH-07
|
||||
- DASH-08
|
||||
- DASH-09
|
||||
- DASH-10
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Seed script exists and contains TypeScript seed logic"
|
||||
- "Script inserts one complete test client with all related data (phases, tasks, deliverables, payments, documents, notes)"
|
||||
- "Client token is generated via nanoid (21 chars, cryptographically secure)"
|
||||
- "Seed script prints shareable URL to console: http://localhost:3000/c/[token]"
|
||||
- "Script can be run via: npx tsx scripts/seed.ts"
|
||||
- "DNS CNAME is configured: welcomeclient.iamcavalli.net → vercel DNS"
|
||||
- "DNS propagation is verified (can be checked via `dig` or online tool)"
|
||||
artifacts:
|
||||
- path: "scripts/seed.ts"
|
||||
provides: "Seed script that inserts first real client with all data"
|
||||
min_lines: 100
|
||||
contains: "import.*nanoid"
|
||||
- path: ".env.local (updated)"
|
||||
provides: "Updated with VERCEL_URL or custom domain setting"
|
||||
contains: "DATABASE_URL"
|
||||
key_links:
|
||||
- from: "scripts/seed.ts"
|
||||
to: "src/db/schema"
|
||||
via: "drizzle db.insert()"
|
||||
pattern: "db.insert\\("
|
||||
- from: "nanoid token"
|
||||
to: "client URL"
|
||||
via: "http://localhost:3000/c/[token]"
|
||||
pattern: "nanoid"
|
||||
|
||||
---
|
||||
|
||||
<objective>
|
||||
**Seed Script + DNS Configuration:** Create a TypeScript seed script that populates the database with one complete test client (including phases, tasks, deliverables, payments, documents, and notes), generates a secret token via nanoid, and prints a shareable dashboard URL. Configure DNS CNAME for welcomeclient.iamcavalli.net to Vercel and verify propagation.
|
||||
|
||||
Purpose: Enable end-to-end testing with real data. One developer can run the seed script and immediately open a working client dashboard. DNS configuration allows the project to be accessed via the production domain.
|
||||
|
||||
Output: Executable seed script + verified DNS CNAME + shareable client link for testing Phase 1.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/research/ARCHITECTURE.md (Data Model section)
|
||||
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-13)
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create scripts/seed.ts to insert first real client with all data</name>
|
||||
<files>
|
||||
scripts/seed.ts
|
||||
</files>
|
||||
<read_first>
|
||||
src/db/schema.ts (all table definitions)
|
||||
src/db/index.ts (db client)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `scripts/seed.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Seed Script — Inserts first test client with complete project data
|
||||
* Run: npx tsx scripts/seed.ts
|
||||
*/
|
||||
|
||||
import { db } from '@/db';
|
||||
import {
|
||||
clients,
|
||||
phases,
|
||||
tasks,
|
||||
deliverables,
|
||||
payments,
|
||||
documents,
|
||||
notes,
|
||||
} from '@/db/schema';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
async function seed() {
|
||||
console.log('🌱 Seeding database...\n');
|
||||
|
||||
try {
|
||||
// 1. Create client
|
||||
const clientToken = nanoid();
|
||||
const [client] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
name: 'Test Client Inc.',
|
||||
brand_name: 'TestBrand',
|
||||
brief:
|
||||
'A comprehensive personal branding overhaul, positioning our company as a premium consultancy in the digital transformation space.',
|
||||
token: clientToken,
|
||||
accepted_total: '5000.00',
|
||||
created_at: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
console.log(
|
||||
'✓ Client created: ' + client.name + ' (ID: ' + client.id + ')'
|
||||
);
|
||||
|
||||
// 2. Create phases
|
||||
const [phase1, phase2, phase3] = await db
|
||||
.insert(phases)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
title: 'Discovery & Strategy',
|
||||
sort_order: 1,
|
||||
status: 'done',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
title: 'Design & Messaging',
|
||||
sort_order: 2,
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
title: 'Implementation & Launch',
|
||||
sort_order: 3,
|
||||
status: 'upcoming',
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Phases created (3 total)');
|
||||
|
||||
// 3. Create tasks
|
||||
const [task1, task2, task3, task4, task5, task6] = await db
|
||||
.insert(tasks)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase1.id,
|
||||
title: 'Stakeholder interviews',
|
||||
description: 'In-depth conversations with leadership team',
|
||||
sort_order: 1,
|
||||
status: 'done',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase1.id,
|
||||
title: 'Competitive analysis',
|
||||
description: 'Research top 10 competitors in the space',
|
||||
sort_order: 2,
|
||||
status: 'done',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase2.id,
|
||||
title: 'Brand positioning document',
|
||||
description:
|
||||
'Write and refine the core positioning statement',
|
||||
sort_order: 1,
|
||||
status: 'in_progress',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase2.id,
|
||||
title: 'Visual identity design',
|
||||
description: 'Logo, color palette, typography',
|
||||
sort_order: 2,
|
||||
status: 'in_progress',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase3.id,
|
||||
title: 'Website build & launch',
|
||||
description: 'Design and develop new company website',
|
||||
sort_order: 1,
|
||||
status: 'todo',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
phase_id: phase3.id,
|
||||
title: 'Social media rollout',
|
||||
description: 'Launch branded social media accounts',
|
||||
sort_order: 2,
|
||||
status: 'todo',
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Tasks created (6 total)');
|
||||
|
||||
// 4. Create deliverables
|
||||
await db
|
||||
.insert(deliverables)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
task_id: task1.id,
|
||||
title: 'Interview notes & synthesis',
|
||||
url: 'https://docs.google.com/document/d/1example',
|
||||
status: 'approved',
|
||||
approved_at: new Date('2026-04-15'),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
task_id: task2.id,
|
||||
title: 'Competitive landscape report',
|
||||
url: 'https://docs.google.com/presentation/d/1example',
|
||||
status: 'approved',
|
||||
approved_at: new Date('2026-04-20'),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
task_id: task3.id,
|
||||
title: 'Brand positioning document (draft)',
|
||||
url: 'https://docs.google.com/document/d/2example',
|
||||
status: 'submitted',
|
||||
approved_at: null,
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
task_id: task4.id,
|
||||
title: 'Logo concepts (3 variations)',
|
||||
url: 'https://www.figma.com/file/example',
|
||||
status: 'pending',
|
||||
approved_at: null,
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Deliverables created (4 total)');
|
||||
|
||||
// 5. Create payments
|
||||
await db
|
||||
.insert(payments)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
label: 'Acconto 50%',
|
||||
amount: '2500.00',
|
||||
status: 'saldato',
|
||||
paid_at: new Date('2026-04-01'),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
label: 'Saldo 50%',
|
||||
amount: '2500.00',
|
||||
status: 'inviata',
|
||||
paid_at: null,
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Payments created (2 total)');
|
||||
|
||||
// 6. Create documents
|
||||
await db
|
||||
.insert(documents)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
label: 'Brand Guidelines PDF',
|
||||
url: 'https://example.com/brand-guidelines.pdf',
|
||||
created_at: new Date(),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
label: 'Design Mockups Figma',
|
||||
url: 'https://www.figma.com/file/example',
|
||||
created_at: new Date(),
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Documents created (2 total)');
|
||||
|
||||
// 7. Create notes
|
||||
await db
|
||||
.insert(notes)
|
||||
.values([
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
body: 'Initial strategy session completed. Key insight: positioning needs to emphasize tech expertise and creative thinking balance.',
|
||||
created_at: new Date('2026-04-10'),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
client_id: client.id,
|
||||
body: 'Phase 1 approved. Moving forward with design phase. Stakeholders excited about direction.',
|
||||
created_at: new Date('2026-04-22'),
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
console.log('✓ Notes created (2 total)');
|
||||
|
||||
// Print shareable URL
|
||||
console.log('\n✨ Seed complete!\n');
|
||||
console.log('📎 Shareable client link:');
|
||||
console.log(
|
||||
` http://localhost:3000/c/${clientToken}\n`
|
||||
);
|
||||
console.log(
|
||||
'This link is unique and secret. Send it to the client via Slack or email.\n'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('❌ Seed failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Uses nanoid for token generation (21 chars, cryptographically secure)
|
||||
- Inserts complete hierarchical data: 1 client → 3 phases → 6 tasks → 4 deliverables + 2 payments + 2 documents + 2 notes
|
||||
- Mix of statuses: phase 1 done, phase 2 active, phase 3 upcoming; tasks have various completion states
|
||||
- Deliverables show different statuses: approved (with timestamp), submitted, pending
|
||||
- Payments: one paid, one sent but unpaid
|
||||
- Notes: 2 decision log entries
|
||||
- Prints shareable URL to console
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f scripts/seed.ts && echo "Seed script exists"</automated>
|
||||
<automated>grep -q "import.*nanoid" scripts/seed.ts && echo "nanoid imported"</automated>
|
||||
<automated>grep -q "db.insert" scripts/seed.ts && echo "Insert statements present"</automated>
|
||||
<automated>grep -q "clientToken" scripts/seed.ts && echo "Token generation present"</automated>
|
||||
<automated>grep -q "http://localhost:3000/c/" scripts/seed.ts && echo "URL printed"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `scripts/seed.ts` exists as TypeScript file
|
||||
- Script imports nanoid and db client
|
||||
- Creates one complete client with all related data (phases, tasks, deliverables, payments, documents, notes)
|
||||
- Prints shareable URL to console
|
||||
- Can be executed via `npx tsx scripts/seed.ts` without errors
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Test seed script execution and verify data is inserted into database</name>
|
||||
<files>
|
||||
None (execution only)
|
||||
</files>
|
||||
<read_first>
|
||||
scripts/seed.ts
|
||||
.env.local
|
||||
</read_first>
|
||||
<action>
|
||||
Run the seed script:
|
||||
```
|
||||
npx tsx scripts/seed.ts
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
🌱 Seeding database...
|
||||
|
||||
✓ Client created: Test Client Inc. (ID: xxx...)
|
||||
✓ Phases created (3 total)
|
||||
✓ Tasks created (6 total)
|
||||
✓ Deliverables created (4 total)
|
||||
✓ Payments created (2 total)
|
||||
✓ Documents created (2 total)
|
||||
✓ Notes created (2 total)
|
||||
|
||||
✨ Seed complete!
|
||||
|
||||
📎 Shareable client link:
|
||||
http://localhost:3000/c/[token]
|
||||
|
||||
This link is unique and secret. Send it to the client via Slack or email.
|
||||
```
|
||||
|
||||
If the script fails:
|
||||
- Verify DATABASE_URL is set and correct
|
||||
- Verify Postgres on Coolify is accessible
|
||||
- Check that schema exists (run `npx drizzle-kit introspect` to confirm)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsx scripts/seed.ts 2>&1 | grep -q "Seed complete" && echo "Seed script succeeded" || echo "Seed script failed"</automated>
|
||||
<automated>npx tsx scripts/seed.ts 2>&1 | grep -oE "http://localhost:3000/c/[a-zA-Z0-9_-]+" | head -1 > /tmp/client_url.txt && test -s /tmp/client_url.txt && echo "Client URL generated" || echo "Client URL not found"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Seed script executes without errors
|
||||
- Output shows all entity types created (client, phases, tasks, deliverables, payments, documents, notes)
|
||||
- Shareable URL is printed to console
|
||||
- Data is inserted into Postgres on Coolify
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Test end-to-end: Open seeded client link in browser and verify dashboard renders</name>
|
||||
<files>
|
||||
None (verification only)
|
||||
</files>
|
||||
<read_first>
|
||||
None
|
||||
</read_first>
|
||||
<action>
|
||||
Start dev server:
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open the seeded client link in browser:
|
||||
- Copy the URL from seed script output (e.g., http://localhost:3000/c/xyz123)
|
||||
- Visit in browser
|
||||
- Verify dashboard renders with:
|
||||
- ✓ Client brand name displayed prominently
|
||||
- ✓ iamcavalli logo in corner
|
||||
- ✓ Global progress bar showing % completion
|
||||
- ✓ All 3 phases visible with status badges (done/active/upcoming)
|
||||
- ✓ Each phase shows progress bar and task count
|
||||
- ✓ Tasks nested under phases with status icons
|
||||
- ✓ Deliverables shown under tasks (with Approved badge if applicable)
|
||||
- ✓ Payment section shows accepted_total (€5000.00) and 2 payment rows
|
||||
- ✓ Payment amounts are NOT visible (only status: saldato, inviata)
|
||||
- ✓ Document section shows clickable links
|
||||
- ✓ Notes section shows decision log entries
|
||||
|
||||
Test edge cases:
|
||||
- Invalid token (http://localhost:3000/c/invalid) → should return 404
|
||||
- Page refresh → data should persist (no client-side state loss)
|
||||
- Mobile view (use DevTools mobile emulator) → layout should be responsive
|
||||
</action>
|
||||
<verify>
|
||||
<automated>curl -s http://localhost:3000/c/invalid | grep -q "404\|not found" && echo "Invalid token returns 404" || echo "404 check inconclusive"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Seeded client link opens without errors
|
||||
- Dashboard renders with client data
|
||||
- All sections visible: header, progress, phases, tasks, deliverables, payments, documents, notes
|
||||
- Invalid token returns 404
|
||||
- Layout is responsive on mobile
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Configure DNS CNAME for welcomeclient.iamcavalli.net → Vercel DNS</name>
|
||||
<files>
|
||||
None (external DNS configuration)
|
||||
</files>
|
||||
<read_first>
|
||||
.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-03)
|
||||
</read_first>
|
||||
<action>
|
||||
**DNS Configuration Steps:**
|
||||
|
||||
1. Log into your domain registrar (where iamcavalli.net is registered)
|
||||
2. Navigate to DNS settings for iamcavalli.net
|
||||
3. Create a new CNAME record:
|
||||
- **Name:** welcomeclient
|
||||
- **Type:** CNAME
|
||||
- **Value:** cname.vercel-dns.com
|
||||
- **TTL:** 3600 (or default)
|
||||
|
||||
4. Save the record
|
||||
|
||||
5. Verify propagation (may take 15 minutes to 2 hours):
|
||||
```
|
||||
dig welcomeclient.iamcavalli.net
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
welcomeclient.iamcavalli.net. 3600 IN CNAME cname.vercel-dns.com.
|
||||
```
|
||||
|
||||
Or use an online tool: https://mxtoolbox.com/cname.aspx
|
||||
|
||||
**Vercel Configuration:**
|
||||
|
||||
1. Go to Vercel dashboard → Project Settings → Domains
|
||||
2. Add domain: `welcomeclient.iamcavalli.net`
|
||||
3. Vercel will show the CNAME record to configure (should match above)
|
||||
4. Click "Add" and wait for verification (usually immediate after DNS propagates)
|
||||
|
||||
**After DNS is live:**
|
||||
- You can access the dashboard via https://welcomeclient.iamcavalli.net/c/[token]
|
||||
- DNS is bidirectional: localhost:3000 still works for dev
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dig welcomeclient.iamcavalli.net +short 2>/dev/null | grep -q "vercel-dns.com" && echo "DNS CNAME configured" || echo "DNS CNAME not yet live"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- CNAME record is created at registrar: welcomeclient → cname.vercel-dns.com
|
||||
- Vercel project has the domain added and verified
|
||||
- `dig` shows the CNAME record pointing to Vercel DNS
|
||||
- Domain is accessible via browser (may take time to propagate)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Client browser → Secret link | Token is in URL; HTTPS encrypts transit; never log token in server logs |
|
||||
| Token generation | nanoid is cryptographically secure (126 bits entropy); non-enumerable |
|
||||
| DNS configuration | CNAME points to Vercel; Vercel controls SSL/TLS for domain |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-05-001 | Information Disclosure | Token in seed output | mitigate | URL is printed to console; developer must not commit or share the seed output; regenerate token in Phase 2 if compromised |
|
||||
| T-05-002 | Information Disclosure | HTTPS for domain | mitigate | Vercel automatically provisions SSL/TLS for custom domain; all traffic to welcomeclient.iamcavalli.net is encrypted |
|
||||
| T-05-003 | Denial of Service | Seed script re-run | accept | Running seed script multiple times creates duplicate clients (same test data); acceptable for dev; Phase 2 adds admin UI to manage clients |
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After plan execution:
|
||||
1. Run `npx tsx scripts/seed.ts` → output shows "Seed complete!"
|
||||
2. Copy the printed URL and visit in browser
|
||||
3. Verify dashboard renders with seeded data
|
||||
4. Test invalid token → 404
|
||||
5. Verify DNS CNAME is live: `dig welcomeclient.iamcavalli.net`
|
||||
6. (Optional) Visit https://welcomeclient.iamcavalli.net/c/[token] once DNS propagates
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Seed script exists and inserts complete test data
|
||||
- One client with 3 phases, 6 tasks, 4 deliverables, 2 payments, 2 documents, 2 notes
|
||||
- Dashboard renders with seeded data via shareable link
|
||||
- Invalid tokens return 404
|
||||
- DNS CNAME is configured and verified
|
||||
- Phase 1 is complete and ready for production (Phase 2 will add auth and CRUD)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md`
|
||||
|
||||
Also update `.planning/ROADMAP.md` to mark Phase 1 complete and set up Phase 2 planning.
|
||||
</output>
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
plan: 05
|
||||
status: complete
|
||||
commit: 073eec7
|
||||
date: 2026-05-14
|
||||
---
|
||||
|
||||
# Plan 01-05 Summary: Seed Script + DNS Configuration
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
**Task 1: scripts/seed.ts** ✓
|
||||
- Created seed script with 107 lines
|
||||
- Inserts 1 client → 3 phases → 6 tasks → 4 deliverables + 2 payments + 2 documents + 2 notes
|
||||
- Token generated via nanoid (21 chars, ~126 bits entropy)
|
||||
- Prints shareable URL: `http://localhost:3000/c/[token]`
|
||||
- Run via: `DATABASE_URL=<url> npx tsx scripts/seed.ts`
|
||||
|
||||
**Task 2: Seed execution verified** ✓
|
||||
- Seed ran successfully against Hetzner Postgres
|
||||
- Test client created: `Test Client Inc.` / `TestBrand`
|
||||
- Shareable link: `http://localhost:3000/c/jXpPhUS-C6pCGAu5Va0Ct`
|
||||
|
||||
**Task 3: E2E dashboard test** ✓
|
||||
- Valid token → HTTP 200 (production build: `next build` + `next start`)
|
||||
- Invalid token → HTTP 404
|
||||
- Dev server also fixed (see Bug Fix below)
|
||||
|
||||
**Task 4: DNS CNAME** — Pending user action
|
||||
- Prerequisites not yet met: no Vercel project linked, no deployment
|
||||
- Steps to complete (after Vercel deployment):
|
||||
1. Run `vercel --prod` or connect via Vercel dashboard
|
||||
2. Add domain `welcomeclient.iamcavalli.net` in Vercel Project Settings → Domains
|
||||
3. At domain registrar for `iamcavalli.net`: add CNAME `welcomeclient → cname.vercel-dns.com`
|
||||
4. Verify: `dig welcomeclient.iamcavalli.net +short` should return `cname.vercel-dns.com`
|
||||
|
||||
## Bug Fixed (outside plan scope)
|
||||
|
||||
**Tailwind v4 scanning `.01_projects/` directory**
|
||||
- Root cause: Tailwind v4 auto-detects and scans ALL files in the repo root
|
||||
- The `.01_projects/sparklingorbit` subdirectory contains a Python `.venv` with `markdown_it` library
|
||||
- That library has a regex comment containing `[-:|]` (a regex character class)
|
||||
- Tailwind interpreted `[-:|]` as an arbitrary CSS property class → generates invalid CSS `-: |`
|
||||
- Dev server Turbopack was treating this as a fatal CSS parse error → 500 on all pages
|
||||
- Fix: added `@source not` directives in `globals.css` to exclude adjacent projects
|
||||
|
||||
## Commits
|
||||
|
||||
- `073eec7` — feat(seed): add seed script + fix Tailwind scanning adjacent projects
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -1,126 +0,0 @@
|
||||
# Phase 1: Foundation & Client Dashboard - Context
|
||||
|
||||
**Gathered:** 2026-05-13
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Costruire il DB schema, il token API e la dashboard cliente read-only. Al termine di questa fase, un link segreto è condivisibile con un cliente reale che può aprirlo su mobile o desktop e vedere lo stato completo del suo progetto — senza login, senza admin, senza interazione. L'admin area (CRUD, auth, commenti, approvazioni) è Phase 2.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Database & Infrastruttura
|
||||
|
||||
- **D-01: Database su Coolify (Hetzner)** — Postgres istanza gestita da Coolify sul server Hetzner già pagato. Zero costo aggiuntivo. Neon è scartato in favore del self-hosting già disponibile.
|
||||
- **D-02: ORM invariato** — Drizzle ORM con driver `postgres-js` (invece di `neon-http`). Schema e migrazioni identici, solo il driver di connessione cambia.
|
||||
- **D-03: DNS in Phase 1** — Configurare `welcomeclient.iamcavalli.net` come CNAME verso Vercel nella Phase 1, non alla fine. Propagazione va verificata subito.
|
||||
|
||||
### Brand & Visual Design
|
||||
|
||||
- **D-04: Brand hardcoded in Phase 1** — Colori e logo iamcavalli fissi nel codice. La personalizzazione admin (tabella `brand_settings`, pannello colori/logo) è demandata a Phase 2.
|
||||
- **D-05: Stile light & clean** — Sfondo chiaro, typography forte, layout professionale e leggibile su mobile.
|
||||
- **D-06: Header dashboard** — Logo iamcavalli piccolo in un angolo (es. top-right o top-left), nome del brand cliente (`brand_name`) in primo piano e prominente. Non il nome del cliente, il nome del suo brand.
|
||||
|
||||
### Layout Fasi e Task
|
||||
|
||||
- **D-07: Timeline laterale per le fasi** — Indicatore temporale/progressione a sinistra, contenuto fase sulla destra. Trasmette senso di avanzamento sequenziale del progetto.
|
||||
- **D-08: Barra progresso per fase** — In cima a ogni fase, una barra % calcolata dai task completati. Sotto la barra, lista dei task.
|
||||
- **D-09: Barra progresso globale in cima** — Progress bar globale del progetto nella parte alta della dashboard, derivata dal totale dei task completati su tutti i task. Il cliente vede subito "sei al X%".
|
||||
|
||||
### Stato Pagamenti
|
||||
|
||||
- **D-10: Pagamenti sempre visibili** — Sezione pagamenti sempre in vista nella dashboard (non nascosta in accordion). Mostra: totale accettato + stato acconto 50% + stato saldo 50%.
|
||||
- **D-11: Stati pagamento** — Tre stati per ogni payment row: `da_saldare` / `inviata` / `saldato`. Mai i prezzi singoli dei servizi — solo `accepted_total`.
|
||||
|
||||
### Storico Decisioni (DASH-10)
|
||||
|
||||
- **D-12: Admin scrive, cliente legge** — Le note/decisioni del log storico sono scritte solo dall'admin. Il cliente le vede in sola lettura nella sua dashboard. La UI per scrivere note è in Phase 2 (admin area). In Phase 1 il campo `body` è in schema e la visualizzazione lato cliente è già presente; sarà vuota finché Phase 2 non porta l'admin.
|
||||
|
||||
### Primo Cliente (Testing)
|
||||
|
||||
- **D-13: Seed script** — Uno script TypeScript (`scripts/seed.ts`) per inserire il primo cliente reale con dati completi (fasi, task, pagamenti, documenti). Eseguibile una volta con `npx tsx scripts/seed.ts`. Nessun form admin o SQL manuale necessario per Phase 1.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
- Scelta del componente UI specifico per la timeline laterale (build custom vs. shadcn primitives)
|
||||
- Struttura CSS delle card fasi e task (spaziatura, bordi, hover state)
|
||||
- Schema colori specifico light & clean (bianco puro, grigi, quale accent color in attesa del brand panel)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Progetto & Requisiti
|
||||
|
||||
- `.planning/PROJECT.md` — Contesto progetto, Core Value, decisioni chiave e vincoli
|
||||
- `.planning/REQUIREMENTS.md` — REQ-IDs Phase 1: DASH-01 through DASH-04, DASH-07 through DASH-10
|
||||
- `.planning/ROADMAP.md` — Success criteria Phase 1
|
||||
|
||||
### Ricerca tecnica
|
||||
|
||||
- `.planning/research/STACK.md` — Stack raccomandato (nota: database cambiato da Neon a Coolify Postgres)
|
||||
- `.planning/research/ARCHITECTURE.md` — Data model completo, component boundaries, build order
|
||||
- `.planning/research/PITFALLS.md` — Pitfall critici: token-as-PK, ClientView enforcement, data model day-one decisions
|
||||
|
||||
### Istruzioni progetto
|
||||
|
||||
- `CLAUDE.md` — Architectural constraints LOCKED (token separato dalla PK, accepted_total denormalizzato, approved_at immutabile, due path auth isolati)
|
||||
|
||||
No external specs beyond the above — requirements fully captured in decisions above.
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
|
||||
- Nessun codice esistente — progetto greenfield.
|
||||
|
||||
### Established Patterns
|
||||
|
||||
- Next.js 15 App Router: Server Components per la dashboard read-only (zero client-side waterfalls per rendering dati)
|
||||
- Drizzle ORM con `postgres-js` invece di `neon-http` (Coolify Postgres connection string via env var `DATABASE_URL`)
|
||||
- nanoid per generazione token: `nanoid()` → 21 char, URL-safe, ~126 bit di entropia
|
||||
|
||||
### Integration Points
|
||||
|
||||
- Dashboard cliente: route `/c/[token]` — Middleware valida token → 404 se mancante → Server Component legge DB e renderizza
|
||||
- Seed script: `scripts/seed.ts` — inserisce dati cliente reale, genera token, stampa URL condivisibile
|
||||
- DNS: CNAME `welcomeclient.iamcavalli.net` → `cname.vercel-dns.com` (da configurare su domain registrar)
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Il portale deve sembrare professionale e in linea con il brand iamcavalli — light & clean, non un SaaS generico
|
||||
- Logo iamcavalli piccolo in corner + nome brand cliente prominente nell'header
|
||||
- Progress bar globale del progetto in cima alla dashboard (percentuale)
|
||||
- Timeline laterale per le fasi (non accordion, non card flat) — trasmette sequenzialità e avanzamento
|
||||
- Barra progresso per singola fase, calcolata da task completati
|
||||
- Personalizzazione colori/logo dal pannello admin è demandata a Phase 2 — in Phase 1 brand hardcoded
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **Brand customization panel** (colori background, testi, logo upload dall'admin) → Phase 2, da aggiungere come requisito nell'area admin
|
||||
- **Three.js / animazioni 3D** → Non necessario per questo tipo di portale. UI curata con Tailwind è sufficiente.
|
||||
- **Commenti e approvazioni cliente** → Phase 2 (DASH-05, DASH-06)
|
||||
- **Auth admin** → Phase 2
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 1-Foundation & Client Dashboard*
|
||||
*Context gathered: 2026-05-13*
|
||||
@@ -1,128 +0,0 @@
|
||||
# Phase 1: Foundation & Client Dashboard - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Phase:** 1 - Foundation & Client Dashboard
|
||||
**Areas discussed:** Database, Three.js, Brand & visual design, Layout fasi e task, Primo cliente, Storico decisioni
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Postgres su Coolify | Zero costo extra — istanza Postgres su Hetzner già pagato | ✓ |
|
||||
| Neon free tier | Postgres serverless gestito, 0.5 GB gratis, dipendenza esterna | |
|
||||
| Oracle Cloud Free Tier | ARM 4vCPU 24GB RAM gratuito, richiede configurazione | |
|
||||
|
||||
**User's choice:** Postgres su Coolify (Hetzner)
|
||||
**Notes:** L'utente ha già un server Hetzner con Coolify attivo (usato per SparklingOrbit). Preferisce zero dipendenze esterne a pagamento e mantenere i dati nella propria infrastruttura.
|
||||
|
||||
---
|
||||
|
||||
## Three.js
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Animazioni sottili | Effetti di sfondo leggeri (particelle, gradienti animati) | |
|
||||
| Elemento hero visivo | Shape 3D animata nella parte alta della dashboard | |
|
||||
| Ripensaci | UI curata con Tailwind è sufficiente, niente 3D | ✓ |
|
||||
|
||||
**User's choice:** Ripensaci — niente three.js
|
||||
**Notes:** L'utente aveva menzionato three.js inizialmente ma dopo riflessione ha preferito concentrarsi su un'UI curata senza 3D.
|
||||
|
||||
---
|
||||
|
||||
## Brand & Visual Design
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Brand hardcoded in Phase 1 | Colori/logo iamcavalli fissi, personalizzazione in Phase 2 | ✓ |
|
||||
| Già configurabile in Phase 1 | Tabella brand_settings e pannello già in Phase 1 | |
|
||||
|
||||
**Brand hardcoded:** ✓ — pannello personalizzazione rimandato a Phase 2
|
||||
|
||||
| Stile visual | Description | Selected |
|
||||
|--------------|-------------|----------|
|
||||
| Dark & premium | Sfondo scuro, accenti brillanti | |
|
||||
| Light & clean | Sfondo chiaro, typography forte | ✓ |
|
||||
| Decido dal pannello | Nessuna preferenza ora | |
|
||||
|
||||
**Stile:** Light & clean
|
||||
|
||||
| Header | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Solo logo + nome progetto | Logo iamcavalli + nome progetto | |
|
||||
| Solo nome brand cliente | brand_name in grande, nessun logo | |
|
||||
| Entrambi | Logo piccolo in corner + brand_name cliente in primo piano | ✓ |
|
||||
|
||||
**Header:** Logo iamcavalli piccolo in un angolo + nome brand cliente prominente
|
||||
|
||||
---
|
||||
|
||||
## Layout Fasi e Task
|
||||
|
||||
| Layout fasi | Description | Selected |
|
||||
|-------------|-------------|----------|
|
||||
| Accordion verticale | Fasi impilate, espandibili | |
|
||||
| Card separate | Card per fase, task sempre visibili | |
|
||||
| Timeline laterale | Indicatore temporale a sinistra, contenuto a destra | ✓ |
|
||||
|
||||
**Layout fasi:** Timeline laterale
|
||||
|
||||
| Task status | Description | Selected |
|
||||
|-------------|-------------|----------|
|
||||
| Badge colorato | Pallino/badge per stato | |
|
||||
| Icona + testo | Icona con etichetta | |
|
||||
| Barra progresso fase | Barra % per fase + lista task | ✓ |
|
||||
|
||||
**Task status:** Barra progresso in cima a ogni fase
|
||||
|
||||
| Avanzamento globale | Description | Selected |
|
||||
|--------------------|-------------|----------|
|
||||
| Sì, in cima | % completamento totale in cima alla dashboard | ✓ |
|
||||
| No, solo per fase | Progresso solo per singola fase | |
|
||||
| No progress bar | Solo stati task | |
|
||||
|
||||
**Progress bar globale:** Sì, in cima alla dashboard
|
||||
|
||||
---
|
||||
|
||||
## Primo Cliente
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Seed script | TypeScript script, `npx tsx scripts/seed.ts` | ✓ |
|
||||
| Form admin minimale | Form no-auth per creare primo cliente | |
|
||||
| SQL diretto Coolify | INSERT manuale via console Coolify | |
|
||||
|
||||
**User's choice:** Seed script
|
||||
**Notes:** Lo script inserisce un cliente reale con fasi, task, pagamenti, documenti e stampa l'URL condivisibile.
|
||||
|
||||
---
|
||||
|
||||
## Storico Decisioni (DASH-10)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Solo admin scrive | Admin aggiunge note, cliente legge in sola lettura | ✓ |
|
||||
| Visibile solo in Phase 2 | UI mostrata solo con admin area | |
|
||||
|
||||
**User's choice:** Admin scrive, cliente legge
|
||||
**Notes:** In Phase 1 lo schema è già presente e il cliente vede le note (sezione vuota inizialmente). La UI per aggiungere note arriva con l'area admin in Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Scelta componente UI specifico per timeline laterale (shadcn primitives vs. custom)
|
||||
- CSS dettagliato delle card fasi e task (spaziatura, bordi, hover)
|
||||
- Accent color provvisorio per Phase 1 (in attesa del brand panel in Phase 2)
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- **Brand customization panel** (colori, logo upload dall'admin) → Phase 2
|
||||
- **Three.js / animazioni 3D** → Scartato
|
||||
- **Commenti e approvazioni** → Phase 2 (DASH-05, DASH-06)
|
||||
@@ -1,302 +0,0 @@
|
||||
# ClientHub — Walking Skeleton (Phase 1)
|
||||
|
||||
**Project:** ClientHub — Freelancer Client Portal
|
||||
**Phase:** 01 — Foundation & Client Dashboard
|
||||
**Date:** 2026-05-13
|
||||
**Status:** Blueprint (decisions below are LOCKED for all subsequent phases)
|
||||
|
||||
---
|
||||
|
||||
## Project Architecture — Locked Decisions
|
||||
|
||||
This Walking Skeleton establishes the architectural foundation for all future phases. These decisions are **immutable** without explicit user approval.
|
||||
|
||||
### Core Stack
|
||||
|
||||
| Layer | Technology | Why | Locked? |
|
||||
|-------|-----------|-----|---------|
|
||||
| **Framework** | Next.js 15 (App Router, TypeScript, src/) | Server Components + Edge Middleware for performance; Vercel-native | ✅ YES |
|
||||
| **Database** | Postgres on Coolify (Hetzner), via `postgres-js` driver | Self-hosted (no Neon/Supabase cost); persistent via external DB | ✅ YES |
|
||||
| **ORM** | Drizzle ORM with postgres-js | Zero-cost serverless driver; schema-as-code migrations | ✅ YES |
|
||||
| **UI** | Tailwind CSS v4 + shadcn/ui components | Utility-first, copied components, mobile-first | ✅ YES |
|
||||
| **Auth (Admin)** | Auth.js v4 Credentials provider (Phase 2) | Single admin account, JWT cookie | ✅ YES |
|
||||
| **Auth (Client)** | Custom Next.js Middleware + token validation | No session store needed; token in URL | ✅ YES |
|
||||
| **Token Generation** | nanoid (21 chars) | Cryptographically secure, URL-safe, non-enumerable | ✅ YES |
|
||||
| **Deployment** | Vercel (Hobby plan) + custom subdomain | Native Next.js; auto-SSL; single deploy command | ✅ YES |
|
||||
|
||||
### Data Model — Locked Entities
|
||||
|
||||
All tables below **must** exist and maintain these field definitions. Modifications require explicit approval.
|
||||
|
||||
```
|
||||
clients
|
||||
id UUID PK (stable, never changes)
|
||||
name TEXT
|
||||
brand_name TEXT
|
||||
brief TEXT
|
||||
token UUID UNIQUE ← SEPARATE from PK, rotatable
|
||||
accepted_total NUMERIC ← denormalized, only price client sees
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
phases
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
title TEXT
|
||||
sort_order INT
|
||||
status TEXT (upcoming | active | done)
|
||||
|
||||
tasks
|
||||
id UUID PK
|
||||
phase_id UUID FK → phases.id
|
||||
title TEXT
|
||||
description TEXT
|
||||
status TEXT (todo | in_progress | done)
|
||||
sort_order INT
|
||||
|
||||
deliverables
|
||||
id UUID PK
|
||||
task_id UUID FK → tasks.id
|
||||
title TEXT
|
||||
url TEXT
|
||||
status TEXT (pending | submitted | approved)
|
||||
approved_at TIMESTAMPTZ ← immutable audit trail
|
||||
|
||||
comments
|
||||
id UUID PK
|
||||
entity_type TEXT (task | deliverable)
|
||||
entity_id UUID
|
||||
author TEXT (client | admin)
|
||||
body TEXT
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
payments
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
label TEXT ("Acconto 50%" | "Saldo 50%")
|
||||
amount NUMERIC
|
||||
status TEXT (da_saldare | inviata | saldato)
|
||||
paid_at TIMESTAMPTZ
|
||||
|
||||
documents
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
label TEXT
|
||||
url TEXT ← external links only, no file uploads
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
notes
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
body TEXT
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
service_catalog
|
||||
id UUID PK
|
||||
name TEXT
|
||||
description TEXT
|
||||
unit_price NUMERIC
|
||||
active BOOLEAN
|
||||
|
||||
quote_items
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
service_id UUID FK → service_catalog.id
|
||||
quantity NUMERIC
|
||||
unit_price NUMERIC
|
||||
subtotal NUMERIC
|
||||
← NEVER exposed via client API
|
||||
```
|
||||
|
||||
### Critical Design Principles — Locked
|
||||
|
||||
1. **`clients.token` is NOT the primary key.** Data is keyed by stable UUID `id`. Token is a separate, rotatable field. Rotation is a single UPDATE statement.
|
||||
|
||||
2. **Client API never exposes `quote_items`.** Server-side filtering enforces this; not a UI trick. The `accepted_total` field is the only price the client API returns.
|
||||
|
||||
3. **`deliverables.approved_at` is immutable.** Once set, it cannot be unset. Provides an audit trail for disputes.
|
||||
|
||||
4. **Two independent auth systems:**
|
||||
- `/c/[token]/*` → Middleware validates token, 404 on miss
|
||||
- `/admin/*` → Auth.js session check (Phase 2)
|
||||
- No overlap; no shared session store
|
||||
|
||||
5. **No file hosting in v1.** Documents are external URLs only (Google Drive, PDFs, Figma links). File uploads → Phase 3+.
|
||||
|
||||
6. **No email in v1.** Deliverables are dashboard links, not email attachments. Email integration → Phase 2+.
|
||||
|
||||
### Directory Structure — Locked
|
||||
|
||||
```
|
||||
IAMCAVALLI/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── c/[token]/
|
||||
│ │ │ ├── page.tsx ← Client dashboard route
|
||||
│ │ │ └── layout.tsx
|
||||
│ │ ├── admin/ ← Phase 2 (protected by middleware)
|
||||
│ │ │ ├── page.tsx ← Admin dashboard
|
||||
│ │ │ ├── clients/
|
||||
│ │ │ │ ├── page.tsx
|
||||
│ │ │ │ └── [id]/
|
||||
│ │ │ ├── catalog/
|
||||
│ │ │ └── ...
|
||||
│ │ ├── layout.tsx
|
||||
│ │ └── globals.css
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ ← shadcn/ui components
|
||||
│ │ ├── client-dashboard.tsx
|
||||
│ │ ├── phase-timeline.tsx
|
||||
│ │ ├── payment-status.tsx
|
||||
│ │ ├── documents-section.tsx
|
||||
│ │ ├── notes-section.tsx
|
||||
│ │ └── ...
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts ← Drizzle schema (source of truth)
|
||||
│ │ ├── migrations/ ← Generated by drizzle-kit
|
||||
│ │ └── index.ts ← db client export
|
||||
│ ├── lib/
|
||||
│ │ ├── client-view.ts ← ClientView type + queries
|
||||
│ │ ├── auth.ts ← Phase 2: Auth helpers
|
||||
│ │ └── ...
|
||||
│ └── middleware.ts ← Token validation at edge
|
||||
├── scripts/
|
||||
│ ├── seed.ts ← Insert first test client
|
||||
│ └── ...
|
||||
├── .env.local ← DATABASE_URL, secrets
|
||||
├── drizzle.config.ts
|
||||
├── next.config.ts
|
||||
├── tailwind.config.ts
|
||||
├── tsconfig.json
|
||||
├── package.json
|
||||
└── .planning/
|
||||
├── ROADMAP.md
|
||||
├── REQUIREMENTS.md
|
||||
├── STATE.md
|
||||
└── phases/
|
||||
└── 01-foundation-client-dashboard/
|
||||
├── 01-CONTEXT.md
|
||||
├── 01-DISCUSSION-LOG.md
|
||||
├── 01-01-PLAN.md
|
||||
├── 01-02-PLAN.md
|
||||
├── 01-03-PLAN.md
|
||||
├── 01-04-PLAN.md
|
||||
├── 01-05-PLAN.md
|
||||
└── SKELETON.md
|
||||
```
|
||||
|
||||
### Deployment — Locked
|
||||
|
||||
- **Host:** Vercel (Hobby plan, $0/month for Phase 1 scale)
|
||||
- **Domain:** welcomeclient.iamcavalli.net (CNAME to Vercel DNS)
|
||||
- **Database:** Postgres on Coolify (existing Hetzner server, Simone manages)
|
||||
- **Environment:** DATABASE_URL injected via Vercel Secrets
|
||||
- **SSL/TLS:** Vercel auto-provisioning for custom domain
|
||||
|
||||
### API Routes Structure (Phase 2+)
|
||||
|
||||
Routes created in Phase 2 will follow this pattern:
|
||||
|
||||
**Client-facing routes** (`/api/c/[token]/...`):
|
||||
- No authentication library needed
|
||||
- Middleware validates token
|
||||
- Routes return ClientView shape only
|
||||
|
||||
**Admin routes** (`/api/admin/...`):
|
||||
- Require Auth.js session
|
||||
- Access full AdminView including quote_items
|
||||
- CRUD operations on all entities
|
||||
|
||||
### UI Layer Principles — Locked
|
||||
|
||||
- **Light & clean visual style:** White backgrounds, strong typography, subtle gray accents
|
||||
- **Mobile-first design:** Tailwind defaults ensure responsive behavior
|
||||
- **Semantic HTML:** Proper heading hierarchy, accessible form controls
|
||||
- **No client-side state management libraries:** Server Components + Server Actions for Phase 1-2
|
||||
- **Progress visualization:** Global bar (top) + per-phase bars (sections) + task status badges
|
||||
- **Brand consistency:** iamcavalli logo in corner, client brand_name prominent
|
||||
|
||||
### Security Assumptions — Locked
|
||||
|
||||
1. **Database credentials are secrets:** DATABASE_URL never logged, committed, or exposed
|
||||
2. **Tokens are non-enumerable:** 21-character nanoid cannot be guessed
|
||||
3. **Client API is isolated:** Admin data never leaks to `/c/[token]/*` routes
|
||||
4. **Admin password** (Phase 2): env var `ADMIN_PASSWORD` protects `/admin/*` before Auth.js is added
|
||||
5. **No PII in logs:** Payment amounts and tokens never logged to Vercel logs
|
||||
|
||||
---
|
||||
|
||||
## What This Skeleton Delivers
|
||||
|
||||
After Phase 1 execution:
|
||||
|
||||
✅ **Functional client portal:**
|
||||
- One client can open their secret link on any device
|
||||
- Dashboard shows project phases, tasks, status, payments, documents, decision log
|
||||
- No login required; link is the secret
|
||||
|
||||
✅ **Production-ready infrastructure:**
|
||||
- Database is live on Coolify Postgres
|
||||
- Custom domain is verified and HTTPS-enabled
|
||||
- Application is deployed on Vercel
|
||||
- One-command deploy pipeline (`git push → Vercel auto-build`)
|
||||
|
||||
✅ **Developer-friendly codebase:**
|
||||
- TypeScript with strict mode
|
||||
- Drizzle ORM manages schema as code
|
||||
- Git-tracked migrations (reproducible database state)
|
||||
- One seed script to populate test data
|
||||
- No manual SQL; no database browser required
|
||||
|
||||
✅ **Foundation for Phase 2:**
|
||||
- Data model is stable and comprehensive
|
||||
- Admin CRUD can be built without schema changes
|
||||
- Auth.js integration point is clear
|
||||
- Comments and approvals schema already exists
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 → Phase 2 Contract
|
||||
|
||||
Phase 2 will extend this skeleton by:
|
||||
|
||||
1. **Admin authentication:** Middleware check + Auth.js session on `/admin/*` routes
|
||||
2. **CRUD operations:** Forms and API routes to edit clients, phases, tasks, deliverables, payments
|
||||
3. **Comments & approvals:** Client-facing UI for commenting and approving deliverables
|
||||
4. **Admin workspace:** Dashboard to manage all clients with state summary and quick actions
|
||||
5. **Payment management:** Update payment status, send payment reminders
|
||||
|
||||
**No schema changes required.** All Phase 2 features fit into the existing data model.
|
||||
|
||||
---
|
||||
|
||||
## Validation Checklist (End of Phase 1)
|
||||
|
||||
- [ ] Next.js 15 application compiles without TypeScript errors
|
||||
- [ ] Database schema is live on Coolify Postgres (all 11 tables)
|
||||
- [ ] Middleware validates tokens at edge
|
||||
- [ ] Client portal route renders complete dashboard with seeded data
|
||||
- [ ] Seed script inserts test client and prints shareable link
|
||||
- [ ] DNS CNAME is live: welcomeclient.iamcavalli.net → Vercel
|
||||
- [ ] Application is deployed on Vercel (accessible via https://welcomeclient.iamcavalli.net/)
|
||||
- [ ] Invalid tokens return 404 (no information leakage)
|
||||
- [ ] Payment amounts are NOT visible on client dashboard (only status)
|
||||
- [ ] Mobile layout is responsive and readable
|
||||
- [ ] All DASH-01 through DASH-10 requirements are satisfied (except DASH-05, DASH-06 which are Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## Future Extensibility Notes
|
||||
|
||||
This skeleton is designed for:
|
||||
|
||||
- **Phase 2:** Admin CRUD + comments + approvals (no schema changes)
|
||||
- **Phase 3:** Service catalog + quote builder (admin-only, client sees only total)
|
||||
- **Phase 4 (v2):** Claude AI onboarding flow (optional; may defer indefinitely)
|
||||
- **Beyond:** Multi-team support, real file uploads, email automation (major schema rework)
|
||||
|
||||
The current design is intentionally simple. Future phases should resist scope creep and maintain the "client sees only what they need" principle.
|
||||
|
||||
---
|
||||
|
||||
**Skeleton locked:** 2026-05-13
|
||||
**Next checkpoint:** Phase 2 planning (`/gsd-plan-phase 2`)
|
||||
@@ -1,481 +0,0 @@
|
||||
---
|
||||
phase: "02-admin-area-interactive-features"
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- package.json
|
||||
- src/proxy.ts
|
||||
- src/app/api/auth/[...nextauth]/route.ts
|
||||
- src/app/admin/login/page.tsx
|
||||
- src/app/admin/login/actions.ts
|
||||
- src/lib/auth.ts
|
||||
- .env.local
|
||||
autonomous: true
|
||||
requirements:
|
||||
- ADMIN-01
|
||||
- ADMIN-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can POST /admin/login with ADMIN_EMAIL + ADMIN_PASSWORD and receive a session JWT cookie"
|
||||
- "Visiting /admin/* without a valid session redirects to /admin/login"
|
||||
- "Visiting /c/[token]/* still validates token at edge (proxy.ts unchanged for client routes)"
|
||||
- "Session is JWT-based (stateless) — no DB users table involved"
|
||||
- "ADMIN_EMAIL and ADMIN_PASSWORD are read from env vars, never hardcoded"
|
||||
artifacts:
|
||||
- path: "src/lib/auth.ts"
|
||||
provides: "NextAuth config — CredentialsProvider validating against ADMIN_EMAIL/ADMIN_PASSWORD env vars"
|
||||
contains: "CredentialsProvider"
|
||||
- path: "src/app/api/auth/[...nextauth]/route.ts"
|
||||
provides: "NextAuth catch-all route handler"
|
||||
contains: "NextAuth"
|
||||
- path: "src/app/admin/login/page.tsx"
|
||||
provides: "Admin login form UI (email + password, submit)"
|
||||
min_lines: 30
|
||||
- path: "src/proxy.ts"
|
||||
provides: "Updated proxy: /c/* token validation + /admin/* session guard"
|
||||
contains: "getToken"
|
||||
key_links:
|
||||
- from: "src/proxy.ts"
|
||||
to: "src/app/api/auth/[...nextauth]/route.ts"
|
||||
via: "getToken({ req, secret: process.env.NEXTAUTH_SECRET })"
|
||||
pattern: "getToken"
|
||||
- from: "src/app/admin/login/page.tsx"
|
||||
to: "/api/auth/callback/credentials"
|
||||
via: "signIn('credentials', { email, password })"
|
||||
pattern: "signIn"
|
||||
- from: "ADMIN_EMAIL + ADMIN_PASSWORD"
|
||||
to: "CredentialsProvider authorize()"
|
||||
via: "process.env.ADMIN_EMAIL"
|
||||
pattern: "ADMIN_EMAIL"
|
||||
---
|
||||
|
||||
<objective>
|
||||
**Auth.js Admin Session + Proxy Guard:** Install next-auth@4, configure a CredentialsProvider that validates against ADMIN_EMAIL/ADMIN_PASSWORD env vars, wire the catch-all API route, build the login page, and extend the existing src/proxy.ts to guard /admin/* routes with a session check.
|
||||
|
||||
Purpose: Gate the entire admin area behind Auth.js JWT session before any admin UI is built. Two independent auth paths are enforced: /c/[token]/* uses edge token validation (unchanged from Phase 1), /admin/* uses getToken() from next-auth/jwt. No DB users table — single admin, env-var credentials only (per D-01, D-02, D-03, D-04).
|
||||
|
||||
Output: Working /admin/login page, session cookie on successful login, automatic redirect to /admin/login for unauthenticated access to any /admin/* route.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
||||
@.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing proxy from Phase 1 (src/proxy.ts) — EXTEND this file, do not create src/middleware.ts -->
|
||||
<!-- Current structure: named export `proxy(request)` + config.matcher = ['/c/:path*'] -->
|
||||
<!-- Next.js requires the export to be named `middleware` — rename proxy→middleware in this task -->
|
||||
<!-- Phase 2 extends it to also handle /admin/:path* session guard using getToken() from next-auth/jwt -->
|
||||
|
||||
Current src/proxy.ts content:
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// Extract token from path: /c/[token]/...
|
||||
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||
if (!tokenMatch) {
|
||||
return NextResponse.rewrite(new URL('/not-found', request.url));
|
||||
}
|
||||
|
||||
const token = tokenMatch[1];
|
||||
|
||||
try {
|
||||
const validateUrl = new URL(
|
||||
`/api/internal/validate-token?token=${encodeURIComponent(token)}`,
|
||||
request.url
|
||||
);
|
||||
const res = await fetch(validateUrl.toString());
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.rewrite(new URL('/not-found', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
} catch {
|
||||
return NextResponse.rewrite(new URL('/not-found', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/c/:path*'],
|
||||
};
|
||||
```
|
||||
|
||||
Note: Next.js middleware MUST be exported as `middleware`, not `proxy`. The Phase 1 file uses `proxy` — this plan must rename it to `middleware` while extending it with /admin/* guard. No src/middleware.ts should ever be created.
|
||||
|
||||
From src/db/index.ts:
|
||||
```typescript
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
export const db = drizzle(client);
|
||||
```
|
||||
|
||||
From src/db/schema.ts (types needed in this plan):
|
||||
```typescript
|
||||
// No schema changes needed — no users table. Auth is env-var only.
|
||||
export type Client = typeof clients.$inferSelect;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install next-auth@4, create src/lib/auth.ts and NextAuth catch-all route</name>
|
||||
<files>
|
||||
package.json
|
||||
src/lib/auth.ts
|
||||
src/app/api/auth/[...nextauth]/route.ts
|
||||
.env.local
|
||||
</files>
|
||||
<action>
|
||||
Install next-auth v4 (stable — v5 is still beta RC as of 2026-05-15, per D-01):
|
||||
```
|
||||
npm install next-auth@4
|
||||
```
|
||||
|
||||
Add to .env.local (generate NEXTAUTH_SECRET with: `openssl rand -base64 32`):
|
||||
```
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=<generated-32-byte-base64-string>
|
||||
ADMIN_EMAIL=simone.cavalli.gestione@gmail.com
|
||||
ADMIN_PASSWORD=<choose-a-strong-password>
|
||||
```
|
||||
|
||||
Create `src/lib/auth.ts` — NextAuth config, no DB adapter (per D-03):
|
||||
```typescript
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) return null;
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (!adminEmail || !adminPassword) {
|
||||
throw new Error("ADMIN_EMAIL and ADMIN_PASSWORD env vars must be set");
|
||||
}
|
||||
|
||||
if (
|
||||
credentials.email === adminEmail &&
|
||||
credentials.password === adminPassword
|
||||
) {
|
||||
// Return minimal session user — no DB lookup needed
|
||||
return { id: "admin", email: adminEmail, name: "Admin" };
|
||||
}
|
||||
|
||||
return null; // null = unauthorized (NextAuth returns 401)
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt", // stateless JWT — no DB session table (per D-03)
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: "/admin/login", // custom login page (per D-07)
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
(session.user as { id?: string }).id = token.id as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Create `src/app/api/auth/[...nextauth]/route.ts` — NextAuth catch-all:
|
||||
```typescript
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
```
|
||||
|
||||
Note: next-auth@4 with App Router uses this export pattern. The handler handles
|
||||
GET (session fetch, CSRF) and POST (sign in, sign out).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q '"next-auth"' package.json && echo "next-auth installed"</automated>
|
||||
<automated>test -f src/lib/auth.ts && grep -q "CredentialsProvider" src/lib/auth.ts && echo "CredentialsProvider configured"</automated>
|
||||
<automated>grep -q "strategy.*jwt" src/lib/auth.ts && echo "JWT session strategy set"</automated>
|
||||
<automated>grep -q "ADMIN_EMAIL" src/lib/auth.ts && echo "ADMIN_EMAIL env var referenced"</automated>
|
||||
<automated>test -f src/app/api/auth/\[...nextauth\]/route.ts && grep -q "NextAuth" src/app/api/auth/\[...nextauth\]/route.ts && echo "NextAuth route created"</automated>
|
||||
<automated>grep -q "NEXTAUTH_SECRET" .env.local && echo "NEXTAUTH_SECRET in .env.local"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- next-auth@4 is in package.json
|
||||
- src/lib/auth.ts exports authOptions with CredentialsProvider using env vars
|
||||
- src/app/api/auth/[...nextauth]/route.ts exports GET and POST handlers
|
||||
- NEXTAUTH_SECRET, ADMIN_EMAIL, ADMIN_PASSWORD are set in .env.local
|
||||
- npm run build passes without errors
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Extend src/proxy.ts to guard /admin/* with session check; create /admin/login page</name>
|
||||
<files>
|
||||
src/proxy.ts
|
||||
src/app/admin/login/page.tsx
|
||||
src/app/admin/login/actions.ts
|
||||
</files>
|
||||
<action>
|
||||
**Replace** `src/proxy.ts` entirely. NEVER create src/middleware.ts — it would be a dead file ignored by Next.js since this project uses src/proxy.ts as the middleware entry point. The new file renames the export from `proxy` to `middleware` (required by Next.js) and adds the /admin/* guard alongside the existing /c/* token validation logic (per D-04):
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// ── ADMIN GUARD ──────────────────────────────────────────────────────────
|
||||
if (pathname.startsWith("/admin")) {
|
||||
// Allow the login page and NextAuth API routes through without session check
|
||||
if (
|
||||
pathname === "/admin/login" ||
|
||||
pathname.startsWith("/api/auth")
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
const loginUrl = new URL("/admin/login", request.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// ── CLIENT TOKEN GUARD ───────────────────────────────────────────────────
|
||||
if (pathname.startsWith("/c/")) {
|
||||
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||
if (!tokenMatch) {
|
||||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||
}
|
||||
|
||||
const clientToken = tokenMatch[1];
|
||||
|
||||
try {
|
||||
const validateUrl = new URL(
|
||||
`/api/internal/validate-token?token=${encodeURIComponent(clientToken)}`,
|
||||
request.url
|
||||
);
|
||||
const res = await fetch(validateUrl.toString());
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
} catch {
|
||||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/admin/:path*", "/c/:path*"],
|
||||
};
|
||||
```
|
||||
|
||||
Note: The export is renamed from `proxy` to `middleware`. This is the only correct Next.js middleware export name. The /c/* logic is preserved verbatim from Phase 1.
|
||||
|
||||
Create `src/app/admin/login/page.tsx` — login form as Client Component:
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/admin";
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false, // handle redirect manually to show errors
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Email o password non corretti.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(callbackUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Admin — ClientHub</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Accesso in corso..." : "Accedi"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Do NOT create src/app/admin/login/actions.ts — the login is handled
|
||||
client-side via signIn(). No Server Action file is needed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/proxy.ts && grep -q "getToken" src/proxy.ts && echo "getToken imported in proxy.ts"</automated>
|
||||
<automated>grep -q "export async function middleware" src/proxy.ts && echo "export named middleware (not proxy)"</automated>
|
||||
<automated>grep -q '"/admin/:path\*"' src/proxy.ts && echo "admin matcher configured"</automated>
|
||||
<automated>grep -q '"/c/:path\*"' src/proxy.ts && echo "client matcher still present"</automated>
|
||||
<automated>grep -q "pathname === \"/admin/login\"" src/proxy.ts && echo "login page exempted from auth guard"</automated>
|
||||
<automated>test -f src/app/admin/login/page.tsx && grep -q "signIn" src/app/admin/login/page.tsx && echo "login page uses signIn"</automated>
|
||||
<automated>grep -q '"use client"' src/app/admin/login/page.tsx && echo "login page is Client Component"</automated>
|
||||
<automated>test ! -f src/middleware.ts && echo "src/middleware.ts does NOT exist (correct)"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- src/proxy.ts guards /admin/* routes: unauthenticated requests redirect to /admin/login?callbackUrl=...
|
||||
- Export renamed from proxy to middleware (required by Next.js)
|
||||
- /admin/login and /api/auth/* are exempt from the session guard
|
||||
- /c/:path* token validation is unchanged
|
||||
- /admin/login page renders email+password form, calls signIn('credentials'), shows error on failure, redirects on success
|
||||
- src/middleware.ts does NOT exist
|
||||
- npm run build passes
|
||||
- Manual verification: visiting http://localhost:3000/admin redirects to /admin/login; successful login redirects back to /admin
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → /admin/* | All admin routes gated by JWT session cookie; proxy.ts rejects unauthenticated requests before any page code runs |
|
||||
| Login form → CredentialsProvider | Email + password transmitted over HTTPS; validated in server-side authorize() only |
|
||||
| NEXTAUTH_SECRET → JWT signing | All session tokens are HMAC-signed; tampering is detectable |
|
||||
| ADMIN_EMAIL/ADMIN_PASSWORD → env vars | Credentials never in source code; must be in .env.local and Vercel environment |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-01 | Spoofing | Admin login | mitigate | CredentialsProvider validates against env vars server-side; password never logged; JWT signed with NEXTAUTH_SECRET |
|
||||
| T-02-02 | Tampering | JWT session cookie | mitigate | next-auth signs JWT with NEXTAUTH_SECRET (HMAC-SHA256); proxy.ts verifies signature on every /admin request via getToken() |
|
||||
| T-02-03 | Information Disclosure | ADMIN_PASSWORD in env | mitigate | Stored only in .env.local (gitignored) and Vercel environment secrets; never returned in API responses |
|
||||
| T-02-04 | Elevation of Privilege | /api/auth/* exemption | accept | NextAuth API routes are exempt from session guard by design; they perform their own CSRF and credential validation internally |
|
||||
| T-02-05 | Denial of Service | Brute-force login | accept | Single admin, not a public product; no rate limiting in v1. If needed in v2, add next-auth rate limit middleware. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After plan execution:
|
||||
1. `npm run build` — no TypeScript errors
|
||||
2. `npm run dev`, visit http://localhost:3000/admin → redirects to /admin/login
|
||||
3. Submit wrong credentials → error message "Email o password non corretti." appears
|
||||
4. Submit correct ADMIN_EMAIL + ADMIN_PASSWORD → redirects to /admin (200, even if page is blank)
|
||||
5. Visit http://localhost:3000/c/any-token → still validates token (client path unchanged)
|
||||
6. Visit http://localhost:3000/api/auth/session after login → returns `{ user: { email, id: "admin" } }`
|
||||
7. Confirm src/middleware.ts does not exist in the repo
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Admin can log in at /admin/login with env-var credentials and receive a JWT session cookie
|
||||
- All /admin/* routes (except /admin/login and /api/auth/*) redirect unauthenticated visitors to /admin/login
|
||||
- Client token route /c/:path* is unaffected
|
||||
- No DB users table exists or is needed
|
||||
- src/proxy.ts is the middleware file — src/middleware.ts never created
|
||||
- npm run build passes cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
phase: "02-admin-area-interactive-features"
|
||||
plan: 01
|
||||
subsystem: "auth"
|
||||
tags: [next-auth, credentials, jwt, admin, session, middleware]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides: [admin-session-auth, admin-login-page, proxy-admin-guard]
|
||||
affects: [src/proxy.ts, src/lib/auth.ts]
|
||||
tech_stack:
|
||||
added: [next-auth@4]
|
||||
patterns: [CredentialsProvider, JWT session strategy, edge proxy guard]
|
||||
key_files:
|
||||
created:
|
||||
- src/lib/auth.ts
|
||||
- src/app/api/auth/[...nextauth]/route.ts
|
||||
- src/app/admin/login/page.tsx
|
||||
modified:
|
||||
- src/proxy.ts
|
||||
- package.json
|
||||
- package-lock.json
|
||||
- .env.local
|
||||
decisions:
|
||||
- "Keep proxy export named 'proxy' (not 'middleware') — Next.js 16 renamed the middleware concept back to proxy, breaking the plan's rename instruction"
|
||||
- "Wrap useSearchParams() in Suspense boundary — required by Next.js App Router for static prerendering"
|
||||
- "Single admin user, env-var credentials only — no DB users table (stateless JWT)"
|
||||
metrics:
|
||||
duration: "~15 minutes"
|
||||
completed: "2026-05-15T08:42:35Z"
|
||||
tasks_completed: 2
|
||||
files_changed: 7
|
||||
---
|
||||
|
||||
# Phase 02 Plan 01: Auth.js Admin Session + Proxy Guard Summary
|
||||
|
||||
Auth.js v4 CredentialsProvider with JWT sessions gates the entire /admin/* area using env-var credentials (no DB users table), with an edge proxy guard in src/proxy.ts that validates sessions via getToken() before any admin page code runs.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: next-auth@4 installation and auth config
|
||||
- Installed `next-auth@4` (stable; v5 still RC as of 2026-05-15)
|
||||
- Created `src/lib/auth.ts` — NextAuthOptions with CredentialsProvider reading `ADMIN_EMAIL` + `ADMIN_PASSWORD` from env vars; JWT session strategy (stateless, no DB adapter)
|
||||
- Created `src/app/api/auth/[...nextauth]/route.ts` — NextAuth catch-all handler (GET + POST)
|
||||
- Updated `.env.local` with `NEXTAUTH_URL`, `NEXTAUTH_SECRET` (32-byte base64), `ADMIN_EMAIL`, `ADMIN_PASSWORD`
|
||||
|
||||
### Task 2: Proxy guard and login page
|
||||
- Extended `src/proxy.ts` with `/admin/*` session guard using `getToken()` from `next-auth/jwt`
|
||||
- `/admin/login` and `/api/auth/*` exempted from the guard (pass-through)
|
||||
- Unauthenticated `/admin/*` requests redirect to `/admin/login?callbackUrl=<original-path>`
|
||||
- `/c/:path*` client token validation logic preserved verbatim from Phase 1
|
||||
- matcher updated: `["/admin/:path*", "/c/:path*"]`
|
||||
- Created `src/app/admin/login/page.tsx` — email+password Client Component with `signIn('credentials')`, inline error display ("Email o password non corretti."), redirect on success
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Description |
|
||||
|------|--------|-------------|
|
||||
| 1 | 5d363a6 | feat(02-01): install next-auth@4, configure CredentialsProvider auth |
|
||||
| 2 | 69f8a7e | feat(02-01): extend proxy.ts with admin session guard, add login page |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Next.js 16 proxy export name is 'proxy', not 'middleware'**
|
||||
- **Found during:** Task 2 first build attempt
|
||||
- **Issue:** The plan instructed renaming the export to `middleware` (Next.js 15 convention), but this project runs Next.js 16.2.6, which introduced the `proxy` concept and requires the function to be named `proxy`. The build failed with: "Proxy is missing expected function export name"
|
||||
- **Fix:** Kept the export name as `proxy` — consistent with the existing Phase 1 file and Next.js 16 API
|
||||
- **Files modified:** `src/proxy.ts`
|
||||
|
||||
**2. [Rule 1 - Bug] useSearchParams() requires Suspense boundary in App Router**
|
||||
- **Found during:** Task 2 second build attempt
|
||||
- **Issue:** `useSearchParams()` in a Client Component causes a build failure during static page generation without a Suspense boundary. Error: "useSearchParams() should be wrapped in a suspense boundary at page /admin/login"
|
||||
- **Fix:** Extracted the form into `AdminLoginForm` component; wrapped it in `<Suspense>` inside the default export `AdminLoginPage`
|
||||
- **Files modified:** `src/app/admin/login/page.tsx`
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all implemented functionality is complete and functional.
|
||||
|
||||
## Threat Surface Scan
|
||||
|
||||
No new security surface beyond what was planned in the threat model:
|
||||
- T-02-01: Mitigated — CredentialsProvider validates against env vars server-side
|
||||
- T-02-02: Mitigated — JWT signed with NEXTAUTH_SECRET, verified via getToken() on every /admin request
|
||||
- T-02-03: Mitigated — ADMIN_PASSWORD stored only in .env.local (gitignored) and Vercel secrets
|
||||
- T-02-04: Accepted — /api/auth/* exempt by design, NextAuth handles its own CSRF
|
||||
- T-02-05: Accepted — No rate limiting in v1
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files exist on disk. Both task commits (5d363a6, 69f8a7e) verified in git log.
|
||||
@@ -1,561 +0,0 @@
|
||||
---
|
||||
phase: "02-admin-area-interactive-features"
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "02-01"
|
||||
files_modified:
|
||||
- src/app/admin/page.tsx
|
||||
- src/app/admin/layout.tsx
|
||||
- src/app/admin/clients/new/page.tsx
|
||||
- src/app/admin/clients/new/actions.ts
|
||||
- src/lib/admin-queries.ts
|
||||
- src/components/admin/ClientRow.tsx
|
||||
- src/components/admin/NavBar.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- ADMIN-01
|
||||
- ADMIN-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can see a list of all clients at /admin with name, brand, and payment status badges"
|
||||
- "Admin can create a new client via /admin/clients/new form; on submit the client row + two payment rows are inserted and the secret link (token) is auto-generated"
|
||||
- "After creating a client, admin is redirected to /admin (or /admin/clients/[id] for detail)"
|
||||
- "The new client's shareable link /c/[token] is visible to the admin immediately after creation"
|
||||
- "Payment status badges for Acconto and Saldo are visible in the client list row"
|
||||
artifacts:
|
||||
- path: "src/app/admin/page.tsx"
|
||||
provides: "Admin client list — Server Component fetching all clients with payments"
|
||||
contains: "export default async function"
|
||||
- path: "src/app/admin/layout.tsx"
|
||||
provides: "Admin layout with minimal NavBar (logo + Clienti link + logout button)"
|
||||
contains: "NavBar"
|
||||
- path: "src/app/admin/clients/new/page.tsx"
|
||||
provides: "New client form page"
|
||||
min_lines: 30
|
||||
- path: "src/app/admin/clients/new/actions.ts"
|
||||
provides: "Server Action: createClient() — inserts client + 2 payment rows"
|
||||
contains: "createClient"
|
||||
- path: "src/lib/admin-queries.ts"
|
||||
provides: "Admin-side DB query functions (getAllClientsWithPayments)"
|
||||
contains: "getAllClientsWithPayments"
|
||||
key_links:
|
||||
- from: "src/app/admin/page.tsx"
|
||||
to: "src/lib/admin-queries.ts"
|
||||
via: "getAllClientsWithPayments()"
|
||||
pattern: "getAllClientsWithPayments"
|
||||
- from: "src/app/admin/clients/new/page.tsx"
|
||||
to: "src/app/admin/clients/new/actions.ts"
|
||||
via: "createClient Server Action"
|
||||
pattern: "createClient"
|
||||
- from: "createClient action"
|
||||
to: "clients + payments tables"
|
||||
via: "db.insert(clients) + db.insert(payments) x2"
|
||||
pattern: "db.insert"
|
||||
---
|
||||
|
||||
<objective>
|
||||
**Admin Client List + Create Client:** Build the admin home page (client list with payment badges) and the new client creation form. The create form auto-generates the nanoid secret token, inserts the client row, and creates two payment rows (Acconto 50% / Saldo 50%) in a single Server Action.
|
||||
|
||||
Purpose: Deliver the first end-to-end admin capability — admin can enter a client's details and immediately get a shareable /c/[token] link. Implements ADMIN-01 (client list with status) and the creation half of ADMIN-02 (per D-05 Server Actions, D-07 list→detail layout, D-09 minimal nav).
|
||||
|
||||
Output: /admin shows all clients with payment badges; /admin/clients/new creates a client and two payment stubs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
||||
@.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Exact schema exports from src/db/schema.ts (do not re-read the file) -->
|
||||
```typescript
|
||||
export const clients = pgTable("clients", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
brand_name: text("brand_name").notNull(),
|
||||
brief: text("brief").notNull(),
|
||||
token: text("token").notNull().unique().$defaultFn(() => nanoid()),
|
||||
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const payments = pgTable("payments", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
client_id: text("client_id").notNull().references(() => clients.id, { onDelete: "cascade" }),
|
||||
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
|
||||
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
||||
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
|
||||
paid_at: timestamp("paid_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export type Client = typeof clients.$inferSelect;
|
||||
export type NewClient = typeof clients.$inferInsert;
|
||||
export type Payment = typeof payments.$inferSelect;
|
||||
```
|
||||
|
||||
From src/db/index.ts:
|
||||
```typescript
|
||||
export const db = drizzle(client); // drizzle-orm/postgres-js
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create src/lib/admin-queries.ts and admin layout + NavBar component</name>
|
||||
<files>
|
||||
src/lib/admin-queries.ts
|
||||
src/app/admin/layout.tsx
|
||||
src/components/admin/NavBar.tsx
|
||||
</files>
|
||||
<action>
|
||||
Create `src/lib/admin-queries.ts` — all admin-side DB reads live here:
|
||||
```typescript
|
||||
import { db } from "@/db";
|
||||
import { clients, payments } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type ClientWithPayments = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand_name: string;
|
||||
token: string;
|
||||
accepted_total: string;
|
||||
created_at: Date;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: string;
|
||||
amount: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getAllClientsWithPayments(): Promise<ClientWithPayments[]> {
|
||||
const allClients = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.orderBy(clients.created_at);
|
||||
|
||||
if (allClients.length === 0) return [];
|
||||
|
||||
const allPayments = await db
|
||||
.select()
|
||||
.from(payments);
|
||||
|
||||
return allClients.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
brand_name: c.brand_name,
|
||||
token: c.token,
|
||||
accepted_total: c.accepted_total ?? "0",
|
||||
created_at: c.created_at,
|
||||
payments: allPayments
|
||||
.filter((p) => p.client_id === c.id)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
status: p.status,
|
||||
amount: p.amount,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getClientById(id: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/components/admin/NavBar.tsx` — minimal nav per D-09 (no sidebar):
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function NavBar() {
|
||||
return (
|
||||
<nav className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="font-semibold text-gray-900">ClientHub</span>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Clienti
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
||||
className="text-sm text-gray-500"
|
||||
>
|
||||
Esci
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/app/admin/layout.tsx` — wraps all /admin/* pages:
|
||||
```typescript
|
||||
import { NavBar } from "@/components/admin/NavBar";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<NavBar />
|
||||
<main className="max-w-5xl mx-auto px-6 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/lib/admin-queries.ts && grep -q "getAllClientsWithPayments" src/lib/admin-queries.ts && echo "admin-queries.ts created"</automated>
|
||||
<automated>grep -q "getClientById" src/lib/admin-queries.ts && echo "getClientById exported"</automated>
|
||||
<automated>test -f src/components/admin/NavBar.tsx && grep -q "signOut" src/components/admin/NavBar.tsx && echo "NavBar with logout"</automated>
|
||||
<automated>test -f src/app/admin/layout.tsx && grep -q "NavBar" src/app/admin/layout.tsx && echo "Admin layout wraps NavBar"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- src/lib/admin-queries.ts exports getAllClientsWithPayments() and getClientById()
|
||||
- NavBar renders with "Clienti" link and "Esci" button
|
||||
- Admin layout wraps all /admin/* pages with NavBar + centered main content area
|
||||
- npm run build passes
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Build /admin client list page and /admin/clients/new create-client flow</name>
|
||||
<files>
|
||||
src/app/admin/page.tsx
|
||||
src/components/admin/ClientRow.tsx
|
||||
src/app/admin/clients/new/page.tsx
|
||||
src/app/admin/clients/new/actions.ts
|
||||
</files>
|
||||
<action>
|
||||
Create `src/components/admin/ClientRow.tsx` — single row in client list table:
|
||||
```typescript
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { ClientWithPayments } from "@/lib/admin-queries";
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
da_saldare: { label: "Da saldare", variant: "destructive" },
|
||||
inviata: { label: "Inviata", variant: "secondary" },
|
||||
saldato: { label: "Saldato", variant: "default" },
|
||||
};
|
||||
|
||||
export function ClientRow({ client }: { client: ClientWithPayments }) {
|
||||
const acconto = client.payments.find((p) => p.label.includes("Acconto"));
|
||||
const saldo = client.payments.find((p) => p.label.includes("Saldo"));
|
||||
|
||||
return (
|
||||
<tr className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<td className="py-3 px-4">
|
||||
<Link
|
||||
href={`/admin/clients/${client.id}`}
|
||||
className="font-medium text-gray-900 hover:underline"
|
||||
>
|
||||
{client.name}
|
||||
</Link>
|
||||
<p className="text-xs text-gray-400">{client.brand_name}</p>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
€ {parseFloat(client.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{acconto && (
|
||||
<Badge variant={statusConfig[acconto.status]?.variant ?? "outline"}>
|
||||
Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{saldo && (
|
||||
<Badge variant={statusConfig[saldo.status]?.variant ?? "outline"}>
|
||||
Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<a
|
||||
href={`/c/${client.token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline font-mono"
|
||||
>
|
||||
/c/{client.token.slice(0, 10)}…
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/app/admin/page.tsx` — Server Component, no client state:
|
||||
```typescript
|
||||
import Link from "next/link";
|
||||
import { getAllClientsWithPayments } from "@/lib/admin-queries";
|
||||
import { ClientRow } from "@/components/admin/ClientRow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const revalidate = 0; // always fresh — admin needs real-time data
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const clients = await getAllClientsWithPayments();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Clienti</h1>
|
||||
<Button asChild>
|
||||
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{clients.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<p>Nessun cliente ancora.</p>
|
||||
<p className="mt-2">
|
||||
<Link href="/admin/clients/new" className="text-blue-600 hover:underline">
|
||||
Crea il primo cliente
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Cliente</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Totale</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Acconto</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Saldo</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-600">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((client) => (
|
||||
<ClientRow key={client.id} client={client} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/app/admin/clients/new/actions.ts` — Server Action (per D-05):
|
||||
```typescript
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { clients, payments } from "@/db/schema";
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1, "Nome richiesto"),
|
||||
brand_name: z.string().min(1, "Nome brand richiesto"),
|
||||
brief: z.string().min(1, "Brief richiesto"),
|
||||
});
|
||||
|
||||
export async function createClient(formData: FormData) {
|
||||
const raw = {
|
||||
name: formData.get("name") as string,
|
||||
brand_name: formData.get("brand_name") as string,
|
||||
brief: formData.get("brief") as string,
|
||||
};
|
||||
|
||||
const parsed = createClientSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
// In v1 return errors as thrown string — form displays validation inline
|
||||
throw new Error(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
|
||||
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
|
||||
const [newClient] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
name: parsed.data.name,
|
||||
brand_name: parsed.data.brand_name,
|
||||
brief: parsed.data.brief,
|
||||
})
|
||||
.returning({ id: clients.id, token: clients.token });
|
||||
|
||||
// Always create two payment stubs per client — Acconto 50% and Saldo 50%
|
||||
// Amounts default to 0 until admin sets accepted_total; admin updates separately
|
||||
await db.insert(payments).values([
|
||||
{
|
||||
client_id: newClient.id,
|
||||
label: "Acconto 50%",
|
||||
amount: "0",
|
||||
status: "da_saldare",
|
||||
},
|
||||
{
|
||||
client_id: newClient.id,
|
||||
label: "Saldo 50%",
|
||||
amount: "0",
|
||||
status: "da_saldare",
|
||||
},
|
||||
]);
|
||||
|
||||
revalidatePath("/admin");
|
||||
redirect(`/admin/clients/${newClient.id}`);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/app/admin/clients/new/page.tsx` — form using the Server Action:
|
||||
```typescript
|
||||
import { createClient } from "./actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NewClientPage() {
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<div className="mb-6">
|
||||
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
|
||||
← Clienti
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nuovo cliente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createClient} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Nome cliente</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="es. Marco Rossi"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="brand_name">Nome brand</Label>
|
||||
<Input
|
||||
id="brand_name"
|
||||
name="brand_name"
|
||||
type="text"
|
||||
placeholder="es. Rossi Studio"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="brief">Brief del progetto</Label>
|
||||
<Textarea
|
||||
id="brief"
|
||||
name="brief"
|
||||
placeholder="Descrizione del progetto e degli obiettivi..."
|
||||
rows={5}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit">Crea cliente</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin">Annulla</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/app/admin/page.tsx && grep -q "getAllClientsWithPayments" src/app/admin/page.tsx && echo "Admin page fetches clients"</automated>
|
||||
<automated>grep -q "ClientRow" src/app/admin/page.tsx && echo "ClientRow used in table"</automated>
|
||||
<automated>test -f src/app/admin/clients/new/actions.ts && grep -q '"use server"' src/app/admin/clients/new/actions.ts && echo "Server Action directive present"</automated>
|
||||
<automated>grep -q "db.insert(clients)" src/app/admin/clients/new/actions.ts && echo "client insert present"</automated>
|
||||
<automated>grep -q "Acconto 50%" src/app/admin/clients/new/actions.ts && grep -q "Saldo 50%" src/app/admin/clients/new/actions.ts && echo "both payment stubs inserted"</automated>
|
||||
<automated>grep -q "createClientSchema" src/app/admin/clients/new/actions.ts && echo "Zod validation present"</automated>
|
||||
<automated>test -f src/app/admin/clients/new/page.tsx && grep -q "action={createClient}" src/app/admin/clients/new/page.tsx && echo "form wired to Server Action"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- /admin shows table of clients with name, brand, totale, acconto badge, saldo badge, client link
|
||||
- Empty state shows "Nessun cliente ancora" with link to create
|
||||
- /admin/clients/new shows form with name, brand_name, brief fields
|
||||
- Submitting the form inserts client row (token auto-generated by nanoid) + 2 payment stubs
|
||||
- After creation, admin is redirected to /admin/clients/[id] (detail page — stub until Plan 03)
|
||||
- npm run build passes
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin browser → Server Action | createClient() runs server-side; input validated with Zod before any DB write |
|
||||
| Admin browser → /admin/* | Middleware session guard (02-01) prevents unauthenticated access to all admin pages and Server Actions |
|
||||
| Client token → DB | Token generated server-side by nanoid(), never user-supplied; cannot be guessed |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-06 | Tampering | createClient Server Action | mitigate | Zod validates all input fields before DB insert; malformed input throws, no partial writes |
|
||||
| T-02-07 | Information Disclosure | Client list page | mitigate | /admin/* protected by middleware session guard from 02-01; unauthenticated requests never reach this Server Component |
|
||||
| T-02-08 | Tampering | token generation | mitigate | token is $defaultFn(() => nanoid()) — server-generated, cryptographically random, never derived from user input |
|
||||
| T-02-09 | Information Disclosure | ClientRow renders full token | accept | Token is shown truncated in UI for usability; full token accessible via /c/[token] link only — acceptable since admin has session auth |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After plan execution:
|
||||
1. `npm run build` — no errors
|
||||
2. Log in as admin, visit /admin — client table renders (empty or with seeded data)
|
||||
3. Click "+ Nuovo cliente" → /admin/clients/new loads with form
|
||||
4. Submit form with valid data → redirects to /admin/clients/[id] (stub page acceptable at this point)
|
||||
5. Return to /admin → new client appears in table with "Da saldare" badges for Acconto and Saldo
|
||||
6. Click the /c/[token] link in the table → opens client dashboard (Phase 1 output)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- /admin shows all clients with payment status badges; empty state handled gracefully
|
||||
- New client can be created via form; token is auto-generated server-side
|
||||
- Two payment stubs (Acconto 50% / Saldo 50%) are created automatically on client creation
|
||||
- Admin can immediately share /c/[token] after creating a client
|
||||
- npm run build passes cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,144 +0,0 @@
|
||||
---
|
||||
phase: 02-admin-area-interactive-features
|
||||
plan: "02"
|
||||
subsystem: ui
|
||||
tags: [nextjs, server-actions, drizzle, shadcn, zod, nanoid, tailwind]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 02-01
|
||||
provides: Auth.js session guard protecting all /admin/* routes via middleware
|
||||
|
||||
provides:
|
||||
- Admin client list at /admin with payment status badges (Acconto/Saldo per client)
|
||||
- New client creation form at /admin/clients/new with Server Action
|
||||
- Automatic payment stub creation (Acconto 50% + Saldo 50%) on client insert
|
||||
- ClientWithPayments query type and getAllClientsWithPayments() utility
|
||||
- Admin layout with NavBar (ClientHub logo, Clienti link, Esci logout button)
|
||||
|
||||
affects:
|
||||
- 02-03
|
||||
- 02-04
|
||||
- client-detail page (Plan 03 will build /admin/clients/[id])
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Server Components for admin data pages (no client state, no useEffect)"
|
||||
- "Server Actions with Zod validation for form mutations (D-05)"
|
||||
- "revalidatePath('/admin') + redirect after mutation"
|
||||
- "nanoid token generated server-side via $defaultFn — never user-supplied"
|
||||
- "Two-query fetch pattern: clients then payments, merged in-memory (avoids JOIN complexity)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/lib/admin-queries.ts
|
||||
- src/components/admin/NavBar.tsx
|
||||
- src/app/admin/layout.tsx
|
||||
- src/components/admin/ClientRow.tsx
|
||||
- src/app/admin/page.tsx
|
||||
- src/app/admin/clients/new/page.tsx
|
||||
- src/app/admin/clients/new/actions.ts
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Zod validates createClient input server-side before any DB write — malformed input throws cleanly with no partial inserts"
|
||||
- "Payment stubs inserted immediately on client creation with amount=0 and status=da_saldare — amounts updated separately by admin"
|
||||
- "Token is $defaultFn(() => nanoid()) — server-generated, never derived from user input, satisfies T-02-08"
|
||||
- "Admin page uses revalidate=0 to always fetch fresh data — admin operations require real-time list"
|
||||
- "Two-query pattern (clients + payments) merged in-memory chosen over Drizzle relations JOIN for simplicity at this scale"
|
||||
|
||||
patterns-established:
|
||||
- "Server Action pattern: 'use server' + Zod schema + db.insert + revalidatePath + redirect"
|
||||
- "ClientRow: presentational component receiving ClientWithPayments, no data fetching"
|
||||
- "Badge variant mapping: da_saldare=destructive, inviata=secondary, saldato=default"
|
||||
|
||||
requirements-completed:
|
||||
- ADMIN-01
|
||||
- ADMIN-02
|
||||
|
||||
# Metrics
|
||||
duration: 30min
|
||||
completed: 2026-05-15
|
||||
---
|
||||
|
||||
# Phase 02 Plan 02: Admin Client List + Create Client Summary
|
||||
|
||||
**Admin home shows all clients with Acconto/Saldo payment badges; new client form inserts client row + two payment stubs via Zod-validated Server Action with nanoid token auto-generation**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~30 min
|
||||
- **Started:** 2026-05-15
|
||||
- **Completed:** 2026-05-15
|
||||
- **Tasks:** 2 (Task 1 pre-committed, Task 2 executed in this run)
|
||||
- **Files modified:** 7
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Admin dashboard at /admin renders all clients in a table with name, brand, totale, Acconto badge, Saldo badge, and truncated secret link
|
||||
- Empty state renders gracefully with a link to create the first client
|
||||
- /admin/clients/new form with nome, brand, brief fields wired to `createClient` Server Action
|
||||
- `createClient` validates with Zod, inserts client row (token auto-generated by nanoid), inserts Acconto 50% + Saldo 50% payment stubs, revalidates /admin, redirects to /admin/clients/[id]
|
||||
- Admin layout wraps all /admin/* pages with NavBar showing "ClientHub" + "Clienti" link + "Esci" logout button
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Create admin-queries.ts, NavBar, and admin layout** - `7029583` (feat)
|
||||
2. **Task 2: Admin client list page and create-client flow** - `f77051a` (feat)
|
||||
|
||||
**Plan metadata:** (docs commit follows)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/lib/admin-queries.ts` - `getAllClientsWithPayments()` and `getClientById()` query utilities; `ClientWithPayments` type
|
||||
- `src/components/admin/NavBar.tsx` - Client component with logo, Clienti nav link, signOut button
|
||||
- `src/app/admin/layout.tsx` - Layout wrapper applying NavBar to all /admin/* pages
|
||||
- `src/components/admin/ClientRow.tsx` - Table row with payment status Badge components and secret link
|
||||
- `src/app/admin/page.tsx` - Server Component fetching all clients; renders table or empty state
|
||||
- `src/app/admin/clients/new/page.tsx` - Form page (Server Component) wired to createClient action
|
||||
- `src/app/admin/clients/new/actions.ts` - Server Action: Zod validation + db.insert(clients) + db.insert(payments) x2
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Zod validates createClient input before any DB operation — malformed input throws a clean error with no partial writes
|
||||
- Payment stubs created with `amount: "0"` — amounts set later by admin via separate update flow (Plan 03)
|
||||
- `revalidate = 0` on /admin page ensures admin always sees fresh data after mutations
|
||||
- Token is entirely server-generated (`$defaultFn(() => nanoid())`) — user cannot supply or influence it (T-02-08 mitigated)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None. Build passed cleanly (existing CSS warning is pre-existing and unrelated to this plan).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
- `/admin/clients/[id]` — redirect destination after client creation. The route does not exist yet; Next.js will render a 404 until Plan 03 builds the client detail page. This is explicitly noted in the plan as acceptable at this stage ("stub until Plan 03").
|
||||
|
||||
## Threat Surface Scan
|
||||
|
||||
All threats in the plan's threat register are addressed:
|
||||
|
||||
| Threat ID | Disposition | Status |
|
||||
|-----------|-------------|--------|
|
||||
| T-02-06 | mitigate | Zod validates createClient before any DB write |
|
||||
| T-02-07 | mitigate | /admin/* protected by 02-01 middleware session guard |
|
||||
| T-02-08 | mitigate | Token is `$defaultFn(() => nanoid())`, never user-supplied |
|
||||
| T-02-09 | accept | Token shown truncated in UI; full token only via /c/[token] link |
|
||||
|
||||
No new security surfaces introduced beyond the threat model.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- /admin client list and /admin/clients/new are fully functional end-to-end
|
||||
- createClient Server Action is the canonical pattern for future admin mutations
|
||||
- Plan 03 can build /admin/clients/[id] detail page using `getClientById()` already exported from admin-queries.ts
|
||||
- Payment stub amounts need an update flow (Plan 03 or later)
|
||||
|
||||
---
|
||||
*Phase: 02-admin-area-interactive-features*
|
||||
*Completed: 2026-05-15*
|
||||
@@ -1,825 +0,0 @@
|
||||
---
|
||||
phase: "02-admin-area-interactive-features"
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "02-02"
|
||||
files_modified:
|
||||
- src/app/admin/clients/[id]/page.tsx
|
||||
- src/app/admin/clients/[id]/actions.ts
|
||||
- src/components/admin/tabs/PhasesTab.tsx
|
||||
- src/components/admin/tabs/PaymentsTab.tsx
|
||||
- src/components/admin/tabs/DocumentsTab.tsx
|
||||
- src/components/admin/tabs/CommentsTab.tsx
|
||||
- src/lib/admin-queries.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- ADMIN-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can open /admin/clients/[id] and see all client data in tabs: Panoramica, Fasi & Task, Documenti, Pagamenti, Commenti"
|
||||
- "Admin can add a phase to a client, add a task to a phase, and change task status — all via Server Actions"
|
||||
- "Admin can add a document (label + URL) and delete it"
|
||||
- "Admin can change the payment status (da_saldare / inviata / saldato) and update the accepted_total on the client row"
|
||||
- "Admin can see all comments left by the client (read-only in this tab) and post a reply as 'admin'"
|
||||
artifacts:
|
||||
- path: "src/app/admin/clients/[id]/page.tsx"
|
||||
provides: "Client workspace with tabbed layout using @radix-ui/react-tabs"
|
||||
contains: "Tabs"
|
||||
- path: "src/app/admin/clients/[id]/actions.ts"
|
||||
provides: "Server Actions: addPhase, addTask, updateTaskStatus, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment"
|
||||
contains: "addPhase"
|
||||
- path: "src/components/admin/tabs/PhasesTab.tsx"
|
||||
provides: "Fasi & Task tab — list phases with tasks, add-phase form, add-task form, task status selector"
|
||||
min_lines: 60
|
||||
- path: "src/components/admin/tabs/PaymentsTab.tsx"
|
||||
provides: "Pagamenti tab — accepted_total field + two payment rows with status selects"
|
||||
min_lines: 40
|
||||
key_links:
|
||||
- from: "src/app/admin/clients/[id]/page.tsx"
|
||||
to: "src/lib/admin-queries.ts"
|
||||
via: "getClientFullDetail(id)"
|
||||
pattern: "getClientFullDetail"
|
||||
- from: "PhasesTab, PaymentsTab, DocumentsTab"
|
||||
to: "src/app/admin/clients/[id]/actions.ts"
|
||||
via: "Server Actions bound to form action={}"
|
||||
pattern: "action={"
|
||||
- from: "updatePaymentStatus / updateAcceptedTotal"
|
||||
to: "payments / clients tables"
|
||||
via: "db.update().set().where()"
|
||||
pattern: "db.update"
|
||||
---
|
||||
|
||||
<objective>
|
||||
**Admin Client Workspace (tabs):** Build the full /admin/clients/[id] detail page with Radix Tabs. Each tab covers one concern: Panoramica (overview), Fasi & Task (add phases/tasks, update status), Documenti (add/delete document links), Pagamenti (update payment status + accepted_total), Commenti (read client comments, post admin reply). All mutations use Server Actions (per D-05). Tabs use @radix-ui/react-tabs + shadcn tabs component (per D-08).
|
||||
|
||||
Purpose: Deliver ADMIN-02 — complete management of every client's data from a single authenticated workspace.
|
||||
|
||||
Output: Admin can fully manage a client's project lifecycle without leaving the detail page.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
||||
@.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From src/db/schema.ts — all types relevant to this plan -->
|
||||
```typescript
|
||||
export type Client = typeof clients.$inferSelect;
|
||||
export type Phase = typeof phases.$inferSelect;
|
||||
export type Task = typeof tasks.$inferSelect;
|
||||
export type Deliverable = typeof deliverables.$inferSelect;
|
||||
export type Comment = typeof comments.$inferSelect;
|
||||
export type Payment = typeof payments.$inferSelect;
|
||||
export type Document = typeof documents.$inferSelect;
|
||||
export type Note = typeof notes.$inferSelect;
|
||||
|
||||
// phases columns: id, client_id, title, sort_order, status (upcoming|active|done)
|
||||
// tasks columns: id, phase_id, title, description, status (todo|in_progress|done), sort_order
|
||||
// comments columns: id, entity_type (task|deliverable), entity_id, author (client|admin), body, created_at
|
||||
// payments columns: id, client_id, label, amount, status (da_saldare|inviata|saldato), paid_at
|
||||
// documents columns: id, client_id, label, url, created_at
|
||||
```
|
||||
|
||||
<!-- From src/lib/admin-queries.ts (02-02 output) -->
|
||||
```typescript
|
||||
export async function getClientById(id: string): Promise<Client | null>;
|
||||
```
|
||||
|
||||
<!-- New function to add to admin-queries.ts in this plan -->
|
||||
<!-- getClientFullDetail(id) must return client + phases + tasks + deliverables + payments + documents + notes + comments -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install @radix-ui/react-tabs + shadcn tabs; add getClientFullDetail() to admin-queries; create Server Actions</name>
|
||||
<files>
|
||||
package.json
|
||||
src/components/ui/tabs.tsx
|
||||
src/lib/admin-queries.ts
|
||||
src/app/admin/clients/[id]/actions.ts
|
||||
</files>
|
||||
<action>
|
||||
Install Radix tabs and add shadcn tabs component (per D-08):
|
||||
```
|
||||
npx shadcn@latest add tabs
|
||||
```
|
||||
This installs @radix-ui/react-tabs and creates src/components/ui/tabs.tsx.
|
||||
|
||||
Extend `src/lib/admin-queries.ts` — add getClientFullDetail() below existing functions.
|
||||
Read the current file first to append without overwriting.
|
||||
|
||||
Add this function:
|
||||
```typescript
|
||||
import { clients, phases, tasks, deliverables, comments, payments, documents, notes } from "@/db/schema";
|
||||
import { eq, inArray, asc } from "drizzle-orm";
|
||||
|
||||
export type ClientFullDetail = {
|
||||
client: Client;
|
||||
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||
payments: Payment[];
|
||||
documents: Document[];
|
||||
notes: Note[];
|
||||
comments: Comment[];
|
||||
};
|
||||
|
||||
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
|
||||
const clientRows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
|
||||
if (clientRows.length === 0) return null;
|
||||
const client = clientRows[0];
|
||||
|
||||
const phasesRows = await db
|
||||
.select()
|
||||
.from(phases)
|
||||
.where(eq(phases.client_id, id))
|
||||
.orderBy(asc(phases.sort_order));
|
||||
|
||||
const phaseIds = phasesRows.map((p) => p.id);
|
||||
|
||||
const tasksRows = phaseIds.length === 0
|
||||
? []
|
||||
: await db.select().from(tasks).where(inArray(tasks.phase_id, phaseIds)).orderBy(asc(tasks.sort_order));
|
||||
|
||||
const taskIds = tasksRows.map((t) => t.id);
|
||||
|
||||
const deliverablesRows = taskIds.length === 0
|
||||
? []
|
||||
: await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds));
|
||||
|
||||
const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, id));
|
||||
const documentsRows = await db.select().from(documents).where(eq(documents.client_id, id)).orderBy(asc(documents.created_at));
|
||||
const notesRows = await db.select().from(notes).where(eq(notes.client_id, id)).orderBy(asc(notes.created_at));
|
||||
|
||||
// Fetch all comments for this client's tasks and deliverables
|
||||
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||
const commentsRows = allEntityIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(inArray(comments.entity_id, allEntityIds))
|
||||
.orderBy(asc(comments.created_at));
|
||||
|
||||
const phasesWithTasks = phasesRows.map((phase) => {
|
||||
const phaseTasks = tasksRows
|
||||
.filter((t) => t.phase_id === phase.id)
|
||||
.map((task) => ({
|
||||
...task,
|
||||
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
|
||||
}));
|
||||
return { ...phase, tasks: phaseTasks };
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
phases: phasesWithTasks,
|
||||
payments: paymentsRows,
|
||||
documents: documentsRows,
|
||||
notes: notesRows,
|
||||
comments: commentsRows,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/app/admin/clients/[id]/actions.ts` — all mutations for the workspace:
|
||||
```typescript
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { phases, tasks, deliverables, documents, payments, clients, comments } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
// ── PHASES ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addPhase(clientId: string, formData: FormData) {
|
||||
const title = (formData.get("title") as string)?.trim();
|
||||
if (!title) throw new Error("Titolo fase richiesto");
|
||||
|
||||
// Determine next sort_order
|
||||
const existingPhases = await db.select({ sort_order: phases.sort_order })
|
||||
.from(phases).where(eq(phases.client_id, clientId));
|
||||
const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1);
|
||||
|
||||
await db.insert(phases).values({
|
||||
client_id: clientId,
|
||||
title,
|
||||
sort_order: maxOrder + 1,
|
||||
status: "upcoming",
|
||||
});
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function updatePhaseStatus(phaseId: string, clientId: string, status: string) {
|
||||
const allowed = ["upcoming", "active", "done"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||
await db.update(phases).set({ status }).where(eq(phases.id, phaseId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── TASKS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addTask(phaseId: string, clientId: string, formData: FormData) {
|
||||
const title = (formData.get("title") as string)?.trim();
|
||||
if (!title) throw new Error("Titolo task richiesto");
|
||||
|
||||
const existingTasks = await db.select({ sort_order: tasks.sort_order })
|
||||
.from(tasks).where(eq(tasks.phase_id, phaseId));
|
||||
const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.sort_order), -1);
|
||||
|
||||
await db.insert(tasks).values({
|
||||
phase_id: phaseId,
|
||||
title,
|
||||
description: (formData.get("description") as string)?.trim() || null,
|
||||
sort_order: maxOrder + 1,
|
||||
status: "todo",
|
||||
});
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function updateTaskStatus(taskId: string, clientId: string, status: string) {
|
||||
const allowed = ["todo", "in_progress", "done"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── DELIVERABLES ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function addDeliverable(taskId: string, clientId: string, formData: FormData) {
|
||||
const title = (formData.get("title") as string)?.trim();
|
||||
const url = (formData.get("url") as string)?.trim() || null;
|
||||
if (!title) throw new Error("Titolo deliverable richiesto");
|
||||
await db.insert(deliverables).values({ task_id: taskId, title, url, status: "pending" });
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── DOCUMENTS ─────────────────────────────────────────────────────────────
|
||||
|
||||
const docSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
url: z.string().url("URL non valido"),
|
||||
});
|
||||
|
||||
export async function addDocument(clientId: string, formData: FormData) {
|
||||
const parsed = docSchema.safeParse({
|
||||
label: formData.get("label"),
|
||||
url: formData.get("url"),
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await db.insert(documents).values({ client_id: clientId, ...parsed.data });
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function deleteDocument(documentId: string, clientId: string) {
|
||||
await db.delete(documents).where(eq(documents.id, documentId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── PAYMENTS ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function updatePaymentStatus(paymentId: string, clientId: string, status: string) {
|
||||
const allowed = ["da_saldare", "inviata", "saldato"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
|
||||
const paid_at = status === "saldato" ? new Date() : null;
|
||||
await db.update(payments).set({ status, paid_at }).where(eq(payments.id, paymentId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
|
||||
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||
const val = parseFloat(raw);
|
||||
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
||||
// Update accepted_total on client row
|
||||
await db.update(clients).set({ accepted_total: raw }).where(eq(clients.id, clientId));
|
||||
// Update payment amounts to 50% each
|
||||
const half = (val / 2).toFixed(2);
|
||||
const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, clientId));
|
||||
for (const p of paymentsRows) {
|
||||
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
|
||||
}
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
// ── COMMENTS (admin reply) ────────────────────────────────────────────────
|
||||
|
||||
export async function postAdminComment(clientId: string, formData: FormData) {
|
||||
const entity = formData.get("entity") as string;
|
||||
const body = (formData.get("body") as string)?.trim();
|
||||
if (!body || !entity) throw new Error("Dati mancanti");
|
||||
const [entity_type, entity_id] = entity.split(":");
|
||||
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
|
||||
if (!["task", "deliverable"].includes(entity_type)) throw new Error("entity_type non valido");
|
||||
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/components/ui/tabs.tsx && echo "shadcn tabs component installed"</automated>
|
||||
<automated>grep -q "getClientFullDetail" src/lib/admin-queries.ts && echo "getClientFullDetail added to admin-queries"</automated>
|
||||
<automated>test -f src/app/admin/clients/\[id\]/actions.ts && grep -q '"use server"' src/app/admin/clients/\[id\]/actions.ts && echo "actions.ts is Server Action file"</automated>
|
||||
<automated>grep -q "addPhase\|addTask\|updatePaymentStatus\|updateAcceptedTotal\|postAdminComment" src/app/admin/clients/\[id\]/actions.ts && echo "all major actions present"</automated>
|
||||
<automated>grep -q "revalidatePath" src/app/admin/clients/\[id\]/actions.ts && echo "revalidatePath called in actions"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- src/components/ui/tabs.tsx exists (shadcn tabs installed)
|
||||
- getClientFullDetail(id) added to admin-queries.ts, returns all nested client data
|
||||
- actions.ts contains addPhase, addTask, updateTaskStatus, addDeliverable, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment — all with revalidatePath
|
||||
- npm run build passes
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Build /admin/clients/[id] detail page with all four tab components</name>
|
||||
<files>
|
||||
src/app/admin/clients/[id]/page.tsx
|
||||
src/components/admin/tabs/PhasesTab.tsx
|
||||
src/components/admin/tabs/PaymentsTab.tsx
|
||||
src/components/admin/tabs/DocumentsTab.tsx
|
||||
src/components/admin/tabs/CommentsTab.tsx
|
||||
</files>
|
||||
<action>
|
||||
Create `src/app/admin/clients/[id]/page.tsx` — Server Component, tab container:
|
||||
```typescript
|
||||
import { notFound } from "next/navigation";
|
||||
import { getClientFullDetail } from "@/lib/admin-queries";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||
import Link from "next/link";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ClientDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const detail = await getClientFullDetail(params.id);
|
||||
if (!detail) notFound();
|
||||
|
||||
const { client, phases, payments, documents, notes, comments } = detail;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
|
||||
← Clienti
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{client.name}</h1>
|
||||
<p className="text-sm text-gray-500">{client.brand_name}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/c/${client.token}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline font-mono bg-blue-50 px-2 py-1 rounded"
|
||||
>
|
||||
Link cliente →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="phases" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
||||
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="phases">
|
||||
<PhasesTab phases={phases} clientId={client.id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="payments">
|
||||
<PaymentsTab
|
||||
payments={payments}
|
||||
acceptedTotal={client.accepted_total ?? "0"}
|
||||
clientId={client.id}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="documents">
|
||||
<DocumentsTab documents={documents} clientId={client.id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="comments">
|
||||
<CommentsTab comments={comments} phases={phases} clientId={client.id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/components/admin/tabs/PhasesTab.tsx`:
|
||||
```typescript
|
||||
import { addPhase, addTask, updateTaskStatus, updatePhaseStatus } from "@/app/admin/clients/[id]/actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||
|
||||
type Props = {
|
||||
phases: ClientFullDetail["phases"];
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
const taskStatusOptions = [
|
||||
{ value: "todo", label: "Da fare" },
|
||||
{ value: "in_progress", label: "In corso" },
|
||||
{ value: "done", label: "Fatto" },
|
||||
];
|
||||
|
||||
const phaseStatusOptions = [
|
||||
{ value: "upcoming", label: "In arrivo" },
|
||||
{ value: "active", label: "Attiva" },
|
||||
{ value: "done", label: "Completata" },
|
||||
];
|
||||
|
||||
export function PhasesTab({ phases, clientId }: Props) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add phase form */}
|
||||
<form
|
||||
action={async (fd) => { "use server"; await addPhase(clientId, fd); }}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<Input name="title" placeholder="Nome nuova fase..." className="max-w-xs" required />
|
||||
<Button type="submit" variant="outline" size="sm">+ Fase</Button>
|
||||
</form>
|
||||
|
||||
{/* Phases list */}
|
||||
{phases.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Nessuna fase ancora.</p>
|
||||
)}
|
||||
{phases.map((phase) => (
|
||||
<div key={phase.id} className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
|
||||
<form
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
await updatePhaseStatus(phase.id, clientId, fd.get("status") as string);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={phase.status}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
{phaseStatusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-xs">Salva</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="space-y-2 mb-3">
|
||||
{phase.tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center justify-between pl-3 border-l-2 border-gray-100">
|
||||
<span className="text-sm text-gray-800">{task.title}</span>
|
||||
<form
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
await updateTaskStatus(task.id, clientId, fd.get("status") as string);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={task.status}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
{taskStatusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-xs px-1">✓</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add task form */}
|
||||
<form
|
||||
action={async (fd) => { "use server"; await addTask(phase.id, clientId, fd); }}
|
||||
className="flex gap-2 mt-2"
|
||||
>
|
||||
<Input name="title" placeholder="Nuovo task..." className="text-sm max-w-xs" required />
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-xs">+ Task</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/components/admin/tabs/PaymentsTab.tsx`:
|
||||
```typescript
|
||||
import { updatePaymentStatus, updateAcceptedTotal } from "@/app/admin/clients/[id]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Payment } from "@/db/schema";
|
||||
|
||||
type Props = {
|
||||
payments: Payment[];
|
||||
acceptedTotal: string;
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
da_saldare: "Da saldare",
|
||||
inviata: "Inviata",
|
||||
saldato: "Saldato",
|
||||
};
|
||||
|
||||
export function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-md">
|
||||
{/* Accepted total */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
|
||||
<form
|
||||
action={async (fd) => { "use server"; await updateAcceptedTotal(clientId, fd); }}
|
||||
className="flex items-end gap-3"
|
||||
>
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||
<Input
|
||||
id="accepted_total"
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={acceptedTotal}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Salva</Button>
|
||||
</form>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Payment rows */}
|
||||
{payments.map((p) => (
|
||||
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||
<span className="text-sm text-gray-600">
|
||||
€ {parseFloat(p.amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={p.status}
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="outline">Aggiorna</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/components/admin/tabs/DocumentsTab.tsx`:
|
||||
```typescript
|
||||
import { addDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Document } from "@/db/schema";
|
||||
|
||||
type Props = { documents: Document[]; clientId: string };
|
||||
|
||||
export function DocumentsTab({ documents, clientId }: Props) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
<form
|
||||
action={async (fd) => { "use server"; await addDocument(clientId, fd); }}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900">Aggiungi documento</h3>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="doc-label">Nome / etichetta</Label>
|
||||
<Input id="doc-label" name="label" placeholder="es. Brief progetto" required />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="doc-url">URL (Google Drive, PDF...)</Label>
|
||||
<Input id="doc-url" name="url" type="url" placeholder="https://drive.google.com/..." required />
|
||||
</div>
|
||||
<Button type="submit" size="sm">Aggiungi</Button>
|
||||
</form>
|
||||
|
||||
{documents.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Nessun documento ancora.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between bg-white border border-gray-200 rounded-lg px-4 py-3">
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{doc.label}
|
||||
</a>
|
||||
<form action={async () => { "use server"; await deleteDocument(doc.id, clientId); }}>
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-red-500 hover:text-red-700 text-xs">
|
||||
Rimuovi
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/components/admin/tabs/CommentsTab.tsx`:
|
||||
```typescript
|
||||
import { postAdminComment } from "@/app/admin/clients/[id]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { Comment } from "@/db/schema";
|
||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||
|
||||
type Props = {
|
||||
comments: Comment[];
|
||||
phases: ClientFullDetail["phases"];
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
export function CommentsTab({ comments, phases, clientId }: Props) {
|
||||
// Build entity label map for display
|
||||
const entityLabels: Record<string, string> = {};
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
entityLabels[task.id] = `Task: ${task.title}`;
|
||||
for (const d of task.deliverables) {
|
||||
entityLabels[d.id] = `Deliverable: ${d.title}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build list of entities the admin can reply on
|
||||
const entities: Array<{ id: string; type: string; label: string }> = [];
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
|
||||
for (const d of task.deliverables) {
|
||||
entities.push({ id: d.id, type: "deliverable", label: `Deliverable: ${d.title}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
{/* Comment list */}
|
||||
{comments.length === 0 && (
|
||||
<p className="text-sm text-gray-400">Nessun commento ancora.</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{comments.map((c) => (
|
||||
<div key={c.id} className={`flex gap-3 ${c.author === "admin" ? "flex-row-reverse" : ""}`}>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 text-sm max-w-xs ${
|
||||
c.author === "admin"
|
||||
? "bg-gray-900 text-white"
|
||||
: "bg-white border border-gray-200 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-medium mb-1 opacity-60">
|
||||
{c.author === "admin" ? "iamcavalli" : "Cliente"} — {entityLabels[c.entity_id] ?? c.entity_id}
|
||||
</p>
|
||||
<p>{c.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Admin reply form */}
|
||||
{entities.length > 0 && (
|
||||
<form
|
||||
action={async (fd) => { "use server"; await postAdminComment(clientId, fd); }}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900 text-sm">Rispondi come admin</h3>
|
||||
<select name="entity" className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white" required>
|
||||
{entities.map((e) => (
|
||||
<option key={e.id} value={`${e.type}:${e.id}`}>{e.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea name="body" placeholder="Scrivi un commento..." rows={3} required />
|
||||
<Button type="submit" size="sm">Invia risposta</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/app/admin/clients/\[id\]/page.tsx && grep -q "Tabs" src/app/admin/clients/\[id\]/page.tsx && echo "Tabs imported in detail page"</automated>
|
||||
<automated>grep -q "getClientFullDetail" src/app/admin/clients/\[id\]/page.tsx && echo "getClientFullDetail called"</automated>
|
||||
<automated>test -f src/components/admin/tabs/PhasesTab.tsx && echo "PhasesTab exists"</automated>
|
||||
<automated>test -f src/components/admin/tabs/PaymentsTab.tsx && echo "PaymentsTab exists"</automated>
|
||||
<automated>test -f src/components/admin/tabs/DocumentsTab.tsx && echo "DocumentsTab exists"</automated>
|
||||
<automated>test -f src/components/admin/tabs/CommentsTab.tsx && echo "CommentsTab exists"</automated>
|
||||
<automated>grep -q "updateAcceptedTotal" src/components/admin/tabs/PaymentsTab.tsx && echo "Payment total update wired"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- /admin/clients/[id] renders with Radix Tabs: Fasi & Task, Pagamenti, Documenti, Commenti
|
||||
- PhasesTab: shows phases with task lists; add phase form and add task form work; task status updates work
|
||||
- PaymentsTab: accepted_total editable; payment status selects update and set paid_at on saldato
|
||||
- DocumentsTab: add document (label + URL) and delete document work
|
||||
- CommentsTab: displays all comments chronologically; admin can post reply on any task/deliverable
|
||||
- npm run build passes cleanly
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin browser → Server Actions | All mutations server-side; session guard in middleware ensures only authenticated admin reaches these |
|
||||
| Server Actions → DB | Input validated with Zod or allowlist checks before any write |
|
||||
| approved_at field | Not touched by any admin action in this plan — immutability enforced by omission |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-10 | Tampering | updateTaskStatus / updatePaymentStatus | mitigate | Server-side allowlist check on status value before db.update(); invalid values throw before any write |
|
||||
| T-02-11 | Tampering | deleteDocument | mitigate | Admin-only route protected by middleware session; no client can call deleteDocument without a valid JWT |
|
||||
| T-02-12 | Information Disclosure | getClientFullDetail fetches comments | accept | Comments are fetched only by admin in this plan; client reads own comments only via client-facing API (Plan 04) |
|
||||
| T-02-13 | Tampering | postAdminComment entity_type parsing | mitigate | entity_type parsed from "type:id" composite value; only "task" and "deliverable" are valid; invalid type rejected in action |
|
||||
| T-02-14 | Elevation of Privilege | Server Action inline "use server" | mitigate | Inline "use server" directives in RSC props are Next.js 15 pattern; each closure captures clientId from Server Component scope, preventing cross-client pollution |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After plan execution:
|
||||
1. `npm run build` — no errors
|
||||
2. Log in, open /admin/clients/[id] → tabs render
|
||||
3. Add a phase → appears in Fasi & Task tab after submit
|
||||
4. Add a task to the phase → appears nested under phase
|
||||
5. Change task status → badge updates
|
||||
6. Set accepted_total to 1000 → both payments show 500.00
|
||||
7. Change payment status to "saldato" → status updates
|
||||
8. Add a document with URL → appears in list; delete it → removed
|
||||
9. If comments exist (from Phase 1 seed), they appear in Commenti tab
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Admin can fully manage client phases, tasks, documents, and payments from the detail page
|
||||
- All mutations use Server Actions with revalidatePath — no client-side fetch or state
|
||||
- accepted_total update correctly sets both payment amounts to 50% each
|
||||
- Payment status "saldato" sets paid_at timestamp
|
||||
- Tab layout renders correctly with @radix-ui/react-tabs
|
||||
- npm run build passes cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md`
|
||||
</output>
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
phase: "02-admin-area-interactive-features"
|
||||
plan: "03"
|
||||
subsystem: "admin-workspace"
|
||||
tags: [admin, tabs, server-actions, phases, tasks, payments, documents, comments]
|
||||
dependency_graph:
|
||||
requires: ["02-02"]
|
||||
provides: ["admin-client-workspace", "getClientFullDetail", "client-detail-mutations"]
|
||||
affects: ["admin-area", "client-data-management"]
|
||||
tech_stack:
|
||||
added:
|
||||
- "@radix-ui/react-tabs ^1.1.13 — tab primitive for client workspace"
|
||||
- "shadcn/ui tabs component — src/components/ui/tabs.tsx"
|
||||
patterns:
|
||||
- "Inline Server Action closures in RSC props (action={async (fd) => { 'use server'; ... }})"
|
||||
- "getClientFullDetail() — waterfall DB query assembling full client data in one call"
|
||||
- "Server-side allowlist validation on all status mutations before db.update()"
|
||||
key_files:
|
||||
created:
|
||||
- src/app/admin/clients/[id]/page.tsx
|
||||
- src/app/admin/clients/[id]/actions.ts
|
||||
- src/components/admin/tabs/PhasesTab.tsx
|
||||
- src/components/admin/tabs/PaymentsTab.tsx
|
||||
- src/components/admin/tabs/DocumentsTab.tsx
|
||||
- src/components/admin/tabs/CommentsTab.tsx
|
||||
- src/components/ui/tabs.tsx
|
||||
modified:
|
||||
- src/lib/admin-queries.ts
|
||||
- package.json
|
||||
- package-lock.json
|
||||
decisions:
|
||||
- "Inline Server Action closures capture clientId/phaseId/taskId from RSC scope — no cross-client pollution (T-02-14)"
|
||||
- "approved_at immutability enforced by omission in addDeliverable — field not set in insert, never updated"
|
||||
- "quote_items never queried in getClientFullDetail — accepted_total is the only price surface returned"
|
||||
- "params in Next.js 16 App Router must be awaited (Promise<{ id: string }>) — applied as deviation fix"
|
||||
metrics:
|
||||
duration_minutes: 25
|
||||
completed_date: "2026-05-15"
|
||||
tasks_completed: 2
|
||||
tasks_total: 2
|
||||
files_created: 7
|
||||
files_modified: 3
|
||||
---
|
||||
|
||||
# Phase 2 Plan 03: Admin Client Workspace (Tabs) Summary
|
||||
|
||||
**One-liner:** Full-featured admin client workspace with Radix Tabs covering phases/tasks, payments, documents, and comments — all mutations via inline Server Actions with server-side validation.
|
||||
|
||||
## What Was Built
|
||||
|
||||
The `/admin/clients/[id]` route delivers a complete project management workspace for the admin. Four tabs cover every concern of a client's lifecycle:
|
||||
|
||||
- **Fasi & Task** — Add phases and nested tasks, update phase/task status with select dropdowns
|
||||
- **Pagamenti** — Edit `accepted_total` (auto-splits to 50% per payment row), update payment status (sets `paid_at` on saldato)
|
||||
- **Documenti** — Add document links (label + external URL) and delete them
|
||||
- **Commenti** — Read all client/admin comments chronologically; post admin replies against any task or deliverable
|
||||
|
||||
All mutations are Server Actions in `src/app/admin/clients/[id]/actions.ts`. The page calls `getClientFullDetail()` which assembles client + phases + tasks + deliverables + payments + documents + notes + comments in a single waterfall query sequence.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Key Files |
|
||||
|------|------|--------|-----------|
|
||||
| 1 | Install tabs, add getClientFullDetail, create Server Actions | 7733566 | package.json, admin-queries.ts, [id]/actions.ts, ui/tabs.tsx |
|
||||
| 2 | Build detail page and all four tab components | 59a46d3 | [id]/page.tsx, PhasesTab, PaymentsTab, DocumentsTab, CommentsTab |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Next.js 16 params must be awaited**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** In Next.js 16 (project uses 16.2.6), dynamic route `params` is a `Promise<{ id: string }>` not a plain object. The plan's template used `params.id` directly which would fail at runtime.
|
||||
- **Fix:** Changed page signature to `params: Promise<{ id: string }>` and added `const { id } = await params;` before use.
|
||||
- **Files modified:** src/app/admin/clients/[id]/page.tsx
|
||||
|
||||
**2. [Rule 2 - Missing critical functionality] Explicit TypeScript types on Server Action closure params**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** Inline closures `async (fd) => { "use server"; ... }` lacked explicit `FormData` type annotation, which could cause TypeScript inference issues.
|
||||
- **Fix:** Added explicit `: FormData` type annotation on all closure parameters.
|
||||
- **Files modified:** PhasesTab.tsx, PaymentsTab.tsx, DocumentsTab.tsx, CommentsTab.tsx
|
||||
|
||||
## Architecture Constraints Respected
|
||||
|
||||
- `clients.token` — Read-only in this plan. Never used as primary key.
|
||||
- `quote_items` — Not queried anywhere in this plan. `accepted_total` is the only price value exposed.
|
||||
- `deliverables.approved_at` — Enforced by omission: `addDeliverable` inserts `status: "pending"` with no `approved_at` field.
|
||||
- Two independent auth paths — Admin workspace is under `/admin/*`, protected by middleware session from Phase 2 Plan 01.
|
||||
|
||||
## Security Notes (Threat Register Mitigations Applied)
|
||||
|
||||
- **T-02-10:** `updateTaskStatus` and `updatePaymentStatus` both validate status against an allowlist before any `db.update()` call.
|
||||
- **T-02-11:** `deleteDocument` is only reachable through admin-protected routes; no client can reach it without a valid Auth.js session.
|
||||
- **T-02-13:** `postAdminComment` parses the composite `"type:id"` entity value and validates `entity_type` is exactly `"task"` or `"deliverable"` before insert.
|
||||
- **T-02-14:** Inline closures capture `clientId`, `phaseId`, `taskId` from the Server Component's own scope, preventing cross-client data pollution.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all data is live from the database via `getClientFullDetail()`.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — no new network endpoints, auth paths, or schema changes introduced beyond what the plan's threat model covers.
|
||||
|
||||
## Self-Check
|
||||
|
||||
- [x] src/app/admin/clients/[id]/page.tsx exists
|
||||
- [x] src/app/admin/clients/[id]/actions.ts exists
|
||||
- [x] src/components/admin/tabs/PhasesTab.tsx exists (153 lines > 60 min)
|
||||
- [x] src/components/admin/tabs/PaymentsTab.tsx exists (96 lines > 40 min)
|
||||
- [x] src/components/admin/tabs/DocumentsTab.tsx exists
|
||||
- [x] src/components/admin/tabs/CommentsTab.tsx exists
|
||||
- [x] Commit 7733566 exists (Task 1)
|
||||
- [x] Commit 59a46d3 exists (Task 2)
|
||||
- [x] npm run build passes cleanly
|
||||
|
||||
## Self-Check: PASSED
|
||||
@@ -1,661 +0,0 @@
|
||||
---
|
||||
phase: "02-admin-area-interactive-features"
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on:
|
||||
- "02-03"
|
||||
files_modified:
|
||||
- src/app/api/client/approve/route.ts
|
||||
- src/app/api/client/comment/route.ts
|
||||
- src/app/c/[token]/page.tsx
|
||||
- src/components/client/ApproveButton.tsx
|
||||
- src/components/client/CommentForm.tsx
|
||||
- src/components/client/CommentList.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DASH-05
|
||||
- DASH-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Client can click 'Approva' on a deliverable and the approved_at timestamp is set immutably in DB"
|
||||
- "The Approva button is hidden once approved_at is set — the approved state shows a timestamp instead"
|
||||
- "Client can submit a comment on a task or deliverable; it appears in the list on reload"
|
||||
- "Comment author is 'client'; admin comments show as 'iamcavalli', client comments show as 'Tu'"
|
||||
- "Both API routes validate the client token from the request body against the DB before writing"
|
||||
- "quote_items is never queried or returned by either API route"
|
||||
artifacts:
|
||||
- path: "src/app/api/client/approve/route.ts"
|
||||
provides: "POST — validates client token, sets deliverable status=approved + approved_at=now() if not already approved"
|
||||
contains: "approved_at"
|
||||
- path: "src/app/api/client/comment/route.ts"
|
||||
provides: "POST — validates client token, inserts comment with author='client'"
|
||||
contains: "author.*client"
|
||||
- path: "src/components/client/ApproveButton.tsx"
|
||||
provides: "Client Component: Approva button that POSTs to /api/client/approve and refreshes the page"
|
||||
contains: "useRouter"
|
||||
- path: "src/components/client/CommentForm.tsx"
|
||||
provides: "Client Component: textarea + submit that POSTs to /api/client/comment"
|
||||
contains: "api/client/comment"
|
||||
- path: "src/app/c/[token]/page.tsx"
|
||||
provides: "Updated client dashboard wiring ApproveButton and CommentForm into deliverable/task sections"
|
||||
contains: "ApproveButton"
|
||||
key_links:
|
||||
- from: "ApproveButton"
|
||||
to: "POST /api/client/approve"
|
||||
via: "fetch('/api/client/approve', { body: JSON.stringify({ token, deliverableId }) })"
|
||||
pattern: "api/client/approve"
|
||||
- from: "POST /api/client/approve"
|
||||
to: "deliverables table"
|
||||
via: "db.update(deliverables).set({ status: 'approved', approved_at: new Date() })"
|
||||
pattern: "approved_at"
|
||||
- from: "CommentForm"
|
||||
to: "POST /api/client/comment"
|
||||
via: "fetch('/api/client/comment', { body: JSON.stringify({ token, entity_type, entity_id, body }) })"
|
||||
pattern: "api/client/comment"
|
||||
- from: "POST /api/client/comment"
|
||||
to: "comments table"
|
||||
via: "db.insert(comments).values({ author: 'client', ... })"
|
||||
pattern: "author.*client"
|
||||
---
|
||||
|
||||
<objective>
|
||||
**Client Interactions — Approvals + Comments:** Add two API routes for client-side mutations (per D-06 — not Server Actions, because the client has no admin session), then update the client dashboard UI to render ApproveButton on pending/submitted deliverables and CommentForm + CommentList on every task and deliverable. Token is validated server-side in each API route against the clients table before any write.
|
||||
|
||||
Purpose: Deliver DASH-05 (deliverable approval with immutable approved_at) and DASH-06 (inline comments). The approved_at immutability rule from CLAUDE.md is enforced in the API route: if approved_at is already set, the request is a no-op (returns 200 but does not overwrite).
|
||||
|
||||
Output: Clients can approve deliverables and leave comments from their dashboard; admin sees both in the workspace (Plan 03 CommentsTab).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
||||
@.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From src/db/schema.ts — types used in this plan -->
|
||||
```typescript
|
||||
export const deliverables = pgTable("deliverables", {
|
||||
id: text("id").primaryKey(),
|
||||
task_id: text("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
url: text("url"),
|
||||
status: text("status").notNull().default("pending"), // pending | submitted | approved
|
||||
approved_at: timestamp("approved_at", { withTimezone: true }), // IMMUTABLE once set
|
||||
});
|
||||
|
||||
export const comments = pgTable("comments", {
|
||||
id: text("id").primaryKey(),
|
||||
entity_type: text("entity_type").notNull(), // task | deliverable
|
||||
entity_id: text("entity_id").notNull(),
|
||||
author: text("author").notNull(), // client | admin
|
||||
body: text("body").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const clients = pgTable("clients", {
|
||||
id: text("id").primaryKey(),
|
||||
token: text("token").notNull().unique(),
|
||||
// ... other fields
|
||||
});
|
||||
|
||||
export type Deliverable = typeof deliverables.$inferSelect;
|
||||
export type Comment = typeof comments.$inferSelect;
|
||||
```
|
||||
|
||||
<!-- From src/lib/client-view.ts (Phase 1) — ClientView shape for reference -->
|
||||
```typescript
|
||||
export interface ClientView {
|
||||
client: { id: string; name: string; brand_name: string; brief: string; accepted_total: string; };
|
||||
phases: Array<{
|
||||
id: string; title: string; status: string; sort_order: number; progress_pct: number;
|
||||
tasks: Array<{
|
||||
id: string; title: string; description: string | null; status: string; sort_order: number;
|
||||
deliverables: Array<{
|
||||
id: string; title: string; url: string | null;
|
||||
status: 'pending' | 'submitted' | 'approved';
|
||||
approved_at: string | null;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
payments: Array<{ id: string; label: string; status: string; }>;
|
||||
documents: Array<{ id: string; label: string; url: string; }>;
|
||||
notes: Array<{ id: string; body: string; created_at: string; }>;
|
||||
global_progress_pct: number;
|
||||
}
|
||||
```
|
||||
|
||||
<!-- ClientView does NOT include comments — they are fetched separately in the page -->
|
||||
<!-- The page must fetch comments independently using db query on entity_ids from the view -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create POST /api/client/approve and POST /api/client/comment API routes</name>
|
||||
<files>
|
||||
src/app/api/client/approve/route.ts
|
||||
src/app/api/client/comment/route.ts
|
||||
</files>
|
||||
<action>
|
||||
Both routes validate the client's token against the DB before any mutation (per D-06).
|
||||
Token comes from the request body JSON. Neither route uses Auth.js — client has no session.
|
||||
Neither route ever queries quote_items (per CLAUDE.md architecture constraint).
|
||||
|
||||
Create `src/app/api/client/approve/route.ts`:
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { clients, deliverables, tasks, phases } from "@/db/schema";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { token, deliverableId } = body as { token?: string; deliverableId?: string };
|
||||
|
||||
if (!token || !deliverableId) {
|
||||
return NextResponse.json({ error: "token e deliverableId richiesti" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate token — find the client
|
||||
const clientRows = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (clientRows.length === 0) {
|
||||
return NextResponse.json({ error: "Token non valido" }, { status: 404 });
|
||||
}
|
||||
|
||||
const clientId = clientRows[0].id;
|
||||
|
||||
// Verify deliverable belongs to this client (prevents cross-client approval)
|
||||
// deliverable → task → phase → client
|
||||
const ownershipCheck = await db
|
||||
.select({ deliverable_id: deliverables.id, approved_at: deliverables.approved_at })
|
||||
.from(deliverables)
|
||||
.innerJoin(tasks, eq(deliverables.task_id, tasks.id))
|
||||
.innerJoin(phases, and(eq(tasks.phase_id, phases.id), eq(phases.client_id, clientId)))
|
||||
.where(eq(deliverables.id, deliverableId))
|
||||
.limit(1);
|
||||
|
||||
if (ownershipCheck.length === 0) {
|
||||
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
|
||||
}
|
||||
|
||||
// IMMUTABILITY RULE (CLAUDE.md): if approved_at is already set, this is a no-op
|
||||
if (ownershipCheck[0].approved_at !== null) {
|
||||
return NextResponse.json({ approved: true, message: "Già approvato" }, { status: 200 });
|
||||
}
|
||||
|
||||
// Set approved — approved_at is immutable once set, client cannot unset it
|
||||
await db
|
||||
.update(deliverables)
|
||||
.set({ status: "approved", approved_at: new Date() })
|
||||
.where(eq(deliverables.id, deliverableId));
|
||||
|
||||
return NextResponse.json({ approved: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error("/api/client/approve error:", err);
|
||||
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/app/api/client/comment/route.ts`:
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { clients, comments, tasks, phases, deliverables } from "@/db/schema";
|
||||
|
||||
const commentSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
entity_type: z.enum(["task", "deliverable"]),
|
||||
entity_id: z.string().min(1),
|
||||
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = commentSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0].message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, entity_type, entity_id, body: commentBody } = parsed.data;
|
||||
|
||||
// Validate token
|
||||
const clientRows = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (clientRows.length === 0) {
|
||||
return NextResponse.json({ error: "Token non valido" }, { status: 404 });
|
||||
}
|
||||
|
||||
const clientId = clientRows[0].id;
|
||||
|
||||
// Verify entity belongs to this client (prevent cross-client comment injection)
|
||||
if (entity_type === "task") {
|
||||
const phasesForClient = await db
|
||||
.select({ id: phases.id })
|
||||
.from(phases)
|
||||
.where(eq(phases.client_id, clientId));
|
||||
const phaseIds = phasesForClient.map((p) => p.id);
|
||||
|
||||
if (phaseIds.length === 0) {
|
||||
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
||||
}
|
||||
|
||||
const taskCheck = await db
|
||||
.select({ id: tasks.id })
|
||||
.from(tasks)
|
||||
.where(
|
||||
inArray(tasks.phase_id, phaseIds)
|
||||
)
|
||||
.then((rows) => rows.find((r) => r.id === entity_id));
|
||||
|
||||
if (!taskCheck) {
|
||||
return NextResponse.json({ error: "Task non trovato" }, { status: 404 });
|
||||
}
|
||||
} else {
|
||||
// deliverable — verify via task → phase → client chain
|
||||
const phasesForClient = await db
|
||||
.select({ id: phases.id })
|
||||
.from(phases)
|
||||
.where(eq(phases.client_id, clientId));
|
||||
const phaseIds = phasesForClient.map((p) => p.id);
|
||||
|
||||
if (phaseIds.length === 0) {
|
||||
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
||||
}
|
||||
|
||||
const taskIds = await db
|
||||
.select({ id: tasks.id })
|
||||
.from(tasks)
|
||||
.where(inArray(tasks.phase_id, phaseIds))
|
||||
.then((rows) => rows.map((r) => r.id));
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 });
|
||||
}
|
||||
|
||||
const delivCheck = await db
|
||||
.select({ id: deliverables.id })
|
||||
.from(deliverables)
|
||||
.where(inArray(deliverables.task_id, taskIds))
|
||||
.then((rows) => rows.find((r) => r.id === entity_id));
|
||||
|
||||
if (!delivCheck) {
|
||||
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
await db.insert(comments).values({
|
||||
entity_type,
|
||||
entity_id,
|
||||
author: "client",
|
||||
body: commentBody,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("/api/client/comment error:", err);
|
||||
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/app/api/client/approve/route.ts && echo "approve route exists"</automated>
|
||||
<automated>grep -q "approved_at.*null" src/app/api/client/approve/route.ts && echo "immutability check present"</automated>
|
||||
<automated>grep -q "phases.client_id.*clientId\|clientId.*phases.client_id" src/app/api/client/approve/route.ts && echo "ownership verification present"</automated>
|
||||
<automated>test -f src/app/api/client/comment/route.ts && echo "comment route exists"</automated>
|
||||
<automated>grep -q "author.*client" src/app/api/client/comment/route.ts && echo "author set to client"</automated>
|
||||
<automated>grep -v '^#' src/app/api/client/approve/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in approve route"</automated>
|
||||
<automated>grep -v '^#' src/app/api/client/comment/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in comment route"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- POST /api/client/approve: validates token, verifies deliverable ownership via phase→client chain, sets status=approved + approved_at=now() only if approved_at is currently null
|
||||
- POST /api/client/comment: validates token, validates entity ownership, inserts comment with author='client'
|
||||
- Both routes return 404 on invalid token or missing entity
|
||||
- Neither route references quote_items
|
||||
- npm run build passes
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Build ApproveButton + CommentForm/List Client Components; wire into client dashboard page</name>
|
||||
<files>
|
||||
src/components/client/ApproveButton.tsx
|
||||
src/components/client/CommentForm.tsx
|
||||
src/components/client/CommentList.tsx
|
||||
src/app/c/[token]/page.tsx
|
||||
</files>
|
||||
<action>
|
||||
Create `src/components/client/ApproveButton.tsx` — Client Component (per D-10, no confirm modal):
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Props = {
|
||||
deliverableId: string;
|
||||
token: string;
|
||||
approvedAt: string | null; // ISO timestamp or null
|
||||
};
|
||||
|
||||
export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Already approved — show immutable confirmation, no button
|
||||
if (approvedAt) {
|
||||
const date = new Date(approvedAt).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
return (
|
||||
<span className="text-xs text-green-700 bg-green-50 border border-green-200 px-2 py-1 rounded">
|
||||
Approvato il {date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/client/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, deliverableId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Errore durante l'approvazione");
|
||||
return;
|
||||
}
|
||||
router.refresh(); // Re-fetch Server Component data — approved_at now set
|
||||
} catch {
|
||||
setError("Errore di rete");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleApprove}
|
||||
disabled={loading}
|
||||
className="text-xs text-green-700 border-green-300 hover:bg-green-50"
|
||||
>
|
||||
{loading ? "Approvazione..." : "Approva"}
|
||||
</Button>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/components/client/CommentList.tsx` — pure presentational:
|
||||
```typescript
|
||||
import type { Comment } from "@/db/schema";
|
||||
|
||||
type Props = { comments: Comment[] };
|
||||
|
||||
export function CommentList({ comments }: Props) {
|
||||
if (comments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{comments.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex gap-2 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 text-xs max-w-xs ${
|
||||
c.author === "admin"
|
||||
? "bg-gray-900 text-white"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium mb-0.5 opacity-60">
|
||||
{c.author === "admin" ? "iamcavalli" : "Tu"}
|
||||
</p>
|
||||
<p>{c.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Create `src/components/client/CommentForm.tsx` — Client Component (per D-11):
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
entityType: "task" | "deliverable";
|
||||
entityId: string;
|
||||
};
|
||||
|
||||
export function CommentForm({ token, entityType, entityId }: Props) {
|
||||
const router = useRouter();
|
||||
const [body, setBody] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!body.trim()) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/client/comment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, entity_type: entityType, entity_id: entityId, body }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Errore durante l'invio");
|
||||
return;
|
||||
}
|
||||
setBody("");
|
||||
router.refresh(); // Re-fetch Server Component to show new comment
|
||||
} catch {
|
||||
setError("Errore di rete");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-3 flex gap-2">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Lascia un commento..."
|
||||
rows={2}
|
||||
className="text-sm resize-none flex-1"
|
||||
/>
|
||||
<div className="flex flex-col justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={loading || !body.trim()}
|
||||
className="text-xs"
|
||||
>
|
||||
{loading ? "Invio..." : "Invia"}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Update `src/app/c/[token]/page.tsx` — extend the existing Phase 1 dashboard to:
|
||||
1. Fetch comments for all task/deliverable ids in this client's data
|
||||
2. Render ApproveButton on each deliverable (pending or submitted)
|
||||
3. Render CommentList + CommentForm below each task and deliverable
|
||||
|
||||
Read the existing page first, then extend it. The page must remain a Server Component.
|
||||
ApproveButton and CommentForm are Client Components embedded within it.
|
||||
|
||||
Key additions to the existing page (add these imports and sections):
|
||||
```typescript
|
||||
// New imports to add:
|
||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||
import { CommentForm } from "@/components/client/CommentForm";
|
||||
import { CommentList } from "@/components/client/CommentList";
|
||||
import { db } from "@/db";
|
||||
import { comments } from "@/db/schema";
|
||||
import { inArray } from "drizzle-orm";
|
||||
|
||||
// After getClientView(), fetch comments:
|
||||
const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id));
|
||||
const allDeliverableIds = view.phases.flatMap((p) =>
|
||||
p.tasks.flatMap((t) => t.deliverables.map((d) => d.id))
|
||||
);
|
||||
const allEntityIds = [...allTaskIds, ...allDeliverableIds];
|
||||
|
||||
const allComments = allEntityIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(inArray(comments.entity_id, allEntityIds))
|
||||
: [];
|
||||
|
||||
// Helper to get comments for a specific entity:
|
||||
const commentsFor = (entityId: string) =>
|
||||
allComments.filter((c) => c.entity_id === entityId);
|
||||
```
|
||||
|
||||
Within the task/deliverable render loop, add below each deliverable:
|
||||
```typescript
|
||||
// Within deliverable rendering:
|
||||
<ApproveButton
|
||||
deliverableId={deliverable.id}
|
||||
token={params.token}
|
||||
approvedAt={deliverable.approved_at}
|
||||
/>
|
||||
<CommentList comments={commentsFor(deliverable.id)} />
|
||||
<CommentForm token={params.token} entityType="deliverable" entityId={deliverable.id} />
|
||||
```
|
||||
|
||||
And below each task:
|
||||
```typescript
|
||||
// Within task rendering (after deliverables):
|
||||
<CommentList comments={commentsFor(task.id)} />
|
||||
<CommentForm token={params.token} entityType="task" entityId={task.id} />
|
||||
```
|
||||
|
||||
The page must read the full Phase 1 client dashboard before modifying it.
|
||||
Preserve all existing Phase 1 UI sections. Only add the interactive elements.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/components/client/ApproveButton.tsx && grep -q '"use client"' src/components/client/ApproveButton.tsx && echo "ApproveButton is Client Component"</automated>
|
||||
<automated>grep -q "router.refresh" src/components/client/ApproveButton.tsx && echo "router.refresh on approval"</automated>
|
||||
<automated>grep -q "approvedAt.*null" src/components/client/ApproveButton.tsx && echo "approved_at check present in button"</automated>
|
||||
<automated>test -f src/components/client/CommentForm.tsx && grep -q '"use client"' src/components/client/CommentForm.tsx && echo "CommentForm is Client Component"</automated>
|
||||
<automated>grep -q "api/client/comment" src/components/client/CommentForm.tsx && echo "CommentForm posts to correct route"</automated>
|
||||
<automated>test -f src/components/client/CommentList.tsx && grep -q "iamcavalli" src/components/client/CommentList.tsx && echo "admin author label present"</automated>
|
||||
<automated>grep -q "ApproveButton" src/app/c/\[token\]/page.tsx && echo "ApproveButton imported in dashboard page"</automated>
|
||||
<automated>grep -q "CommentForm" src/app/c/\[token\]/page.tsx && echo "CommentForm imported in dashboard page"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- ApproveButton renders on deliverables with approved_at=null; shows immutable "Approvato il [date]" once set
|
||||
- CommentForm posts to /api/client/comment and calls router.refresh() on success
|
||||
- CommentList shows client comments as "Tu", admin comments as "iamcavalli"
|
||||
- Client dashboard page fetches comments server-side and renders all three components inline
|
||||
- Phase 1 existing UI is preserved — only interactive elements are added
|
||||
- npm run build passes cleanly
|
||||
- Manual verification: approve a deliverable → refreshed page shows date badge; submit comment → refreshed page shows comment in list
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Client browser → POST /api/client/approve | Unauthenticated client route; token in request body is the only credential |
|
||||
| Client browser → POST /api/client/comment | Same — token in body is the only credential |
|
||||
| Token → client ownership | Each API route validates token → client, then verifies entity belongs to that client via DB join |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-15 | Spoofing | /api/client/approve token validation | mitigate | Token validated server-side via DB lookup before any mutation; expired or rotated tokens return 404 |
|
||||
| T-02-16 | Elevation of Privilege | Cross-client approval | mitigate | Ownership check: deliverable → task → phase → client_id match enforced via innerJoin before update; a client cannot approve another client's deliverable |
|
||||
| T-02-17 | Tampering | approved_at immutability | mitigate | API route checks approved_at !== null before running UPDATE; once set, the field cannot be overwritten via this route — enforced at application layer |
|
||||
| T-02-18 | Tampering | Comment injection across clients | mitigate | entity ownership verified via DB join (task/deliverable → phase → client_id) before insert; client can only comment on their own entities |
|
||||
| T-02-19 | Information Disclosure | CommentList renders all comments | accept | Comments are scoped to entity_ids belonging to the validated client; server-side filtering before rendering |
|
||||
| T-02-20 | Denial of Service | POST /api/client/comment body length | mitigate | Zod schema enforces max 2000 chars on body; requests exceeding this return 400 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After plan execution:
|
||||
1. `npm run build` — no errors
|
||||
2. Open client dashboard at /c/[valid-token]
|
||||
3. Locate a deliverable with status=pending or status=submitted → "Approva" button visible
|
||||
4. Click Approva → page refreshes → button replaced with "Approvato il [date]"
|
||||
5. Refresh page again → approval still shows (persisted in DB)
|
||||
6. In admin workspace → CommentsTab shows the approved deliverable's state
|
||||
7. Open CommentForm under a task → type a message → click Invia
|
||||
8. Page refreshes → comment appears as "Tu" in the list
|
||||
9. In admin workspace → CommentsTab shows the comment with author "Cliente"
|
||||
10. Test invalid token: POST /api/client/approve with wrong token → 404
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Client can approve deliverables; approved_at is set once and immutable (CLAUDE.md constraint enforced)
|
||||
- Client can submit comments on tasks and deliverables
|
||||
- Both API routes validate token and verify entity ownership before writing
|
||||
- CommentList shows author correctly: "Tu" for client, "iamcavalli" for admin
|
||||
- Phase 1 client dashboard UI is fully preserved; interactive elements are additive
|
||||
- npm run build passes cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-admin-area-interactive-features/02-04-SUMMARY.md`
|
||||
</output>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user