--- 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`