Files
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

570 lines
21 KiB
Markdown

---
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"
---
<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 (Edge-compatible fetch pattern) + internal validate-token API route</name>
<files>
src/middleware.ts
app/api/internal/validate-token/route.ts
</files>
<read_first>
src/db/schema.ts (clients table definition)
package.json (verify Next.js version)
</read_first>
<action>
**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)
</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 "from '@/db'" src/middleware.ts && echo "middleware does not import drizzle/db (good — Edge safe)"</automated>
<automated>test -f app/api/internal/validate-token/route.ts && echo "internal validate-token route exists"</automated>
<automated>grep -q "clients.token" app/api/internal/validate-token/route.ts && echo "Token DB query in API route"</automated>
</verify>
<acceptance_criteria>
- `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
</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, 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<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 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
</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>grep -q "inArray" src/lib/client-view.ts && echo "inArray scoping present"</automated>
<automated>grep -q "accepted_total.*?? '0'" src/lib/client-view.ts && echo "null coalescing on accepted_total"</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>