Files
clienthub/.planning/phases/01-foundation-client-dashboard/01-03-PLAN.md
T
Simone Cavalli 81c667838f docs(01-foundation-client-dashboard): complete phase 1 planning with 5-plan structure
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>
2026-05-13 11:27:19 +02:00

18 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 03 execute 2
01-01
01-02
src/middleware.ts
src/lib/client-view.ts
app/c/[token]/page.tsx
app/c/[token]/layout.tsx
true
DASH-01
DASH-02
DASH-03
DASH-04
truths artifacts key_links
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
path provides contains
src/middleware.ts Token validation at Next.js edge middleware function middleware
path provides contains
src/lib/client-view.ts Client-safe type definitions and query functions ClientView
path provides min_lines contains
app/c/[token]/page.tsx Server Component rendering client dashboard 30 export default async function
path provides min_lines
app/c/[token]/layout.tsx Layout for token-authenticated routes 10
from to via pattern
src/middleware.ts Database query for token validation db.select().from(clients).where(eq(clients.token, token)) clients.token
from to via pattern
app/c/[token]/page.tsx src/lib/client-view.ts import { getClientView } getClientView
from to via pattern
ClientView type Rendering props ensures no quote_items quote_items
**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.

<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 (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 Task 1: Create src/middleware.ts to validate client tokens at the edge src/middleware.ts src/db/schema.ts (clients table definition) package.json (verify Next.js version) 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)
test -f src/middleware.ts && echo "middleware.ts exists" grep -q "export.*function middleware" src/middleware.ts && echo "middleware function exported" grep -q "matcher.*c/" src/middleware.ts && echo "matcher configured for /c/ routes" grep -q "clients.token" src/middleware.ts && echo "Token validation query present" - `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 Task 2: Create src/lib/client-view.ts with ClientView type and query functions src/lib/client-view.ts src/db/schema.ts (all table definitions) 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
test -f src/lib/client-view.ts && echo "client-view.ts exists" grep -q "interface ClientView" src/lib/client-view.ts && echo "ClientView interface defined" grep -q "export async function getClientView" src/lib/client-view.ts && echo "getClientView function exported" ! grep -q "quote_items\|service_catalog" src/lib/client-view.ts && echo "quote_items not referenced (good)" npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "TypeScript errors" || echo "TypeScript OK" - `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 Task 3: Create app/c/[token]/page.tsx Server Component to render client dashboard app/c/[token]/page.tsx app/c/[token]/layout.tsx src/lib/client-view.ts (ClientView interface) 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
test -f app/c/\[token\]/page.tsx && echo "Client page route exists" grep -q "export default async function" app/c/\[token\]/page.tsx && echo "Server Component syntax correct" grep -q "getClientView" app/c/\[token\]/page.tsx && echo "getClientView is called" grep -q "notFound()" app/c/\[token\]/page.tsx && echo "404 handling in place" test -f app/c/\[token\]/layout.tsx && echo "Layout file exists" - `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

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

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

<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>
After completion, create `.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md`