Files
clienthub/.planning/phases/01-foundation-client-dashboard/01-02-PLAN.md
T
Simone Cavalli 2123dc9d00 fix(01-foundation): resolve plan checker blockers — 3 fixes across 01-02, 01-03, 01-04
- 01-02: wave corrected from 1 to 2 (has depends_on: ["01-01"])
- 01-03: middleware rewritten to Edge-compatible fetch pattern; internal API route
  app/api/internal/validate-token/route.ts handles DB query in Node.js runtime;
  tasks/deliverables queries scoped with inArray(); accepted_total null-coalesced
- 01-04: Task 1 and Task 6 merged → 5 tasks total (was 6, exceeded threshold)
- STATE.md: updated to reflect Phase 1 planning verified, ready for execution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:20:50 +02:00

15 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation-client-dashboard 02 execute 2
01-01
src/db/schema.ts
drizzle.config.ts
.env.local
true
DASH-01
DASH-02
DASH-03
DASH-04
truths artifacts key_links
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
path provides min_lines contains
src/db/schema.ts Complete Drizzle ORM schema definition for all entities 200 export const clients = pgTable
path provides contains
drizzle.config.ts Drizzle Kit configuration pointing to src/db/schema.ts schema:
path provides min_files
src/db/migrations/ Migration files generated by drizzle-kit 1
from to via pattern
src/db/schema.ts clients table pgTable definition export const clients.*pgTable
from to via pattern
src/db/schema.ts token field uuid().unique() token.*uuid.*unique
from to via pattern
drizzle-kit push Postgres on Coolify DATABASE_URL 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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)

<threat_model>

Trust Boundaries

Boundary Description
Migration files → Database Schema migrations are deployed via drizzle-kit push; any schema change is version-controlled
Schema definition → ORM runtime TypeScript schema is the source of truth; Drizzle generates types from schema, not from introspection
Token field → Access control Token is marked unique and separate from PK; enforced by DB constraints

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-02-001 Tampering Token field uniqueness mitigate Database enforces UNIQUE constraint on token field; no client can have duplicate token
T-02-002 Information Disclosure Schema version history accept Migrations are version-controlled in git; leaking migration files does not expose secrets (passwords in .env.local only)
T-02-003 Denial of Service quote_items table accept Admin-only; client API never queries it; no data loss from client-side DOS attacks

</threat_model>

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

<success_criteria>

  • Drizzle schema is defined and exported from src/db/schema.ts
  • All 11 tables are created in Coolify Postgres
  • Token field is unique and separate from id
  • Migrations are version-controlled in git
  • TypeScript types are available for import in API routes
  • Ready to proceed to Plan 03 (Middleware + Client Portal route) </success_criteria>
After completion, create `.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md`