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:
2026-06-11 06:17:29 +02:00
parent bdcdd26f17
commit 23cb057f0b
65 changed files with 22681 additions and 0 deletions
@@ -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`)