docs(07): phase 7 planning complete — 2 plans (schema migration + admin crud)
Wave 1: 07-01-PLAN.md — Unified services table (audit trail, backfill, validation) Wave 2: 07-02-PLAN.md — Admin catalog CRUD rewire (preserve historical references) Ready for execution: /gsd-execute-phase 7 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
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*
|
||||
@@ -0,0 +1,369 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
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*
|
||||
@@ -0,0 +1,569 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
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*
|
||||
@@ -0,0 +1,864 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
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*
|
||||
@@ -0,0 +1,567 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,126 @@
|
||||
# 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*
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,302 @@
|
||||
# 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`)
|
||||
Reference in New Issue
Block a user