diff --git a/.gitignore b/.gitignore index e8aef84..9dbfe73 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,15 @@ .DS_Store *.pem +# staging per la cancellazione — vedi cestino/LEGGIMI.md +/cestino/ + +# cache del plugin impeccable (globale), si rigenera +.impeccable/ + +# cartelle di lavoro lasciate dal plugin claude-security +/CLAUDE-SECURITY-*/ + # debug npm-debug.log* yarn-debug.log* diff --git a/.planning/SECURITY-AUDIT-INFRA.md b/.planning/SECURITY-AUDIT-INFRA.md index 6cf96d0..aba8805 100644 --- a/.planning/SECURITY-AUDIT-INFRA.md +++ b/.planning/SECURITY-AUDIT-INFRA.md @@ -28,8 +28,10 @@ configurazione di deploy e superficie di rete in produzione. > Il resto della sezione è conservato come traccia di ciò che è stato trovato. **Dove:** -- `.planning/phases/07-unified-service-catalog/07-01-SUMMARY.md:188,195,202` - `.planning/milestones/v2.0-phases/07-unified-service-catalog/07-01-SUMMARY.md:188,195,202` +- (esisteva anche in `.planning/phases/07-unified-service-catalog/`, cartella duplicata + spostata in `cestino/` il 2026-07-28 durante la pulizia — la stringa era già stata espunta + da entrambe le copie) **Cosa:** stringa completa in plaintext, dentro git, con credenziale reale: @@ -37,10 +39,11 @@ configurazione di deploy e superficie di rete in produzione. postgresql://clienthub:@178.104.27.55:5432/clienthub?sslmode=disable ``` -**Verificato:** la password presente in quei file è **ancora attiva** — coincide con la voce -`DATABASE_URL` (porta 5432) di `.env.local`. Non è stata ruotata. +**Verificato (versione corretta):** la password presente in quei file **non è più attiva** — +il verifier SCRAM del DB non combacia. Coincideva solo con la voce `DATABASE_URL` porta 5432 +di `.env.local`, che è a sua volta stale. Era già stata ruotata prima di questo audit. -**Aggravanti:** +**Aggravanti — valgono solo per il periodo in cui la credenziale era viva:** - `sslmode=disable` → traffico Postgres in chiaro sulla rete. - Il commit è nella storia di git: cancellare il file **non basta**, la credenziale resta recuperabile da qualunque clone o dal remote Gitea. diff --git a/.planning/phases/01-foundation-client-dashboard/01-01-PLAN.md b/.planning/phases/01-foundation-client-dashboard/01-01-PLAN.md deleted file mode 100644 index 78619e0..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-01-PLAN.md +++ /dev/null @@ -1,273 +0,0 @@ ---- -phase: "01-foundation-client-dashboard" -plan: 01 -type: execute -wave: 1 -depends_on: [] -files_modified: - - package.json - - tsconfig.json - - next.config.ts - - src/app/layout.tsx - - src/app/page.tsx - - .env.local -autonomous: true -requirements: - - DASH-01 - - DASH-02 - -must_haves: - truths: - - "Next.js 15 App Router is bootstrapped and compiles without errors" - - "DATABASE_URL env var is set and Drizzle can connect to Postgres" - - "A simple test route exists and responds with 200" - - "TypeScript strict mode is enabled" - artifacts: - - path: "package.json" - provides: "All dependencies for Next.js + Drizzle + auth + UI" - contains: "next@15" - - path: "src/app/layout.tsx" - provides: "Root layout with Tailwind setup" - min_lines: 15 - - path: ".env.local" - provides: "DATABASE_URL pointing to Coolify Postgres" - contains: "DATABASE_URL" - key_links: - - from: ".env.local" - to: "Drizzle client initialization" - via: "process.env.DATABASE_URL" - pattern: "DATABASE_URL=postgres://" - - from: "src/db/index.ts" - to: "Postgres on Coolify" - via: "postgres-js driver" - pattern: "import.*postgres.*from.*postgres-js" ---- - - -**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. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.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 - - - - - - Task 1: Bootstrap Next.js 15 with TypeScript, App Router, src/ directory, and Tailwind CSS v4 - - package.json - tsconfig.json - next.config.ts - src/app/layout.tsx - src/app/page.tsx - tailwind.config.ts - postcss.config.mjs - .gitignore - - - None (greenfield project) - - - 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 `` and `` exist with proper className for Tailwind - - Modify `src/app/page.tsx`: - - Replace default template with a simple div: `
Welcome to ClientHub
` - - Keep it minimal — this route will be replaced in Phase 2 -
- - grep -q "\"next\": \"^15" package.json && echo "Next.js 15 installed" - grep -q "\"strict\": true" tsconfig.json && echo "TypeScript strict mode enabled" - test -f src/app/layout.tsx && grep -q "globals.css" src/app/layout.tsx && echo "Tailwind globals imported" - test -f next.config.ts && echo "next.config.ts exists" - - - - `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 - -
- - - Task 2: Install Drizzle ORM, postgres-js, and supporting libraries; create .env.local with DATABASE_URL - - package.json - .env.local - .env.example - src/db/index.ts - - - None (greenfield) - - - 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). - - - grep -q "drizzle-orm" package.json && echo "Drizzle installed" - grep -q "postgres" package.json && echo "postgres-js installed" - grep -q "drizzle-kit" package.json && echo "drizzle-kit installed" - test -f .env.local && grep -q "DATABASE_URL" .env.local && echo ".env.local exists with DATABASE_URL" - test -f .env.example && echo ".env.example exists" - grep -q "postgres" src/db/index.ts && echo "postgres-js driver imported in db/index.ts" - - - - `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 - - - - - Task 3: Install shadcn/ui components and configure; add lucide-react icons - - package.json - components.json - src/components/ui/*.tsx (multiple) - - - tailwind.config.ts - - - 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. - - - test -f components.json && echo "components.json created" - test -d src/components/ui && ls src/components/ui/ | wc -l | grep -qE "[0-9]+" && echo "UI components installed" - grep -q "lucide-react" package.json && echo "lucide-react installed" - - - - `components.json` exists with proper shadcn configuration - - At least 8 component files exist in `src/components/ui/` - - `npm run build` succeeds - - - -
- - -## 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 | - - - - -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 - - - -- 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) - - - -After completion, create `.planning/phases/01-foundation-client-dashboard/01-01-SUMMARY.md` - diff --git a/.planning/phases/01-foundation-client-dashboard/01-01-SUMMARY.md b/.planning/phases/01-foundation-client-dashboard/01-01-SUMMARY.md deleted file mode 100644 index 784f95e..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-01-SUMMARY.md +++ /dev/null @@ -1,190 +0,0 @@ ---- -phase: 01-foundation-client-dashboard -plan: 01 -subsystem: infra -tags: [nextjs, drizzle-orm, postgres, tailwind, shadcn, typescript] - -# Dependency graph -requires: [] -provides: - - Next.js 16 App Router project with TypeScript strict mode - - Tailwind CSS v4 + shadcn/ui components (button, card, badge, progress, input, label, select, separator, table, textarea) - - Drizzle ORM + postgres-js driver configured (db client in src/db/index.ts) - - drizzle.config.ts ready for migrations - - .env.local with DATABASE_URL placeholder - - lucide-react icons - - src/lib/utils.ts cn() helper -affects: - - 01-02-schema - - 01-03-client-route - - 01-04-dashboard-ui - - 01-05-seed-deploy - -# Tech tracking -tech-stack: - added: - - next@16.2.6 - - drizzle-orm@0.45.2 - - drizzle-kit@0.31.10 - - postgres@3.4.9 - - tailwindcss@4.x - - shadcn/ui (Radix preset) - - lucide-react@1.14.0 - - nanoid@5.1.11 - - zod@4.4.3 - - react-hook-form + @hookform/resolvers - - clsx + tailwind-merge + class-variance-authority - patterns: - - App Router with Server Components as default - - Drizzle ORM with postgres-js driver (not neon-http) for Coolify Postgres - - shadcn/ui components in src/components/ui/ (copied, not wrapped) - - cn() utility for conditional classnames - -key-files: - created: - - src/app/layout.tsx (root layout, metadata, viewport, Tailwind globals) - - src/app/page.tsx (placeholder route) - - src/app/globals.css (Tailwind v4 CSS-first) - - src/db/index.ts (Drizzle client with postgres-js) - - src/lib/utils.ts (cn() helper) - - src/components/ui/*.tsx (10 shadcn components) - - drizzle.config.ts (migration config) - - components.json (shadcn config) - - .env.example (public template) - modified: - - package.json (all deps added) - - .gitignore (allow .env.example, block all other .env*) - -key-decisions: - - "Usato Next.js 16.2.6 (latest stable) invece di 15.x — create-next-app@latest installa la versione corrente" - - "viewport spostato in export dedicato (Next.js 16 API) invece che in metadata" - - "src/db/index.ts usa drizzle-orm/postgres-js con import default di postgres (non Client class)" - - ".env.example aggiunto con eccezione in .gitignore (non .env.local che resta ignorato)" - -patterns-established: - - "Database client: import postgres from 'postgres' + drizzle(client) in src/db/index.ts" - - "shadcn/ui: componenti copiati in src/components/ui/, usabili come primitivi" - - "cn() utility per merge classi Tailwind in src/lib/utils.ts" - -requirements-completed: - - DASH-01 - - DASH-02 - -# Metrics -duration: 15min -completed: 2026-05-13 ---- - -# Phase 1 Plan 01: Walking Skeleton — Next.js 16 + Drizzle + shadcn/ui bootstrapped su Coolify Postgres - -**Next.js 16.2.6 App Router con TypeScript strict, Tailwind v4, Drizzle ORM + postgres-js per Coolify Postgres, e 10 componenti shadcn/ui installati e pronti.** - -## Performance - -- **Duration:** ~15 min -- **Started:** 2026-05-13T13:26:00Z -- **Completed:** 2026-05-13T13:41:00Z -- **Tasks:** 3/3 -- **Files modified:** 20+ - -## Accomplishments - -- Next.js 16.2.6 con App Router, TypeScript strict mode, Tailwind CSS v4 — `npm run build` passa senza errori TypeScript -- Drizzle ORM + postgres-js configurati con client in `src/db/index.ts`, pronto per le migrazioni del Plan 02 -- 10 componenti shadcn/ui installati + lucide-react: base UI completa per i plan successivi - -## Task Commits - -1. **Task 1: Bootstrap Next.js 16** - `9563b87` (chore) -2. **Task 2: Drizzle ORM + postgres-js + librerie** - `6b5609b` (feat) -3. **Task 3: shadcn/ui + lucide-react** - `f842007` (feat) - -## Files Created/Modified - -- `src/app/layout.tsx` - Root layout con metadata ClientHub, lang="it", viewport export corretto per Next.js 16 -- `src/app/page.tsx` - Placeholder minimale (sarà sostituito in Phase 2) -- `src/app/globals.css` - Tailwind v4 CSS-first con variabili CSS -- `src/db/index.ts` - Client Drizzle con postgres-js driver, guard su DATABASE_URL -- `src/lib/utils.ts` - cn() helper con clsx + tailwind-merge -- `src/components/ui/*.tsx` - 10 componenti: button, card, badge, progress, input, label, select, separator, table, textarea -- `drizzle.config.ts` - Config drizzle-kit per migrazioni (dialect postgresql, schema src/db/schema.ts) -- `components.json` - Configurazione shadcn/ui (Radix preset, @/ aliases, CSS variables) -- `.env.example` - Template pubblico DATABASE_URL -- `.gitignore` - Aggiunta eccezione per .env.example, blocco tutti gli altri .env* -- `package.json` - Tutte le dipendenze Phase 1 installate - -## Decisions Made - -- Installato Next.js 16.2.6 (latest stable via `create-next-app@latest`) invece di 15.x — versione superiore, retrocompatibile -- `viewport` spostato in export dedicato (`export const viewport: Viewport`) come richiede Next.js 16 API — evita warning di build -- `src/db/index.ts` usa `import postgres from 'postgres'` (default export, non `Client` class) — API corretta del driver postgres-js -- `drizzle-orm/postgres-js` come adapter Drizzle invece di `drizzle-orm/neon-http` — allineato con decisione D-02 (Coolify Postgres, non Neon) - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 3 - Blocking] create-next-app rifiuta cartella con lettere maiuscole** -- **Found during:** Task 1 -- **Issue:** `create-next-app .` fallisce con "name can no longer contain capital letters" perché la cartella si chiama `IAMCAVALLI` -- **Fix:** Creato progetto in directory temporanea `/Users/simonecavalli/clienthub` poi spostati tutti i file nel repo principale -- **Files modified:** Nessun file extra — stesso risultato del comando diretto -- **Verification:** `npm run build` passa, tutti i file sono al posto corretto -- **Committed in:** 9563b87 - -**2. [Rule 1 - Bug] viewport in metadata genera warning Next.js 16** -- **Found during:** Task 1 (prima build) -- **Issue:** `metadata.viewport` è deprecato in Next.js 16; Next.js emette warning e richiede export `viewport` separato -- **Fix:** Aggiunto `export const viewport: Viewport = { ... }` e rimosso `viewport` da `metadata` -- **Files modified:** src/app/layout.tsx -- **Verification:** Build pulita senza warning viewport -- **Committed in:** 9563b87 - -**3. [Rule 3 - Blocking] API postgres driver non è Client class** -- **Found during:** Task 2 -- **Issue:** Il PLAN suggeriva `import { Client } from 'postgres'` ma il driver `postgres` esporta una funzione default, non una classe `Client` -- **Fix:** Usato `import postgres from 'postgres'` con `drizzle-orm/postgres-js` adapter — API corretta -- **Files modified:** src/db/index.ts -- **Verification:** TypeScript compila senza errori -- **Committed in:** 6b5609b - -**4. [Rule 3 - Blocking] shadcn init interattivo non risponde a --yes** -- **Found during:** Task 3 -- **Issue:** `npx shadcn@latest init --yes` richiede selezione manuale (libreria e preset) — non si automatizza -- **Fix:** Creato manualmente `components.json` con config corretta (Radix, CSS variables, @/ aliases) poi usato direttamente `shadcn add` per i componenti -- **Files modified:** components.json (creato manualmente) -- **Verification:** `npx shadcn@latest add button card ...` funziona senza problemi -- **Committed in:** f842007 - ---- - -**Total deviations:** 4 auto-fixed (2 Rule 3 blocking, 1 Rule 1 bug, 1 Rule 3 blocking) -**Impact on plan:** Tutte le deviazioni necessarie per il corretto funzionamento. Nessuno scope creep. - -## Issues Encountered - -- `.env.example` era bloccato da `.env*` pattern nel `.gitignore` — aggiunta eccezione `!.env.example` (file pubblico senza segreti, corretto da tracciare in git) - -## User Setup Required - -Prima di eseguire il Plan 02 (schema + migrazioni), aggiornare `.env.local` con le credenziali reali del database Coolify: - -``` -DATABASE_URL=postgresql://[user]:[password]@[coolify-host]:5432/clienthub -``` - -Le credenziali si trovano nel pannello Coolify su Hetzner. Il file `.env.local` è escluso dal git (`.gitignore`). - -## Threat Surface Scan - -Nessuna nuova superficie di sicurezza non prevista dal piano. Il threat model T-01-001 (DATABASE_URL in .env.local) è mitigato correttamente: `.env*` esclusi dal `.gitignore`, `.env.example` non contiene credenziali reali. - -## Next Phase Readiness - -- Plan 02 (schema Drizzle) può partire immediatamente — `src/db/index.ts` e `drizzle.config.ts` sono pronti -- L'utente deve aggiornare `DATABASE_URL` in `.env.local` con le credenziali reali Coolify prima di eseguire `drizzle-kit push` -- Build stabile, TypeScript strict attivo, zero errori - ---- -*Phase: 01-foundation-client-dashboard* -*Completed: 2026-05-13* diff --git a/.planning/phases/01-foundation-client-dashboard/01-02-PLAN.md b/.planning/phases/01-foundation-client-dashboard/01-02-PLAN.md deleted file mode 100644 index ae49598..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-02-PLAN.md +++ /dev/null @@ -1,369 +0,0 @@ ---- -phase: "01-foundation-client-dashboard" -plan: 02 -type: execute -wave: 2 -depends_on: - - "01-01" -files_modified: - - src/db/schema.ts - - drizzle.config.ts - - .env.local -autonomous: true -requirements: - - DASH-01 - - DASH-02 - - DASH-03 - - DASH-04 - -must_haves: - truths: - - "Drizzle schema is complete and matches the data model from ARCHITECTURE.md" - - "All 11 tables are defined: clients, phases, tasks, deliverables, comments, payments, documents, notes, service_catalog, quote_items" - - "Token field on clients is a separate UUID, not the primary key" - - "approved_at on deliverables is TIMESTAMPTZ" - - "drizzle-kit push has been run and database schema is live" - - "TypeScript types are exported from schema.ts for use in API routes" - artifacts: - - path: "src/db/schema.ts" - provides: "Complete Drizzle ORM schema definition for all entities" - min_lines: 200 - contains: "export const clients = pgTable" - - path: "drizzle.config.ts" - provides: "Drizzle Kit configuration pointing to src/db/schema.ts" - contains: "schema:" - - path: "src/db/migrations/" - provides: "Migration files generated by drizzle-kit" - min_files: 1 - key_links: - - from: "src/db/schema.ts" - to: "clients table" - via: "pgTable definition" - pattern: "export const clients.*pgTable" - - from: "src/db/schema.ts" - to: "token field" - via: "uuid().unique()" - pattern: "token.*uuid.*unique" - - from: "drizzle-kit push" - to: "Postgres on Coolify" - via: "DATABASE_URL" - pattern: "DATABASE_URL" - ---- - - -**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. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/research/ARCHITECTURE.md -@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md -@.planning/phases/01-foundation-client-dashboard/01-01-SUMMARY.md - - - - - - Task 1: Create Drizzle schema definition (src/db/schema.ts) with all 11 tables - - src/db/schema.ts - - - .planning/research/ARCHITECTURE.md (Data Model section, lines 69-142) - - - 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` - - - test -f src/db/schema.ts && echo "schema.ts exists" - grep -c "export const" src/db/schema.ts | grep -q "1[1-9]\|2[0-9]" && echo "Multiple table exports found" - grep -q "token.*uuid.*unique" src/db/schema.ts && echo "Token field is separate and unique" - grep -q "approved_at.*timestamp" src/db/schema.ts && echo "approved_at field exists" - grep -q "accepted_total" src/db/schema.ts && echo "accepted_total denormalized field exists" - npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "TypeScript errors found" || echo "TypeScript compiles" - - - - `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 - - - - - Task 2: Create drizzle.config.ts and generate migrations - - drizzle.config.ts - src/db/migrations/* - - - src/db/schema.ts - .env.local - - - 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 - - - test -f drizzle.config.ts && echo "drizzle.config.ts created" - test -d src/db/migrations && ls src/db/migrations/*.sql 2>/dev/null | wc -l | grep -q "[1-9]" && echo "Migration files generated" - grep -l "CREATE TABLE" src/db/migrations/*.sql | wc -l | grep -q "[1-9]" && echo "SQL migration contains CREATE TABLE" - - - - `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 - - - - - Task 3: [BLOCKING] Run drizzle-kit push to apply schema to Coolify Postgres - - None (schema is pushed to DB, not local files) - - - .env.local (verify DATABASE_URL is set) - src/db/migrations/ (ensure migrations exist) - - - 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. - - - if grep -q "^DATABASE_URL=postgresql://" .env.local; then echo "DATABASE_URL is set"; else echo "DATABASE_URL NOT SET"; fi - npx drizzle-kit push 2>&1 | grep -q "successfully\|already\|applied" && echo "Schema push completed" - - - - 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) - - - - - - -## 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 | - - - - -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 - - - -- 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) - - - -After completion, create `.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md` - diff --git a/.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md b/.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md deleted file mode 100644 index 8cba053..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -phase: 01-foundation-client-dashboard -plan: 02 -subsystem: database -tags: [drizzle-orm, postgres, schema, migrations, nanoid] - -# Dependency graph -requires: - - 01-01 (drizzle-kit, postgres-js driver, DATABASE_URL in .env.local) -provides: - - src/db/schema.ts con 10 tabelle complete - - TypeScript types esportati per tutte le entità (Client, Phase, Task, ecc.) - - Migration file SQL in src/db/migrations/ - - Schema live su Postgres 16 (Hetzner/Coolify) -affects: - - 01-03-client-route (usa clients, phases, tasks, deliverables, payments, documents, notes) - - 01-04-dashboard-ui (usa tutti i types esportati) - - 01-05-seed-deploy (inserisce dati con i types NewClient, NewPhase, ecc.) - -# Tech tracking -tech-stack: - added: [] - patterns: - - "ID strategy: text + nanoid() via $defaultFn (non uuid() nativo Postgres) — nanoid genera stringhe 21-char URL-safe, non UUID formato xxxxxxxx-xxxx-xxxx" - - "drizzle-kit push richiede DATABASE_URL passata esplicitamente come env var (non carica .env.local automaticamente)" - - "Relations Drizzle definite per tutti gli FK — usabili in query con with: { ... }" - -key-files: - created: - - src/db/schema.ts (245 righe — 10 tabelle + relations + TypeScript types) - - src/db/migrations/0000_pretty_typhoid_mary.sql (migration SQL completa) - - src/db/migrations/meta/ (drizzle-kit metadata) - - src/db/migrations/relations.ts (relazioni per introspect) - - src/db/migrations/schema.ts (schema per introspect) - modified: [] - -key-decisions: - - "Usato text + $defaultFn(() => nanoid()) invece di uuid().defaultRandom() — nanoid genera ID URL-safe crittograficamente sicuri (21 char, ~126 bit entropia), non UUID formato PostgreSQL" - - "drizzle.config.ts dal Plan 01 già corretto (defineConfig + dialect postgresql + url:) — nessuna modifica necessaria" - - "clients.token: text notNull unique con nanoid — separato dall'id PK, rotabile con single UPDATE" - - "drizzle-kit push richiede DATABASE_URL come env var esplicita (non auto-load .env.local)" - -# Metrics -duration: 15min -completed: 2026-05-13 ---- - -# Phase 1 Plan 02: Drizzle Schema + Migration — 10 tabelle live su Postgres - -**Schema Drizzle ORM completo con 10 tabelle, migration SQL generata e schema live sul database Postgres 16 (Hetzner/Coolify). TypeScript strict compila senza errori.** - -## Performance - -- **Duration:** ~15 min -- **Started:** 2026-05-13T20:21:00Z -- **Completed:** 2026-05-13T20:36:00Z -- **Tasks:** 3/3 -- **Files modified:** 6 - -## Accomplishments - -- `src/db/schema.ts` creato con 10 tabelle complete + relations Drizzle + TypeScript types esportati -- Vincoli architetturali LOCKED rispettati: `clients.token` separato dall'id PK (unique, notNull, nanoid), `accepted_total` denormalizzato, `approved_at` nullable (audit trail immutabile), `quote_items` mai esposto al client API -- Migration SQL (`0000_pretty_typhoid_mary.sql`) generata con tutti i `CREATE TABLE` e FK constraints -- `npx drizzle-kit push` eseguito con successo — tutte e 10 le tabelle create su `postgresql://178.104.27.55:5432/clienthub` -- Verifica via `information_schema.tables`: clients, comments, deliverables, documents, notes, payments, phases, quote_items, service_catalog, tasks - -## Task Commits - -1. **Task 1: Drizzle schema (src/db/schema.ts)** - `1bdbe7a` (feat) -2. **Task 2: Migration generation (drizzle-kit generate)** - `a6ec599` (chore) -3. **Task 3: [BLOCKING] drizzle-kit push → Postgres live** - `abcbb52` (feat) - -## Files Created/Modified - -- `src/db/schema.ts` — 10 tabelle: clients (token separato + accepted_total), phases, tasks, deliverables (approved_at nullable), comments (polimorfici), payments (da_saldare/inviata/saldato), documents, notes, service_catalog, quote_items -- `src/db/migrations/0000_pretty_typhoid_mary.sql` — Migration SQL completa con CREATE TABLE + FK + UNIQUE constraint su token -- `src/db/migrations/meta/` — Drizzle-kit metadata (snapshot JSON) -- `src/db/migrations/relations.ts` — Relations per introspect -- `src/db/migrations/schema.ts` — Schema per introspect - -## Decisions Made - -- **ID strategy:** `text + $defaultFn(() => nanoid())` invece di `uuid().defaultRandom()`. La colonna Drizzle `uuid()` si aspetta il formato PostgreSQL `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, mentre `nanoid()` genera stringhe 21-char URL-safe. Usare `text` è corretto e allineato con la decisione architetturale di token crittograficamente sicuro. -- **drizzle.config.ts invariato:** La versione dal Plan 01 usa già `defineConfig`, `dialect: "postgresql"` e `url:` (sintassi aggiornata drizzle-kit v0.31) — nessuna modifica necessaria rispetto alla versione suggerita nel piano (che usava l'API obsoleta `driver: 'pg'`). - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 1 - Bug] uuid() non compatibile con nanoid() come defaultFn** -- **Found during:** Task 1 -- **Issue:** Il piano suggeriva `uuid('id').primaryKey().defaultValue(nanoid())` ma Drizzle `uuid()` si aspetta UUID nel formato `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. nanoid() genera stringhe come `Tcyf3muFXVOX9QO9pBUES` (21 char, non UUID validi). Usare `defaultValue(nanoid())` su una colonna `uuid()` avrebbe causato errori a runtime al primo INSERT. -- **Fix:** Cambiato a `text('id').primaryKey().$defaultFn(() => nanoid())` per tutte le PK e per il campo `token`. Semantica identica (ID crittograficamente sicuro), tipo colonna SQL `text` invece di `uuid`. -- **Files modified:** src/db/schema.ts -- **Commit:** 1bdbe7a - -**2. [Rule 1 - Bug] drizzle.config.ts dal piano usa API obsoleta** -- **Found during:** Task 2 -- **Issue:** Il piano suggeriva `driver: 'pg'` e `dbCredentials: { connectionString: ... }` — sintassi drizzle-kit <0.30. Il file esistente usa già `defineConfig` con `dialect: "postgresql"` e `dbCredentials: { url: ... }` — sintassi corretta per drizzle-kit 0.31. -- **Fix:** Mantenuto il file esistente senza modifiche (era già corretto). -- **Files modified:** nessuno -- **Commit:** nessuno necessario - -**3. [Rule 3 - Blocking] drizzle-kit push non carica .env.local automaticamente** -- **Found during:** Task 3 -- **Issue:** `npx drizzle-kit push` fallisce con "connection url required" perché drizzle-kit non carica `.env.local` automaticamente (solo `.env`). -- **Fix:** Passato `DATABASE_URL` esplicitamente come variabile d'ambiente al comando: `DATABASE_URL="..." npx drizzle-kit push`. -- **Files modified:** nessuno (solo comando di esecuzione) -- **Commit:** abcbb52 - -## Known Stubs - -Nessuno. Il piano è infrastrutturale (schema + DB) — nessun componente UI o dato presentato al cliente. Le tabelle sono vuote, ma questo è intenzionale: il seed script è previsto nel Plan 05. - -## Threat Surface Scan - -Il threat model T-02-001 (unicità token) è mitigato: `CONSTRAINT "clients_token_unique" UNIQUE("token")` è attivo nel database. T-02-002 e T-02-003 sono accettati come da piano. - -Nessuna nuova superficie di sicurezza non prevista dal threat model. - -## Self-Check - -- [x] `src/db/schema.ts` esiste (245 righe, 10 tabelle pgTable + relations + types) -- [x] `src/db/migrations/0000_pretty_typhoid_mary.sql` esiste con 10 CREATE TABLE -- [x] Commit `1bdbe7a` esiste (schema) -- [x] Commit `a6ec599` esiste (migrations) -- [x] Commit `abcbb52` esiste (push) -- [x] 10 tabelle verificate live su Postgres via `information_schema.tables` -- [x] `clients.token` è `text NOT NULL UNIQUE` con nanoid — separato dalla PK -- [x] `approved_at` è `timestamp with time zone` nullable -- [x] TypeScript strict: `npm run build` — zero errori TypeScript - -## Self-Check: PASSED - -## Next Phase Readiness - -- Plan 03 (Middleware + route `/c/[token]`) può partire — lo schema è live e i types sono importabili -- Import pattern: `import { clients, phases, tasks, ... } from '@/db/schema'` -- Import types: `import type { Client, Phase, Task, ... } from '@/db/schema'` - ---- -*Phase: 01-foundation-client-dashboard* -*Completed: 2026-05-13* \ No newline at end of file diff --git a/.planning/phases/01-foundation-client-dashboard/01-03-PLAN.md b/.planning/phases/01-foundation-client-dashboard/01-03-PLAN.md deleted file mode 100644 index 8b0d98d..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-03-PLAN.md +++ /dev/null @@ -1,569 +0,0 @@ ---- -phase: "01-foundation-client-dashboard" -plan: 03 -type: execute -wave: 2 -depends_on: - - "01-01" - - "01-02" -files_modified: - - src/middleware.ts - - app/api/internal/validate-token/route.ts - - src/lib/client-view.ts - - app/c/[token]/page.tsx - - app/c/[token]/layout.tsx -autonomous: true -requirements: - - DASH-01 - - DASH-02 - - DASH-03 - - DASH-04 - -must_haves: - truths: - - "Middleware validates token at edge and returns 404 if token not found" - - "Client can open /c/[token] without login" - - "Server Component fetches client data from DB via token" - - "ClientView type ensures quote_items is never exposed to client API" - - "All phase, task, payment, document, and note data is fetched and passed to UI" - - "TypeScript types are exported for downstream UI rendering" - artifacts: - - path: "src/middleware.ts" - provides: "Token validation using fetch to internal API route (Edge-compatible)" - contains: "function middleware" - - path: "app/api/internal/validate-token/route.ts" - provides: "Node.js API route that queries DB and returns 200/404 for token validation" - min_lines: 20 - contains: "clients.token" - - path: "src/lib/client-view.ts" - provides: "Client-safe type definitions and query functions" - contains: "ClientView" - - path: "app/c/[token]/page.tsx" - provides: "Server Component rendering client dashboard" - min_lines: 30 - contains: "export default async function" - - path: "app/c/[token]/layout.tsx" - provides: "Layout for token-authenticated routes" - min_lines: 10 - key_links: - - from: "src/middleware.ts" - to: "app/api/internal/validate-token/route.ts" - via: "fetch('/api/internal/validate-token?token=X')" - pattern: "validate-token" - - from: "app/api/internal/validate-token/route.ts" - to: "Database query for token validation" - via: "db.select().from(clients).where(eq(clients.token, token))" - pattern: "clients\\.token" - - from: "app/c/[token]/page.tsx" - to: "src/lib/client-view.ts" - via: "import { getClientView }" - pattern: "getClientView" - - from: "ClientView type" - to: "Rendering props" - via: "ensures no quote_items" - pattern: "quote_items" - ---- - - -**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. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.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 - - - - - - Task 1: Create src/middleware.ts (Edge-compatible fetch pattern) + internal validate-token API route - - src/middleware.ts - app/api/internal/validate-token/route.ts - - - src/db/schema.ts (clients table definition) - package.json (verify Next.js version) - - - **Why two files:** Next.js middleware runs in the Edge runtime by default. The postgres-js driver (used by Drizzle) requires Node.js `net`/`tls` APIs unavailable at the Edge. The solution is a two-layer pattern: middleware uses `fetch()` to call an internal API route that runs in the Node.js runtime and does the actual DB query. - - Create `app/api/internal/validate-token/route.ts` (Node.js runtime, does DB query): - - ```typescript - import { NextRequest, NextResponse } from 'next/server'; - import { eq } from 'drizzle-orm'; - import { db } from '@/db'; - import { clients } from '@/db/schema'; - - export async function GET(request: NextRequest) { - const token = request.nextUrl.searchParams.get('token'); - - if (!token) { - return NextResponse.json({ valid: false }, { status: 400 }); - } - - try { - const rows = await db - .select({ id: clients.id }) - .from(clients) - .where(eq(clients.token, token)) - .limit(1); - - if (rows.length === 0) { - return NextResponse.json({ valid: false }, { status: 404 }); - } - - return NextResponse.json({ valid: true }, { status: 200 }); - } catch { - return NextResponse.json({ valid: false }, { status: 500 }); - } - } - ``` - - Create `src/middleware.ts` (Edge-compatible, uses fetch): - - ```typescript - import { NextRequest, NextResponse } from 'next/server'; - - export async function middleware(request: NextRequest) { - const pathname = request.nextUrl.pathname; - - // Extract token from path: /c/[token]/... - const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/); - if (!tokenMatch) { - return NextResponse.rewrite(new URL('/not-found', request.url)); - } - - const token = tokenMatch[1]; - - try { - // Call internal Node.js API route — Edge middleware cannot use postgres-js directly - const validateUrl = new URL( - `/api/internal/validate-token?token=${encodeURIComponent(token)}`, - request.url - ); - const res = await fetch(validateUrl.toString()); - - if (!res.ok) { - return NextResponse.rewrite(new URL('/not-found', request.url)); - } - - return NextResponse.next(); - } catch { - return NextResponse.rewrite(new URL('/not-found', request.url)); - } - } - - export const config = { - matcher: ['/c/:path*'], - }; - ``` - - Key points: - - Middleware is Edge-compatible: no Node.js imports, only `fetch()` - - DB query lives in the API route (Node.js runtime) where postgres-js works correctly - - Token is URL-encoded before being passed as query param - - Non-existent or invalid tokens resolve to `/not-found` (Next.js built-in 404 page) - - Internal API route should not be called directly by clients (no auth secret needed — it only returns boolean valid/invalid) - - - test -f src/middleware.ts && echo "middleware.ts exists" - grep -q "export.*function middleware" src/middleware.ts && echo "middleware function exported" - grep -q "matcher.*c/" src/middleware.ts && echo "matcher configured for /c/ routes" - ! grep -q "from '@/db'" src/middleware.ts && echo "middleware does not import drizzle/db (good — Edge safe)" - test -f app/api/internal/validate-token/route.ts && echo "internal validate-token route exists" - grep -q "clients.token" app/api/internal/validate-token/route.ts && echo "Token DB query in API route" - - - - `src/middleware.ts` does NOT import Drizzle/postgres-js (Edge-safe) - - `src/middleware.ts` fetches `/api/internal/validate-token?token=X` - - `app/api/internal/validate-token/route.ts` queries `clients.token` via Drizzle - - Non-existent tokens return `/not-found` (404) - - Matcher configured for `/c/:path*` - - TypeScript compiles without errors - - - - - Task 2: Create src/lib/client-view.ts with ClientView type and query functions - - src/lib/client-view.ts - - - src/db/schema.ts (all table definitions) - - - Create `src/lib/client-view.ts`: - - ```typescript - import { eq, inArray } from 'drizzle-orm'; - import { db } from '@/db'; - import { clients, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema'; - - /** - * ClientView: The ONLY data shape returned to client-facing routes. - * Deliberately excludes: quote_items, service_catalog, service prices. - * Enforced server-side: client API never touches admin data. - */ - export interface ClientView { - client: { - id: string; - name: string; - brand_name: string; - brief: string; - accepted_total: string; // only total, never breakdown - }; - phases: Array<{ - id: string; - title: string; - status: 'upcoming' | 'active' | 'done'; - sort_order: number; - tasks: Array<{ - id: string; - title: string; - description: string | null; - status: 'todo' | 'in_progress' | 'done'; - sort_order: number; - deliverables: Array<{ - id: string; - title: string; - url: string | null; - status: 'pending' | 'submitted' | 'approved'; - approved_at: string | null; // ISO timestamp - }>; - }>; - progress_pct: number; // % of tasks done in this phase - }>; - payments: Array<{ - id: string; - label: string; // "Acconto 50%" | "Saldo 50%" - status: 'da_saldare' | 'inviata' | 'saldato'; - }>; - documents: Array<{ - id: string; - label: string; - url: string; - }>; - notes: Array<{ - id: string; - body: string; - created_at: string; // ISO timestamp - }>; - global_progress_pct: number; // % of all tasks done across all phases - } - - /** - * getClientView: Fetch all client data and return only ClientView shape. - * NEVER queries quote_items. - */ - export async function getClientView(token: string): Promise { - // Fetch client - const clientRow = await db - .select() - .from(clients) - .where(eq(clients.token, token)) - .limit(1); - - if (clientRow.length === 0) { - return null; - } - - const client = clientRow[0]; - - // Fetch all phases for this client - const phasesRows = await db - .select() - .from(phases) - .where(eq(phases.client_id, client.id)) - .orderBy(phases.sort_order); - - // Fetch tasks scoped to this client's phases only - const phaseIds = phasesRows.map((p) => p.id); - const tasksRows = phaseIds.length === 0 - ? [] - : await db - .select() - .from(tasks) - .where(inArray(tasks.phase_id, phaseIds)) - .orderBy(tasks.sort_order); - - // Fetch deliverables scoped to this client's tasks only - const taskIds = tasksRows.map((t) => t.id); - const deliverables_rows = taskIds.length === 0 - ? [] - : await db - .select() - .from(deliverables) - .where(inArray(deliverables.task_id, taskIds)); - - // Fetch payments - const paymentsRows = await db - .select() - .from(payments) - .where(eq(payments.client_id, client.id)); - - // Fetch documents - const documentsRows = await db - .select() - .from(documents) - .where(eq(documents.client_id, client.id)); - - // Fetch notes - const notesRows = await db - .select() - .from(notes) - .where(eq(notes.client_id, client.id)) - .orderBy(notes.created_at); - - // Build hierarchical structure - const phasesList = phasesRows.map((phase) => { - const phaseTasksRows = tasksRows.filter((t) => t.phase_id === phase.id); - - const tasksList = phaseTasksRows.map((task) => { - const taskDeliverables = deliverables_rows - .filter((d) => d.task_id === task.id) - .map((d) => ({ - id: d.id, - title: d.title, - url: d.url, - status: d.status as 'pending' | 'submitted' | 'approved', - approved_at: d.approved_at ? new Date(d.approved_at).toISOString() : null, - })); - - return { - id: task.id, - title: task.title, - description: task.description, - status: task.status as 'todo' | 'in_progress' | 'done', - sort_order: task.sort_order, - deliverables: taskDeliverables, - }; - }); - - // Calculate progress for this phase - const taskCount = tasksList.length; - const doneCount = tasksList.filter((t) => t.status === 'done').length; - const progress_pct = taskCount === 0 ? 0 : Math.round((doneCount / taskCount) * 100); - - return { - id: phase.id, - title: phase.title, - status: phase.status as 'upcoming' | 'active' | 'done', - sort_order: phase.sort_order, - tasks: tasksList, - progress_pct, - }; - }); - - // Calculate global progress - const allTasks = phasesRows.flatMap((p) => - tasksRows.filter((t) => t.phase_id === p.id) - ); - const allDoneTasks = allTasks.filter((t) => t.status === 'done').length; - const globalProgressPct = allTasks.length === 0 ? 0 : Math.round((allDoneTasks / allTasks.length) * 100); - - // Map payments (do NOT expose amount — only label and status) - const paymentsList = paymentsRows.map((p) => ({ - id: p.id, - label: p.label, - status: p.status as 'da_saldare' | 'inviata' | 'saldato', - })); - - // Map documents - const documentsList = documentsRows.map((d) => ({ - id: d.id, - label: d.label, - url: d.url, - })); - - // Map notes - const notesList = notesRows.map((n) => ({ - id: n.id, - body: n.body, - created_at: new Date(n.created_at).toISOString(), - })); - - return { - client: { - id: client.id, - name: client.name, - brand_name: client.brand_name, - brief: client.brief, - accepted_total: client.accepted_total ?? '0', - }, - phases: phasesList, - payments: paymentsList, - documents: documentsList, - notes: notesList, - global_progress_pct: globalProgressPct, - }; - } - ``` - - Key points: - - `ClientView` interface explicitly omits admin data - - `getClientView()` never queries `quote_items`, `service_catalog`, or service prices - - Payments are returned WITHOUT amount (only label and status) - - All timestamps are ISO strings for JSON serialization - - Progress percentages are calculated server-side - - - test -f src/lib/client-view.ts && echo "client-view.ts exists" - grep -q "interface ClientView" src/lib/client-view.ts && echo "ClientView interface defined" - grep -q "export async function getClientView" src/lib/client-view.ts && echo "getClientView function exported" - ! grep -q "quote_items\|service_catalog" src/lib/client-view.ts && echo "quote_items not referenced (good)" - grep -q "inArray" src/lib/client-view.ts && echo "inArray scoping present" - grep -q "accepted_total.*?? '0'" src/lib/client-view.ts && echo "null coalescing on accepted_total" - npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "TypeScript errors" || echo "TypeScript OK" - - - - `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 - - - - - Task 3: Create app/c/[token]/page.tsx Server Component to render client dashboard - - app/c/[token]/page.tsx - app/c/[token]/layout.tsx - - - src/lib/client-view.ts (ClientView interface) - - - 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 ( -
- {/* Placeholder: Dashboard will be built in Plan 04 */} -
-

{view.client.brand_name}

-

{view.client.brief}

-

Token: {params.token}

-
-
- ); - } - ``` - - 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 -
- - test -f app/c/\[token\]/page.tsx && echo "Client page route exists" - grep -q "export default async function" app/c/\[token\]/page.tsx && echo "Server Component syntax correct" - grep -q "getClientView" app/c/\[token\]/page.tsx && echo "getClientView is called" - grep -q "notFound()" app/c/\[token\]/page.tsx && echo "404 handling in place" - test -f app/c/\[token\]/layout.tsx && echo "Layout file exists" - - - - `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 - -
- -
- - -## 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 | - - - - -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 - - - -- 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) - - - -After completion, create `.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md` - diff --git a/.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md b/.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md deleted file mode 100644 index c222910..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -phase: 01-foundation-client-dashboard -plan: 03 -subsystem: client-portal -tags: [nextjs, middleware, drizzle-orm, client-view, server-component, edge-runtime] - -# Dependency graph -requires: - - 01-01 (Next.js bootstrap, DATABASE_URL, Drizzle client in src/db/index.ts) - - 01-02 (src/db/schema.ts — clients, phases, tasks, deliverables, payments, documents, notes) -provides: - - src/proxy.ts (Edge proxy per validazione token su /c/:path*) - - src/app/api/internal/validate-token/route.ts (Node.js route che interroga clients.token) - - src/lib/client-view.ts (ClientView interface + getClientView() function) - - src/app/c/[token]/page.tsx (Server Component placeholder per dashboard cliente) - - src/app/c/[token]/layout.tsx (Layout con metadata per rotte token-auth) -affects: - - 01-04-dashboard-ui (consuma ClientView interface e getClientView()) - - 01-05-seed-deploy (genera token, verifica accesso /c/[token]) - -# Tech tracking -tech-stack: - added: [] - patterns: - - "Edge proxy pattern: proxy.ts usa fetch() verso /api/internal/validate-token — nessun import Drizzle/postgres-js in Edge runtime" - - "Next.js 16 breaking change: file convention rinominata da 'middleware' a 'proxy'; export function rinominata da 'middleware' a 'proxy'" - - "Next.js 15+ breaking change: params in Server Component è Promise<{ token: string }> — await params prima dell'uso" - - "ClientView enforced server-side: interface TypeScript + query function che non tocca mai quote_items o service_catalog" - - "inArray() per scoping tasks/deliverables: previene full table scan su clienti che non appartengono alla sessione" - -key-files: - created: - - src/proxy.ts (proxy Edge-compatible — rinominato da middleware.ts per Next.js 16) - - src/app/api/internal/validate-token/route.ts (Node.js route, query clients.token via Drizzle) - - src/lib/client-view.ts (ClientView interface + getClientView() — 209 righe) - - src/app/c/[token]/page.tsx (Server Component placeholder — 28 righe) - - src/app/c/[token]/layout.tsx (Layout con metadata — 14 righe) - modified: [] - -key-decisions: - - "Next.js 16 richiede file 'proxy.ts' (non 'middleware.ts') ed export function 'proxy' (non 'middleware') — auto-corretto da Rule 1" - - "params nelle Server Component Next.js 15+ è Promise<{ token: string }> — await obbligatorio (breaking change)" - - "ClientView.payments non espone 'amount' — solo label e status — vincolo architetturale LOCKED rispettato" - - "getClientView() usa inArray() per scopare tasks e deliverables ai soli phase_id del cliente, evitando leak cross-client" - -# Metrics -duration: 25min -completed: 2026-05-14 ---- - -# Phase 1 Plan 03: Token Middleware + Client Portal Data Layer — Route /c/[token] operativa - -**Edge proxy con validazione token, ClientView type system che esclude quote_items, e Server Component che fetcha tutti i dati cliente senza esporre segreti admin. Build Next.js 16 senza errori TypeScript.** - -## Performance - -- **Duration:** ~25 min -- **Started:** 2026-05-13T22:50:00Z -- **Completed:** 2026-05-14T00:15:00Z -- **Tasks:** 3/3 -- **Files created:** 5 - -## Accomplishments - -- `src/proxy.ts`: proxy Edge-compatible per Next.js 16 — valida token via `fetch('/api/internal/validate-token')` senza import Drizzle/postgres-js. Matcher configurato su `/c/:path*`. Token non trovato → rewrite su `/not-found`. -- `src/app/api/internal/validate-token/route.ts`: route Node.js che interroga `clients.token` via Drizzle ORM. Ritorna `{ valid: true }` (200) o `{ valid: false }` (404/400/500). Drizzle funziona correttamente qui perché non è nel runtime Edge. -- `src/lib/client-view.ts`: `ClientView` interface che esclude esplicitamente `quote_items`, `service_catalog` e prezzi singoli. `getClientView()` esegue 6 query scoped (client by token → phases → tasks via inArray → deliverables via inArray → payments → documents → notes). Progress % calcolata server-side. `accepted_total: client.accepted_total ?? '0'`. -- `src/app/c/[token]/page.tsx`: Server Component con `await params`, chiama `getClientView(token)`, ritorna `notFound()` se null. ISR a 60s. -- `src/app/c/[token]/layout.tsx`: layout minimo con metadata. -- `npm run build` completato con successo — zero errori TypeScript, tutte le route presenti nel build output. - -## Task Commits - -1. **Task 1: Edge proxy + validate-token API route** — `ef34817` (feat) -2. **Task 2: ClientView type system + getClientView()** — `14787ba` (feat) -3. **Task 3: /c/[token] Server Component + layout** — `8b5e723` (feat, include deviazione Rule 1) - -## Files Created/Modified - -- `src/proxy.ts` — Edge proxy per /c/:path* (rinominato da middleware.ts — vedi Deviazioni) -- `src/app/api/internal/validate-token/route.ts` — Node.js route, query `eq(clients.token, token)`, risposta 200/404 -- `src/lib/client-view.ts` — ClientView interface (quote_items mai inclusi) + getClientView() con inArray scoping -- `src/app/c/[token]/page.tsx` — Server Component placeholder, notFound() su token invalido -- `src/app/c/[token]/layout.tsx` — Layout con metadata - -## Decisions Made - -- **Next.js 16 proxy convention:** La nuova convenzione rinomina `middleware.ts` → `proxy.ts` ed esige `export function proxy` (non `middleware`). La build ha segnalato il deprecation warning al primo tentativo. Risolto con rename + export update (Rule 1). -- **params come Promise:** Next.js 15+ tratta `params` come `Promise<{ token: string }>` nelle Server Component. Il piano usava la sintassi Next.js 14 sincrona — aggiornato ad `await params` per conformità (Rule 1). -- **ClientView.payments senza amount:** Il campo `amount` di `payments` è intenzionalmente omesso dalla `ClientView`. Il cliente vede solo `label` e `status`. Vincolo architetturale LOCKED rispettato a livello di query. - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 1 - Bug] Next.js 16 depreca la convenzione 'middleware' in favore di 'proxy'** -- **Found during:** Task 3 — `npm run build` ha emesso warning e poi errore: "Proxy is missing expected function export name" -- **Issue:** Next.js 16 ha rinominato la file convention da `middleware.ts` a `proxy.ts` e richiede che la funzione esportata si chiami `proxy` (o `default`), non `middleware`. -- **Fix:** Rinominato `src/middleware.ts` → `src/proxy.ts`; rinominato `export async function middleware` → `export async function proxy`. Il commit ef34817 conteneva middleware.ts — il Task 3 commit 8b5e723 include la modifica. -- **Files modified:** src/proxy.ts (rinominato da src/middleware.ts) -- **Commit:** 8b5e723 - -**2. [Rule 1 - Bug] params come Promise in Next.js 15+ Server Component** -- **Found during:** Task 3 — il piano usava la sintassi `params: { token: string }` di Next.js 14 -- **Issue:** In Next.js 15+, i `params` nelle Server Component sono `Promise<{ token: string }>`. Usare la sintassi sincrona avrebbe causato TypeScript error e comportamento errato a runtime. -- **Fix:** Tipizzato `params: Promise<{ token: string }>` e aggiunto `const { token } = await params;` prima dell'uso. -- **Files modified:** src/app/c/[token]/page.tsx -- **Commit:** 8b5e723 - -## Known Stubs - -La `page.tsx` mostra un placeholder minimo (brand_name, brief, token). Questo è **intenzionale e documentato nel piano**: "Placeholder content — full UI in Plan 04". Il Plan 04 (Dashboard UI) sostituirà questo placeholder con la UI completa. Il goal del piano (route operativa + data fetching corretto) è raggiunto pienamente. - -## Threat Surface Scan - -Nessuna nuova superficie di sicurezza non prevista dal threat model 01-03: -- T-03-001 (ClientView shape): mitigato — interface TypeScript + getClientView() non tocca mai quote_items/service_catalog -- T-03-002 (Token parameter): mitigato — proxy valida il token prima che qualsiasi pagina venga renderizzata; token invalidi → /not-found -- T-03-003 (DoS su getClientView): accettato — query indicizzate su client_id/token - -L'API route `/api/internal/validate-token` è accessibile pubblicamente (nessun secret header). Questo è intenzionale: ritorna solo `{ valid: boolean }` — nessun dato cliente esposto. Un attaccante può enumerare token validi con brute force ma: (1) nanoid 21 chars offre ~126 bit di entropia, rendendo il brute force computazionalmente impossibile; (2) nessun dato sensibile è esposto dalla route. - -## Self-Check - -- [x] `src/proxy.ts` esiste (edge proxy — rinominato da middleware.ts) -- [x] `src/app/api/internal/validate-token/route.ts` esiste (query clients.token via Drizzle) -- [x] `src/lib/client-view.ts` esiste (ClientView interface + getClientView()) -- [x] `src/app/c/[token]/page.tsx` esiste (Server Component con await params) -- [x] `src/app/c/[token]/layout.tsx` esiste -- [x] Commit `ef34817` esiste (Task 1) -- [x] Commit `14787ba` esiste (Task 2) -- [x] Commit `8b5e723` esiste (Task 3) -- [x] `npm run build` completato senza errori TypeScript -- [x] Build output mostra `/api/internal/validate-token` (Dynamic) e `/c/[token]` (Dynamic) -- [x] `proxy.ts` NON importa `@/db` o `drizzle-orm` (Edge safe) -- [x] `client-view.ts` non contiene query su `quote_items` o `service_catalog` -- [x] `accepted_total: client.accepted_total ?? '0'` presente - -## Self-Check: PASSED - -## Next Phase Readiness - -- Plan 04 (Dashboard UI) può partire — `getClientView()` è disponibile e testa TypeScript OK -- Import pattern: `import { getClientView, type ClientView } from '@/lib/client-view'` -- La route `/c/[token]` è operativa — con un cliente seedato da Plan 05 sarà accessibile - ---- -*Phase: 01-foundation-client-dashboard* -*Completed: 2026-05-14* \ No newline at end of file diff --git a/.planning/phases/01-foundation-client-dashboard/01-04-PLAN.md b/.planning/phases/01-foundation-client-dashboard/01-04-PLAN.md deleted file mode 100644 index b658e95..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-04-PLAN.md +++ /dev/null @@ -1,864 +0,0 @@ ---- -phase: "01-foundation-client-dashboard" -plan: 04 -type: execute -wave: 2 -depends_on: - - "01-01" - - "01-02" - - "01-03" -files_modified: - - app/c/[token]/page.tsx - - src/components/client-dashboard.tsx - - src/components/phase-timeline.tsx - - src/components/payment-status.tsx - - src/components/documents-section.tsx - - src/components/notes-section.tsx - - src/app/globals.css - - tailwind.config.ts -autonomous: true -requirements: - - DASH-02 - - DASH-03 - - DASH-04 - - DASH-07 - - DASH-08 - - DASH-09 - - DASH-10 - -must_haves: - truths: - - "Client dashboard displays client brand name prominently with iamcavalli logo in corner" - - "Global progress bar at top shows % of all tasks completed" - - "Phases are displayed as lateral timeline (left indicator, content right)" - - "Each phase shows progress bar (% from completed tasks) + task list with status badges" - - "Tasks are nested within phases with status visible (todo/in_progress/done)" - - "Payment section always visible: accepted_total + Acconto 50% status + Saldo 50% status (NO amounts)" - - "Document links are clickable (opens external URL)" - - "Notes/decision log is visible (read-only, may be empty)" - - "Layout is mobile-responsive and light & clean visual style" - artifacts: - - path: "app/c/[token]/page.tsx" - provides: "Server Component rendering ClientDashboard" - min_lines: 20 - - path: "src/components/client-dashboard.tsx" - provides: "Layout wrapper + main sections (header, progress, phases, payments, documents, notes)" - min_lines: 50 - - path: "src/components/phase-timeline.tsx" - provides: "Lateral timeline rendering with phase cards and task lists" - min_lines: 80 - - path: "src/components/payment-status.tsx" - provides: "Payment section: accepted_total + 2 payment rows with status" - min_lines: 30 - - path: "src/components/documents-section.tsx" - provides: "List of external document links" - min_lines: 20 - - path: "src/components/notes-section.tsx" - provides: "Read-only notes list with timestamps" - min_lines: 20 - - path: "tailwind.config.ts" - provides: "Light & clean design tokens (updated from bootstrap)" - contains: "colors" - key_links: - - from: "app/c/[token]/page.tsx" - to: "ClientDashboard component" - via: "import { ClientDashboard }" - pattern: " -**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. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.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) - - - - - - Task 1: Configure design tokens (tailwind.config.ts + globals.css) and wire app/c/[token]/page.tsx to ClientDashboard - - tailwind.config.ts - src/app/globals.css - app/c/[token]/page.tsx - - - tailwind.config.ts (current bootstrap) - src/app/globals.css (current bootstrap) - src/components/client-dashboard.tsx (will exist after Task 2 — read after Task 2 completes) - src/lib/client-view.ts (ClientView interface) - - - Update `tailwind.config.ts` to define light & clean design tokens: - - ```typescript - import type { Config } from 'tailwindcss'; - - const config: Config = { - content: [ - './src/pages/**/*.{js,ts,jsx,tsx,mdx}', - './src/components/**/*.{js,ts,jsx,tsx,mdx}', - './src/app/**/*.{js,ts,jsx,tsx,mdx}', - ], - theme: { - extend: { - colors: { - // Light & clean palette - 'primary': '#1a1a1a', // deep charcoal for text - 'secondary': '#666666', // medium gray for secondary text - 'tertiary': '#999999', // light gray for hints - 'bg-light': '#ffffff', // pure white - 'bg-subtle': '#f9f9f9', // very light gray - 'border-light': '#e5e5e5', // subtle border - 'accent': '#0066cc', // blue accent (will be brand-aware in Phase 2) - 'success': '#22c55e', // green for done - 'warning': '#eab308', // yellow for in-progress - 'info': '#3b82f6', // blue for pending - }, - spacing: { - 'xs': '0.5rem', - 'sm': '1rem', - 'md': '1.5rem', - 'lg': '2rem', - 'xl': '3rem', - }, - fontSize: { - 'xs': '0.75rem', - 'sm': '0.875rem', - 'base': '1rem', - 'lg': '1.125rem', - 'xl': '1.25rem', - '2xl': '1.5rem', - '3xl': '1.875rem', - }, - fontFamily: { - 'sans': [ - 'system-ui', - '-apple-system', - 'BlinkMacSystemFont', - '"Segoe UI"', - 'Roboto', - '"Helvetica Neue"', - 'sans-serif', - ], - }, - }, - }, - plugins: [], - }; - - export default config; - ``` - - Update `src/app/globals.css`: - - ```css - @tailwind base; - @tailwind components; - @tailwind utilities; - - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - html { - scroll-behavior: smooth; - } - - body { - @apply bg-white text-primary font-sans; - line-height: 1.6; - } - - h1 { - @apply text-3xl font-bold tracking-tight; - } - - h2 { - @apply text-2xl font-bold; - } - - h3 { - @apply text-xl font-semibold; - } - - p { - @apply text-base text-secondary; - } - - a { - @apply text-accent hover:underline transition-colors; - } - - .border-subtle { - @apply border border-border-light; - } - - .bg-subtle { - @apply bg-bg-subtle; - } - ``` - - Update `app/c/[token]/page.tsx` to replace the Plan 03 placeholder with the full ClientDashboard render: - - ```typescript - import { getClientView } from '@/lib/client-view'; - import { ClientDashboard } from '@/components/client-dashboard'; - import { notFound } from 'next/navigation'; - - export const revalidate = 60; - - export async function generateMetadata({ - params, - }: { - params: { token: string }; - }) { - const view = await getClientView(params.token); - - if (!view) { - return { title: 'Not Found' }; - } - - return { - title: `${view.client.brand_name} — Project Status | iamcavalli`, - description: view.client.brief || 'Project status dashboard', - }; - } - - export default async function ClientPage({ - params, - }: { - params: { token: string }; - }) { - const view = await getClientView(params.token); - - if (!view) { - notFound(); - } - - return ; - } - ``` - - Note: `getClientView` is called twice (once in `generateMetadata`, once in `ClientPage`). Next.js 15 deduplicates fetch calls within the same render, and since this is a DB query via Drizzle (not fetch), use React `cache()` in `client-view.ts` if double-call is a concern — acceptable for Phase 1 given low traffic. - - - grep -q "colors:" tailwind.config.ts && echo "Color tokens defined" - grep -q "primary\|accent\|success" tailwind.config.ts && echo "Key colors present" - grep -q "@tailwind" src/app/globals.css && echo "Tailwind directives in globals.css" - grep -q "ClientDashboard" app/c/\[token\]/page.tsx && echo "ClientDashboard wired in page" - grep -q "generateMetadata" app/c/\[token\]/page.tsx && echo "Dynamic metadata present" - - - - `tailwind.config.ts` contains color tokens: primary, secondary, accent, success, warning - - `globals.css` includes Tailwind directives and base typography - - `app/c/[token]/page.tsx` renders `` with dynamic metadata - - 404 returned if token invalid - - `npm run build` succeeds - - - - - Task 2: Create ClientDashboard wrapper component with header, global progress, and section layout - - src/components/client-dashboard.tsx - - - src/lib/client-view.ts (ClientView interface) - .planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-06 through D-10) - - - 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 ( -
- {/* Header: Logo + Brand Name */} -
-
-
- {/* iamcavalli logo (small, corner) */} -
iamcavalli
- - {/* Client brand name (prominent) */} -

