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