2123dc9d00
- 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>
370 lines
15 KiB
Markdown
370 lines
15 KiB
Markdown
---
|
|
phase: "01-foundation-client-dashboard"
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- "01-01"
|
|
files_modified:
|
|
- src/db/schema.ts
|
|
- drizzle.config.ts
|
|
- .env.local
|
|
autonomous: true
|
|
requirements:
|
|
- DASH-01
|
|
- DASH-02
|
|
- DASH-03
|
|
- DASH-04
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Drizzle schema is complete and matches the data model from ARCHITECTURE.md"
|
|
- "All 11 tables are defined: clients, phases, tasks, deliverables, comments, payments, documents, notes, service_catalog, quote_items"
|
|
- "Token field on clients is a separate UUID, not the primary key"
|
|
- "approved_at on deliverables is TIMESTAMPTZ"
|
|
- "drizzle-kit push has been run and database schema is live"
|
|
- "TypeScript types are exported from schema.ts for use in API routes"
|
|
artifacts:
|
|
- path: "src/db/schema.ts"
|
|
provides: "Complete Drizzle ORM schema definition for all entities"
|
|
min_lines: 200
|
|
contains: "export const clients = pgTable"
|
|
- path: "drizzle.config.ts"
|
|
provides: "Drizzle Kit configuration pointing to src/db/schema.ts"
|
|
contains: "schema:"
|
|
- path: "src/db/migrations/"
|
|
provides: "Migration files generated by drizzle-kit"
|
|
min_files: 1
|
|
key_links:
|
|
- from: "src/db/schema.ts"
|
|
to: "clients table"
|
|
via: "pgTable definition"
|
|
pattern: "export const clients.*pgTable"
|
|
- from: "src/db/schema.ts"
|
|
to: "token field"
|
|
via: "uuid().unique()"
|
|
pattern: "token.*uuid.*unique"
|
|
- from: "drizzle-kit push"
|
|
to: "Postgres on Coolify"
|
|
via: "DATABASE_URL"
|
|
pattern: "DATABASE_URL"
|
|
|
|
---
|
|
|
|
<objective>
|
|
**Database Schema + Drizzle Migrations:** Define the complete data model in Drizzle ORM, generate database migrations, and push the schema to Coolify Postgres. This plan creates the schema that all subsequent plans depend on.
|
|
|
|
Purpose: Establish the single source of truth for data shape. Enforces critical decisions: token as separate field, accepted_total denormalized, approved_at immutable, ClientView vs. AdminView separation in queries.
|
|
|
|
Output: `src/db/schema.ts` with all 11 tables fully defined, migration files, and Postgres schema live on Coolify.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/research/ARCHITECTURE.md
|
|
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md
|
|
@.planning/phases/01-foundation-client-dashboard/01-01-SUMMARY.md
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create Drizzle schema definition (src/db/schema.ts) with all 11 tables</name>
|
|
<files>
|
|
src/db/schema.ts
|
|
</files>
|
|
<read_first>
|
|
.planning/research/ARCHITECTURE.md (Data Model section, lines 69-142)
|
|
</read_first>
|
|
<action>
|
|
Create `src/db/schema.ts` with the following tables (exact order, exact field names):
|
|
|
|
```typescript
|
|
import { pgTable, text, uuid, integer, numeric, timestamp, boolean, unique, index } from 'drizzle-orm/pg-core';
|
|
import { relations } from 'drizzle-orm';
|
|
import { nanoid } from 'nanoid';
|
|
|
|
// ============ CLIENTS ============
|
|
export const clients = pgTable('clients', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
name: text('name').notNull(),
|
|
brand_name: text('brand_name').notNull(),
|
|
brief: text('brief').notNull(),
|
|
token: uuid('token').notNull().unique().defaultValue(nanoid()),
|
|
accepted_total: numeric('accepted_total', { precision: 10, scale: 2 }).default('0'),
|
|
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// ============ PHASES ============
|
|
export const phases = pgTable('phases', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
|
title: text('title').notNull(),
|
|
sort_order: integer('sort_order').notNull().default(0),
|
|
status: text('status').notNull().default('upcoming'), // upcoming | active | done
|
|
});
|
|
|
|
// ============ TASKS ============
|
|
export const tasks = pgTable('tasks', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
phase_id: uuid('phase_id').notNull().references(() => phases.id, { onDelete: 'cascade' }),
|
|
title: text('title').notNull(),
|
|
description: text('description'),
|
|
status: text('status').notNull().default('todo'), // todo | in_progress | done
|
|
sort_order: integer('sort_order').notNull().default(0),
|
|
});
|
|
|
|
// ============ DELIVERABLES ============
|
|
export const deliverables = pgTable('deliverables', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
task_id: uuid('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
|
|
title: text('title').notNull(),
|
|
url: text('url'),
|
|
status: text('status').notNull().default('pending'), // pending | submitted | approved
|
|
approved_at: timestamp('approved_at', { withTimezone: true }), // immutable audit trail
|
|
});
|
|
|
|
// ============ COMMENTS ============
|
|
export const comments = pgTable('comments', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
entity_type: text('entity_type').notNull(), // task | deliverable
|
|
entity_id: uuid('entity_id').notNull(),
|
|
author: text('author').notNull(), // client | admin
|
|
body: text('body').notNull(),
|
|
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// ============ PAYMENTS ============
|
|
export const payments = pgTable('payments', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
|
label: text('label').notNull(), // "Acconto 50%" | "Saldo 50%"
|
|
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
|
|
status: text('status').notNull().default('da_saldare'), // da_saldare | inviata | saldato
|
|
paid_at: timestamp('paid_at', { withTimezone: true }),
|
|
});
|
|
|
|
// ============ DOCUMENTS ============
|
|
export const documents = pgTable('documents', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
|
label: text('label').notNull(),
|
|
url: text('url').notNull(),
|
|
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// ============ NOTES (Decision Log) ============
|
|
export const notes = pgTable('notes', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
|
body: text('body').notNull(),
|
|
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
// ============ SERVICE CATALOG ============
|
|
export const service_catalog = pgTable('service_catalog', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
name: text('name').notNull(),
|
|
description: text('description'),
|
|
unit_price: numeric('unit_price', { precision: 10, scale: 2 }).notNull(),
|
|
active: boolean('active').notNull().default(true),
|
|
});
|
|
|
|
// ============ QUOTE ITEMS ============
|
|
export const quote_items = pgTable('quote_items', {
|
|
id: uuid('id').primaryKey().defaultValue(nanoid()),
|
|
client_id: uuid('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
|
|
service_id: uuid('service_id').notNull().references(() => service_catalog.id, { onDelete: 'restrict' }),
|
|
quantity: numeric('quantity', { precision: 10, scale: 2 }).notNull(),
|
|
unit_price: numeric('unit_price', { precision: 10, scale: 2 }).notNull(),
|
|
subtotal: numeric('subtotal', { precision: 10, scale: 2 }).notNull(),
|
|
});
|
|
|
|
// ============ RELATIONS ============
|
|
export const clientsRelations = relations(clients, ({ many }) => ({
|
|
phases: many(phases),
|
|
payments: many(payments),
|
|
documents: many(documents),
|
|
notes: many(notes),
|
|
quote_items: many(quote_items),
|
|
}));
|
|
|
|
export const phasesRelations = relations(phases, ({ one, many }) => ({
|
|
client: one(clients, { fields: [phases.client_id], references: [clients.id] }),
|
|
tasks: many(tasks),
|
|
}));
|
|
|
|
export const tasksRelations = relations(tasks, ({ one, many }) => ({
|
|
phase: one(phases, { fields: [tasks.phase_id], references: [phases.id] }),
|
|
deliverables: many(deliverables),
|
|
}));
|
|
|
|
export const deliverablesRelations = relations(deliverables, ({ one }) => ({
|
|
task: one(tasks, { fields: [deliverables.task_id], references: [tasks.id] }),
|
|
}));
|
|
```
|
|
|
|
Notes:
|
|
- Use `nanoid()` for all UUID primary keys (not SQL-generated UUIDs) — this ensures consistent, cryptographically secure IDs
|
|
- Token is `uuid().notNull().unique()` — separate from id, rotatable
|
|
- `approved_at` is nullable (no approval initially)
|
|
- Relations use cascading deletes for data integrity
|
|
- All timestamp fields use `withTimezone: true`
|
|
</action>
|
|
<verify>
|
|
<automated>test -f src/db/schema.ts && echo "schema.ts exists"</automated>
|
|
<automated>grep -c "export const" src/db/schema.ts | grep -q "1[1-9]\|2[0-9]" && echo "Multiple table exports found"</automated>
|
|
<automated>grep -q "token.*uuid.*unique" src/db/schema.ts && echo "Token field is separate and unique"</automated>
|
|
<automated>grep -q "approved_at.*timestamp" src/db/schema.ts && echo "approved_at field exists"</automated>
|
|
<automated>grep -q "accepted_total" src/db/schema.ts && echo "accepted_total denormalized field exists"</automated>
|
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "TypeScript errors found" || echo "TypeScript compiles"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `src/db/schema.ts` exists with all 11 tables defined
|
|
- All table exports are present: clients, phases, tasks, deliverables, comments, payments, documents, notes, service_catalog, quote_items
|
|
- Token field is separate from id PK and marked as unique
|
|
- Relations are defined for all foreign keys
|
|
- TypeScript compiles without errors
|
|
</acceptance_criteria>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create drizzle.config.ts and generate migrations</name>
|
|
<files>
|
|
drizzle.config.ts
|
|
src/db/migrations/*
|
|
</files>
|
|
<read_first>
|
|
src/db/schema.ts
|
|
.env.local
|
|
</read_first>
|
|
<action>
|
|
Create `drizzle.config.ts` in project root:
|
|
|
|
```typescript
|
|
import type { Config } from 'drizzle-kit';
|
|
|
|
export default {
|
|
schema: './src/db/schema.ts',
|
|
out: './src/db/migrations',
|
|
driver: 'pg',
|
|
dbCredentials: {
|
|
connectionString: process.env.DATABASE_URL!,
|
|
},
|
|
} satisfies Config;
|
|
```
|
|
|
|
Run migration generation:
|
|
```
|
|
npx drizzle-kit generate
|
|
```
|
|
|
|
This creates `src/db/migrations/` directory with a numbered migration file (e.g., `0000_initial_schema.sql`).
|
|
|
|
Verify the generated SQL contains:
|
|
- All 11 CREATE TABLE statements
|
|
- Foreign key constraints
|
|
- Unique constraints on token
|
|
</action>
|
|
<verify>
|
|
<automated>test -f drizzle.config.ts && echo "drizzle.config.ts created"</automated>
|
|
<automated>test -d src/db/migrations && ls src/db/migrations/*.sql 2>/dev/null | wc -l | grep -q "[1-9]" && echo "Migration files generated"</automated>
|
|
<automated>grep -l "CREATE TABLE" src/db/migrations/*.sql | wc -l | grep -q "[1-9]" && echo "SQL migration contains CREATE TABLE"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `drizzle.config.ts` exists with correct driver (pg) and schema path
|
|
- `src/db/migrations/` directory exists with at least one .sql file
|
|
- Generated SQL file contains CREATE TABLE statements for all 11 tables
|
|
</acceptance_criteria>
|
|
</task>
|
|
|
|
<task type="auto" gate="blocking">
|
|
<name>Task 3: [BLOCKING] Run drizzle-kit push to apply schema to Coolify Postgres</name>
|
|
<files>
|
|
None (schema is pushed to DB, not local files)
|
|
</files>
|
|
<read_first>
|
|
.env.local (verify DATABASE_URL is set)
|
|
src/db/migrations/ (ensure migrations exist)
|
|
</read_first>
|
|
<action>
|
|
Before running push, verify DATABASE_URL is set in .env.local:
|
|
```
|
|
cat .env.local | grep DATABASE_URL
|
|
```
|
|
|
|
If DATABASE_URL is not yet available (Coolify not configured), STOP here and ask executor to provide Coolify credentials. This task cannot proceed without a valid connection string.
|
|
|
|
Once DATABASE_URL is confirmed:
|
|
```
|
|
npx drizzle-kit push
|
|
```
|
|
|
|
Drizzle will connect to the database and apply all migrations.
|
|
|
|
If push succeeds, you will see:
|
|
```
|
|
✓ All migrations have been successfully applied
|
|
```
|
|
|
|
If the database schema was already created, drizzle-kit will detect it and skip unchanged tables.
|
|
</action>
|
|
<verify>
|
|
<automated>if grep -q "^DATABASE_URL=postgresql://" .env.local; then echo "DATABASE_URL is set"; else echo "DATABASE_URL NOT SET"; fi</automated>
|
|
<automated>npx drizzle-kit push 2>&1 | grep -q "successfully\|already\|applied" && echo "Schema push completed"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- DATABASE_URL env var is set in .env.local
|
|
- `npx drizzle-kit push` runs without connection errors
|
|
- Schema is created in Coolify Postgres (all 11 tables exist)
|
|
- Executor can confirm with: `npx drizzle-kit introspect` (shows all tables)
|
|
</acceptance_criteria>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Migration files → Database | Schema migrations are deployed via drizzle-kit push; any schema change is version-controlled |
|
|
| Schema definition → ORM runtime | TypeScript schema is the source of truth; Drizzle generates types from schema, not from introspection |
|
|
| Token field → Access control | Token is marked unique and separate from PK; enforced by DB constraints |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-02-001 | Tampering | Token field uniqueness | mitigate | Database enforces UNIQUE constraint on token field; no client can have duplicate token |
|
|
| T-02-002 | Information Disclosure | Schema version history | accept | Migrations are version-controlled in git; leaking migration files does not expose secrets (passwords in .env.local only) |
|
|
| T-02-003 | Denial of Service | quote_items table | accept | Admin-only; client API never queries it; no data loss from client-side DOS attacks |
|
|
|
|
</threat_model>
|
|
|
|
<verification>
|
|
After plan execution:
|
|
1. Run `npx drizzle-kit push` → "successfully applied" message
|
|
2. Run `npx drizzle-kit introspect` → lists all 11 tables
|
|
3. Check `src/db/migrations/` → at least one .sql file exists
|
|
4. Check `src/db/schema.ts` → all tables are exported
|
|
5. Verify TypeScript: `npm run build` → no errors
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Drizzle schema is defined and exported from `src/db/schema.ts`
|
|
- All 11 tables are created in Coolify Postgres
|
|
- Token field is unique and separate from id
|
|
- Migrations are version-controlled in git
|
|
- TypeScript types are available for import in API routes
|
|
- Ready to proceed to Plan 03 (Middleware + Client Portal route)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md`
|
|
</output>
|