- {view.client.brand_name} -

- - {/* Spacer for balance */} -
-
-
-
- - {/* Global Progress Bar */} -
-
-
-

Project Progress

- -

- {view.global_progress_pct}% Complete -

-
-
-
- - {/* Main Content */} -
- {/* Brief */} - {view.client.brief && ( -
-

- "{view.client.brief}" -

-
- )} - - {/* Phase Timeline */} -
-

Project Phases

- -
- - {/* Payment Status */} -
-

Payment Status

- -
- - {/* Documents */} - {view.documents.length > 0 && ( -
-

Documents & Files

- -
- )} - - {/* Notes / Decision Log */} - {view.notes.length > 0 && ( -
-

Notes & Decisions

- -
- )} -
- - {/* Footer */} -
-
-

- This is a private project dashboard. Do not share your unique link. -

-
-
-
- ); - } - ``` - - 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 -
- - test -f src/components/client-dashboard.tsx && echo "ClientDashboard component exists" - grep -q "export function ClientDashboard" src/components/client-dashboard.tsx && echo "Component exported" - grep -q "iamcavalli" src/components/client-dashboard.tsx && echo "Logo text present" - grep -q "brand_name" src/components/client-dashboard.tsx && echo "Brand name rendered" - grep -q "global_progress_pct" src/components/client-dashboard.tsx && echo "Progress bar displays" - - - - 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 - -
- - - Task 3: Create PhaseTimeline component for lateral timeline layout with task lists - - src/components/phase-timeline.tsx - - - src/lib/client-view.ts (phase and task structure) - .planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-07, D-08) - - - 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 ( -
- {phases.map((phase, index) => ( -
- {/* Left: Timeline Indicator */} -
- {/* Circle indicator */} -
- {phase.status === 'done' ? ( - - ) : phase.status === 'active' ? ( - - ) : ( - - )} -
- - {/* Vertical line (not on last) */} - {index < phases.length - 1 && ( -
- )} -
- - {/* Right: Phase Content */} -
- {/* Phase Card */} - - {/* Phase Header */} -
-

- {phase.title} -

- - {phase.status === 'upcoming' ? 'Upcoming' : - phase.status === 'active' ? 'In Progress' : 'Done'} - -
- - {/* Phase Progress Bar */} -
-
-

- Phase Progress -

-

- {phase.progress_pct}% -

-
- -
- - {/* Task List */} -
-

- Tasks ({phase.tasks.filter(t => t.status === 'done').length} of {phase.tasks.length}) -

- {phase.tasks.length === 0 ? ( -

No tasks yet

- ) : ( -
    - {phase.tasks.map((task) => ( -
  • - {/* Task Status Icon */} - {task.status === 'done' ? ( - - ) : task.status === 'in_progress' ? ( - - ) : ( - - )} - - {/* Task Content */} -
    -

    - {task.title} -

    - {task.description && ( -

    - {task.description} -

    - )} - {/* Deliverables */} - {task.deliverables.length > 0 && ( -
    - {task.deliverables.map((d) => ( -
    - - {d.title} - - {d.status === 'approved' && ( - - Approved - - )} -
    - ))} -
    - )} -
    -
  • - ))} -
- )} -
-
-
-
- ))} -
- ); - } - ``` - - 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 - - - test -f src/components/phase-timeline.tsx && echo "PhaseTimeline component exists" - grep -q "export function PhaseTimeline" src/components/phase-timeline.tsx && echo "Component exported" - grep -q "CheckCircle2\|Circle" src/components/phase-timeline.tsx && echo "Icons imported" - grep -q "progress_pct" src/components/phase-timeline.tsx && echo "Progress bar displays" - - - - 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 - - - - - Task 4: Create PaymentStatus component (accepted_total + payment rows with status badges) - - src/components/payment-status.tsx - - - src/lib/client-view.ts (payments shape, PaymentStatus type) - .planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-10, D-11) - - - 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 ( - - {/* Total */} -
-

- Totale Preventivo Accettato -

-

- €{parseFloat(accepted_total || '0').toLocaleString('it-IT', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} -

-
- - {/* Payment Rows */} -
- {payments.map((payment) => { - const config = statusConfig[payment.status as keyof typeof statusConfig]; - const Icon = config?.icon || Clock; - - return ( -
-
- -

- {payment.label} -

-
- - {config?.label || payment.status} - -
- ); - })} -
- - {/* Note */} -

- I pagamenti sono suddivisi in due rate da 50% ciascuna. - Contattaci per domande sui dettagli. -

-
- ); - } - ``` - - Key points: - - Shows `accepted_total` formatted as Euro currency — NEVER individual line-item amounts - - Two payment rows (Acconto 50%, Saldo 50%) with status badges only - - Status badge colors: da_saldare = blue, inviata = yellow, saldato = green - - Card + Badge from shadcn/ui -
- - test -f src/components/payment-status.tsx && echo "PaymentStatus component exists" - grep -q "export function PaymentStatus" src/components/payment-status.tsx && echo "Component exported" - grep -q "accepted_total" src/components/payment-status.tsx && echo "Total displayed" - grep -q "da_saldare\|inviata\|saldato" src/components/payment-status.tsx && echo "Status config present" - - - - Component exists and is exported - - Displays accepted_total formatted as Euro (no individual amounts) - - Renders payment rows with status badges (da_saldare/inviata/saldato) - - Uses shadcn/ui Card and Badge - -
- - - Task 5: Create DocumentsSection and NotesSection components (external links + read-only notes) - - src/components/documents-section.tsx - src/components/notes-section.tsx - - - src/lib/client-view.ts (documents and notes shapes) - .planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-12) - - - 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 ( -
- {documents.map((doc) => ( - - - - {doc.label} - - - - - ))} -
- ); - } - ``` - - 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 ( -

- No notes yet. Decisions will appear here as they are made. -

- ); - } - - return ( -
- {notes.map((note) => ( - -

- {note.body} -

-

- {new Date(note.created_at).toLocaleDateString('it-IT', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} -

-
- ))} -
- ); - } - ``` - - Key points: - - DocumentsSection: clickable external links with ExternalLink icon, `rel="noopener noreferrer"` for security - - NotesSection: read-only, client never writes (admin writes in Phase 2 admin area) - - NotesSection: empty state shown as italic hint when no notes exist - - Timestamps formatted in Italian locale -
- - test -f src/components/documents-section.tsx && echo "DocumentsSection component exists" - test -f src/components/notes-section.tsx && echo "NotesSection component exists" - grep -q "export function DocumentsSection" src/components/documents-section.tsx && echo "DocumentsSection exported" - grep -q "export function NotesSection" src/components/notes-section.tsx && echo "NotesSection exported" - grep -q "noopener noreferrer" src/components/documents-section.tsx && echo "External link security present" - - - - Both components exist and are exported - - DocumentsSection renders clickable external links with ExternalLink icon and secure rel attributes - - NotesSection shows read-only notes with Italian-formatted timestamps - - NotesSection shows empty state hint when notes array is empty - -
- - - - - -## 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 | - - - - -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) - - - -- 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) - - - -After completion, create `.planning/phases/01-foundation-client-dashboard/01-04-SUMMARY.md` - diff --git a/.planning/phases/01-foundation-client-dashboard/01-04-SUMMARY.md b/.planning/phases/01-foundation-client-dashboard/01-04-SUMMARY.md deleted file mode 100644 index 1f0cd55..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-04-SUMMARY.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -phase: 01-foundation-client-dashboard -plan: 04 -subsystem: client-portal-ui -tags: [nextjs, tailwind-v4, shadcn-ui, server-components, client-dashboard, responsive] - -requires: - - 01-01 (Next.js 16 bootstrap, Tailwind v4, shadcn/ui) - - 01-02 (schema DB: clients, phases, tasks, deliverables, payments, documents, notes) - - 01-03 (ClientView interface + getClientView(), route /c/[token] operativa) -provides: - - src/components/client-dashboard.tsx (layout wrapper completo) - - src/components/phase-timeline.tsx (timeline laterale con progress bar per fase) - - src/components/payment-status.tsx (stato pagamenti senza importi singoli) - - src/components/documents-section.tsx (link documenti esterni) - - src/components/notes-section.tsx (log decisioni read-only) - - src/app/globals.css (design token Tailwind v4 — palette light & clean) - - app/c/[token]/page.tsx (Server Component che renderizza ClientDashboard) -affects: - - 01-05-seed-deploy (la dashboard e' completa — il seed popola i dati, il deploy la espone) - -tech-stack: - added: [] - patterns: - - "Tailwind v4: design token via @theme inline in globals.css (NON tailwind.config.ts)" - - "Colori arbitrari inline con sintassi [#hex] — compatibile Tailwind v4" - - "SVG inline al posto di lucide-react — compatibilita' massima con bundle size ridotto" - - "Server Components puri per tutti i componenti dashboard (nessun 'use client')" - - "React.cache() in page.tsx per deduplicare getClientView() tra generateMetadata e render" - -key-files: - created: - - src/components/client-dashboard.tsx (99 righe — wrapper con header, progress, sezioni) - - src/components/phase-timeline.tsx (201 righe — timeline laterale con task e deliverable) - - src/components/payment-status.tsx (98 righe — totale + righe stato, zero importi singoli) - - src/components/documents-section.tsx (75 righe — link esterni sicuri) - - src/components/notes-section.tsx (68 righe — log decisioni read-only) - modified: - - src/app/globals.css (token Tailwind v4: primary, secondary, tertiary, bg-subtle, border-light, accent, success, warning, info) - - src/app/c/[token]/page.tsx (import ClientDashboard, generateMetadata dinamico, React.cache) - -key-decisions: - - "Tailwind v4 usa @theme in globals.css — tailwind.config.ts non esiste in questo progetto" - - "SVG inline invece di lucide-react — evita dipendenza da icone e garantisce compatibilita'" - - "Colori arbitrari [#hex] in classi Tailwind invece di classi custom — piu' esplicito e manutenibile" - - "Server Components puri — nessun 'use client' necessario per componenti read-only" - - "React.cache() per deduplicare le due chiamate getClientView in generateMetadata + ClientPage" - -duration: 45min -completed: 2026-05-14 ---- - -# Phase 1 Plan 04: Client Dashboard UI — Vertical Slice completo - -**Tutti i componenti UI della dashboard cliente renderizzati come Server Components con design light & clean in Tailwind v4: header con logo iamcavalli + brand name cliente, progress bar globale, timeline laterale delle fasi con barre per fase e task list, sezione pagamenti con badge stato (zero importi singoli), link documenti esterni e log note read-only.** - -## Performance - -- **Duration:** ~45 min -- **Started:** 2026-05-14T19:35:00Z -- **Completed:** 2026-05-14T20:20:00Z -- **Tasks:** 5/5 -- **Files creati:** 5 -- **Files modificati:** 2 - -## Accomplishments - -- **globals.css**: Token di design Tailwind v4 via `@theme inline` — palette light & clean con 9 variabili colore (primary, secondary, tertiary, bg-subtle, border-light, accent, success, warning, info). Adattamento critico: il progetto usa Tailwind v4 che non ha `tailwind.config.ts`. - -- **app/c/[token]/page.tsx**: Server Component aggiornato con `ClientDashboard`, `generateMetadata` dinamico (titolo con brand_name), `React.cache()` per deduplicare le due chiamate a `getClientView`. - -- **client-dashboard.tsx**: Layout wrapper completo. Header sticky con "iamcavalli" in angolo sinistro (xs, tracking-widest) e `brand_name` centrato e prominente (D-06). Progress bar globale con percentuale (D-09). Brief con accent bar sinistra. Sezioni ordinate: PhaseTimeline, PaymentStatus (sempre visibile — D-10), Documents e Notes (condizionali). Footer con avviso link privato. - -- **phase-timeline.tsx**: Timeline laterale a due colonne (D-07). Colonna sinistra: cerchio con icona SVG per stato (checkmark verde per done, cerchio pieno blu per active, cerchio vuoto grigio per upcoming) + linea verticale tra fasi. Colonna destra: Card con badge stato, progress bar per fase con contatore "X di N task" (D-08), task list con icone stato e line-through per done. Deliverable annidati con badge "Approvato". - -- **payment-status.tsx**: Card con `accepted_total` in EUR come unico importo visibile (vincolo LOCKED). Righe pagamento con dot colorato + badge stato semantico (blu=da_saldare, giallo=inviata, verde=saldato) — MAI importi singoli (T-04-001 mitigato, D-11 rispettato). - -- **documents-section.tsx**: Link esterni con `target="_blank" rel="noopener noreferrer"` (T-04-002). Icone SVG inline per documento ed external link. Hover state con transizione colore accent. - -- **notes-section.tsx**: Note read-only con timestamp in locale it-IT. Empty state informativo. Server Component puro (D-12: admin scrive in Phase 2, cliente legge). - -- **npm run build**: completato senza errori TypeScript. 1 warning CSS da Lightning CSS optimizer (selettore con caratteri speciali) — noto, non bloccante, non dipendente dal nostro codice. - -## Task Commits - -1. **Task 1: Design tokens + wire page.tsx** — `4e703d7` -2. **Task 2: ClientDashboard wrapper** — `debd391` -3. **Task 3: PhaseTimeline** — `5d5c8ea` -4. **Task 4: PaymentStatus** — `a4e2de0` -5. **Task 5: DocumentsSection + NotesSection** — `8602bfa` - -## Files Created/Modified - -- `src/app/globals.css` — @theme con 9 token colore light & clean -- `src/app/c/[token]/page.tsx` — ClientDashboard + generateMetadata + React.cache -- `src/components/client-dashboard.tsx` — layout wrapper completo -- `src/components/phase-timeline.tsx` — timeline laterale con progress per fase -- `src/components/payment-status.tsx` — totale accettato + badge stato (nessun importo) -- `src/components/documents-section.tsx` — link esterni sicuri -- `src/components/notes-section.tsx` — note read-only con timestamp italiano - -## Decisions Made - -- **Tailwind v4 senza tailwind.config.ts:** Il piano originale assumeva Tailwind v3 con `tailwind.config.ts`. Il progetto usa Tailwind v4 che gestisce i token via `@theme inline` in globals.css. Adattamento automatico. -- **SVG inline invece di lucide-react:** Lucide-react v1.14 ha alcune icone con nomi diversi. Usare SVG inline elimina la dipendenza ed e' compatibile con Server Components senza `'use client'`. -- **Server Components puri:** I componenti sono tutti read-only e non usano hooks React — nessun `'use client'` necessario, ottimizzazione del bundle. -- **Colori arbitrari [#hex]:** Usare classi come `text-[#1a1a1a]` invece di classi custom — piu' esplicito, nessun conflitto con shadcn/ui che usa le proprie variabili CSS (`bg-card`, `text-muted-foreground`, etc.). - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 1 - Bug] tailwind.config.ts non esiste — Tailwind v4 usa @theme in globals.css** -- **Found during:** Task 1 -- **Issue:** Il piano indicava di aggiornare `tailwind.config.ts` con i color token. Questo file non esiste perche' il progetto usa Tailwind v4, che utilizza CSS `@theme inline` in `globals.css` invece del file di configurazione JavaScript. -- **Fix:** Token definiti in `globals.css` via `@theme inline { --color-primary: #1a1a1a; ... }`. Tailwind v4 mappa automaticamente queste variabili come classi utilitarie. -- **Files modified:** src/app/globals.css -- **Commit:** 4e703d7 - -**2. [Rule 1 - Bug] lucide-react usato come 'use client' — rimpiazzato con SVG inline** -- **Found during:** Task 2/3 (design) -- **Issue:** Il piano importava `CheckCircle2`, `Circle`, `Clock`, `ExternalLink` da `lucide-react`. I componenti sono Server Components puri — aggiungere `'use client'` solo per le icone avrebbe spostato tutto il rendering lato client inutilmente. -- **Fix:** SVG inline (Heroicons style) al posto di lucide-react per tutti i componenti. Mantiene i componenti come Server Components puri. -- **Files modified:** phase-timeline.tsx, payment-status.tsx, documents-section.tsx -- **Commit:** 5d5c8ea, a4e2de0, 8602bfa - -## Known Stubs - -Nessuno stub. Tutti i componenti ricevono dati reali dalla `ClientView` e li renderizzano completamente. Gli empty state (no documenti, no note) sono stati implementati come stati legittimi — non stub. - -## Threat Surface Scan - -| Flag | File | Description | -|------|------|-------------| -| Verificato T-04-001 | payment-status.tsx | `amount` assente dalla query e dal componente — solo `accepted_total` e `status` per riga | -| Verificato T-04-002 | documents-section.tsx | `rel="noopener noreferrer"` su tutti i link esterni | - -Nessuna nuova superficie di sicurezza non prevista dal threat model 01-04. - -## Self-Check: PASSED - -- [x] `src/app/globals.css` esiste con @theme e token colore -- [x] `src/app/c/[token]/page.tsx` renderizza ClientDashboard con generateMetadata -- [x] `src/components/client-dashboard.tsx` esiste (99 righe) -- [x] `src/components/phase-timeline.tsx` esiste (201 righe) -- [x] `src/components/payment-status.tsx` esiste (98 righe) — nessun campo `amount` -- [x] `src/components/documents-section.tsx` esiste (75 righe) -- [x] `src/components/notes-section.tsx` esiste (68 righe) -- [x] Commit `4e703d7` esiste (Task 1) -- [x] Commit `debd391` esiste (Task 2) -- [x] Commit `5d5c8ea` esiste (Task 3) -- [x] Commit `a4e2de0` esiste (Task 4) -- [x] Commit `8602bfa` esiste (Task 5) -- [x] `npm run build` completato senza errori TypeScript -- [x] `payment-status.tsx` non contiene il campo `amount` -- [x] `documents-section.tsx` contiene `rel="noopener noreferrer"` - -## Next Phase Readiness - -- Plan 05 (Seed script + DNS) puo' partire -- La dashboard e' pienamente funzionale: basta un cliente seedato per vederla operativa -- Il seed script deve inserire client, phases, tasks, deliverables, payments, documents, notes -- La route /c/[token] e' operativa dal Plan 03 — Plan 05 aggiunge il seed + configurazione DNS - ---- -*Phase: 01-foundation-client-dashboard* -*Completed: 2026-05-14* \ No newline at end of file diff --git a/.planning/phases/01-foundation-client-dashboard/01-05-PLAN.md b/.planning/phases/01-foundation-client-dashboard/01-05-PLAN.md deleted file mode 100644 index 9876e1b..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-05-PLAN.md +++ /dev/null @@ -1,567 +0,0 @@ ---- -phase: "01-foundation-client-dashboard" -plan: 05 -type: execute -wave: 3 -depends_on: - - "01-01" - - "01-02" - - "01-03" - - "01-04" -files_modified: - - scripts/seed.ts - - .env.local -autonomous: true -requirements: - - DASH-01 - - DASH-02 - - DASH-03 - - DASH-04 - - DASH-07 - - DASH-08 - - DASH-09 - - DASH-10 - -must_haves: - truths: - - "Seed script exists and contains TypeScript seed logic" - - "Script inserts one complete test client with all related data (phases, tasks, deliverables, payments, documents, notes)" - - "Client token is generated via nanoid (21 chars, cryptographically secure)" - - "Seed script prints shareable URL to console: http://localhost:3000/c/[token]" - - "Script can be run via: npx tsx scripts/seed.ts" - - "DNS CNAME is configured: welcomeclient.iamcavalli.net → vercel DNS" - - "DNS propagation is verified (can be checked via `dig` or online tool)" - artifacts: - - path: "scripts/seed.ts" - provides: "Seed script that inserts first real client with all data" - min_lines: 100 - contains: "import.*nanoid" - - path: ".env.local (updated)" - provides: "Updated with VERCEL_URL or custom domain setting" - contains: "DATABASE_URL" - key_links: - - from: "scripts/seed.ts" - to: "src/db/schema" - via: "drizzle db.insert()" - pattern: "db.insert\\(" - - from: "nanoid token" - to: "client URL" - via: "http://localhost:3000/c/[token]" - pattern: "nanoid" - ---- - - -**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. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/research/ARCHITECTURE.md (Data Model section) -@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-13) - - - - - - Task 1: Create scripts/seed.ts to insert first real client with all data - - scripts/seed.ts - - - src/db/schema.ts (all table definitions) - src/db/index.ts (db client) - - - 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 - - - test -f scripts/seed.ts && echo "Seed script exists" - grep -q "import.*nanoid" scripts/seed.ts && echo "nanoid imported" - grep -q "db.insert" scripts/seed.ts && echo "Insert statements present" - grep -q "clientToken" scripts/seed.ts && echo "Token generation present" - grep -q "http://localhost:3000/c/" scripts/seed.ts && echo "URL printed" - - - - `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 - - - - - Task 2: Test seed script execution and verify data is inserted into database - - None (execution only) - - - scripts/seed.ts - .env.local - - - 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) - - - npx tsx scripts/seed.ts 2>&1 | grep -q "Seed complete" && echo "Seed script succeeded" || echo "Seed script failed" - 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" - - - - 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 - - - - - Task 3: Test end-to-end: Open seeded client link in browser and verify dashboard renders - - None (verification only) - - - None - - - 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 - - - curl -s http://localhost:3000/c/invalid | grep -q "404\|not found" && echo "Invalid token returns 404" || echo "404 check inconclusive" - - - - 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 - - - - - Task 4: Configure DNS CNAME for welcomeclient.iamcavalli.net → Vercel DNS - - None (external DNS configuration) - - - .planning/phases/01-foundation-client-dashboard/01-CONTEXT.md (D-03) - - - **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 - - - dig welcomeclient.iamcavalli.net +short 2>/dev/null | grep -q "vercel-dns.com" && echo "DNS CNAME configured" || echo "DNS CNAME not yet live" - - - - 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) - - - - - - -## 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 | - - - - -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 - - - -- 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) - - - -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. - diff --git a/.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md b/.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md deleted file mode 100644 index c5bc286..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -plan: 05 -status: complete -commit: 073eec7 -date: 2026-05-14 ---- - -# Plan 01-05 Summary: Seed Script + DNS Configuration - -## Tasks Completed - -**Task 1: scripts/seed.ts** ✓ -- Created seed script with 107 lines -- Inserts 1 client → 3 phases → 6 tasks → 4 deliverables + 2 payments + 2 documents + 2 notes -- Token generated via nanoid (21 chars, ~126 bits entropy) -- Prints shareable URL: `http://localhost:3000/c/[token]` -- Run via: `DATABASE_URL= npx tsx scripts/seed.ts` - -**Task 2: Seed execution verified** ✓ -- Seed ran successfully against Hetzner Postgres -- Test client created: `Test Client Inc.` / `TestBrand` -- Shareable link: `http://localhost:3000/c/jXpPhUS-C6pCGAu5Va0Ct` - -**Task 3: E2E dashboard test** ✓ -- Valid token → HTTP 200 (production build: `next build` + `next start`) -- Invalid token → HTTP 404 -- Dev server also fixed (see Bug Fix below) - -**Task 4: DNS CNAME** — Pending user action -- Prerequisites not yet met: no Vercel project linked, no deployment -- Steps to complete (after Vercel deployment): - 1. Run `vercel --prod` or connect via Vercel dashboard - 2. Add domain `welcomeclient.iamcavalli.net` in Vercel Project Settings → Domains - 3. At domain registrar for `iamcavalli.net`: add CNAME `welcomeclient → cname.vercel-dns.com` - 4. Verify: `dig welcomeclient.iamcavalli.net +short` should return `cname.vercel-dns.com` - -## Bug Fixed (outside plan scope) - -**Tailwind v4 scanning `.01_projects/` directory** -- Root cause: Tailwind v4 auto-detects and scans ALL files in the repo root -- The `.01_projects/sparklingorbit` subdirectory contains a Python `.venv` with `markdown_it` library -- That library has a regex comment containing `[-:|]` (a regex character class) -- Tailwind interpreted `[-:|]` as an arbitrary CSS property class → generates invalid CSS `-: |` -- Dev server Turbopack was treating this as a fatal CSS parse error → 500 on all pages -- Fix: added `@source not` directives in `globals.css` to exclude adjacent projects - -## Commits - -- `073eec7` — feat(seed): add seed script + fix Tailwind scanning adjacent projects - - \ No newline at end of file diff --git a/.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md b/.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md deleted file mode 100644 index 745fe97..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md +++ /dev/null @@ -1,126 +0,0 @@ -# Phase 1: Foundation & Client Dashboard - Context - -**Gathered:** 2026-05-13 -**Status:** Ready for planning - - -## Phase Boundary - -Costruire il DB schema, il token API e la dashboard cliente read-only. Al termine di questa fase, un link segreto è condivisibile con un cliente reale che può aprirlo su mobile o desktop e vedere lo stato completo del suo progetto — senza login, senza admin, senza interazione. L'admin area (CRUD, auth, commenti, approvazioni) è Phase 2. - - - - -## Implementation Decisions - -### Database & Infrastruttura - -- **D-01: Database su Coolify (Hetzner)** — Postgres istanza gestita da Coolify sul server Hetzner già pagato. Zero costo aggiuntivo. Neon è scartato in favore del self-hosting già disponibile. -- **D-02: ORM invariato** — Drizzle ORM con driver `postgres-js` (invece di `neon-http`). Schema e migrazioni identici, solo il driver di connessione cambia. -- **D-03: DNS in Phase 1** — Configurare `welcomeclient.iamcavalli.net` come CNAME verso Vercel nella Phase 1, non alla fine. Propagazione va verificata subito. - -### Brand & Visual Design - -- **D-04: Brand hardcoded in Phase 1** — Colori e logo iamcavalli fissi nel codice. La personalizzazione admin (tabella `brand_settings`, pannello colori/logo) è demandata a Phase 2. -- **D-05: Stile light & clean** — Sfondo chiaro, typography forte, layout professionale e leggibile su mobile. -- **D-06: Header dashboard** — Logo iamcavalli piccolo in un angolo (es. top-right o top-left), nome del brand cliente (`brand_name`) in primo piano e prominente. Non il nome del cliente, il nome del suo brand. - -### Layout Fasi e Task - -- **D-07: Timeline laterale per le fasi** — Indicatore temporale/progressione a sinistra, contenuto fase sulla destra. Trasmette senso di avanzamento sequenziale del progetto. -- **D-08: Barra progresso per fase** — In cima a ogni fase, una barra % calcolata dai task completati. Sotto la barra, lista dei task. -- **D-09: Barra progresso globale in cima** — Progress bar globale del progetto nella parte alta della dashboard, derivata dal totale dei task completati su tutti i task. Il cliente vede subito "sei al X%". - -### Stato Pagamenti - -- **D-10: Pagamenti sempre visibili** — Sezione pagamenti sempre in vista nella dashboard (non nascosta in accordion). Mostra: totale accettato + stato acconto 50% + stato saldo 50%. -- **D-11: Stati pagamento** — Tre stati per ogni payment row: `da_saldare` / `inviata` / `saldato`. Mai i prezzi singoli dei servizi — solo `accepted_total`. - -### Storico Decisioni (DASH-10) - -- **D-12: Admin scrive, cliente legge** — Le note/decisioni del log storico sono scritte solo dall'admin. Il cliente le vede in sola lettura nella sua dashboard. La UI per scrivere note è in Phase 2 (admin area). In Phase 1 il campo `body` è in schema e la visualizzazione lato cliente è già presente; sarà vuota finché Phase 2 non porta l'admin. - -### Primo Cliente (Testing) - -- **D-13: Seed script** — Uno script TypeScript (`scripts/seed.ts`) per inserire il primo cliente reale con dati completi (fasi, task, pagamenti, documenti). Eseguibile una volta con `npx tsx scripts/seed.ts`. Nessun form admin o SQL manuale necessario per Phase 1. - -### Claude's Discretion - -- Scelta del componente UI specifico per la timeline laterale (build custom vs. shadcn primitives) -- Struttura CSS delle card fasi e task (spaziatura, bordi, hover state) -- Schema colori specifico light & clean (bianco puro, grigi, quale accent color in attesa del brand panel) - - - - -## Canonical References - -**Downstream agents MUST read these before planning or implementing.** - -### Progetto & Requisiti - -- `.planning/PROJECT.md` — Contesto progetto, Core Value, decisioni chiave e vincoli -- `.planning/REQUIREMENTS.md` — REQ-IDs Phase 1: DASH-01 through DASH-04, DASH-07 through DASH-10 -- `.planning/ROADMAP.md` — Success criteria Phase 1 - -### Ricerca tecnica - -- `.planning/research/STACK.md` — Stack raccomandato (nota: database cambiato da Neon a Coolify Postgres) -- `.planning/research/ARCHITECTURE.md` — Data model completo, component boundaries, build order -- `.planning/research/PITFALLS.md` — Pitfall critici: token-as-PK, ClientView enforcement, data model day-one decisions - -### Istruzioni progetto - -- `CLAUDE.md` — Architectural constraints LOCKED (token separato dalla PK, accepted_total denormalizzato, approved_at immutabile, due path auth isolati) - -No external specs beyond the above — requirements fully captured in decisions above. - - - - -## Existing Code Insights - -### Reusable Assets - -- Nessun codice esistente — progetto greenfield. - -### Established Patterns - -- Next.js 15 App Router: Server Components per la dashboard read-only (zero client-side waterfalls per rendering dati) -- Drizzle ORM con `postgres-js` invece di `neon-http` (Coolify Postgres connection string via env var `DATABASE_URL`) -- nanoid per generazione token: `nanoid()` → 21 char, URL-safe, ~126 bit di entropia - -### Integration Points - -- Dashboard cliente: route `/c/[token]` — Middleware valida token → 404 se mancante → Server Component legge DB e renderizza -- Seed script: `scripts/seed.ts` — inserisce dati cliente reale, genera token, stampa URL condivisibile -- DNS: CNAME `welcomeclient.iamcavalli.net` → `cname.vercel-dns.com` (da configurare su domain registrar) - - - - -## Specific Ideas - -- Il portale deve sembrare professionale e in linea con il brand iamcavalli — light & clean, non un SaaS generico -- Logo iamcavalli piccolo in corner + nome brand cliente prominente nell'header -- Progress bar globale del progetto in cima alla dashboard (percentuale) -- Timeline laterale per le fasi (non accordion, non card flat) — trasmette sequenzialità e avanzamento -- Barra progresso per singola fase, calcolata da task completati -- Personalizzazione colori/logo dal pannello admin è demandata a Phase 2 — in Phase 1 brand hardcoded - - - - -## Deferred Ideas - -- **Brand customization panel** (colori background, testi, logo upload dall'admin) → Phase 2, da aggiungere come requisito nell'area admin -- **Three.js / animazioni 3D** → Non necessario per questo tipo di portale. UI curata con Tailwind è sufficiente. -- **Commenti e approvazioni cliente** → Phase 2 (DASH-05, DASH-06) -- **Auth admin** → Phase 2 - - - ---- - -*Phase: 1-Foundation & Client Dashboard* -*Context gathered: 2026-05-13* diff --git a/.planning/phases/01-foundation-client-dashboard/01-DISCUSSION-LOG.md b/.planning/phases/01-foundation-client-dashboard/01-DISCUSSION-LOG.md deleted file mode 100644 index a15fb7c..0000000 --- a/.planning/phases/01-foundation-client-dashboard/01-DISCUSSION-LOG.md +++ /dev/null @@ -1,128 +0,0 @@ -# Phase 1: Foundation & Client Dashboard - Discussion Log - -> **Audit trail only.** Do not use as input to planning, research, or execution agents. -> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. - -**Date:** 2026-05-13 -**Phase:** 1 - Foundation & Client Dashboard -**Areas discussed:** Database, Three.js, Brand & visual design, Layout fasi e task, Primo cliente, Storico decisioni - ---- - -## Database - -| Option | Description | Selected | -|--------|-------------|----------| -| Postgres su Coolify | Zero costo extra — istanza Postgres su Hetzner già pagato | ✓ | -| Neon free tier | Postgres serverless gestito, 0.5 GB gratis, dipendenza esterna | | -| Oracle Cloud Free Tier | ARM 4vCPU 24GB RAM gratuito, richiede configurazione | | - -**User's choice:** Postgres su Coolify (Hetzner) -**Notes:** L'utente ha già un server Hetzner con Coolify attivo (usato per SparklingOrbit). Preferisce zero dipendenze esterne a pagamento e mantenere i dati nella propria infrastruttura. - ---- - -## Three.js - -| Option | Description | Selected | -|--------|-------------|----------| -| Animazioni sottili | Effetti di sfondo leggeri (particelle, gradienti animati) | | -| Elemento hero visivo | Shape 3D animata nella parte alta della dashboard | | -| Ripensaci | UI curata con Tailwind è sufficiente, niente 3D | ✓ | - -**User's choice:** Ripensaci — niente three.js -**Notes:** L'utente aveva menzionato three.js inizialmente ma dopo riflessione ha preferito concentrarsi su un'UI curata senza 3D. - ---- - -## Brand & Visual Design - -| Option | Description | Selected | -|--------|-------------|----------| -| Brand hardcoded in Phase 1 | Colori/logo iamcavalli fissi, personalizzazione in Phase 2 | ✓ | -| Già configurabile in Phase 1 | Tabella brand_settings e pannello già in Phase 1 | | - -**Brand hardcoded:** ✓ — pannello personalizzazione rimandato a Phase 2 - -| Stile visual | Description | Selected | -|--------------|-------------|----------| -| Dark & premium | Sfondo scuro, accenti brillanti | | -| Light & clean | Sfondo chiaro, typography forte | ✓ | -| Decido dal pannello | Nessuna preferenza ora | | - -**Stile:** Light & clean - -| Header | Description | Selected | -|--------|-------------|----------| -| Solo logo + nome progetto | Logo iamcavalli + nome progetto | | -| Solo nome brand cliente | brand_name in grande, nessun logo | | -| Entrambi | Logo piccolo in corner + brand_name cliente in primo piano | ✓ | - -**Header:** Logo iamcavalli piccolo in un angolo + nome brand cliente prominente - ---- - -## Layout Fasi e Task - -| Layout fasi | Description | Selected | -|-------------|-------------|----------| -| Accordion verticale | Fasi impilate, espandibili | | -| Card separate | Card per fase, task sempre visibili | | -| Timeline laterale | Indicatore temporale a sinistra, contenuto a destra | ✓ | - -**Layout fasi:** Timeline laterale - -| Task status | Description | Selected | -|-------------|-------------|----------| -| Badge colorato | Pallino/badge per stato | | -| Icona + testo | Icona con etichetta | | -| Barra progresso fase | Barra % per fase + lista task | ✓ | - -**Task status:** Barra progresso in cima a ogni fase - -| Avanzamento globale | Description | Selected | -|--------------------|-------------|----------| -| Sì, in cima | % completamento totale in cima alla dashboard | ✓ | -| No, solo per fase | Progresso solo per singola fase | | -| No progress bar | Solo stati task | | - -**Progress bar globale:** Sì, in cima alla dashboard - ---- - -## Primo Cliente - -| Option | Description | Selected | -|--------|-------------|----------| -| Seed script | TypeScript script, `npx tsx scripts/seed.ts` | ✓ | -| Form admin minimale | Form no-auth per creare primo cliente | | -| SQL diretto Coolify | INSERT manuale via console Coolify | | - -**User's choice:** Seed script -**Notes:** Lo script inserisce un cliente reale con fasi, task, pagamenti, documenti e stampa l'URL condivisibile. - ---- - -## Storico Decisioni (DASH-10) - -| Option | Description | Selected | -|--------|-------------|----------| -| Solo admin scrive | Admin aggiunge note, cliente legge in sola lettura | ✓ | -| Visibile solo in Phase 2 | UI mostrata solo con admin area | | - -**User's choice:** Admin scrive, cliente legge -**Notes:** In Phase 1 lo schema è già presente e il cliente vede le note (sezione vuota inizialmente). La UI per aggiungere note arriva con l'area admin in Phase 2. - ---- - -## Claude's Discretion - -- Scelta componente UI specifico per timeline laterale (shadcn primitives vs. custom) -- CSS dettagliato delle card fasi e task (spaziatura, bordi, hover) -- Accent color provvisorio per Phase 1 (in attesa del brand panel in Phase 2) - -## Deferred Ideas - -- **Brand customization panel** (colori, logo upload dall'admin) → Phase 2 -- **Three.js / animazioni 3D** → Scartato -- **Commenti e approvazioni** → Phase 2 (DASH-05, DASH-06) diff --git a/.planning/phases/01-foundation-client-dashboard/SKELETON.md b/.planning/phases/01-foundation-client-dashboard/SKELETON.md deleted file mode 100644 index bfe2ef1..0000000 --- a/.planning/phases/01-foundation-client-dashboard/SKELETON.md +++ /dev/null @@ -1,302 +0,0 @@ -# ClientHub — Walking Skeleton (Phase 1) - -**Project:** ClientHub — Freelancer Client Portal -**Phase:** 01 — Foundation & Client Dashboard -**Date:** 2026-05-13 -**Status:** Blueprint (decisions below are LOCKED for all subsequent phases) - ---- - -## Project Architecture — Locked Decisions - -This Walking Skeleton establishes the architectural foundation for all future phases. These decisions are **immutable** without explicit user approval. - -### Core Stack - -| Layer | Technology | Why | Locked? | -|-------|-----------|-----|---------| -| **Framework** | Next.js 15 (App Router, TypeScript, src/) | Server Components + Edge Middleware for performance; Vercel-native | ✅ YES | -| **Database** | Postgres on Coolify (Hetzner), via `postgres-js` driver | Self-hosted (no Neon/Supabase cost); persistent via external DB | ✅ YES | -| **ORM** | Drizzle ORM with postgres-js | Zero-cost serverless driver; schema-as-code migrations | ✅ YES | -| **UI** | Tailwind CSS v4 + shadcn/ui components | Utility-first, copied components, mobile-first | ✅ YES | -| **Auth (Admin)** | Auth.js v4 Credentials provider (Phase 2) | Single admin account, JWT cookie | ✅ YES | -| **Auth (Client)** | Custom Next.js Middleware + token validation | No session store needed; token in URL | ✅ YES | -| **Token Generation** | nanoid (21 chars) | Cryptographically secure, URL-safe, non-enumerable | ✅ YES | -| **Deployment** | Vercel (Hobby plan) + custom subdomain | Native Next.js; auto-SSL; single deploy command | ✅ YES | - -### Data Model — Locked Entities - -All tables below **must** exist and maintain these field definitions. Modifications require explicit approval. - -``` -clients - id UUID PK (stable, never changes) - name TEXT - brand_name TEXT - brief TEXT - token UUID UNIQUE ← SEPARATE from PK, rotatable - accepted_total NUMERIC ← denormalized, only price client sees - created_at TIMESTAMPTZ - -phases - id UUID PK - client_id UUID FK → clients.id - title TEXT - sort_order INT - status TEXT (upcoming | active | done) - -tasks - id UUID PK - phase_id UUID FK → phases.id - title TEXT - description TEXT - status TEXT (todo | in_progress | done) - sort_order INT - -deliverables - id UUID PK - task_id UUID FK → tasks.id - title TEXT - url TEXT - status TEXT (pending | submitted | approved) - approved_at TIMESTAMPTZ ← immutable audit trail - -comments - id UUID PK - entity_type TEXT (task | deliverable) - entity_id UUID - author TEXT (client | admin) - body TEXT - created_at TIMESTAMPTZ - -payments - id UUID PK - client_id UUID FK → clients.id - label TEXT ("Acconto 50%" | "Saldo 50%") - amount NUMERIC - status TEXT (da_saldare | inviata | saldato) - paid_at TIMESTAMPTZ - -documents - id UUID PK - client_id UUID FK → clients.id - label TEXT - url TEXT ← external links only, no file uploads - created_at TIMESTAMPTZ - -notes - id UUID PK - client_id UUID FK → clients.id - body TEXT - created_at TIMESTAMPTZ - -service_catalog - id UUID PK - name TEXT - description TEXT - unit_price NUMERIC - active BOOLEAN - -quote_items - id UUID PK - client_id UUID FK → clients.id - service_id UUID FK → service_catalog.id - quantity NUMERIC - unit_price NUMERIC - subtotal NUMERIC - ← NEVER exposed via client API -``` - -### Critical Design Principles — Locked - -1. **`clients.token` is NOT the primary key.** Data is keyed by stable UUID `id`. Token is a separate, rotatable field. Rotation is a single UPDATE statement. - -2. **Client API never exposes `quote_items`.** Server-side filtering enforces this; not a UI trick. The `accepted_total` field is the only price the client API returns. - -3. **`deliverables.approved_at` is immutable.** Once set, it cannot be unset. Provides an audit trail for disputes. - -4. **Two independent auth systems:** - - `/c/[token]/*` → Middleware validates token, 404 on miss - - `/admin/*` → Auth.js session check (Phase 2) - - No overlap; no shared session store - -5. **No file hosting in v1.** Documents are external URLs only (Google Drive, PDFs, Figma links). File uploads → Phase 3+. - -6. **No email in v1.** Deliverables are dashboard links, not email attachments. Email integration → Phase 2+. - -### Directory Structure — Locked - -``` -IAMCAVALLI/ -├── src/ -│ ├── app/ -│ │ ├── c/[token]/ -│ │ │ ├── page.tsx ← Client dashboard route -│ │ │ └── layout.tsx -│ │ ├── admin/ ← Phase 2 (protected by middleware) -│ │ │ ├── page.tsx ← Admin dashboard -│ │ │ ├── clients/ -│ │ │ │ ├── page.tsx -│ │ │ │ └── [id]/ -│ │ │ ├── catalog/ -│ │ │ └── ... -│ │ ├── layout.tsx -│ │ └── globals.css -│ ├── components/ -│ │ ├── ui/ ← shadcn/ui components -│ │ ├── client-dashboard.tsx -│ │ ├── phase-timeline.tsx -│ │ ├── payment-status.tsx -│ │ ├── documents-section.tsx -│ │ ├── notes-section.tsx -│ │ └── ... -│ ├── db/ -│ │ ├── schema.ts ← Drizzle schema (source of truth) -│ │ ├── migrations/ ← Generated by drizzle-kit -│ │ └── index.ts ← db client export -│ ├── lib/ -│ │ ├── client-view.ts ← ClientView type + queries -│ │ ├── auth.ts ← Phase 2: Auth helpers -│ │ └── ... -│ └── middleware.ts ← Token validation at edge -├── scripts/ -│ ├── seed.ts ← Insert first test client -│ └── ... -├── .env.local ← DATABASE_URL, secrets -├── drizzle.config.ts -├── next.config.ts -├── tailwind.config.ts -├── tsconfig.json -├── package.json -└── .planning/ - ├── ROADMAP.md - ├── REQUIREMENTS.md - ├── STATE.md - └── phases/ - └── 01-foundation-client-dashboard/ - ├── 01-CONTEXT.md - ├── 01-DISCUSSION-LOG.md - ├── 01-01-PLAN.md - ├── 01-02-PLAN.md - ├── 01-03-PLAN.md - ├── 01-04-PLAN.md - ├── 01-05-PLAN.md - └── SKELETON.md -``` - -### Deployment — Locked - -- **Host:** Vercel (Hobby plan, $0/month for Phase 1 scale) -- **Domain:** welcomeclient.iamcavalli.net (CNAME to Vercel DNS) -- **Database:** Postgres on Coolify (existing Hetzner server, Simone manages) -- **Environment:** DATABASE_URL injected via Vercel Secrets -- **SSL/TLS:** Vercel auto-provisioning for custom domain - -### API Routes Structure (Phase 2+) - -Routes created in Phase 2 will follow this pattern: - -**Client-facing routes** (`/api/c/[token]/...`): -- No authentication library needed -- Middleware validates token -- Routes return ClientView shape only - -**Admin routes** (`/api/admin/...`): -- Require Auth.js session -- Access full AdminView including quote_items -- CRUD operations on all entities - -### UI Layer Principles — Locked - -- **Light & clean visual style:** White backgrounds, strong typography, subtle gray accents -- **Mobile-first design:** Tailwind defaults ensure responsive behavior -- **Semantic HTML:** Proper heading hierarchy, accessible form controls -- **No client-side state management libraries:** Server Components + Server Actions for Phase 1-2 -- **Progress visualization:** Global bar (top) + per-phase bars (sections) + task status badges -- **Brand consistency:** iamcavalli logo in corner, client brand_name prominent - -### Security Assumptions — Locked - -1. **Database credentials are secrets:** DATABASE_URL never logged, committed, or exposed -2. **Tokens are non-enumerable:** 21-character nanoid cannot be guessed -3. **Client API is isolated:** Admin data never leaks to `/c/[token]/*` routes -4. **Admin password** (Phase 2): env var `ADMIN_PASSWORD` protects `/admin/*` before Auth.js is added -5. **No PII in logs:** Payment amounts and tokens never logged to Vercel logs - ---- - -## What This Skeleton Delivers - -After Phase 1 execution: - -✅ **Functional client portal:** -- One client can open their secret link on any device -- Dashboard shows project phases, tasks, status, payments, documents, decision log -- No login required; link is the secret - -✅ **Production-ready infrastructure:** -- Database is live on Coolify Postgres -- Custom domain is verified and HTTPS-enabled -- Application is deployed on Vercel -- One-command deploy pipeline (`git push → Vercel auto-build`) - -✅ **Developer-friendly codebase:** -- TypeScript with strict mode -- Drizzle ORM manages schema as code -- Git-tracked migrations (reproducible database state) -- One seed script to populate test data -- No manual SQL; no database browser required - -✅ **Foundation for Phase 2:** -- Data model is stable and comprehensive -- Admin CRUD can be built without schema changes -- Auth.js integration point is clear -- Comments and approvals schema already exists - ---- - -## Phase 1 → Phase 2 Contract - -Phase 2 will extend this skeleton by: - -1. **Admin authentication:** Middleware check + Auth.js session on `/admin/*` routes -2. **CRUD operations:** Forms and API routes to edit clients, phases, tasks, deliverables, payments -3. **Comments & approvals:** Client-facing UI for commenting and approving deliverables -4. **Admin workspace:** Dashboard to manage all clients with state summary and quick actions -5. **Payment management:** Update payment status, send payment reminders - -**No schema changes required.** All Phase 2 features fit into the existing data model. - ---- - -## Validation Checklist (End of Phase 1) - -- [ ] Next.js 15 application compiles without TypeScript errors -- [ ] Database schema is live on Coolify Postgres (all 11 tables) -- [ ] Middleware validates tokens at edge -- [ ] Client portal route renders complete dashboard with seeded data -- [ ] Seed script inserts test client and prints shareable link -- [ ] DNS CNAME is live: welcomeclient.iamcavalli.net → Vercel -- [ ] Application is deployed on Vercel (accessible via https://welcomeclient.iamcavalli.net/) -- [ ] Invalid tokens return 404 (no information leakage) -- [ ] Payment amounts are NOT visible on client dashboard (only status) -- [ ] Mobile layout is responsive and readable -- [ ] All DASH-01 through DASH-10 requirements are satisfied (except DASH-05, DASH-06 which are Phase 2) - ---- - -## Future Extensibility Notes - -This skeleton is designed for: - -- **Phase 2:** Admin CRUD + comments + approvals (no schema changes) -- **Phase 3:** Service catalog + quote builder (admin-only, client sees only total) -- **Phase 4 (v2):** Claude AI onboarding flow (optional; may defer indefinitely) -- **Beyond:** Multi-team support, real file uploads, email automation (major schema rework) - -The current design is intentionally simple. Future phases should resist scope creep and maintain the "client sees only what they need" principle. - ---- - -**Skeleton locked:** 2026-05-13 -**Next checkpoint:** Phase 2 planning (`/gsd-plan-phase 2`) diff --git a/.planning/phases/02-admin-area-interactive-features/02-01-PLAN.md b/.planning/phases/02-admin-area-interactive-features/02-01-PLAN.md deleted file mode 100644 index 24d1525..0000000 --- a/.planning/phases/02-admin-area-interactive-features/02-01-PLAN.md +++ /dev/null @@ -1,481 +0,0 @@ ---- -phase: "02-admin-area-interactive-features" -plan: 01 -type: execute -wave: 1 -depends_on: [] -files_modified: - - package.json - - src/proxy.ts - - src/app/api/auth/[...nextauth]/route.ts - - src/app/admin/login/page.tsx - - src/app/admin/login/actions.ts - - src/lib/auth.ts - - .env.local -autonomous: true -requirements: - - ADMIN-01 - - ADMIN-02 - -must_haves: - truths: - - "Admin can POST /admin/login with ADMIN_EMAIL + ADMIN_PASSWORD and receive a session JWT cookie" - - "Visiting /admin/* without a valid session redirects to /admin/login" - - "Visiting /c/[token]/* still validates token at edge (proxy.ts unchanged for client routes)" - - "Session is JWT-based (stateless) — no DB users table involved" - - "ADMIN_EMAIL and ADMIN_PASSWORD are read from env vars, never hardcoded" - artifacts: - - path: "src/lib/auth.ts" - provides: "NextAuth config — CredentialsProvider validating against ADMIN_EMAIL/ADMIN_PASSWORD env vars" - contains: "CredentialsProvider" - - path: "src/app/api/auth/[...nextauth]/route.ts" - provides: "NextAuth catch-all route handler" - contains: "NextAuth" - - path: "src/app/admin/login/page.tsx" - provides: "Admin login form UI (email + password, submit)" - min_lines: 30 - - path: "src/proxy.ts" - provides: "Updated proxy: /c/* token validation + /admin/* session guard" - contains: "getToken" - key_links: - - from: "src/proxy.ts" - to: "src/app/api/auth/[...nextauth]/route.ts" - via: "getToken({ req, secret: process.env.NEXTAUTH_SECRET })" - pattern: "getToken" - - from: "src/app/admin/login/page.tsx" - to: "/api/auth/callback/credentials" - via: "signIn('credentials', { email, password })" - pattern: "signIn" - - from: "ADMIN_EMAIL + ADMIN_PASSWORD" - to: "CredentialsProvider authorize()" - via: "process.env.ADMIN_EMAIL" - pattern: "ADMIN_EMAIL" ---- - - -**Auth.js Admin Session + Proxy Guard:** Install next-auth@4, configure a CredentialsProvider that validates against ADMIN_EMAIL/ADMIN_PASSWORD env vars, wire the catch-all API route, build the login page, and extend the existing src/proxy.ts to guard /admin/* routes with a session check. - -Purpose: Gate the entire admin area behind Auth.js JWT session before any admin UI is built. Two independent auth paths are enforced: /c/[token]/* uses edge token validation (unchanged from Phase 1), /admin/* uses getToken() from next-auth/jwt. No DB users table — single admin, env-var credentials only (per D-01, D-02, D-03, D-04). - -Output: Working /admin/login page, session cookie on successful login, automatic redirect to /admin/login for unauthenticated access to any /admin/* route. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/ROADMAP.md -@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md -@.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md - - - - - - - -Current src/proxy.ts content: -```typescript -import { NextRequest, NextResponse } from 'next/server'; - -export async function proxy(request: NextRequest) { - const pathname = request.nextUrl.pathname; - - // Extract token from path: /c/[token]/... - const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/); - if (!tokenMatch) { - return NextResponse.rewrite(new URL('/not-found', request.url)); - } - - const token = tokenMatch[1]; - - try { - const validateUrl = new URL( - `/api/internal/validate-token?token=${encodeURIComponent(token)}`, - request.url - ); - const res = await fetch(validateUrl.toString()); - - if (!res.ok) { - return NextResponse.rewrite(new URL('/not-found', request.url)); - } - - return NextResponse.next(); - } catch { - return NextResponse.rewrite(new URL('/not-found', request.url)); - } -} - -export const config = { - matcher: ['/c/:path*'], -}; -``` - -Note: Next.js middleware MUST be exported as `middleware`, not `proxy`. The Phase 1 file uses `proxy` — this plan must rename it to `middleware` while extending it with /admin/* guard. No src/middleware.ts should ever be created. - -From src/db/index.ts: -```typescript -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -export const db = drizzle(client); -``` - -From src/db/schema.ts (types needed in this plan): -```typescript -// No schema changes needed — no users table. Auth is env-var only. -export type Client = typeof clients.$inferSelect; -``` - - - - - - - Task 1: Install next-auth@4, create src/lib/auth.ts and NextAuth catch-all route - - package.json - src/lib/auth.ts - src/app/api/auth/[...nextauth]/route.ts - .env.local - - - Install next-auth v4 (stable — v5 is still beta RC as of 2026-05-15, per D-01): - ``` - npm install next-auth@4 - ``` - - Add to .env.local (generate NEXTAUTH_SECRET with: `openssl rand -base64 32`): - ``` - NEXTAUTH_URL=http://localhost:3000 - NEXTAUTH_SECRET= - ADMIN_EMAIL=simone.cavalli.gestione@gmail.com - ADMIN_PASSWORD= - ``` - - Create `src/lib/auth.ts` — NextAuth config, no DB adapter (per D-03): - ```typescript - import type { NextAuthOptions } from "next-auth"; - import CredentialsProvider from "next-auth/providers/credentials"; - - export const authOptions: NextAuthOptions = { - providers: [ - CredentialsProvider({ - name: "credentials", - credentials: { - email: { label: "Email", type: "email" }, - password: { label: "Password", type: "password" }, - }, - async authorize(credentials) { - if (!credentials?.email || !credentials?.password) return null; - - const adminEmail = process.env.ADMIN_EMAIL; - const adminPassword = process.env.ADMIN_PASSWORD; - - if (!adminEmail || !adminPassword) { - throw new Error("ADMIN_EMAIL and ADMIN_PASSWORD env vars must be set"); - } - - if ( - credentials.email === adminEmail && - credentials.password === adminPassword - ) { - // Return minimal session user — no DB lookup needed - return { id: "admin", email: adminEmail, name: "Admin" }; - } - - return null; // null = unauthorized (NextAuth returns 401) - }, - }), - ], - session: { - strategy: "jwt", // stateless JWT — no DB session table (per D-03) - maxAge: 30 * 24 * 60 * 60, // 30 days - }, - pages: { - signIn: "/admin/login", // custom login page (per D-07) - }, - callbacks: { - async jwt({ token, user }) { - if (user) { - token.id = user.id; - } - return token; - }, - async session({ session, token }) { - if (session.user) { - (session.user as { id?: string }).id = token.id as string; - } - return session; - }, - }, - }; - ``` - - Create `src/app/api/auth/[...nextauth]/route.ts` — NextAuth catch-all: - ```typescript - import NextAuth from "next-auth"; - import { authOptions } from "@/lib/auth"; - - const handler = NextAuth(authOptions); - - export { handler as GET, handler as POST }; - ``` - - Note: next-auth@4 with App Router uses this export pattern. The handler handles - GET (session fetch, CSRF) and POST (sign in, sign out). - - - grep -q '"next-auth"' package.json && echo "next-auth installed" - test -f src/lib/auth.ts && grep -q "CredentialsProvider" src/lib/auth.ts && echo "CredentialsProvider configured" - grep -q "strategy.*jwt" src/lib/auth.ts && echo "JWT session strategy set" - grep -q "ADMIN_EMAIL" src/lib/auth.ts && echo "ADMIN_EMAIL env var referenced" - test -f src/app/api/auth/\[...nextauth\]/route.ts && grep -q "NextAuth" src/app/api/auth/\[...nextauth\]/route.ts && echo "NextAuth route created" - grep -q "NEXTAUTH_SECRET" .env.local && echo "NEXTAUTH_SECRET in .env.local" - npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - - - - next-auth@4 is in package.json - - src/lib/auth.ts exports authOptions with CredentialsProvider using env vars - - src/app/api/auth/[...nextauth]/route.ts exports GET and POST handlers - - NEXTAUTH_SECRET, ADMIN_EMAIL, ADMIN_PASSWORD are set in .env.local - - npm run build passes without errors - - - - - Task 2: Extend src/proxy.ts to guard /admin/* with session check; create /admin/login page - - src/proxy.ts - src/app/admin/login/page.tsx - src/app/admin/login/actions.ts - - - **Replace** `src/proxy.ts` entirely. NEVER create src/middleware.ts — it would be a dead file ignored by Next.js since this project uses src/proxy.ts as the middleware entry point. The new file renames the export from `proxy` to `middleware` (required by Next.js) and adds the /admin/* guard alongside the existing /c/* token validation logic (per D-04): - - ```typescript - import { NextRequest, NextResponse } from "next/server"; - import { getToken } from "next-auth/jwt"; - - export async function middleware(request: NextRequest) { - const pathname = request.nextUrl.pathname; - - // ── ADMIN GUARD ────────────────────────────────────────────────────────── - if (pathname.startsWith("/admin")) { - // Allow the login page and NextAuth API routes through without session check - if ( - pathname === "/admin/login" || - pathname.startsWith("/api/auth") - ) { - return NextResponse.next(); - } - - const token = await getToken({ - req: request, - secret: process.env.NEXTAUTH_SECRET, - }); - - if (!token) { - const loginUrl = new URL("/admin/login", request.url); - loginUrl.searchParams.set("callbackUrl", pathname); - return NextResponse.redirect(loginUrl); - } - - return NextResponse.next(); - } - - // ── CLIENT TOKEN GUARD ─────────────────────────────────────────────────── - if (pathname.startsWith("/c/")) { - const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/); - if (!tokenMatch) { - return NextResponse.rewrite(new URL("/not-found", request.url)); - } - - const clientToken = tokenMatch[1]; - - try { - const validateUrl = new URL( - `/api/internal/validate-token?token=${encodeURIComponent(clientToken)}`, - request.url - ); - const res = await fetch(validateUrl.toString()); - - if (!res.ok) { - return NextResponse.rewrite(new URL("/not-found", request.url)); - } - - return NextResponse.next(); - } catch { - return NextResponse.rewrite(new URL("/not-found", request.url)); - } - } - - return NextResponse.next(); - } - - export const config = { - matcher: ["/admin/:path*", "/c/:path*"], - }; - ``` - - Note: The export is renamed from `proxy` to `middleware`. This is the only correct Next.js middleware export name. The /c/* logic is preserved verbatim from Phase 1. - - Create `src/app/admin/login/page.tsx` — login form as Client Component: - ```typescript - "use client"; - - import { useState } from "react"; - import { signIn } from "next-auth/react"; - import { useRouter, useSearchParams } from "next/navigation"; - import { Button } from "@/components/ui/button"; - import { Input } from "@/components/ui/input"; - import { Label } from "@/components/ui/label"; - import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; - - export default function AdminLoginPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const callbackUrl = searchParams.get("callbackUrl") ?? "/admin"; - - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setLoading(true); - setError(null); - - const result = await signIn("credentials", { - email, - password, - redirect: false, // handle redirect manually to show errors - }); - - if (result?.error) { - setError("Email o password non corretti."); - setLoading(false); - return; - } - - router.replace(callbackUrl); - } - - return ( -
- - - Admin — ClientHub - - -
-
- - setEmail(e.target.value)} - required - autoComplete="email" - /> -
-
- - setPassword(e.target.value)} - required - autoComplete="current-password" - /> -
- {error && ( -

{error}

- )} - -
-
-
-
- ); - } - ``` - - Do NOT create src/app/admin/login/actions.ts — the login is handled - client-side via signIn(). No Server Action file is needed. -
- - test -f src/proxy.ts && grep -q "getToken" src/proxy.ts && echo "getToken imported in proxy.ts" - grep -q "export async function middleware" src/proxy.ts && echo "export named middleware (not proxy)" - grep -q '"/admin/:path\*"' src/proxy.ts && echo "admin matcher configured" - grep -q '"/c/:path\*"' src/proxy.ts && echo "client matcher still present" - grep -q "pathname === \"/admin/login\"" src/proxy.ts && echo "login page exempted from auth guard" - test -f src/app/admin/login/page.tsx && grep -q "signIn" src/app/admin/login/page.tsx && echo "login page uses signIn" - grep -q '"use client"' src/app/admin/login/page.tsx && echo "login page is Client Component" - test ! -f src/middleware.ts && echo "src/middleware.ts does NOT exist (correct)" - npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - - - - src/proxy.ts guards /admin/* routes: unauthenticated requests redirect to /admin/login?callbackUrl=... - - Export renamed from proxy to middleware (required by Next.js) - - /admin/login and /api/auth/* are exempt from the session guard - - /c/:path* token validation is unchanged - - /admin/login page renders email+password form, calls signIn('credentials'), shows error on failure, redirects on success - - src/middleware.ts does NOT exist - - npm run build passes - - Manual verification: visiting http://localhost:3000/admin redirects to /admin/login; successful login redirects back to /admin - -
- -
- - -## Trust Boundaries - -| Boundary | Description | -|----------|-------------| -| Browser → /admin/* | All admin routes gated by JWT session cookie; proxy.ts rejects unauthenticated requests before any page code runs | -| Login form → CredentialsProvider | Email + password transmitted over HTTPS; validated in server-side authorize() only | -| NEXTAUTH_SECRET → JWT signing | All session tokens are HMAC-signed; tampering is detectable | -| ADMIN_EMAIL/ADMIN_PASSWORD → env vars | Credentials never in source code; must be in .env.local and Vercel environment | - -## STRIDE Threat Register - -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-02-01 | Spoofing | Admin login | mitigate | CredentialsProvider validates against env vars server-side; password never logged; JWT signed with NEXTAUTH_SECRET | -| T-02-02 | Tampering | JWT session cookie | mitigate | next-auth signs JWT with NEXTAUTH_SECRET (HMAC-SHA256); proxy.ts verifies signature on every /admin request via getToken() | -| T-02-03 | Information Disclosure | ADMIN_PASSWORD in env | mitigate | Stored only in .env.local (gitignored) and Vercel environment secrets; never returned in API responses | -| T-02-04 | Elevation of Privilege | /api/auth/* exemption | accept | NextAuth API routes are exempt from session guard by design; they perform their own CSRF and credential validation internally | -| T-02-05 | Denial of Service | Brute-force login | accept | Single admin, not a public product; no rate limiting in v1. If needed in v2, add next-auth rate limit middleware. | - - - -After plan execution: -1. `npm run build` — no TypeScript errors -2. `npm run dev`, visit http://localhost:3000/admin → redirects to /admin/login -3. Submit wrong credentials → error message "Email o password non corretti." appears -4. Submit correct ADMIN_EMAIL + ADMIN_PASSWORD → redirects to /admin (200, even if page is blank) -5. Visit http://localhost:3000/c/any-token → still validates token (client path unchanged) -6. Visit http://localhost:3000/api/auth/session after login → returns `{ user: { email, id: "admin" } }` -7. Confirm src/middleware.ts does not exist in the repo - - - -- Admin can log in at /admin/login with env-var credentials and receive a JWT session cookie -- All /admin/* routes (except /admin/login and /api/auth/*) redirect unauthenticated visitors to /admin/login -- Client token route /c/:path* is unaffected -- No DB users table exists or is needed -- src/proxy.ts is the middleware file — src/middleware.ts never created -- npm run build passes cleanly - - - -After completion, create `.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md` - diff --git a/.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md b/.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md deleted file mode 100644 index 0e904bc..0000000 --- a/.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -phase: "02-admin-area-interactive-features" -plan: 01 -subsystem: "auth" -tags: [next-auth, credentials, jwt, admin, session, middleware] -dependency_graph: - requires: [] - provides: [admin-session-auth, admin-login-page, proxy-admin-guard] - affects: [src/proxy.ts, src/lib/auth.ts] -tech_stack: - added: [next-auth@4] - patterns: [CredentialsProvider, JWT session strategy, edge proxy guard] -key_files: - created: - - src/lib/auth.ts - - src/app/api/auth/[...nextauth]/route.ts - - src/app/admin/login/page.tsx - modified: - - src/proxy.ts - - package.json - - package-lock.json - - .env.local -decisions: - - "Keep proxy export named 'proxy' (not 'middleware') — Next.js 16 renamed the middleware concept back to proxy, breaking the plan's rename instruction" - - "Wrap useSearchParams() in Suspense boundary — required by Next.js App Router for static prerendering" - - "Single admin user, env-var credentials only — no DB users table (stateless JWT)" -metrics: - duration: "~15 minutes" - completed: "2026-05-15T08:42:35Z" - tasks_completed: 2 - files_changed: 7 ---- - -# Phase 02 Plan 01: Auth.js Admin Session + Proxy Guard Summary - -Auth.js v4 CredentialsProvider with JWT sessions gates the entire /admin/* area using env-var credentials (no DB users table), with an edge proxy guard in src/proxy.ts that validates sessions via getToken() before any admin page code runs. - -## What Was Built - -### Task 1: next-auth@4 installation and auth config -- Installed `next-auth@4` (stable; v5 still RC as of 2026-05-15) -- Created `src/lib/auth.ts` — NextAuthOptions with CredentialsProvider reading `ADMIN_EMAIL` + `ADMIN_PASSWORD` from env vars; JWT session strategy (stateless, no DB adapter) -- Created `src/app/api/auth/[...nextauth]/route.ts` — NextAuth catch-all handler (GET + POST) -- Updated `.env.local` with `NEXTAUTH_URL`, `NEXTAUTH_SECRET` (32-byte base64), `ADMIN_EMAIL`, `ADMIN_PASSWORD` - -### Task 2: Proxy guard and login page -- Extended `src/proxy.ts` with `/admin/*` session guard using `getToken()` from `next-auth/jwt` -- `/admin/login` and `/api/auth/*` exempted from the guard (pass-through) -- Unauthenticated `/admin/*` requests redirect to `/admin/login?callbackUrl=` -- `/c/:path*` client token validation logic preserved verbatim from Phase 1 -- matcher updated: `["/admin/:path*", "/c/:path*"]` -- Created `src/app/admin/login/page.tsx` — email+password Client Component with `signIn('credentials')`, inline error display ("Email o password non corretti."), redirect on success - -## Commits - -| Task | Commit | Description | -|------|--------|-------------| -| 1 | 5d363a6 | feat(02-01): install next-auth@4, configure CredentialsProvider auth | -| 2 | 69f8a7e | feat(02-01): extend proxy.ts with admin session guard, add login page | - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 1 - Bug] Next.js 16 proxy export name is 'proxy', not 'middleware'** -- **Found during:** Task 2 first build attempt -- **Issue:** The plan instructed renaming the export to `middleware` (Next.js 15 convention), but this project runs Next.js 16.2.6, which introduced the `proxy` concept and requires the function to be named `proxy`. The build failed with: "Proxy is missing expected function export name" -- **Fix:** Kept the export name as `proxy` — consistent with the existing Phase 1 file and Next.js 16 API -- **Files modified:** `src/proxy.ts` - -**2. [Rule 1 - Bug] useSearchParams() requires Suspense boundary in App Router** -- **Found during:** Task 2 second build attempt -- **Issue:** `useSearchParams()` in a Client Component causes a build failure during static page generation without a Suspense boundary. Error: "useSearchParams() should be wrapped in a suspense boundary at page /admin/login" -- **Fix:** Extracted the form into `AdminLoginForm` component; wrapped it in `` inside the default export `AdminLoginPage` -- **Files modified:** `src/app/admin/login/page.tsx` - -## Known Stubs - -None — all implemented functionality is complete and functional. - -## Threat Surface Scan - -No new security surface beyond what was planned in the threat model: -- T-02-01: Mitigated — CredentialsProvider validates against env vars server-side -- T-02-02: Mitigated — JWT signed with NEXTAUTH_SECRET, verified via getToken() on every /admin request -- T-02-03: Mitigated — ADMIN_PASSWORD stored only in .env.local (gitignored) and Vercel secrets -- T-02-04: Accepted — /api/auth/* exempt by design, NextAuth handles its own CSRF -- T-02-05: Accepted — No rate limiting in v1 - -## Self-Check: PASSED - -All created files exist on disk. Both task commits (5d363a6, 69f8a7e) verified in git log. diff --git a/.planning/phases/02-admin-area-interactive-features/02-02-PLAN.md b/.planning/phases/02-admin-area-interactive-features/02-02-PLAN.md deleted file mode 100644 index 3579884..0000000 --- a/.planning/phases/02-admin-area-interactive-features/02-02-PLAN.md +++ /dev/null @@ -1,561 +0,0 @@ ---- -phase: "02-admin-area-interactive-features" -plan: 02 -type: execute -wave: 2 -depends_on: - - "02-01" -files_modified: - - src/app/admin/page.tsx - - src/app/admin/layout.tsx - - src/app/admin/clients/new/page.tsx - - src/app/admin/clients/new/actions.ts - - src/lib/admin-queries.ts - - src/components/admin/ClientRow.tsx - - src/components/admin/NavBar.tsx -autonomous: true -requirements: - - ADMIN-01 - - ADMIN-02 - -must_haves: - truths: - - "Admin can see a list of all clients at /admin with name, brand, and payment status badges" - - "Admin can create a new client via /admin/clients/new form; on submit the client row + two payment rows are inserted and the secret link (token) is auto-generated" - - "After creating a client, admin is redirected to /admin (or /admin/clients/[id] for detail)" - - "The new client's shareable link /c/[token] is visible to the admin immediately after creation" - - "Payment status badges for Acconto and Saldo are visible in the client list row" - artifacts: - - path: "src/app/admin/page.tsx" - provides: "Admin client list — Server Component fetching all clients with payments" - contains: "export default async function" - - path: "src/app/admin/layout.tsx" - provides: "Admin layout with minimal NavBar (logo + Clienti link + logout button)" - contains: "NavBar" - - path: "src/app/admin/clients/new/page.tsx" - provides: "New client form page" - min_lines: 30 - - path: "src/app/admin/clients/new/actions.ts" - provides: "Server Action: createClient() — inserts client + 2 payment rows" - contains: "createClient" - - path: "src/lib/admin-queries.ts" - provides: "Admin-side DB query functions (getAllClientsWithPayments)" - contains: "getAllClientsWithPayments" - key_links: - - from: "src/app/admin/page.tsx" - to: "src/lib/admin-queries.ts" - via: "getAllClientsWithPayments()" - pattern: "getAllClientsWithPayments" - - from: "src/app/admin/clients/new/page.tsx" - to: "src/app/admin/clients/new/actions.ts" - via: "createClient Server Action" - pattern: "createClient" - - from: "createClient action" - to: "clients + payments tables" - via: "db.insert(clients) + db.insert(payments) x2" - pattern: "db.insert" ---- - - -**Admin Client List + Create Client:** Build the admin home page (client list with payment badges) and the new client creation form. The create form auto-generates the nanoid secret token, inserts the client row, and creates two payment rows (Acconto 50% / Saldo 50%) in a single Server Action. - -Purpose: Deliver the first end-to-end admin capability — admin can enter a client's details and immediately get a shareable /c/[token] link. Implements ADMIN-01 (client list with status) and the creation half of ADMIN-02 (per D-05 Server Actions, D-07 list→detail layout, D-09 minimal nav). - -Output: /admin shows all clients with payment badges; /admin/clients/new creates a client and two payment stubs. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/ROADMAP.md -@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md -@.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md - - - -```typescript -export const clients = pgTable("clients", { - id: text("id").primaryKey().$defaultFn(() => nanoid()), - name: text("name").notNull(), - brand_name: text("brand_name").notNull(), - brief: text("brief").notNull(), - token: text("token").notNull().unique().$defaultFn(() => nanoid()), - accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"), - created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const payments = pgTable("payments", { - id: text("id").primaryKey().$defaultFn(() => nanoid()), - client_id: text("client_id").notNull().references(() => clients.id, { onDelete: "cascade" }), - label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%" - amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), - status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato - paid_at: timestamp("paid_at", { withTimezone: true }), -}); - -export type Client = typeof clients.$inferSelect; -export type NewClient = typeof clients.$inferInsert; -export type Payment = typeof payments.$inferSelect; -``` - -From src/db/index.ts: -```typescript -export const db = drizzle(client); // drizzle-orm/postgres-js -``` - - - - - - - Task 1: Create src/lib/admin-queries.ts and admin layout + NavBar component - - src/lib/admin-queries.ts - src/app/admin/layout.tsx - src/components/admin/NavBar.tsx - - - Create `src/lib/admin-queries.ts` — all admin-side DB reads live here: - ```typescript - import { db } from "@/db"; - import { clients, payments } from "@/db/schema"; - import { eq } from "drizzle-orm"; - - export type ClientWithPayments = { - id: string; - name: string; - brand_name: string; - token: string; - accepted_total: string; - created_at: Date; - payments: Array<{ - id: string; - label: string; - status: string; - amount: string; - }>; - }; - - export async function getAllClientsWithPayments(): Promise { - const allClients = await db - .select() - .from(clients) - .orderBy(clients.created_at); - - if (allClients.length === 0) return []; - - const allPayments = await db - .select() - .from(payments); - - return allClients.map((c) => ({ - id: c.id, - name: c.name, - brand_name: c.brand_name, - token: c.token, - accepted_total: c.accepted_total ?? "0", - created_at: c.created_at, - payments: allPayments - .filter((p) => p.client_id === c.id) - .map((p) => ({ - id: p.id, - label: p.label, - status: p.status, - amount: p.amount, - })), - })); - } - - export async function getClientById(id: string) { - const rows = await db - .select() - .from(clients) - .where(eq(clients.id, id)) - .limit(1); - return rows[0] ?? null; - } - ``` - - Create `src/components/admin/NavBar.tsx` — minimal nav per D-09 (no sidebar): - ```typescript - "use client"; - - import Link from "next/link"; - import { signOut } from "next-auth/react"; - import { Button } from "@/components/ui/button"; - - export function NavBar() { - return ( - - ); - } - ``` - - Create `src/app/admin/layout.tsx` — wraps all /admin/* pages: - ```typescript - import { NavBar } from "@/components/admin/NavBar"; - - export default function AdminLayout({ - children, - }: { - children: React.ReactNode; - }) { - return ( -
- -
{children}
-
- ); - } - ``` -
- - test -f src/lib/admin-queries.ts && grep -q "getAllClientsWithPayments" src/lib/admin-queries.ts && echo "admin-queries.ts created" - grep -q "getClientById" src/lib/admin-queries.ts && echo "getClientById exported" - test -f src/components/admin/NavBar.tsx && grep -q "signOut" src/components/admin/NavBar.tsx && echo "NavBar with logout" - test -f src/app/admin/layout.tsx && grep -q "NavBar" src/app/admin/layout.tsx && echo "Admin layout wraps NavBar" - npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - - - - src/lib/admin-queries.ts exports getAllClientsWithPayments() and getClientById() - - NavBar renders with "Clienti" link and "Esci" button - - Admin layout wraps all /admin/* pages with NavBar + centered main content area - - npm run build passes - -
- - - Task 2: Build /admin client list page and /admin/clients/new create-client flow - - src/app/admin/page.tsx - src/components/admin/ClientRow.tsx - src/app/admin/clients/new/page.tsx - src/app/admin/clients/new/actions.ts - - - Create `src/components/admin/ClientRow.tsx` — single row in client list table: - ```typescript - import Link from "next/link"; - import { Badge } from "@/components/ui/badge"; - import type { ClientWithPayments } from "@/lib/admin-queries"; - - const statusConfig: Record = { - da_saldare: { label: "Da saldare", variant: "destructive" }, - inviata: { label: "Inviata", variant: "secondary" }, - saldato: { label: "Saldato", variant: "default" }, - }; - - export function ClientRow({ client }: { client: ClientWithPayments }) { - const acconto = client.payments.find((p) => p.label.includes("Acconto")); - const saldo = client.payments.find((p) => p.label.includes("Saldo")); - - return ( - - - - {client.name} - -

{client.brand_name}

- - - € {parseFloat(client.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })} - - - {acconto && ( - - Acconto: {statusConfig[acconto.status]?.label ?? acconto.status} - - )} - - - {saldo && ( - - Saldo: {statusConfig[saldo.status]?.label ?? saldo.status} - - )} - - - - /c/{client.token.slice(0, 10)}… - - - - ); - } - ``` - - Create `src/app/admin/page.tsx` — Server Component, no client state: - ```typescript - import Link from "next/link"; - import { getAllClientsWithPayments } from "@/lib/admin-queries"; - import { ClientRow } from "@/components/admin/ClientRow"; - import { Button } from "@/components/ui/button"; - - export const revalidate = 0; // always fresh — admin needs real-time data - - export default async function AdminDashboard() { - const clients = await getAllClientsWithPayments(); - - return ( -
-
-

Clienti

- -
- - {clients.length === 0 ? ( -
-

Nessun cliente ancora.

-

- - Crea il primo cliente - -

-
- ) : ( -
- - - - - - - - - - - - {clients.map((client) => ( - - ))} - -
ClienteTotaleAccontoSaldoLink
-
- )} -
- ); - } - ``` - - Create `src/app/admin/clients/new/actions.ts` — Server Action (per D-05): - ```typescript - "use server"; - - import { redirect } from "next/navigation"; - import { revalidatePath } from "next/cache"; - import { z } from "zod"; - import { db } from "@/db"; - import { clients, payments } from "@/db/schema"; - - const createClientSchema = z.object({ - name: z.string().min(1, "Nome richiesto"), - brand_name: z.string().min(1, "Nome brand richiesto"), - brief: z.string().min(1, "Brief richiesto"), - }); - - export async function createClient(formData: FormData) { - const raw = { - name: formData.get("name") as string, - brand_name: formData.get("brand_name") as string, - brief: formData.get("brief") as string, - }; - - const parsed = createClientSchema.safeParse(raw); - if (!parsed.success) { - // In v1 return errors as thrown string — form displays validation inline - throw new Error(parsed.error.issues.map((i) => i.message).join(", ")); - } - - // Insert client — token and id are auto-generated by $defaultFn(() => nanoid()) - const [newClient] = await db - .insert(clients) - .values({ - name: parsed.data.name, - brand_name: parsed.data.brand_name, - brief: parsed.data.brief, - }) - .returning({ id: clients.id, token: clients.token }); - - // Always create two payment stubs per client — Acconto 50% and Saldo 50% - // Amounts default to 0 until admin sets accepted_total; admin updates separately - await db.insert(payments).values([ - { - client_id: newClient.id, - label: "Acconto 50%", - amount: "0", - status: "da_saldare", - }, - { - client_id: newClient.id, - label: "Saldo 50%", - amount: "0", - status: "da_saldare", - }, - ]); - - revalidatePath("/admin"); - redirect(`/admin/clients/${newClient.id}`); - } - ``` - - Create `src/app/admin/clients/new/page.tsx` — form using the Server Action: - ```typescript - import { createClient } from "./actions"; - import { Button } from "@/components/ui/button"; - import { Input } from "@/components/ui/input"; - import { Label } from "@/components/ui/label"; - import { Textarea } from "@/components/ui/textarea"; - import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; - import Link from "next/link"; - - export default function NewClientPage() { - return ( -
-
- - ← Clienti - -
- - - Nuovo cliente - - -
-
- - -
-
- - -
-
- - +
+ + + + +
+

Tagging e Tassonomia

+ +
+ +
+
+ Categoria + Entry Offer +
+
+ Ticket + Low Ticket +
+
+ + +
+
+ Tipo +
+ Audit + Done For You + +
+
+
+ Obiettivo +
+ Primo Acquisto + Up Scala Valore + Lead Gen + +
+
+
+
+ +
+ + +
+
+
+

Servizi Inclusi & Matrice Tier

+

Associa i singoli servizi del catalogo ai Tier A, B e C

+
+ +
+ + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ServizioPrezzo UnitarioABC
QA - Test Cross Browser e responsive€400,00
Raccolta e Mappatura Materiali€100,00
Audit iniziale (UX/UI, struttura, conversione)€500,00
Workshop (1° fase)€900,00
Totale Servizi Calcolati€2.600,00€3.300,00€3.900,00
Prezzo Pubblico Scontato + + + + + +
+
+
+ +
+ + +
+
+

Promessa di Trasformazione

+

Struttura la proposta commerciale secondo un modello di copywriting orientato al risultato

+
+ +
+ + +
+ Aiuto + +
+ + +
+ A ottenere + +
+ + +
+ In + +
+ + +
+ Senza + +
+ +
+
+ + +
+
+ + +
+ + + +
+ + +
+ + + + + \ No newline at end of file diff --git a/scripts/migrate-services.ts b/scripts/migrate-services.ts deleted file mode 100644 index 57c4b05..0000000 --- a/scripts/migrate-services.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { db } from "@/db"; -import { service_catalog, offer_services, services } from "@/db/schema"; -import { eq, and } from "drizzle-orm"; - -async function migrate() { - console.log("Starting services unification migration...\n"); - - // 1. Backfill from service_catalog (operational pricing, used by quote_items) - const catalogRows = await db.select().from(service_catalog); - let catalogInserted = 0; - let catalogSkipped = 0; - - for (const row of catalogRows) { - const existing = await db - .select() - .from(services) - .where(and(eq(services.migrated_from, "service_catalog"), eq(services.migrated_id, row.id))) - .limit(1); - - if (existing.length > 0) { - catalogSkipped++; - continue; - } - - await db.insert(services).values({ - name: row.name, - description: row.description, - unit_price: row.unit_price, - category: "catalog", - active: row.active, - migrated_from: "service_catalog", - migrated_id: row.id, - }); - catalogInserted++; - } - console.log(`service_catalog: ${catalogInserted} inserted, ${catalogSkipped} skipped (already migrated)`); - - // 2. Backfill from offer_services (marketing pricing, used by offer_micro_services) - const offerRows = await db.select().from(offer_services); - let offerInserted = 0; - let offerSkipped = 0; - let renamed = 0; - - for (const row of offerRows) { - const existing = await db - .select() - .from(services) - .where(and(eq(services.migrated_from, "offer_services"), eq(services.migrated_id, row.id))) - .limit(1); - - if (existing.length > 0) { - offerSkipped++; - continue; - } - - // Name collision check against ALL services rows inserted so far (both sources) - const collision = await db - .select() - .from(services) - .where(eq(services.name, row.name)) - .limit(1); - - const finalName = collision.length > 0 ? `${row.name} (Offer)` : row.name; - if (collision.length > 0) renamed++; - - await db.insert(services).values({ - name: finalName, - description: row.transformation_description, - unit_price: row.price, - category: "offer", - active: row.active, - migrated_from: "offer_services", - migrated_id: row.id, - }); - offerInserted++; - } - console.log(`offer_services: ${offerInserted} inserted, ${offerSkipped} skipped (already migrated), ${renamed} renamed for collision`); - - console.log("\nMigration complete. Run scripts/validate-services-migration.ts next."); - process.exit(0); -} - -migrate().catch((err) => { - console.error("Migration failed:", err); - process.exit(1); -}); diff --git a/scripts/migrate-tags.ts b/scripts/migrate-tags.ts deleted file mode 100644 index bebcdfa..0000000 --- a/scripts/migrate-tags.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { db } from "@/db"; -import { services, tags } from "@/db/schema"; -import { eq, and } from "drizzle-orm"; - -async function migrate() { - console.log("Starting tags migration: assigning 'Offerta' tag to services migrated from offer_services...\n"); - - const offerServices = await db - .select({ id: services.id }) - .from(services) - .where(eq(services.migrated_from, "offer_services")); - - let taggedCount = 0; - let skippedCount = 0; - - for (const service of offerServices) { - const existing = await db - .select() - .from(tags) - .where( - and( - eq(tags.entity_type, "services"), - eq(tags.entity_id, service.id), - eq(tags.name, "Offerta") - ) - ) - .limit(1); - - if (existing.length > 0) { - skippedCount++; - continue; - } - - await db.insert(tags).values({ - entity_type: "services", - entity_id: service.id, - name: "Offerta", - }); - taggedCount++; - } - - console.log(`Assigned 'Offerta' tag: ${taggedCount} services, ${skippedCount} already tagged`); - console.log("\nMigration complete. Run scripts/validate-tags-migration.ts next."); - process.exit(0); -} - -migrate().catch((err) => { - console.error("Migration failed:", err); - process.exit(1); -}); diff --git a/scripts/push-11-tags-migration.ts b/scripts/push-11-tags-migration.ts deleted file mode 100644 index f3f000e..0000000 --- a/scripts/push-11-tags-migration.ts +++ /dev/null @@ -1,55 +0,0 @@ -// PRODUCTION DEPLOY NOTE: This migration is additive-only (CREATE TABLE IF NOT EXISTS + -// 2 indexes, no drops/truncates). Per CLAUDE.md Data Safety, apply to production via -// SSH+docker exec BEFORE pushing Phase 11 schema-dependent code (Plans 02-04). -// Run: npx tsx scripts/push-11-tags-migration.ts (with prod DATABASE_URL) -import postgres from "postgres"; - -async function push() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - console.error("DATABASE_URL environment variable is required"); - process.exit(1); - } - - const client = postgres(databaseUrl); - - try { - console.log("Pushing tags table migration..."); - - await client` - CREATE TABLE IF NOT EXISTS tags ( - id text PRIMARY KEY, - entity_type text NOT NULL, - entity_id text NOT NULL, - name text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL - ) - `; - - await client` - CREATE UNIQUE INDEX IF NOT EXISTS tags_entity_name_unique - ON tags (entity_type, entity_id, name) - `; - - await client` - CREATE INDEX IF NOT EXISTS tags_entity_idx - ON tags (entity_type, entity_id) - `; - - console.log("✓ tags table created successfully"); - process.exit(0); - } catch (err: unknown) { - if (err instanceof Error) { - if (err.message.includes("already exists")) { - console.log("✓ tags table already exists (skipped)"); - process.exit(0); - } - console.error("Error pushing migration:", err.message); - } else { - console.error("Unknown error:", err); - } - process.exit(1); - } -} - -push(); diff --git a/scripts/push-11b-fase-column.ts b/scripts/push-11b-fase-column.ts deleted file mode 100644 index 2754328..0000000 --- a/scripts/push-11b-fase-column.ts +++ /dev/null @@ -1,27 +0,0 @@ -// PRODUCTION DEPLOY NOTE: additive-only (ADD COLUMN IF NOT EXISTS, no drops/truncates). -// Per CLAUDE.md Data Safety, apply to the live DB via SSH tunnel / docker exec BEFORE -// deploying the schema-dependent catalog code. Idempotent — safe to re-run. -// Run: npx tsx scripts/push-11b-fase-column.ts (with DATABASE_URL pointing at the DB) -import postgres from "postgres"; - -async function push() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - console.error("DATABASE_URL environment variable is required"); - process.exit(1); - } - - const client = postgres(databaseUrl); - - try { - console.log("Adding services.fase column..."); - await client`ALTER TABLE services ADD COLUMN IF NOT EXISTS fase text`; - console.log("✓ services.fase column ready"); - process.exit(0); - } catch (err: unknown) { - console.error("Error pushing migration:", err instanceof Error ? err.message : err); - process.exit(1); - } -} - -push(); diff --git a/scripts/push-12-offer-tier-schema.ts b/scripts/push-12-offer-tier-schema.ts deleted file mode 100644 index 39cac2d..0000000 --- a/scripts/push-12-offer-tier-schema.ts +++ /dev/null @@ -1,142 +0,0 @@ -// PRODUCTION DEPLOY NOTE: This migration (0008_offer_tier_schema.sql) is additive-only -// (ADD COLUMN IF NOT EXISTS, a guarded DO-block CHECK constraint, CREATE TABLE IF NOT -// EXISTS, and CREATE INDEX IF NOT EXISTS — no drops/truncates). Per CLAUDE.md Data -// Safety, this script MUST be run against PRODUCTION via SSH+docker exec BEFORE Phase -// 12 Wave 3 code (query layer reading offer_tier_services/tier_letter/public_price/ -// category/ticket) is exercised against production data. This is the BLOCKING step -// for Plan 02. Idempotent — safe to re-run. -// Run: npx tsx scripts/push-12-offer-tier-schema.ts (with prod DATABASE_URL) -import postgres from "postgres"; - -async function push() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - console.error("DATABASE_URL environment variable is required"); - process.exit(1); - } - - const client = postgres(databaseUrl); - - try { - console.log("Pushing offer tier schema migration (0008)..."); - - // offer_macros: archive flag + short description + category/ticket dimensions + - // structured transformation promise - const offerMacrosColumns: Array<[string, string]> = [ - ["description", "text"], - ["category", "text"], - ["ticket", "text"], - ["is_archived", "boolean NOT NULL DEFAULT false"], - ["cliente_ideale", "text"], - ["risultato", "text"], - ["tempo", "text"], - ["pain", "text"], - ["metodo", "text"], - ]; - - for (const [column, type] of offerMacrosColumns) { - try { - await client.unsafe( - `ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS ${column} ${type}` - ); - console.log(` ✓ offer_macros.${column} ready`); - } catch (err: unknown) { - if (err instanceof Error && err.message.includes("already exists")) { - console.log(` ✓ offer_macros.${column} already exists (skipped)`); - } else { - throw err; - } - } - } - - // offer_micros: tier designation (A/B/C) + manual public price - const offerMicrosColumns: Array<[string, string]> = [ - ["tier_letter", "text"], - ["public_price", "numeric(10, 2)"], - ]; - - for (const [column, type] of offerMicrosColumns) { - try { - await client.unsafe( - `ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS ${column} ${type}` - ); - console.log(` ✓ offer_micros.${column} ready`); - } catch (err: unknown) { - if (err instanceof Error && err.message.includes("already exists")) { - console.log(` ✓ offer_micros.${column} already exists (skipped)`); - } else { - throw err; - } - } - } - - // CHECK constraint for tier_letter, guarded so it's safe to re-run. - try { - await client.unsafe(` - DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'offer_micros_tier_letter_check' - ) THEN - ALTER TABLE offer_micros - ADD CONSTRAINT offer_micros_tier_letter_check - CHECK (tier_letter IS NULL OR tier_letter IN ('A', 'B', 'C')); - END IF; - END $$; - `); - console.log(" ✓ offer_micros_tier_letter_check constraint ready"); - } catch (err: unknown) { - if (err instanceof Error && err.message.includes("already exists")) { - console.log(" ✓ offer_micros_tier_letter_check already exists (skipped)"); - } else { - throw err; - } - } - - // New junction table: tier (offer_micros) <-> unified services catalog. - try { - await client.unsafe(` - CREATE TABLE IF NOT EXISTS offer_tier_services ( - tier_id text NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE, - service_id text NOT NULL REFERENCES services(id) ON DELETE CASCADE, - created_at timestamp with time zone DEFAULT now() NOT NULL, - PRIMARY KEY (tier_id, service_id) - ) - `); - console.log(" ✓ offer_tier_services table ready"); - } catch (err: unknown) { - if (err instanceof Error && err.message.includes("already exists")) { - console.log(" ✓ offer_tier_services table already exists (skipped)"); - } else { - throw err; - } - } - - try { - await client.unsafe(` - CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx ON offer_tier_services USING btree (tier_id) - `); - console.log(" ✓ offer_tier_services_tier_idx index ready"); - } catch (err: unknown) { - if (err instanceof Error && err.message.includes("already exists")) { - console.log(" ✓ offer_tier_services_tier_idx already exists (skipped)"); - } else { - throw err; - } - } - - console.log("✓ Migration 0008 (offer tier schema) applied successfully"); - process.exit(0); - } catch (err: unknown) { - if (err instanceof Error) { - console.error("Error pushing migration:", err.message); - } else { - console.error("Unknown error:", err); - } - process.exit(1); - } finally { - await client.end(); - } -} - -push(); diff --git a/scripts/push-phase10-migration.ts b/scripts/push-phase10-migration.ts deleted file mode 100644 index 1e004f8..0000000 --- a/scripts/push-phase10-migration.ts +++ /dev/null @@ -1,79 +0,0 @@ -import postgres from "postgres"; -import { readFileSync } from "fs"; -import { join } from "path"; - -// Applies 0005_phase_10_crm_leads_activities_reminders.sql (additive-only: -// ALTER TABLE leads ADD COLUMN x8, CREATE TABLE activities/reminders, indexes). -// Idempotent: skips if already applied. Never drops or truncates anything. - -async function push() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - console.error("DATABASE_URL environment variable is required"); - process.exit(1); - } - - const sql = postgres(databaseUrl, { max: 1 }); - - try { - const cols = await sql` - SELECT column_name FROM information_schema.columns - WHERE table_name = 'leads' ORDER BY ordinal_position - `; - const tables = await sql` - SELECT table_name FROM information_schema.tables - WHERE table_schema = 'public' AND table_name IN ('activities', 'reminders') - `; - const colNames = cols.map((c) => c.column_name); - console.log("Pre-state — leads columns:", colNames.join(", ")); - console.log( - "Pre-state — CRM tables:", - tables.map((t) => t.table_name).join(", ") || "none" - ); - - if (colNames.includes("status") && tables.length === 2) { - console.log("✓ Migration 0005 already applied (skipped)"); - process.exit(0); - } - - const migrationSql = readFileSync( - join( - __dirname, - "../src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql" - ), - "utf-8" - ); - - console.log("Applying migration 0005 in a transaction..."); - await sql.begin(async (tx) => { - await tx.unsafe(migrationSql); - }); - - const postCols = await sql` - SELECT column_name FROM information_schema.columns - WHERE table_name = 'leads' ORDER BY ordinal_position - `; - const postTables = await sql` - SELECT table_name FROM information_schema.tables - WHERE table_schema = 'public' AND table_name IN ('activities', 'reminders') - `; - console.log( - "Post-state — leads columns:", - postCols.map((c) => c.column_name).join(", ") - ); - console.log( - "Post-state — CRM tables:", - postTables.map((t) => t.table_name).join(", ") - ); - console.log("✓ Migration 0005 applied successfully"); - process.exit(0); - } catch (err: unknown) { - console.error( - "Error applying migration:", - err instanceof Error ? err.message : err - ); - process.exit(1); - } -} - -push(); diff --git a/scripts/push-services-migration.ts b/scripts/push-services-migration.ts deleted file mode 100644 index 5100819..0000000 --- a/scripts/push-services-migration.ts +++ /dev/null @@ -1,48 +0,0 @@ -import postgres from "postgres"; -import { readFileSync } from "fs"; -import { join } from "path"; - -async function push() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - console.error("DATABASE_URL environment variable is required"); - process.exit(1); - } - - const client = postgres(databaseUrl); - - try { - console.log("Pushing services table migration..."); - - // Create the services table - await client` - CREATE TABLE IF NOT EXISTS services ( - id text PRIMARY KEY, - name text NOT NULL, - description text, - unit_price numeric(10, 2) NOT NULL, - category text, - active boolean DEFAULT true NOT NULL, - migrated_from text, - migrated_id text, - created_at timestamp with time zone DEFAULT now() NOT NULL - ) - `; - - console.log("✓ services table created successfully"); - process.exit(0); - } catch (err: unknown) { - if (err instanceof Error) { - if (err.message.includes("already exists")) { - console.log("✓ services table already exists (skipped)"); - process.exit(0); - } - console.error("Error pushing migration:", err.message); - } else { - console.error("Unknown error:", err); - } - process.exit(1); - } -} - -push(); diff --git a/scripts/reset-and-import-services.ts b/scripts/reset-and-import-services.ts deleted file mode 100644 index 9aeb2ef..0000000 --- a/scripts/reset-and-import-services.ts +++ /dev/null @@ -1,134 +0,0 @@ -// One-shot: deletes 3 legacy test services, then imports 55 from Notion CSV. -// offer_tier_services rows cascade-delete automatically (ON DELETE CASCADE). -// Run via SSH tunnel: -// DATABASE_URL=$(node --env-file=.env.local -e 'const u=new URL(process.env.DATABASE_URL); u.host="127.0.0.1:54321"; process.stdout.write(u.toString())') \ -// npx tsx scripts/reset-and-import-services.ts -import postgres from "postgres"; -import { customAlphabet } from "nanoid"; - -const nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 12); - -type ServiceRow = { - name: string; - unit_price: number; - category: string; - fase: string | null; -}; - -const SERVICES: ServiceRow[] = [ - { name: "Raccolta e Mappatura Materiali", unit_price: 100, category: "Signature Offer", fase: "Fase 1 → Onboarding / Setup" }, - { name: "Audit iniziale (UX/UI, struttura, conversione)", unit_price: 500, category: "Signature Offer", fase: "Fase 1 → Onboarding / Setup" }, - { name: "Workshop (1° fase)", unit_price: 900, category: "Signature Offer", fase: "Fase 1 → Onboarding / Setup" }, - { name: "Analisi Competitor", unit_price: 400, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" }, - { name: "Documento di restituzione (problemi + lista ottimizzazioni)", unit_price: 500, category: "Entry Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Redesign visivo dell'above-the-fold / hero (il 'prima → dopo')", unit_price: 700, category: "Entry Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Call di restituzione", unit_price: 200, category: "Entry Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Call di presentazione Prima/Dopo", unit_price: 200, category: "Entry Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Roadmap / istruzioni operative + mini kit", unit_price: 600, category: "Entry Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "UX Research", unit_price: 1200, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" }, - { name: "Customer Journey", unit_price: 900, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" }, - { name: "Architettura (sitemap)", unit_price: 500, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" }, - { name: "Brand Identity - Visual identity", unit_price: 2000, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" }, - { name: "Brand Identity - Voice identity", unit_price: 800, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" }, - { name: "Direzione Creative (moodboard)", unit_price: 600, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" }, - { name: "Workshop (2° fase)", unit_price: 900, category: "Signature Offer", fase: "Fase 2 → Analisi / Strategia" }, - { name: "Settaggio CMS (Wordpress Webflow ecc)", unit_price: 500, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "UX - UI", unit_price: 1000, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Wireframe (Low-Mid Fidelity)", unit_price: 600, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Homepage (Figma + Dev)", unit_price: 1500, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Revisione #1", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "- Chi siamo", unit_price: 600, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "- Contatti", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "- Blog (Archivio)", unit_price: 300, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "- - Blog post (Template Singolo)", unit_price: 600, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "- Case Study (Archivio)", unit_price: 300, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "- - Case Study (Template Singolo)", unit_price: 800, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "- Landing Page (Metodo o Differenziante)", unit_price: 1000, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "- - - Thank You Page", unit_price: 300, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "- - - 404", unit_price: 150, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Responsive (inclusa nelle pagine?)", unit_price: 800, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "UX writing (Revisione testi)", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Seo Setup (basic on-page)", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Seo Avanzato (+ keyword + ricerca + blog post dentro retainer)", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Revisione #2 finale", unit_price: 400, category: "Signature Offer", fase: "Fase 3 → Esecuzione / Core" }, - { name: "Web Core Vitals (Optimization)", unit_price: 500, category: "Signature Offer", fase: "Fase 4 → Raffinamento / Extra" }, - { name: "QA - Test Cross Browser e responsive", unit_price: 400, category: "Signature Offer", fase: "Fase 4 → Raffinamento / Extra" }, - { name: "Setting GA4 - Tag Manager - Hotjar/Clarify", unit_price: 500, category: "Signature Offer", fase: "Fase 4 → Raffinamento / Extra" }, - { name: "Raccolta Feedback Post Lancio", unit_price: 200, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Go-live / messa online & accessi", unit_price: 300, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Audit uscita", unit_price: 500, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Real User Testing", unit_price: 1200, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Follow Up Handover", unit_price: 200, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Video Tutorial per micro modifiche in autonomia", unit_price: 400, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Revisioni Extra illimitate (sicuri illimitati???)", unit_price: 1200, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Brand Kit (youtube - linkedin - insta)", unit_price: 600, category: "Signature Offer", fase: "Fase 5 → Offboarding / Consegna" }, - { name: "Mantenimento tecnico (sito sempre online, bello, funzionante)", unit_price: 200, category: "Retainer Offer", fase: null }, - { name: "Monitoraggio dati (GA4 / Hotjar-Clarity)", unit_price: 100, category: "Retainer Offer", fase: null }, - { name: "Report mensile", unit_price: 100, category: "Retainer Offer", fase: null }, - { name: "CRO - analisi UX (hotmap, punti di drop)", unit_price: 200, category: "Retainer Offer", fase: null }, - { name: "CRO - implementazione miglioramenti", unit_price: 300, category: "Retainer Offer", fase: null }, - { name: "Call di Mentorship/Consulenza/allineamento/revisione", unit_price: 800, category: "Retainer Offer", fase: null }, - { name: "Art direction su direzione da prendere (consulente strategico interno disponibile 24/7)", unit_price: 2000, category: "Retainer Offer", fase: null }, - { name: "Extra landing (prezzo singolo per pompare prezzo)", unit_price: 3000, category: "Retainer Offer", fase: null }, - { name: "SEO avanzato (Blog post)", unit_price: 400, category: "Retainer Offer", fase: null }, -]; - -const LEGACY_IDS = [ - "LLi-DynaQ_Y13vgREFtSB", // Audit Iniziale (test) - "MlhY34V7M_ylSPekeuIsz", // Raccolta e Mappatura Materiali (test, wrong category) - "lDZkctWhcXqsYVDy2nac6", // Analisi Competitor (test, wrong category) -]; - -async function run() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - console.error("DATABASE_URL environment variable is required"); - process.exit(1); - } - - const client = postgres(databaseUrl); - - try { - // Step 1: delete legacy test services (offer_tier_services cascade-deletes) - console.log("Deleting 3 legacy test services..."); - const deleted = await client` - DELETE FROM services WHERE id = ANY(${LEGACY_IDS}) RETURNING name - `; - for (const row of deleted) console.log(` ✗ deleted: ${row.name}`); - console.log(); - - // Step 2: import 55 Notion services - console.log(`Importing ${SERVICES.length} services...`); - let inserted = 0; - let skipped = 0; - - for (const svc of SERVICES) { - const existing = await client` - SELECT id FROM services WHERE name = ${svc.name} LIMIT 1 - `; - if (existing.length > 0) { - console.log(` ↷ exists: ${svc.name}`); - skipped++; - continue; - } - const id = nanoid(); - await client` - INSERT INTO services (id, name, unit_price, category, fase, active, migrated_from) - VALUES (${id}, ${svc.name}, ${svc.unit_price}, ${svc.category}, ${svc.fase}, true, 'notion-csv-2026-06-18') - `; - console.log(` ✓ ${svc.name}`); - inserted++; - } - - const [{ count }] = await client`SELECT count(*) FROM services`; - console.log(`\n✓ Done — ${inserted} inserted, ${skipped} skipped. Total in DB: ${count}`); - process.exit(0); - } catch (err) { - console.error("Error:", err instanceof Error ? err.message : err); - process.exit(1); - } finally { - await client.end(); - } -} - -run(); diff --git a/scripts/validate-phase8-migration.ts b/scripts/validate-phase8-migration.ts deleted file mode 100644 index 742c576..0000000 --- a/scripts/validate-phase8-migration.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { db } from "@/db"; -import { - offer_phases, - offer_phase_services, - quotes, - quote_items, - projects, - phases, - leads, -} from "@/db/schema"; -import { sql } from "drizzle-orm"; - -/** - * Validation script for Phase 8 migration - * - * Checks that all new tables/columns exist and FKs are intact - * Exit code 0 if all checks pass, 1 if any fail - */ - -type ValidationResult = { - name: string; - passed: boolean; - error?: string; -}; - -const results: ValidationResult[] = []; - -async function runChecks() { - console.log("=== Phase 8 Migration Validation ===\n"); - - // Check 1: offer_phases table exists - try { - const phaseCount = await db - .select({ count: sql`COUNT(*)` }) - .from(offer_phases); - results.push({ - name: "offer_phases table exists", - passed: true, - error: `(${phaseCount[0].count} rows)`, - }); - } catch (e) { - results.push({ - name: "offer_phases table exists", - passed: false, - error: (e as Error).message, - }); - } - - // Check 2: offer_phase_services table exists - try { - const serviceCount = await db - .select({ count: sql`COUNT(*)` }) - .from(offer_phase_services); - results.push({ - name: "offer_phase_services table exists", - passed: true, - error: `(${serviceCount[0].count} rows)`, - }); - } catch (e) { - results.push({ - name: "offer_phase_services table exists", - passed: false, - error: (e as Error).message, - }); - } - - // Check 3: quotes table exists - try { - const quoteCount = await db - .select({ count: sql`COUNT(*)` }) - .from(quotes); - results.push({ - name: "quotes table exists", - passed: true, - error: `(${quoteCount[0].count} rows)`, - }); - } catch (e) { - results.push({ - name: "quotes table exists", - passed: false, - error: (e as Error).message, - }); - } - - // Check 4: leads table exists - try { - const leadCount = await db - .select({ count: sql`COUNT(*)` }) - .from(leads); - results.push({ - name: "leads table exists", - passed: true, - error: `(${leadCount[0].count} rows)`, - }); - } catch (e) { - results.push({ - name: "leads table exists", - passed: false, - error: (e as Error).message, - }); - } - - // Check 5: quote_items.quote_id column exists - try { - const col = await db - .select({ column_name: sql`column_name` }) - .from( - sql`information_schema.columns` - ) - .where( - sql`table_name = 'quote_items' AND column_name = 'quote_id'` - ); - results.push({ - name: "quote_items.quote_id column exists", - passed: col.length > 0, - error: col.length > 0 ? undefined : "Column not found", - }); - } catch (e) { - results.push({ - name: "quote_items.quote_id column exists", - passed: false, - error: (e as Error).message, - }); - } - - // Check 6: quote_items.offer_micro_id column exists - try { - const col = await db - .select({ column_name: sql`column_name` }) - .from( - sql`information_schema.columns` - ) - .where( - sql`table_name = 'quote_items' AND column_name = 'offer_micro_id'` - ); - results.push({ - name: "quote_items.offer_micro_id column exists", - passed: col.length > 0, - error: col.length > 0 ? undefined : "Column not found", - }); - } catch (e) { - results.push({ - name: "quote_items.offer_micro_id column exists", - passed: false, - error: (e as Error).message, - }); - } - - // Check 7: quote_items.offer_phase_id column exists - try { - const col = await db - .select({ column_name: sql`column_name` }) - .from( - sql`information_schema.columns` - ) - .where( - sql`table_name = 'quote_items' AND column_name = 'offer_phase_id'` - ); - results.push({ - name: "quote_items.offer_phase_id column exists", - passed: col.length > 0, - error: col.length > 0 ? undefined : "Column not found", - }); - } catch (e) { - results.push({ - name: "quote_items.offer_phase_id column exists", - passed: false, - error: (e as Error).message, - }); - } - - // Check 8: projects.offer_id column exists - try { - const col = await db - .select({ column_name: sql`column_name` }) - .from( - sql`information_schema.columns` - ) - .where( - sql`table_name = 'projects' AND column_name = 'offer_id'` - ); - results.push({ - name: "projects.offer_id column exists", - passed: col.length > 0, - error: col.length > 0 ? undefined : "Column not found", - }); - } catch (e) { - results.push({ - name: "projects.offer_id column exists", - passed: false, - error: (e as Error).message, - }); - } - - // Check 9: projects.created_from_lead_id column exists - try { - const col = await db - .select({ column_name: sql`column_name` }) - .from( - sql`information_schema.columns` - ) - .where( - sql`table_name = 'projects' AND column_name = 'created_from_lead_id'` - ); - results.push({ - name: "projects.created_from_lead_id column exists", - passed: col.length > 0, - error: col.length > 0 ? undefined : "Column not found", - }); - } catch (e) { - results.push({ - name: "projects.created_from_lead_id column exists", - passed: false, - error: (e as Error).message, - }); - } - - // Check 10: phases.offer_phase_id column exists - try { - const col = await db - .select({ column_name: sql`column_name` }) - .from( - sql`information_schema.columns` - ) - .where( - sql`table_name = 'phases' AND column_name = 'offer_phase_id'` - ); - results.push({ - name: "phases.offer_phase_id column exists", - passed: col.length > 0, - error: col.length > 0 ? undefined : "Column not found", - }); - } catch (e) { - results.push({ - name: "phases.offer_phase_id column exists", - passed: false, - error: (e as Error).message, - }); - } - - // Print results - let allPassed = true; - console.log("Checks:\n"); - for (const result of results) { - const status = result.passed ? "✓" : "✗"; - const msg = result.error - ? ` ${result.error}` - : " passed"; - console.log(`${status} ${result.name}${msg}`); - if (!result.passed) allPassed = false; - } - - console.log( - `\n=== Result: ${allPassed ? "ALL CHECKS PASSED" : "SOME CHECKS FAILED"} ===` - ); - process.exit(allPassed ? 0 : 1); -} - -runChecks().catch((e) => { - console.error("Validation script error:", e); - process.exit(1); -}); diff --git a/scripts/validate-services-migration.ts b/scripts/validate-services-migration.ts deleted file mode 100644 index 70bad65..0000000 --- a/scripts/validate-services-migration.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { db } from "@/db"; -import { service_catalog, offer_services, services, quote_items, offer_micro_services } from "@/db/schema"; -import { sql, eq } from "drizzle-orm"; - -async function validate() { - let failures = 0; - - // Check 1: row counts match - const [catalogCount] = await db.select({ n: sql`count(*)::int` }).from(service_catalog); - const [offerCount] = await db.select({ n: sql`count(*)::int` }).from(offer_services); - const [migratedCatalog] = await db - .select({ n: sql`count(*)::int` }) - .from(services) - .where(eq(services.migrated_from, "service_catalog")); - const [migratedOffer] = await db - .select({ n: sql`count(*)::int` }) - .from(services) - .where(eq(services.migrated_from, "offer_services")); - - console.log(`service_catalog rows: ${catalogCount.n} | migrated: ${migratedCatalog.n}`); - console.log(`offer_services rows: ${offerCount.n} | migrated: ${migratedOffer.n}`); - - if (catalogCount.n !== migratedCatalog.n) { - console.log("FAIL: service_catalog row count does not match migrated count"); - failures++; - } else { - console.log("PASS: service_catalog fully migrated"); - } - - if (offerCount.n !== migratedOffer.n) { - console.log("FAIL: offer_services row count does not match migrated count"); - failures++; - } else { - console.log("PASS: offer_services fully migrated"); - } - - // Check 2: no orphaned migrated_id (every migrated_id from service_catalog still exists in service_catalog) - const orphanedCatalog = await db.execute(sql` - SELECT COUNT(*)::int AS n FROM services s - WHERE s.migrated_from = 'service_catalog' - AND NOT EXISTS (SELECT 1 FROM service_catalog sc WHERE sc.id = s.migrated_id) - `); - const orphanedCatalogCount = (orphanedCatalog as unknown as Array<{ n: number }>)[0]?.n ?? 0; - if (orphanedCatalogCount > 0) { - console.log(`FAIL: ${orphanedCatalogCount} services rows reference missing service_catalog ids`); - failures++; - } else { - console.log("PASS: no orphaned service_catalog references"); - } - - const orphanedOffer = await db.execute(sql` - SELECT COUNT(*)::int AS n FROM services s - WHERE s.migrated_from = 'offer_services' - AND NOT EXISTS (SELECT 1 FROM offer_services os WHERE os.id = s.migrated_id) - `); - const orphanedOfferCount = (orphanedOffer as unknown as Array<{ n: number }>)[0]?.n ?? 0; - if (orphanedOfferCount > 0) { - console.log(`FAIL: ${orphanedOfferCount} services rows reference missing offer_services ids`); - failures++; - } else { - console.log("PASS: no orphaned offer_services references"); - } - - // Check 3: existing quote_items.service_id still resolve in service_catalog (untouched FK — must remain valid) - const orphanedQuoteItems = await db.execute(sql` - SELECT COUNT(*)::int AS n FROM quote_items qi - WHERE qi.service_id IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM service_catalog sc WHERE sc.id = qi.service_id) - `); - const orphanedQuoteItemsCount = (orphanedQuoteItems as unknown as Array<{ n: number }>)[0]?.n ?? 0; - if (orphanedQuoteItemsCount > 0) { - console.log(`FAIL: ${orphanedQuoteItemsCount} quote_items reference missing service_catalog ids (pre-existing FK broken!)`); - failures++; - } else { - console.log("PASS: quote_items.service_id -> service_catalog FK intact (unchanged by this migration)"); - } - - // Check 4: existing offer_micro_services.service_id still resolve in offer_services (untouched FK — must remain valid) - const orphanedMicroServices = await db.execute(sql` - SELECT COUNT(*)::int AS n FROM offer_micro_services oms - WHERE NOT EXISTS (SELECT 1 FROM offer_services os WHERE os.id = oms.service_id) - `); - const orphanedMicroServicesCount = (orphanedMicroServices as unknown as Array<{ n: number }>)[0]?.n ?? 0; - if (orphanedMicroServicesCount > 0) { - console.log(`FAIL: ${orphanedMicroServicesCount} offer_micro_services reference missing offer_services ids (pre-existing FK broken!)`); - failures++; - } else { - console.log("PASS: offer_micro_services.service_id -> offer_services FK intact (unchanged by this migration)"); - } - - // Check 5: name collision report (informational) - const collisions = await db.execute(sql` - SELECT name, COUNT(*)::int AS n FROM services GROUP BY name HAVING COUNT(*) > 1 - `); - const collisionRows = collisions as unknown as Array<{ name: string; n: number }>; - if (collisionRows.length > 0) { - console.log(`WARNING: ${collisionRows.length} duplicate names remain in services (review):`, collisionRows); - } else { - console.log("PASS: no duplicate names in services"); - } - - // Check 6: informational — count of net-new (non-migrated) services - const [netNew] = await db - .select({ n: sql`count(*)::int` }) - .from(services) - .where(sql`migrated_from IS NULL`); - console.log(`INFO: ${netNew.n} net-new services created post-migration (migrated_from IS NULL)`); - - console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`); - process.exit(failures === 0 ? 0 : 1); -} - -validate().catch((err) => { - console.error("Validation failed:", err); - process.exit(1); -}); diff --git a/scripts/validate-tags-migration.ts b/scripts/validate-tags-migration.ts deleted file mode 100644 index 3c2d582..0000000 --- a/scripts/validate-tags-migration.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { db } from "@/db"; -import { services, tags } from "@/db/schema"; -import { eq, and, sql } from "drizzle-orm"; - -async function validate() { - let failures = 0; - - const [offerServicesCount] = await db - .select({ n: sql`count(*)::int` }) - .from(services) - .where(eq(services.migrated_from, "offer_services")); - - const [offerTagCount] = await db - .select({ n: sql`count(*)::int` }) - .from(tags) - .where(and(eq(tags.entity_type, "services"), eq(tags.name, "Offerta"))); - - console.log(`offer_services-derived services: ${offerServicesCount.n} | Offerta tags: ${offerTagCount.n}`); - - if (offerServicesCount.n !== offerTagCount.n) { - console.log("FAIL: Offerta tag count does not match offer_services-derived services count"); - failures++; - } else { - console.log("PASS: all offer_services-derived services have the Offerta tag"); - } - - const orphanedTags = await db.execute(sql` - SELECT COUNT(*)::int AS n FROM tags t - WHERE t.entity_type = 'services' - AND NOT EXISTS (SELECT 1 FROM services s WHERE s.id = t.entity_id) - `); - const orphanedTagsCount = (orphanedTags as unknown as Array<{ n: number }>)[0]?.n ?? 0; - if (orphanedTagsCount > 0) { - console.log(`FAIL: ${orphanedTagsCount} tags reference non-existent services`); - failures++; - } else { - console.log("PASS: no orphaned tag references"); - } - - const tagCounts = await db.execute(sql` - SELECT entity_type, COUNT(*)::int AS n FROM tags GROUP BY entity_type - `); - const counts = tagCounts as unknown as Array<{ entity_type: string; n: number }>; - for (const row of counts) { - console.log(`INFO: ${row.n} tags for entity_type=${row.entity_type}`); - } - - console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`); - process.exit(failures === 0 ? 0 : 1); -} - -validate().catch((err) => { - console.error("Validation failed:", err); - process.exit(1); -}); diff --git a/scripts/verify-12-03-actions.ts b/scripts/verify-12-03-actions.ts deleted file mode 100644 index 32f6adb..0000000 --- a/scripts/verify-12-03-actions.ts +++ /dev/null @@ -1,242 +0,0 @@ -// Phase 12 Plan 03 — Task 2 verification script for actions.ts additions. -// -// This project has no test runner configured (no `scripts.test` in package.json), -// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so -// this script does NOT execute any DB calls or server actions (server actions -// also require an Auth.js session, unavailable outside a request context). It -// typechecks against the real exports/types from -// src/app/admin/offers/actions.ts and documents the 9 expected behaviors from -// the plan's block, for manual run against a dev DB later if desired. -// -// Run (manual, dev DB only, inside a request context with admin session): -// npx tsx scripts/verify-12-03-actions.ts - -import type { SaveOfferEditorPayload } from "@/app/admin/offers/actions"; -import { - saveOfferEditor, - toggleOfferArchived, - addOfferTag, - removeOfferTag, - renameOfferOption, - createOfferMacro, -} from "@/app/admin/offers/actions"; - -// ── Test 1: tier_letter Zod validation ─────────────────────────────────────── -// saveOfferEditor(macroId, payload) rejects (throws) when payload.tiers contains -// a tier_letter not in ["A","B","C"] — Zod enum validation, defense-in-depth -// alongside the DB CHECK constraint from Plan 01. -async function test1_invalidTierLetter(macroId: string) { - const badPayload = { - internal_name: "Test", - public_name: "Test", - tiers: [ - { - tier_letter: "D", // invalid — not in ["A","B","C"] - internal_name: "Tier X", - public_name: "Tier X", - duration_months: 3, - assignedServiceIds: [], - }, - ], - tipoTags: [], - obiettivoTags: [], - } as unknown as SaveOfferEditorPayload; - // Expected: saveOfferEditor(macroId, badPayload) throws (Zod enum mismatch - // surfaces as parsed.error.issues[0].message before any DB write). - return () => saveOfferEditor(macroId, badPayload); -} - -// ── Test 2: macro scalar field update ──────────────────────────────────────── -// saveOfferEditor updates offer_macros scalars (internal_name, description, -// category, ticket, cliente_ideale, risultato, tempo, pain, metodo) in one -// db.update(offer_macros)...where(eq(id, macroId)). -async function test2_macroScalars(macroId: string) { - const payload: SaveOfferEditorPayload = { - internal_name: "Offerta Test", - public_name: "Offerta Pubblica", - description: "Una descrizione", - category: "Signature Offer", - ticket: "Mid Ticket", - cliente_ideale: "Coach", - risultato: "Lead qualificati", - tempo: "90 giorni", - pain: "Mancanza di processo", - metodo: "Sistema X", - tiers: [], - tipoTags: [], - obiettivoTags: [], - }; - // Expected: after call, offer_macros row for macroId has all 9 scalar - // fields set to the payload values. - return () => saveOfferEditor(macroId, payload); -} - -// ── Test 3: tier upsert (update existing id, insert when no id) ────────────── -// If tier.id is provided -> db.update(offer_micros).set({...}); if no id -> -// db.insert(offer_micros).values({..., macro_id: macroId}). -async function test3_tierUpsert(macroId: string, existingTierId: string) { - const payload: SaveOfferEditorPayload = { - internal_name: "Offerta Test", - public_name: "Offerta Pubblica", - tiers: [ - { - id: existingTierId, // -> update path - tier_letter: "A", - internal_name: "Tier A", - public_name: "Tier A Pubblico", - duration_months: 3, - public_price: 1500, - assignedServiceIds: [], - }, - { - // no id -> insert path (supports macros with < 3 tiers) - tier_letter: "B", - internal_name: "Tier B", - public_name: "Tier B Pubblico", - duration_months: 6, - public_price: 2500, - assignedServiceIds: [], - }, - ], - tipoTags: [], - obiettivoTags: [], - }; - // Expected: existingTierId row updated in place; a new offer_micros row - // created for tier B with macro_id = macroId. - return () => saveOfferEditor(macroId, payload); -} - -// ── Test 4: offer_tier_services delete-then-reinsert ───────────────────────── -// A tier with assignedServiceIds: ["svc1","svc2"] -> after call, -// offer_tier_services has exactly 2 rows for that tier_id, both svc1/svc2. -async function test4_tierServicesReplace(macroId: string, tierId: string) { - const payload: SaveOfferEditorPayload = { - internal_name: "Offerta Test", - public_name: "Offerta Pubblica", - tiers: [ - { - id: tierId, - tier_letter: "A", - internal_name: "Tier A", - public_name: "Tier A Pubblico", - duration_months: 3, - assignedServiceIds: ["svc1", "svc2"], - }, - ], - tipoTags: [], - obiettivoTags: [], - }; - // Expected: offer_tier_services has exactly 2 rows where tier_id === tierId, - // service_id IN ("svc1", "svc2"). - return () => saveOfferEditor(macroId, payload); -} - -// ── Test 5: Tipo/Obiettivo tags delete-then-reinsert ───────────────────────── -// saveOfferEditor replaces tags where entity_type IN -// ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId. -async function test5_tagsReplace(macroId: string) { - const payload: SaveOfferEditorPayload = { - internal_name: "Offerta Test", - public_name: "Offerta Pubblica", - tiers: [], - tipoTags: ["Audit", "Coaching"], - obiettivoTags: ["Lead Generation"], - }; - // Expected: tags table has 2 rows entity_type="offer_macros.tipo" (Audit, - // Coaching) and 1 row entity_type="offer_macros.obiettivo" (Lead - // Generation), all entity_id = macroId; any prior rows for this macroId + - // these two entity_types are removed first. - return () => saveOfferEditor(macroId, payload); -} - -// ── Test 6: toggleOfferArchived ────────────────────────────────────────────── -// toggleOfferArchived(macroId, archived: boolean) sets offer_macros.is_archived = archived. -async function test6_toggleArchived(macroId: string) { - // Expected: offer_macros.is_archived === true after toggleOfferArchived(macroId, true); - // === false after toggleOfferArchived(macroId, false). - return [ - () => toggleOfferArchived(macroId, true), - () => toggleOfferArchived(macroId, false), - ]; -} - -// ── Test 7: addOfferTag / removeOfferTag ───────────────────────────────────── -// addOfferTag(dimension, macroId, value) / removeOfferTag(dimension, macroId, -// value) work for dimension in ("tipo","obiettivo"). Reject any other -// dimension value. -async function test7_tagCrud(macroId: string) { - // Expected: addOfferTag("tipo", macroId, "Audit") inserts a tags row - // (entity_type="offer_macros.tipo", entity_id=macroId, name="Audit"); - // removeOfferTag("tipo", macroId, "Audit") deletes it. - // addOfferTag("invalid" as any, macroId, "x") throws. - return { - addTipo: () => addOfferTag("tipo", macroId, "Audit"), - removeTipo: () => removeOfferTag("tipo", macroId, "Audit"), - addObiettivo: () => addOfferTag("obiettivo", macroId, "Lead Generation"), - removeObiettivo: () => removeOfferTag("obiettivo", macroId, "Lead Generation"), - // @ts-expect-error -- intentional invalid dimension to verify the throw path - invalid: () => addOfferTag("invalid", macroId, "x"), - }; -} - -// ── Test 8: renameOfferOption ──────────────────────────────────────────────── -// renameOfferOption(field, oldValue, newValue) for field in ("categoria", -// "ticket") updates offer_macros.category/ticket for all matching rows; for -// field in ("tipo","obiettivo") updates tags.name. -async function test8_renameOption() { - // Expected: - // renameOfferOption("categoria", "Entry Offer", "Offerta Base") updates - // all offer_macros rows where category = "Entry Offer" -> "Offerta Base" - // renameOfferOption("ticket", "Low Ticket", "Ticket Basso") updates - // all offer_macros rows where ticket = "Low Ticket" -> "Ticket Basso" - // renameOfferOption("tipo", "Audit", "Diagnosi") updates all tags rows - // where entity_type="offer_macros.tipo" and name="Audit" -> "Diagnosi" - // renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead") - // updates tags rows where entity_type="offer_macros.obiettivo" - return { - renameCategoria: () => renameOfferOption("categoria", "Entry Offer", "Offerta Base"), - renameTicket: () => renameOfferOption("ticket", "Low Ticket", "Ticket Basso"), - renameTipo: () => renameOfferOption("tipo", "Audit", "Diagnosi"), - renameObiettivo: () => renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead"), - }; -} - -// ── Test 9: createOfferMacro ───────────────────────────────────────────────── -// createOfferMacro(formData) creates a new offer_macros row from internal_name -// (required) + optional public_name/description/category. If public_name is -// omitted, defaults to internal_name. -async function test9_createOfferMacro() { - const fd1 = new FormData(); - fd1.append("internal_name", "Nuova Offerta Interna"); - fd1.append("description", "Descrizione breve"); - fd1.append("category", "Entry Offer"); - // Expected: new offer_macros row with internal_name="Nuova Offerta Interna", - // public_name="Nuova Offerta Interna" (defaulted), description set, - // category="Entry Offer". - - const fd2 = new FormData(); - fd2.append("internal_name", "Altra Offerta"); - fd2.append("public_name", "Nome Pubblico Esplicito"); - // Expected: new offer_macros row with public_name="Nome Pubblico Esplicito" - // (not defaulted, since explicitly provided). - - return { - withDefaultPublicName: () => createOfferMacro(fd1), - withExplicitPublicName: () => createOfferMacro(fd2), - }; -} - -// Not executed automatically (production DB + no request context for -// requireAdmin) — typecheck-only verification. - -export { - test1_invalidTierLetter, - test2_macroScalars, - test3_tierUpsert, - test4_tierServicesReplace, - test5_tagsReplace, - test6_toggleArchived, - test7_tagCrud, - test8_renameOption, - test9_createOfferMacro, -}; diff --git a/scripts/verify-12-03-queries.ts b/scripts/verify-12-03-queries.ts deleted file mode 100644 index 0548077..0000000 --- a/scripts/verify-12-03-queries.ts +++ /dev/null @@ -1,112 +0,0 @@ -// Phase 12 Plan 03 — Task 1 verification script for offer-queries.ts additions. -// -// This project has no test runner configured (no `scripts.test` in package.json), -// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so -// this script does NOT execute any DB calls. It typechecks against the real -// exports/types from src/lib/offer-queries.ts and documents the 5 expected -// behaviors from the plan's block, for manual run against a dev DB -// later if desired. -// -// Run (manual, dev DB only): npx tsx scripts/verify-12-03-queries.ts - -import { - getOfferEditorData, - getOfferListCards, - getOfferFieldOptions, - type OfferEditorData, - type OfferListCard, - type OfferFieldOptions, - type OfferTierData, -} from "@/lib/offer-queries"; - -// ── Test 1: getOfferListCards() ────────────────────────────────────────────── -// Input: 2 offer_macros rows (1 with is_archived=true, 1 with is_archived=false). -// Expected: array of length 2, both present, ordered by sort_order, each row -// shaped { id, internal_name, description, category, is_archived }. Archived -// rows ARE returned — filtering is client-side (UI-SPEC "Mostra offerte archiviate"). -async function test1_getOfferListCards() { - const cards: OfferListCard[] = await getOfferListCards(); - // Expected assertions (manual): - // cards.length === 2 - // cards.some(c => c.is_archived === true) - // cards.some(c => c.is_archived === false) - return cards; -} - -// ── Test 2: getOfferEditorData(macroId) — macro with 0 tiers ───────────────── -// Input: macro with category = "Signature Offer", 0 offer_micros rows, 3 -// services with category="Signature Offer" + 2 with a different category. -// Expected: { macro: {...}, tiers: [], availableServices: [...3 matching...], -// tipoTags: [], obiettivoTags: [] } -async function test2_emptyTiers(macroId: string) { - const data: OfferEditorData | null = await getOfferEditorData(macroId); - // Expected assertions (manual): - // data !== null - // data.tiers.length === 0 - // data.availableServices.length === 3 (only services.category === macro.category) - // data.tipoTags.length === 0 && data.obiettivoTags.length === 0 - return data; -} - -// ── Test 3: getOfferEditorData(macroId) — 3 tiers, tier A has 2 services ───── -// Input: macro with 3 offer_micros rows (tier_letter A/B/C) and 2 -// offer_tier_services rows assigned to tier A. -// Expected: tiers sorted A->B->C; each tier has -// { id, tier_letter, public_price, assignedServiceIds, servicesTotal }; -// tier A's servicesTotal === sum(unit_price) of its 2 assigned services; -// tiers B/C have assignedServiceIds === [] and servicesTotal === "0". -async function test3_tiersWithServices(macroId: string) { - const data: OfferEditorData | null = await getOfferEditorData(macroId); - // Expected assertions (manual): - // data.tiers.map(t => t.tier_letter) === ["A", "B", "C"] - // data.tiers[0].assignedServiceIds.length === 2 - // Number(data.tiers[0].servicesTotal) === sum of the 2 services' unit_price - // data.tiers[1].assignedServiceIds === [] && data.tiers[1].servicesTotal === "0" - // data.tiers[2].assignedServiceIds === [] && data.tiers[2].servicesTotal === "0" - const tierA: OfferTierData | undefined = data?.tiers[0]; - return { data, tierA }; -} - -// ── Test 4: getOfferEditorData(macroId) — tipoTags/obiettivoTags ───────────── -// Input: 2 "tipo" tags + 1 "obiettivo" tag exist for the macro (entity_type = -// "offer_macros.tipo" / "offer_macros.obiettivo", entity_id = macroId). -// Expected: tipoTags.length === 2, obiettivoTags.length === 1. -async function test4_tags(macroId: string) { - const data: OfferEditorData | null = await getOfferEditorData(macroId); - // Expected assertions (manual): - // data?.tipoTags.length === 2 - // data?.obiettivoTags.length === 1 - return data; -} - -// ── Test 5: getOfferFieldOptions() ─────────────────────────────────────────── -// Input: 2 macros with category "Entry Offer"/"Signature Offer", 1 "tipo" tag -// "Audit", 1 "obiettivo" tag "Lead Generation". -// Expected: categoria contains both values; tipo === ["Audit"]; -// obiettivo === ["Lead Generation"]. -async function test5_fieldOptions() { - const options: OfferFieldOptions = await getOfferFieldOptions(); - // Expected assertions (manual): - // options.categoria includes "Entry Offer" and "Signature Offer" - // options.tipo includes "Audit" - // options.obiettivo includes "Lead Generation" - return options; -} - -// Not executed automatically (production DB) — typecheck-only verification. -// To run manually against a dev DB: uncomment the call below. -// void (async () => { -// await test1_getOfferListCards(); -// await test2_emptyTiers("macro-id"); -// await test3_tiersWithServices("macro-id"); -// await test4_tags("macro-id"); -// await test5_fieldOptions(); -// })(); - -export { - test1_getOfferListCards, - test2_emptyTiers, - test3_tiersWithServices, - test4_tags, - test5_fieldOptions, -};