docs(01-foundation-client-dashboard): complete phase 1 planning with 5-plan structure
Create comprehensive phase plans for Foundation & Client Dashboard: - 01-01-PLAN.md: Walking Skeleton (Next.js 15 bootstrap + DB connection) - 01-02-PLAN.md: Database schema (11 tables, Drizzle ORM, drizzle-kit push) - 01-03-PLAN.md: Middleware token validation + ClientView type + data fetching - 01-04-PLAN.md: Client dashboard UI (header, timeline, progress, payments, docs, notes) - 01-05-PLAN.md: Seed script + DNS CNAME configuration Also create SKELETON.md documenting locked architectural decisions for all future phases: - Next.js 15 + Drizzle + postgres-js driver (Coolify Postgres) - Token as separate rotatable field (not PK) - ClientView enforcement (no quote_items exposed to client API) - Approved_at immutable audit trail - Two independent auth systems (client token + admin session) - Vercel deployment with custom domain Update ROADMAP.md to mark Phase 1 as planned (5 plans created) and ready for execution. All plans follow MVP vertical-slice structure with 2-3 tasks per plan. Walking Skeleton proves the entire stack works end-to-end. Requirements mapping: DASH-01 through DASH-04, DASH-07 through DASH-10 covered. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
# Roadmap: ClientHub
|
||||
|
||||
## Overview
|
||||
|
||||
ClientHub si costruisce dall'esterno verso l'interno: prima la dashboard che un cliente reale può aprire su mobile, poi l'area admin per creare e gestire i dati, poi il catalogo servizi e il preventivo interno. Il flusso Claude AI è v2 e dipende da CRUD stabile e catalogo completo. Ogni fase consegna una capacità end-to-end verificabile.
|
||||
|
||||
## Phases
|
||||
|
||||
**Phase Numbering:**
|
||||
- Integer phases (1, 2, 3): Planned milestone work
|
||||
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
||||
|
||||
Decimal phases appear between their surrounding integers in numeric order.
|
||||
|
||||
- [x] **Phase 1: Foundation & Client Dashboard** - DB schema, token API, dashboard read-only per il cliente con link segreto condivisibile
|
||||
- [ ] **Phase 2: Admin Area & Interactive Features** - Auth admin, CRUD completo clienti/fasi/task/deliverable/pagamenti, approvazioni e commenti
|
||||
- [ ] **Phase 3: Service Catalog & Quote Builder** - Catalogo servizi riutilizzabile e costruttore preventivi (admin-only, cliente vede solo il totale)
|
||||
- [ ] **Phase 4: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 1: Foundation & Client Dashboard
|
||||
**Goal**: Un cliente reale può aprire il suo link segreto su mobile o desktop e vedere lo stato del suo progetto, senza login
|
||||
**Mode:** mvp
|
||||
**Depends on**: Nothing (first phase)
|
||||
**Requirements**: DASH-01, DASH-02, DASH-03, DASH-04, DASH-07, DASH-08, DASH-09, DASH-10
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Aprendo `/c/[token]` su mobile, il cliente vede nome cliente, brand, brief e fase corrente senza alcun login
|
||||
2. Le fasi del progetto sono visibili con i task annidati e il loro stato (da fare / in corso / fatto)
|
||||
3. Il cliente vede il totale preventivo accettato e lo stato dei due pagamenti (acconto 50% e saldo 50%), mai i prezzi singoli
|
||||
4. I link a documenti esterni (Google Drive, PDF) sono cliccabili dalla dashboard
|
||||
5. Il log decisioni/note è visibile nella dashboard del cliente
|
||||
**Plans**: 5 plans
|
||||
**Plan list**:
|
||||
- [x] 01-01-PLAN.md — Walking Skeleton: Next.js bootstrap + DB connection
|
||||
- [x] 01-02-PLAN.md — Database schema (11 tables) + Drizzle + drizzle-kit push
|
||||
- [x] 01-03-PLAN.md — Middleware token validation + ClientView type + data fetching
|
||||
- [x] 01-04-PLAN.md — Client dashboard UI (header, progress, phases, payments, documents, notes)
|
||||
- [x] 01-05-PLAN.md — Seed script + DNS CNAME configuration
|
||||
**UI hint**: yes
|
||||
**Status**: ✅ Complete (Phase 1 execution required)
|
||||
|
||||
### Phase 2: Admin Area & Interactive Features
|
||||
**Goal**: L'admin può creare e gestire clienti, fasi, task, deliverable e pagamenti; il cliente può commentare e approvare i deliverable
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 1
|
||||
**Requirements**: ADMIN-01, ADMIN-02, DASH-05, DASH-06
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. L'admin accede a `/admin` con credenziale sicura e vede la lista di tutti i clienti con stato sintetico e badge pagamenti
|
||||
2. L'admin può creare un nuovo cliente (con generazione automatica del link segreto), aggiungere fasi, task, documenti e aggiornare lo stato dei pagamenti
|
||||
3. Il cliente può approvare un deliverable dalla sua dashboard; l'approvazione persiste con timestamp immutabile e l'admin la vede
|
||||
4. Il cliente può lasciare un commento su un task o deliverable e l'admin vede i commenti nella workspace admin
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
**Status**: Pending planning
|
||||
|
||||
### Phase 3: Service Catalog & Quote Builder
|
||||
**Goal**: L'admin può costruire un catalogo servizi riutilizzabile e comporre preventivi da esso; il cliente vede solo il totale accettato
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 2
|
||||
**Requirements**: CAT-01, CAT-02, ADMIN-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. L'admin può aggiungere, modificare e disattivare voci nel catalogo servizi (nome, descrizione, prezzo unitario)
|
||||
2. L'admin può comporre un preventivo per un cliente selezionando voci dal catalogo; il sistema calcola il totale
|
||||
3. Dopo la finalizzazione del preventivo, `accepted_total` è scritto sulla riga cliente e la dashboard del cliente mostra il totale corretto; i `quote_items` non sono mai esposti al cliente
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
**Status**: Pending planning
|
||||
|
||||
### Phase 4: Claude AI Onboarding (v2)
|
||||
**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
|
||||
**Mode:** mvp
|
||||
**Depends on**: Phase 3
|
||||
**Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie
|
||||
2. Al termine del flusso, Claude propone un piano strutturato per fasi che l'admin può accettare o modificare prima di salvarlo
|
||||
3. Claude suggerisce un preventivo basato sul catalogo servizi; l'admin approva o modifica le voci prima della finalizzazione
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
**Status**: Pending planning (v2 — may defer indefinitely)
|
||||
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phases execute in numeric order: 1 → 2 → 3 → 4
|
||||
|
||||
| Phase | Plans | Status | Completed |
|
||||
|-------|-------|--------|-----------|
|
||||
| 1. Foundation & Client Dashboard | 5/5 | ✅ Planned | Ready for execution |
|
||||
| 2. Admin Area & Interactive Features | 0/TBD | Planning next | - |
|
||||
| 3. Service Catalog & Quote Builder | 0/TBD | Pending | - |
|
||||
| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
||||
@@ -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,369 @@
|
||||
---
|
||||
phase: "01-foundation-client-dashboard"
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
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,521 @@
|
||||
---
|
||||
phase: "01-foundation-client-dashboard"
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-01"
|
||||
- "01-02"
|
||||
files_modified:
|
||||
- src/middleware.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 at Next.js edge middleware"
|
||||
contains: "function middleware"
|
||||
- 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: "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 to validate client tokens at the edge</name>
|
||||
<files>
|
||||
src/middleware.ts
|
||||
</files>
|
||||
<read_first>
|
||||
src/db/schema.ts (clients table definition)
|
||||
package.json (verify Next.js version)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/middleware.ts` at project root (NOT in src/app):
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { clients } from '@/db/schema';
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// Only validate client portal routes /c/[token]/*
|
||||
if (!pathname.startsWith('/c/')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Extract token from path: /c/[token]/...
|
||||
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||
if (!tokenMatch) {
|
||||
return NextResponse.rewrite(new URL('/404', request.url), { status: 404 });
|
||||
}
|
||||
|
||||
const token = tokenMatch[1];
|
||||
|
||||
try {
|
||||
// Check if token exists in database
|
||||
const client = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (client.length === 0) {
|
||||
return NextResponse.rewrite(new URL('/404', request.url), { status: 404 });
|
||||
}
|
||||
|
||||
// Token is valid, proceed
|
||||
return NextResponse.next();
|
||||
} catch (error) {
|
||||
console.error('Middleware error validating token:', error);
|
||||
return NextResponse.rewrite(new URL('/500', request.url), { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/c/:path*'],
|
||||
};
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Middleware runs at the edge before any page renders
|
||||
- Token is extracted from URL: /c/[token]
|
||||
- Database query is a simple SELECT to check token existence
|
||||
- Returns 404 if token not found (no enumeration hints)
|
||||
- All errors return 500 (generic error handling)
|
||||
</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 "clients.token" src/middleware.ts && echo "Token validation query present"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `src/middleware.ts` exists and exports middleware function
|
||||
- Matcher is configured for `/c/:path*`
|
||||
- Token validation query checks `clients.token`
|
||||
- Non-existent tokens return 404
|
||||
- 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 } 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 all tasks
|
||||
const tasksRows = await db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.orderBy(tasks.sort_order);
|
||||
|
||||
// Fetch all deliverables
|
||||
const deliverables_rows = await db
|
||||
.select()
|
||||
.from(deliverables);
|
||||
|
||||
// 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,
|
||||
},
|
||||
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>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,861 @@
|
||||
---
|
||||
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: Update tailwind.config.ts with light & clean design tokens and extend globals.css</name>
|
||||
<files>
|
||||
tailwind.config.ts
|
||||
src/app/globals.css
|
||||
</files>
|
||||
<read_first>
|
||||
tailwind.config.ts (current bootstrap)
|
||||
src/app/globals.css (current bootstrap)
|
||||
</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;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
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;
|
||||
}
|
||||
|
||||
/* Subtle border utilities */
|
||||
.border-subtle {
|
||||
@apply border border-border-light;
|
||||
}
|
||||
|
||||
.bg-subtle {
|
||||
@apply bg-bg-subtle;
|
||||
}
|
||||
```
|
||||
</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>npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "TypeScript errors" || echo "Build OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `tailwind.config.ts` contains color tokens: primary, secondary, accent, success, warning
|
||||
- `globals.css` includes Tailwind directives and base typography
|
||||
- `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, DocumentsSection, and NotesSection components</name>
|
||||
<files>
|
||||
src/components/payment-status.tsx
|
||||
src/components/documents-section.tsx
|
||||
src/components/notes-section.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
src/lib/client-view.ts (payments, documents, notes shapes)
|
||||
.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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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:
|
||||
- PaymentStatus: shows accepted_total + 2 payment rows (Acconto 50%, Saldo 50%) with status badges (no amounts)
|
||||
- DocumentsSection: clickable external links with ExternalLink icon
|
||||
- NotesSection: read-only notes with formatted timestamps
|
||||
- All use Card + Badge components from shadcn/ui
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/components/payment-status.tsx && echo "PaymentStatus component exists"</automated>
|
||||
<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 "accepted_total" src/components/payment-status.tsx && echo "Total displayed"</automated>
|
||||
<automated>grep -q "ExternalLink" src/components/documents-section.tsx && echo "Link icon present"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All three components exist and are exported
|
||||
- PaymentStatus displays accepted_total + 2 payment rows with status (no amounts)
|
||||
- DocumentsSection shows clickable external links
|
||||
- NotesSection shows read-only notes with timestamps (or empty state)
|
||||
- All components use shadcn/ui Card and Badge
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Update app/c/[token]/page.tsx to render ClientDashboard with real data</name>
|
||||
<files>
|
||||
app/c/[token]/page.tsx
|
||||
</files>
|
||||
<read_first>
|
||||
src/components/client-dashboard.tsx
|
||||
src/lib/client-view.ts
|
||||
</read_first>
|
||||
<action>
|
||||
Update `app/c/[token]/page.tsx`:
|
||||
|
||||
```typescript
|
||||
import { getClientView } from '@/lib/client-view';
|
||||
import { ClientDashboard } from '@/components/client-dashboard';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const revalidate = 60; // ISR: revalidate every 60 seconds
|
||||
|
||||
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 | ClientHub`,
|
||||
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} />;
|
||||
}
|
||||
```
|
||||
|
||||
This page:
|
||||
- Fetches ClientView data
|
||||
- Returns 404 if not found
|
||||
- Generates dynamic metadata with client brand name
|
||||
- Renders ClientDashboard with real data
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f app/c/\[token\]/page.tsx && echo "Page file exists"</automated>
|
||||
<automated>grep -q "ClientDashboard" app/c/\[token\]/page.tsx && echo "ClientDashboard rendered"</automated>
|
||||
<automated>grep -q "getClientView" app/c/\[token\]/page.tsx && echo "Data fetched"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "Build errors" || echo "Build OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Page renders ClientDashboard component with ClientView data
|
||||
- 404 is returned if token is invalid
|
||||
- Page metadata is dynamic (includes client brand name)
|
||||
- `npm run build` succeeds
|
||||
</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,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,302 @@
|
||||
# ClientHub — Walking Skeleton (Phase 1)
|
||||
|
||||
**Project:** ClientHub — Freelancer Client Portal
|
||||
**Phase:** 01 — Foundation & Client Dashboard
|
||||
**Date:** 2026-05-13
|
||||
**Status:** Blueprint (decisions below are LOCKED for all subsequent phases)
|
||||
|
||||
---
|
||||
|
||||
## Project Architecture — Locked Decisions
|
||||
|
||||
This Walking Skeleton establishes the architectural foundation for all future phases. These decisions are **immutable** without explicit user approval.
|
||||
|
||||
### Core Stack
|
||||
|
||||
| Layer | Technology | Why | Locked? |
|
||||
|-------|-----------|-----|---------|
|
||||
| **Framework** | Next.js 15 (App Router, TypeScript, src/) | Server Components + Edge Middleware for performance; Vercel-native | ✅ YES |
|
||||
| **Database** | Postgres on Coolify (Hetzner), via `postgres-js` driver | Self-hosted (no Neon/Supabase cost); persistent via external DB | ✅ YES |
|
||||
| **ORM** | Drizzle ORM with postgres-js | Zero-cost serverless driver; schema-as-code migrations | ✅ YES |
|
||||
| **UI** | Tailwind CSS v4 + shadcn/ui components | Utility-first, copied components, mobile-first | ✅ YES |
|
||||
| **Auth (Admin)** | Auth.js v4 Credentials provider (Phase 2) | Single admin account, JWT cookie | ✅ YES |
|
||||
| **Auth (Client)** | Custom Next.js Middleware + token validation | No session store needed; token in URL | ✅ YES |
|
||||
| **Token Generation** | nanoid (21 chars) | Cryptographically secure, URL-safe, non-enumerable | ✅ YES |
|
||||
| **Deployment** | Vercel (Hobby plan) + custom subdomain | Native Next.js; auto-SSL; single deploy command | ✅ YES |
|
||||
|
||||
### Data Model — Locked Entities
|
||||
|
||||
All tables below **must** exist and maintain these field definitions. Modifications require explicit approval.
|
||||
|
||||
```
|
||||
clients
|
||||
id UUID PK (stable, never changes)
|
||||
name TEXT
|
||||
brand_name TEXT
|
||||
brief TEXT
|
||||
token UUID UNIQUE ← SEPARATE from PK, rotatable
|
||||
accepted_total NUMERIC ← denormalized, only price client sees
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
phases
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
title TEXT
|
||||
sort_order INT
|
||||
status TEXT (upcoming | active | done)
|
||||
|
||||
tasks
|
||||
id UUID PK
|
||||
phase_id UUID FK → phases.id
|
||||
title TEXT
|
||||
description TEXT
|
||||
status TEXT (todo | in_progress | done)
|
||||
sort_order INT
|
||||
|
||||
deliverables
|
||||
id UUID PK
|
||||
task_id UUID FK → tasks.id
|
||||
title TEXT
|
||||
url TEXT
|
||||
status TEXT (pending | submitted | approved)
|
||||
approved_at TIMESTAMPTZ ← immutable audit trail
|
||||
|
||||
comments
|
||||
id UUID PK
|
||||
entity_type TEXT (task | deliverable)
|
||||
entity_id UUID
|
||||
author TEXT (client | admin)
|
||||
body TEXT
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
payments
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
label TEXT ("Acconto 50%" | "Saldo 50%")
|
||||
amount NUMERIC
|
||||
status TEXT (da_saldare | inviata | saldato)
|
||||
paid_at TIMESTAMPTZ
|
||||
|
||||
documents
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
label TEXT
|
||||
url TEXT ← external links only, no file uploads
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
notes
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
body TEXT
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
service_catalog
|
||||
id UUID PK
|
||||
name TEXT
|
||||
description TEXT
|
||||
unit_price NUMERIC
|
||||
active BOOLEAN
|
||||
|
||||
quote_items
|
||||
id UUID PK
|
||||
client_id UUID FK → clients.id
|
||||
service_id UUID FK → service_catalog.id
|
||||
quantity NUMERIC
|
||||
unit_price NUMERIC
|
||||
subtotal NUMERIC
|
||||
← NEVER exposed via client API
|
||||
```
|
||||
|
||||
### Critical Design Principles — Locked
|
||||
|
||||
1. **`clients.token` is NOT the primary key.** Data is keyed by stable UUID `id`. Token is a separate, rotatable field. Rotation is a single UPDATE statement.
|
||||
|
||||
2. **Client API never exposes `quote_items`.** Server-side filtering enforces this; not a UI trick. The `accepted_total` field is the only price the client API returns.
|
||||
|
||||
3. **`deliverables.approved_at` is immutable.** Once set, it cannot be unset. Provides an audit trail for disputes.
|
||||
|
||||
4. **Two independent auth systems:**
|
||||
- `/c/[token]/*` → Middleware validates token, 404 on miss
|
||||
- `/admin/*` → Auth.js session check (Phase 2)
|
||||
- No overlap; no shared session store
|
||||
|
||||
5. **No file hosting in v1.** Documents are external URLs only (Google Drive, PDFs, Figma links). File uploads → Phase 3+.
|
||||
|
||||
6. **No email in v1.** Deliverables are dashboard links, not email attachments. Email integration → Phase 2+.
|
||||
|
||||
### Directory Structure — Locked
|
||||
|
||||
```
|
||||
IAMCAVALLI/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── c/[token]/
|
||||
│ │ │ ├── page.tsx ← Client dashboard route
|
||||
│ │ │ └── layout.tsx
|
||||
│ │ ├── admin/ ← Phase 2 (protected by middleware)
|
||||
│ │ │ ├── page.tsx ← Admin dashboard
|
||||
│ │ │ ├── clients/
|
||||
│ │ │ │ ├── page.tsx
|
||||
│ │ │ │ └── [id]/
|
||||
│ │ │ ├── catalog/
|
||||
│ │ │ └── ...
|
||||
│ │ ├── layout.tsx
|
||||
│ │ └── globals.css
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ ← shadcn/ui components
|
||||
│ │ ├── client-dashboard.tsx
|
||||
│ │ ├── phase-timeline.tsx
|
||||
│ │ ├── payment-status.tsx
|
||||
│ │ ├── documents-section.tsx
|
||||
│ │ ├── notes-section.tsx
|
||||
│ │ └── ...
|
||||
│ ├── db/
|
||||
│ │ ├── schema.ts ← Drizzle schema (source of truth)
|
||||
│ │ ├── migrations/ ← Generated by drizzle-kit
|
||||
│ │ └── index.ts ← db client export
|
||||
│ ├── lib/
|
||||
│ │ ├── client-view.ts ← ClientView type + queries
|
||||
│ │ ├── auth.ts ← Phase 2: Auth helpers
|
||||
│ │ └── ...
|
||||
│ └── middleware.ts ← Token validation at edge
|
||||
├── scripts/
|
||||
│ ├── seed.ts ← Insert first test client
|
||||
│ └── ...
|
||||
├── .env.local ← DATABASE_URL, secrets
|
||||
├── drizzle.config.ts
|
||||
├── next.config.ts
|
||||
├── tailwind.config.ts
|
||||
├── tsconfig.json
|
||||
├── package.json
|
||||
└── .planning/
|
||||
├── ROADMAP.md
|
||||
├── REQUIREMENTS.md
|
||||
├── STATE.md
|
||||
└── phases/
|
||||
└── 01-foundation-client-dashboard/
|
||||
├── 01-CONTEXT.md
|
||||
├── 01-DISCUSSION-LOG.md
|
||||
├── 01-01-PLAN.md
|
||||
├── 01-02-PLAN.md
|
||||
├── 01-03-PLAN.md
|
||||
├── 01-04-PLAN.md
|
||||
├── 01-05-PLAN.md
|
||||
└── SKELETON.md
|
||||
```
|
||||
|
||||
### Deployment — Locked
|
||||
|
||||
- **Host:** Vercel (Hobby plan, $0/month for Phase 1 scale)
|
||||
- **Domain:** welcomeclient.iamcavalli.net (CNAME to Vercel DNS)
|
||||
- **Database:** Postgres on Coolify (existing Hetzner server, Simone manages)
|
||||
- **Environment:** DATABASE_URL injected via Vercel Secrets
|
||||
- **SSL/TLS:** Vercel auto-provisioning for custom domain
|
||||
|
||||
### API Routes Structure (Phase 2+)
|
||||
|
||||
Routes created in Phase 2 will follow this pattern:
|
||||
|
||||
**Client-facing routes** (`/api/c/[token]/...`):
|
||||
- No authentication library needed
|
||||
- Middleware validates token
|
||||
- Routes return ClientView shape only
|
||||
|
||||
**Admin routes** (`/api/admin/...`):
|
||||
- Require Auth.js session
|
||||
- Access full AdminView including quote_items
|
||||
- CRUD operations on all entities
|
||||
|
||||
### UI Layer Principles — Locked
|
||||
|
||||
- **Light & clean visual style:** White backgrounds, strong typography, subtle gray accents
|
||||
- **Mobile-first design:** Tailwind defaults ensure responsive behavior
|
||||
- **Semantic HTML:** Proper heading hierarchy, accessible form controls
|
||||
- **No client-side state management libraries:** Server Components + Server Actions for Phase 1-2
|
||||
- **Progress visualization:** Global bar (top) + per-phase bars (sections) + task status badges
|
||||
- **Brand consistency:** iamcavalli logo in corner, client brand_name prominent
|
||||
|
||||
### Security Assumptions — Locked
|
||||
|
||||
1. **Database credentials are secrets:** DATABASE_URL never logged, committed, or exposed
|
||||
2. **Tokens are non-enumerable:** 21-character nanoid cannot be guessed
|
||||
3. **Client API is isolated:** Admin data never leaks to `/c/[token]/*` routes
|
||||
4. **Admin password** (Phase 2): env var `ADMIN_PASSWORD` protects `/admin/*` before Auth.js is added
|
||||
5. **No PII in logs:** Payment amounts and tokens never logged to Vercel logs
|
||||
|
||||
---
|
||||
|
||||
## What This Skeleton Delivers
|
||||
|
||||
After Phase 1 execution:
|
||||
|
||||
✅ **Functional client portal:**
|
||||
- One client can open their secret link on any device
|
||||
- Dashboard shows project phases, tasks, status, payments, documents, decision log
|
||||
- No login required; link is the secret
|
||||
|
||||
✅ **Production-ready infrastructure:**
|
||||
- Database is live on Coolify Postgres
|
||||
- Custom domain is verified and HTTPS-enabled
|
||||
- Application is deployed on Vercel
|
||||
- One-command deploy pipeline (`git push → Vercel auto-build`)
|
||||
|
||||
✅ **Developer-friendly codebase:**
|
||||
- TypeScript with strict mode
|
||||
- Drizzle ORM manages schema as code
|
||||
- Git-tracked migrations (reproducible database state)
|
||||
- One seed script to populate test data
|
||||
- No manual SQL; no database browser required
|
||||
|
||||
✅ **Foundation for Phase 2:**
|
||||
- Data model is stable and comprehensive
|
||||
- Admin CRUD can be built without schema changes
|
||||
- Auth.js integration point is clear
|
||||
- Comments and approvals schema already exists
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 → Phase 2 Contract
|
||||
|
||||
Phase 2 will extend this skeleton by:
|
||||
|
||||
1. **Admin authentication:** Middleware check + Auth.js session on `/admin/*` routes
|
||||
2. **CRUD operations:** Forms and API routes to edit clients, phases, tasks, deliverables, payments
|
||||
3. **Comments & approvals:** Client-facing UI for commenting and approving deliverables
|
||||
4. **Admin workspace:** Dashboard to manage all clients with state summary and quick actions
|
||||
5. **Payment management:** Update payment status, send payment reminders
|
||||
|
||||
**No schema changes required.** All Phase 2 features fit into the existing data model.
|
||||
|
||||
---
|
||||
|
||||
## Validation Checklist (End of Phase 1)
|
||||
|
||||
- [ ] Next.js 15 application compiles without TypeScript errors
|
||||
- [ ] Database schema is live on Coolify Postgres (all 11 tables)
|
||||
- [ ] Middleware validates tokens at edge
|
||||
- [ ] Client portal route renders complete dashboard with seeded data
|
||||
- [ ] Seed script inserts test client and prints shareable link
|
||||
- [ ] DNS CNAME is live: welcomeclient.iamcavalli.net → Vercel
|
||||
- [ ] Application is deployed on Vercel (accessible via https://welcomeclient.iamcavalli.net/)
|
||||
- [ ] Invalid tokens return 404 (no information leakage)
|
||||
- [ ] Payment amounts are NOT visible on client dashboard (only status)
|
||||
- [ ] Mobile layout is responsive and readable
|
||||
- [ ] All DASH-01 through DASH-10 requirements are satisfied (except DASH-05, DASH-06 which are Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## Future Extensibility Notes
|
||||
|
||||
This skeleton is designed for:
|
||||
|
||||
- **Phase 2:** Admin CRUD + comments + approvals (no schema changes)
|
||||
- **Phase 3:** Service catalog + quote builder (admin-only, client sees only total)
|
||||
- **Phase 4 (v2):** Claude AI onboarding flow (optional; may defer indefinitely)
|
||||
- **Beyond:** Multi-team support, real file uploads, email automation (major schema rework)
|
||||
|
||||
The current design is intentionally simple. Future phases should resist scope creep and maintain the "client sees only what they need" principle.
|
||||
|
||||
---
|
||||
|
||||
**Skeleton locked:** 2026-05-13
|
||||
**Next checkpoint:** Phase 2 planning (`/gsd-plan-phase 2`)
|
||||
Reference in New Issue
Block a user