81c667838f
Create comprehensive phase plans for Foundation & Client Dashboard: - 01-01-PLAN.md: Walking Skeleton (Next.js 15 bootstrap + DB connection) - 01-02-PLAN.md: Database schema (11 tables, Drizzle ORM, drizzle-kit push) - 01-03-PLAN.md: Middleware token validation + ClientView type + data fetching - 01-04-PLAN.md: Client dashboard UI (header, timeline, progress, payments, docs, notes) - 01-05-PLAN.md: Seed script + DNS CNAME configuration Also create SKELETON.md documenting locked architectural decisions for all future phases: - Next.js 15 + Drizzle + postgres-js driver (Coolify Postgres) - Token as separate rotatable field (not PK) - ClientView enforcement (no quote_items exposed to client API) - Approved_at immutable audit trail - Two independent auth systems (client token + admin session) - Vercel deployment with custom domain Update ROADMAP.md to mark Phase 1 as planned (5 plans created) and ready for execution. All plans follow MVP vertical-slice structure with 2-3 tasks per plan. Walking Skeleton proves the entire stack works end-to-end. Requirements mapping: DASH-01 through DASH-04, DASH-07 through DASH-10 covered. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
522 lines
18 KiB
Markdown
522 lines
18 KiB
Markdown
---
|
|
phase: "01-foundation-client-dashboard"
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- "01-01"
|
|
- "01-02"
|
|
files_modified:
|
|
- src/middleware.ts
|
|
- src/lib/client-view.ts
|
|
- app/c/[token]/page.tsx
|
|
- app/c/[token]/layout.tsx
|
|
autonomous: true
|
|
requirements:
|
|
- DASH-01
|
|
- DASH-02
|
|
- DASH-03
|
|
- DASH-04
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Middleware validates token at edge and returns 404 if token not found"
|
|
- "Client can open /c/[token] without login"
|
|
- "Server Component fetches client data from DB via token"
|
|
- "ClientView type ensures quote_items is never exposed to client API"
|
|
- "All phase, task, payment, document, and note data is fetched and passed to UI"
|
|
- "TypeScript types are exported for downstream UI rendering"
|
|
artifacts:
|
|
- path: "src/middleware.ts"
|
|
provides: "Token validation at Next.js edge middleware"
|
|
contains: "function middleware"
|
|
- path: "src/lib/client-view.ts"
|
|
provides: "Client-safe type definitions and query functions"
|
|
contains: "ClientView"
|
|
- path: "app/c/[token]/page.tsx"
|
|
provides: "Server Component rendering client dashboard"
|
|
min_lines: 30
|
|
contains: "export default async function"
|
|
- path: "app/c/[token]/layout.tsx"
|
|
provides: "Layout for token-authenticated routes"
|
|
min_lines: 10
|
|
key_links:
|
|
- from: "src/middleware.ts"
|
|
to: "Database query for token validation"
|
|
via: "db.select().from(clients).where(eq(clients.token, token))"
|
|
pattern: "clients\\.token"
|
|
- from: "app/c/[token]/page.tsx"
|
|
to: "src/lib/client-view.ts"
|
|
via: "import { getClientView }"
|
|
pattern: "getClientView"
|
|
- from: "ClientView type"
|
|
to: "Rendering props"
|
|
via: "ensures no quote_items"
|
|
pattern: "quote_items"
|
|
|
|
---
|
|
|
|
<objective>
|
|
**Token Middleware + Client Portal Data Layer:** Create Next.js middleware to validate client tokens at the edge, build the ClientView type system that enforces ClientView vs. AdminView separation, and create a Server Component that fetches and prepares all client dashboard data without exposing admin secrets (quote_items, service prices).
|
|
|
|
Purpose: Establish the secure client access pattern: middleware validates token → Server Component fetches data → UI receives ClientView shape only. This prevents accidental exposure of admin data to clients.
|
|
|
|
Output: Fully functional `/c/[token]` route that fetches real client data and prepares it for rendering. No client-side waterfalls.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/research/ARCHITECTURE.md (Data Flow section, lines 29-50)
|
|
@.planning/research/PITFALLS.md (Pitfall 2: Client API Exposes Admin Data, lines 26-38)
|
|
@.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md
|
|
@.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create src/middleware.ts to validate client tokens at the edge</name>
|
|
<files>
|
|
src/middleware.ts
|
|
</files>
|
|
<read_first>
|
|
src/db/schema.ts (clients table definition)
|
|
package.json (verify Next.js version)
|
|
</read_first>
|
|
<action>
|
|
Create `src/middleware.ts` at project root (NOT in src/app):
|
|
|
|
```typescript
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { eq } from 'drizzle-orm';
|
|
import { db } from '@/db';
|
|
import { clients } from '@/db/schema';
|
|
|
|
export async function middleware(request: NextRequest) {
|
|
const pathname = request.nextUrl.pathname;
|
|
|
|
// Only validate client portal routes /c/[token]/*
|
|
if (!pathname.startsWith('/c/')) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Extract token from path: /c/[token]/...
|
|
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
|
if (!tokenMatch) {
|
|
return NextResponse.rewrite(new URL('/404', request.url), { status: 404 });
|
|
}
|
|
|
|
const token = tokenMatch[1];
|
|
|
|
try {
|
|
// Check if token exists in database
|
|
const client = await db
|
|
.select({ id: clients.id })
|
|
.from(clients)
|
|
.where(eq(clients.token, token))
|
|
.limit(1);
|
|
|
|
if (client.length === 0) {
|
|
return NextResponse.rewrite(new URL('/404', request.url), { status: 404 });
|
|
}
|
|
|
|
// Token is valid, proceed
|
|
return NextResponse.next();
|
|
} catch (error) {
|
|
console.error('Middleware error validating token:', error);
|
|
return NextResponse.rewrite(new URL('/500', request.url), { status: 500 });
|
|
}
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ['/c/:path*'],
|
|
};
|
|
```
|
|
|
|
Key points:
|
|
- Middleware runs at the edge before any page renders
|
|
- Token is extracted from URL: /c/[token]
|
|
- Database query is a simple SELECT to check token existence
|
|
- Returns 404 if token not found (no enumeration hints)
|
|
- All errors return 500 (generic error handling)
|
|
</action>
|
|
<verify>
|
|
<automated>test -f src/middleware.ts && echo "middleware.ts exists"</automated>
|
|
<automated>grep -q "export.*function middleware" src/middleware.ts && echo "middleware function exported"</automated>
|
|
<automated>grep -q "matcher.*c/" src/middleware.ts && echo "matcher configured for /c/ routes"</automated>
|
|
<automated>grep -q "clients.token" src/middleware.ts && echo "Token validation query present"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `src/middleware.ts` exists and exports middleware function
|
|
- Matcher is configured for `/c/:path*`
|
|
- Token validation query checks `clients.token`
|
|
- Non-existent tokens return 404
|
|
- TypeScript compiles without errors
|
|
</acceptance_criteria>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create src/lib/client-view.ts with ClientView type and query functions</name>
|
|
<files>
|
|
src/lib/client-view.ts
|
|
</files>
|
|
<read_first>
|
|
src/db/schema.ts (all table definitions)
|
|
</read_first>
|
|
<action>
|
|
Create `src/lib/client-view.ts`:
|
|
|
|
```typescript
|
|
import { eq } from 'drizzle-orm';
|
|
import { db } from '@/db';
|
|
import { clients, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema';
|
|
|
|
/**
|
|
* ClientView: The ONLY data shape returned to client-facing routes.
|
|
* Deliberately excludes: quote_items, service_catalog, service prices.
|
|
* Enforced server-side: client API never touches admin data.
|
|
*/
|
|
export interface ClientView {
|
|
client: {
|
|
id: string;
|
|
name: string;
|
|
brand_name: string;
|
|
brief: string;
|
|
accepted_total: string; // only total, never breakdown
|
|
};
|
|
phases: Array<{
|
|
id: string;
|
|
title: string;
|
|
status: 'upcoming' | 'active' | 'done';
|
|
sort_order: number;
|
|
tasks: Array<{
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
status: 'todo' | 'in_progress' | 'done';
|
|
sort_order: number;
|
|
deliverables: Array<{
|
|
id: string;
|
|
title: string;
|
|
url: string | null;
|
|
status: 'pending' | 'submitted' | 'approved';
|
|
approved_at: string | null; // ISO timestamp
|
|
}>;
|
|
}>;
|
|
progress_pct: number; // % of tasks done in this phase
|
|
}>;
|
|
payments: Array<{
|
|
id: string;
|
|
label: string; // "Acconto 50%" | "Saldo 50%"
|
|
status: 'da_saldare' | 'inviata' | 'saldato';
|
|
}>;
|
|
documents: Array<{
|
|
id: string;
|
|
label: string;
|
|
url: string;
|
|
}>;
|
|
notes: Array<{
|
|
id: string;
|
|
body: string;
|
|
created_at: string; // ISO timestamp
|
|
}>;
|
|
global_progress_pct: number; // % of all tasks done across all phases
|
|
}
|
|
|
|
/**
|
|
* getClientView: Fetch all client data and return only ClientView shape.
|
|
* NEVER queries quote_items.
|
|
*/
|
|
export async function getClientView(token: string): Promise<ClientView | null> {
|
|
// Fetch client
|
|
const clientRow = await db
|
|
.select()
|
|
.from(clients)
|
|
.where(eq(clients.token, token))
|
|
.limit(1);
|
|
|
|
if (clientRow.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const client = clientRow[0];
|
|
|
|
// Fetch all phases for this client
|
|
const phasesRows = await db
|
|
.select()
|
|
.from(phases)
|
|
.where(eq(phases.client_id, client.id))
|
|
.orderBy(phases.sort_order);
|
|
|
|
// Fetch all tasks
|
|
const tasksRows = await db
|
|
.select()
|
|
.from(tasks)
|
|
.orderBy(tasks.sort_order);
|
|
|
|
// Fetch all deliverables
|
|
const deliverables_rows = await db
|
|
.select()
|
|
.from(deliverables);
|
|
|
|
// Fetch payments
|
|
const paymentsRows = await db
|
|
.select()
|
|
.from(payments)
|
|
.where(eq(payments.client_id, client.id));
|
|
|
|
// Fetch documents
|
|
const documentsRows = await db
|
|
.select()
|
|
.from(documents)
|
|
.where(eq(documents.client_id, client.id));
|
|
|
|
// Fetch notes
|
|
const notesRows = await db
|
|
.select()
|
|
.from(notes)
|
|
.where(eq(notes.client_id, client.id))
|
|
.orderBy(notes.created_at);
|
|
|
|
// Build hierarchical structure
|
|
const phasesList = phasesRows.map((phase) => {
|
|
const phaseTasksRows = tasksRows.filter((t) => t.phase_id === phase.id);
|
|
|
|
const tasksList = phaseTasksRows.map((task) => {
|
|
const taskDeliverables = deliverables_rows
|
|
.filter((d) => d.task_id === task.id)
|
|
.map((d) => ({
|
|
id: d.id,
|
|
title: d.title,
|
|
url: d.url,
|
|
status: d.status as 'pending' | 'submitted' | 'approved',
|
|
approved_at: d.approved_at ? new Date(d.approved_at).toISOString() : null,
|
|
}));
|
|
|
|
return {
|
|
id: task.id,
|
|
title: task.title,
|
|
description: task.description,
|
|
status: task.status as 'todo' | 'in_progress' | 'done',
|
|
sort_order: task.sort_order,
|
|
deliverables: taskDeliverables,
|
|
};
|
|
});
|
|
|
|
// Calculate progress for this phase
|
|
const taskCount = tasksList.length;
|
|
const doneCount = tasksList.filter((t) => t.status === 'done').length;
|
|
const progress_pct = taskCount === 0 ? 0 : Math.round((doneCount / taskCount) * 100);
|
|
|
|
return {
|
|
id: phase.id,
|
|
title: phase.title,
|
|
status: phase.status as 'upcoming' | 'active' | 'done',
|
|
sort_order: phase.sort_order,
|
|
tasks: tasksList,
|
|
progress_pct,
|
|
};
|
|
});
|
|
|
|
// Calculate global progress
|
|
const allTasks = phasesRows.flatMap((p) =>
|
|
tasksRows.filter((t) => t.phase_id === p.id)
|
|
);
|
|
const allDoneTasks = allTasks.filter((t) => t.status === 'done').length;
|
|
const globalProgressPct = allTasks.length === 0 ? 0 : Math.round((allDoneTasks / allTasks.length) * 100);
|
|
|
|
// Map payments (do NOT expose amount — only label and status)
|
|
const paymentsList = paymentsRows.map((p) => ({
|
|
id: p.id,
|
|
label: p.label,
|
|
status: p.status as 'da_saldare' | 'inviata' | 'saldato',
|
|
}));
|
|
|
|
// Map documents
|
|
const documentsList = documentsRows.map((d) => ({
|
|
id: d.id,
|
|
label: d.label,
|
|
url: d.url,
|
|
}));
|
|
|
|
// Map notes
|
|
const notesList = notesRows.map((n) => ({
|
|
id: n.id,
|
|
body: n.body,
|
|
created_at: new Date(n.created_at).toISOString(),
|
|
}));
|
|
|
|
return {
|
|
client: {
|
|
id: client.id,
|
|
name: client.name,
|
|
brand_name: client.brand_name,
|
|
brief: client.brief,
|
|
accepted_total: client.accepted_total,
|
|
},
|
|
phases: phasesList,
|
|
payments: paymentsList,
|
|
documents: documentsList,
|
|
notes: notesList,
|
|
global_progress_pct: globalProgressPct,
|
|
};
|
|
}
|
|
```
|
|
|
|
Key points:
|
|
- `ClientView` interface explicitly omits admin data
|
|
- `getClientView()` never queries `quote_items`, `service_catalog`, or service prices
|
|
- Payments are returned WITHOUT amount (only label and status)
|
|
- All timestamps are ISO strings for JSON serialization
|
|
- Progress percentages are calculated server-side
|
|
</action>
|
|
<verify>
|
|
<automated>test -f src/lib/client-view.ts && echo "client-view.ts exists"</automated>
|
|
<automated>grep -q "interface ClientView" src/lib/client-view.ts && echo "ClientView interface defined"</automated>
|
|
<automated>grep -q "export async function getClientView" src/lib/client-view.ts && echo "getClientView function exported"</automated>
|
|
<automated>! grep -q "quote_items\|service_catalog" src/lib/client-view.ts && echo "quote_items not referenced (good)"</automated>
|
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "TypeScript errors" || echo "TypeScript OK"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `src/lib/client-view.ts` exists with `ClientView` interface and `getClientView()` function
|
|
- Interface does NOT include quote_items, service_catalog, or individual service prices
|
|
- Payments are returned with only label and status (no amount)
|
|
- Function returns hierarchical data: client → phases → tasks → deliverables
|
|
- Progress percentages are calculated server-side
|
|
- TypeScript compiles without errors
|
|
</acceptance_criteria>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Create app/c/[token]/page.tsx Server Component to render client dashboard</name>
|
|
<files>
|
|
app/c/[token]/page.tsx
|
|
app/c/[token]/layout.tsx
|
|
</files>
|
|
<read_first>
|
|
src/lib/client-view.ts (ClientView interface)
|
|
</read_first>
|
|
<action>
|
|
Create `app/c/[token]/layout.tsx`:
|
|
|
|
```typescript
|
|
import type { Metadata } from 'next';
|
|
|
|
export const metadata: Metadata = {
|
|
title: 'Client Portal',
|
|
description: 'Project status dashboard',
|
|
};
|
|
|
|
export default function ClientLayout({
|
|
children,
|
|
params,
|
|
}: {
|
|
children: React.ReactNode;
|
|
params: { token: string };
|
|
}) {
|
|
return <>{children}</>;
|
|
}
|
|
```
|
|
|
|
Create `app/c/[token]/page.tsx` (Server Component):
|
|
|
|
```typescript
|
|
import { getClientView } from '@/lib/client-view';
|
|
import { notFound } from 'next/navigation';
|
|
|
|
export const revalidate = 60; // ISR: revalidate every 60 seconds
|
|
|
|
export default async function ClientDashboard({
|
|
params,
|
|
}: {
|
|
params: { token: string };
|
|
}) {
|
|
const view = await getClientView(params.token);
|
|
|
|
if (!view) {
|
|
notFound();
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-white">
|
|
{/* Placeholder: Dashboard will be built in Plan 04 */}
|
|
<div className="p-6">
|
|
<h1 className="text-2xl font-bold">{view.client.brand_name}</h1>
|
|
<p className="text-gray-600">{view.client.brief}</p>
|
|
<p className="text-sm text-gray-400 mt-2">Token: {params.token}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
This page:
|
|
- Fetches ClientView data via `getClientView()`
|
|
- Uses Server Component (no Client Component overhead)
|
|
- Returns 404 if token not found
|
|
- Minimal placeholder content (full UI in Plan 04)
|
|
- ISR enabled: revalidates every 60 seconds so updates are visible within a minute
|
|
</action>
|
|
<verify>
|
|
<automated>test -f app/c/\[token\]/page.tsx && echo "Client page route exists"</automated>
|
|
<automated>grep -q "export default async function" app/c/\[token\]/page.tsx && echo "Server Component syntax correct"</automated>
|
|
<automated>grep -q "getClientView" app/c/\[token\]/page.tsx && echo "getClientView is called"</automated>
|
|
<automated>grep -q "notFound()" app/c/\[token\]/page.tsx && echo "404 handling in place"</automated>
|
|
<automated>test -f app/c/\[token\]/layout.tsx && echo "Layout file exists"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `app/c/[token]/page.tsx` exists as a Server Component
|
|
- `app/c/[token]/layout.tsx` exists with metadata
|
|
- Page calls `getClientView()` and renders minimal placeholder
|
|
- 404 is returned if view is null
|
|
- `npm run build` succeeds
|
|
</acceptance_criteria>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Client request → Middleware | Middleware validates token before any page renders; 404 on invalid token |
|
|
| Server Component → Database | getClientView() queries only client-safe fields; never queries quote_items |
|
|
| ClientView → Serialization | ClientView type prevents accidental inclusion of admin data in JSON responses |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-03-001 | Information Disclosure | ClientView shape | mitigate | TypeScript interface enforces shape; admin data fields are never included; IDE warnings if field is accessed |
|
|
| T-03-002 | Tampering | Token parameter | mitigate | Middleware validates token before page renders; invalid tokens → 404 before DB state is exposed |
|
|
| T-03-003 | Denial of Service | getClientView() query | accept | Queries are indexed on client_id and token; no N+1 queries; Postgres will handle reasonable load |
|
|
|
|
</threat_model>
|
|
|
|
<verification>
|
|
After plan execution:
|
|
1. Run `npm run build` → no errors
|
|
2. Visit `http://localhost:3000/c/invalid-token` → should return 404 (after db is seeded)
|
|
3. Check `src/middleware.ts` → validates token at edge
|
|
4. Check `src/lib/client-view.ts` → ClientView interface does not expose quote_items
|
|
5. Check `app/c/[token]/page.tsx` → Server Component structure correct
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Middleware validates tokens at the edge
|
|
- Server Component fetches ClientView data without exposing admin secrets
|
|
- Invalid tokens return 404
|
|
- TypeScript enforces ClientView shape (no quote_items, no prices)
|
|
- Route is ready for UI rendering (Plan 04)
|
|
- Ready to proceed to Plan 04 (Dashboard UI)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md`
|
|
</output>
|