docs(04): create phase plan — 4 plans in 2 waves for multi-project architecture
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,830 @@
|
||||
---
|
||||
phase: 04-progetti-multi-project
|
||||
plan: "04"
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- 04-02-PLAN.md
|
||||
- 04-03-PLAN.md
|
||||
files_modified:
|
||||
- src/app/api/internal/validate-slug/route.ts
|
||||
- src/proxy.ts
|
||||
- src/lib/client-view.ts
|
||||
- src/app/c/[token]/page.tsx
|
||||
- src/app/admin/clients/[id]/edit/page.tsx
|
||||
autonomous: false
|
||||
requirements:
|
||||
- PROJ-02
|
||||
- PROJ-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Accedendo a /c/mario-rossi (dove mario-rossi è lo slug di un cliente) la dashboard si apre correttamente"
|
||||
- "Accedendo a /c/[token] (token storico) la dashboard continua a funzionare come prima"
|
||||
- "Se il cliente ha 1 progetto la dashboard mostra direttamente il workspace senza tabs"
|
||||
- "Se il cliente ha 2+ progetti la dashboard mostra tabs con i nomi dei progetti"
|
||||
- "Lo slug è impostabile da /admin/clients/[id]/edit con preview del link risultante"
|
||||
- "La dashboard cliente NON espone mai quote_items (CLAUDE.md constraint)"
|
||||
artifacts:
|
||||
- path: "src/app/api/internal/validate-slug/route.ts"
|
||||
provides: "API route che risolve slug → clientId"
|
||||
contains: "clients.slug"
|
||||
- path: "src/proxy.ts"
|
||||
provides: "Middleware con slug-first resolution"
|
||||
contains: "validate-slug"
|
||||
- path: "src/lib/client-view.ts"
|
||||
provides: "Query functions per dashboard multi-progetto"
|
||||
exports: ["getClientWithProjectsByToken", "getProjectView"]
|
||||
- path: "src/app/c/[token]/page.tsx"
|
||||
provides: "Dashboard cliente con logica single/multi-project"
|
||||
contains: "projects.length === 1"
|
||||
- path: "src/app/admin/clients/[id]/edit/page.tsx"
|
||||
provides: "Form edit con campo slug e link preview"
|
||||
contains: "slug"
|
||||
key_links:
|
||||
- from: "src/proxy.ts"
|
||||
to: "src/app/api/internal/validate-slug/route.ts"
|
||||
via: "fetch /api/internal/validate-slug?slug=..."
|
||||
pattern: "validate-slug"
|
||||
- from: "src/app/c/[token]/page.tsx"
|
||||
to: "src/lib/client-view.ts"
|
||||
via: "getClientWithProjectsByToken(token)"
|
||||
pattern: "getClientWithProjectsByToken"
|
||||
- from: "src/lib/client-view.ts"
|
||||
to: "src/db/schema.ts"
|
||||
via: "query phases/payments/etc con project_id"
|
||||
pattern: "project_id"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Slug resolution middleware, dashboard cliente multi-progetto, e campo slug nell'edit cliente. Consegna la funzionalità lato cliente: link personalizzato /c/mario-rossi, dashboard con tabs per 2+ progetti o vista diretta per 1 progetto.
|
||||
|
||||
Purpose: Completa il ciclo end-to-end della fase 4 — l'admin imposta lo slug, il cliente accede con il link personalizzato, vede i propri progetti organizzati per tab.
|
||||
|
||||
Output: Middleware slug-first, client-view.ts riscritto per multi-project, dashboard cliente con tabs, edit page con slug field.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/PROJECT.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/CLAUDE.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-01-SUMMARY.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-02-SUMMARY.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-03-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Tutto il necessario per implementare senza esplorare il codebase. -->
|
||||
|
||||
Middleware attuale (src/proxy.ts):
|
||||
- Check admin: getToken → redirect a /admin/login se assente
|
||||
- Check client: match /c/[token], chiama /api/internal/validate-token?token=...
|
||||
- Se validate-token risponde !ok → rewrite /not-found
|
||||
- MODIFICA: before validate-token, try validate-slug first (D-06)
|
||||
|
||||
API route validate-token (usato come template esatto per validate-slug):
|
||||
- Path: src/app/api/internal/validate-token/route.ts (leggere per avere il pattern preciso)
|
||||
- Pattern: GET, query param, db.select where eq(clients.token, token), return 200/404 json
|
||||
|
||||
Schema clients (da 04-01):
|
||||
```typescript
|
||||
clients: { id, name, brand_name, brief, token, slug (nullable unique), accepted_total, archived, created_at }
|
||||
```
|
||||
|
||||
client-view.ts attuale:
|
||||
- getClientView(token: string) → ClientView (fasi, pagamenti, documenti, note per il cliente)
|
||||
- DA RISCRIVERE COMPLETAMENTE per multi-project model
|
||||
|
||||
Nuove funzioni necessarie in client-view.ts:
|
||||
1. getClientWithProjectsByToken(tokenOrSlug: string) — trova il client (via token), restituisce { client, projects[] }
|
||||
NOTA: il param si chiama tokenOrSlug perché la page /c/[token] riceve il valore del path — potrebbe essere token o slug. Il middleware ha già validato l'accesso, ma la page deve fare il lookup corretto.
|
||||
Lookup order: prima per slug, poi per token.
|
||||
2. getProjectView(projectId: string) → ProjectView — dati di un singolo progetto per la dashboard cliente
|
||||
CRITICAL: NON includere quote_items. Includere: phases+tasks+deliverables, payments (solo status, NON unit_price/subtotal), documents, notes.
|
||||
|
||||
shadcn Tabs già presente per multi-project tabs (D-10):
|
||||
- import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
- Tabs è un Client Component (ha "use client" internamente)
|
||||
|
||||
Slug validation rule (D-04, Pitfall 5):
|
||||
- Regex: /^[a-z0-9-]{3,50}$/
|
||||
- Formato: lowercase, numeri, hyphens, min 3 max 50 chars
|
||||
- Zod: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().nullable()
|
||||
|
||||
Edit page cliente attuale (src/app/admin/clients/[id]/edit/page.tsx):
|
||||
- Leggere il file per capire il form attuale e aggiungere il campo slug
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Slug API route + middleware slug-first resolution + client-view.ts rewrite</name>
|
||||
<files>
|
||||
src/app/api/internal/validate-slug/route.ts
|
||||
src/proxy.ts
|
||||
src/lib/client-view.ts
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- src/app/api/internal/validate-token/route.ts — template ESATTO per validate-slug (stesso pattern, stesso formato risposta)
|
||||
- src/proxy.ts — leggere INTERAMENTE: capire la struttura attuale del client token guard per inserire slug-first prima del token check
|
||||
- src/lib/client-view.ts — leggere INTERAMENTE prima di riscriverlo: capire ClientView type e getClientView pattern, specialmente cosa è incluso/escluso
|
||||
- CLAUDE.md Architecture Constraints — ricordare: quote_items MAI esposti via client API; deliverables.approved_at immutable
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**A. Creare src/app/api/internal/validate-slug/route.ts**
|
||||
|
||||
Clonare validate-token/route.ts sostituendo il lookup token con slug:
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { clients } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Called by Edge middleware to resolve slug → client existence
|
||||
// Returns 200 + { clientId } if found, 404 if not
|
||||
export async function GET(request: NextRequest) {
|
||||
const slug = request.nextUrl.searchParams.get("slug");
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json({ error: "slug required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ clientId: rows[0].id }, { status: 200 });
|
||||
}
|
||||
```
|
||||
|
||||
**B. Aggiornare src/proxy.ts — slug-first resolution (D-06)**
|
||||
|
||||
Modificare il blocco `if (pathname.startsWith("/c/"))` esistente:
|
||||
|
||||
PRIMA (attuale):
|
||||
```
|
||||
const clientToken = tokenMatch[1];
|
||||
// chiama solo validate-token
|
||||
```
|
||||
|
||||
DOPO (nuovo):
|
||||
```typescript
|
||||
if (pathname.startsWith("/c/")) {
|
||||
const slugOrTokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||
if (!slugOrTokenMatch) {
|
||||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||
}
|
||||
|
||||
const slugOrToken = slugOrTokenMatch[1];
|
||||
|
||||
try {
|
||||
// TRY SLUG FIRST (D-06) — slug lookup before token fallback
|
||||
// Rationale: slugs are user-friendly names; tokens are fallback for existing links
|
||||
const validateSlugUrl = new URL(
|
||||
`/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
|
||||
request.url
|
||||
);
|
||||
let res = await fetch(validateSlugUrl.toString());
|
||||
|
||||
// If slug not found, fall back to TOKEN validation (existing pattern)
|
||||
if (!res.ok) {
|
||||
const validateTokenUrl = new URL(
|
||||
`/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
|
||||
request.url
|
||||
);
|
||||
res = await fetch(validateTokenUrl.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));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Il resto del file (admin guard, config) rimane invariato.
|
||||
|
||||
**C. Riscrivere src/lib/client-view.ts per multi-project model**
|
||||
|
||||
Riscrivere COMPLETAMENTE il file. Le nuove funzioni sostituiscono getClientView.
|
||||
|
||||
```typescript
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
clients,
|
||||
projects,
|
||||
phases,
|
||||
tasks,
|
||||
deliverables,
|
||||
payments,
|
||||
documents,
|
||||
notes,
|
||||
comments,
|
||||
} from "@/db/schema";
|
||||
import { eq, inArray, asc, or } from "drizzle-orm";
|
||||
|
||||
// ── TYPES ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProjectView {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
client_id: string;
|
||||
accepted_total: string;
|
||||
};
|
||||
phases: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
sort_order: number;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
sort_order: number;
|
||||
deliverables: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
url: string | null;
|
||||
status: string;
|
||||
approved_at: Date | null; // immutable once set — CLAUDE.md constraint
|
||||
}>;
|
||||
}>;
|
||||
progress_pct: number;
|
||||
}>;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: string;
|
||||
// amount and unit_price are NOT included — client sees only status (DASH-07)
|
||||
}>;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
created_at: Date;
|
||||
}>;
|
||||
notes: Array<{
|
||||
id: string;
|
||||
body: string;
|
||||
created_at: Date;
|
||||
}>;
|
||||
comments: Array<{
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
author: string;
|
||||
body: string;
|
||||
created_at: Date;
|
||||
}>;
|
||||
global_progress_pct: number;
|
||||
}
|
||||
|
||||
export interface ClientProjectSummary {
|
||||
client: {
|
||||
id: string;
|
||||
name: string;
|
||||
brand_name: string;
|
||||
token: string;
|
||||
slug: string | null;
|
||||
};
|
||||
projects: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
archived: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── QUERIES ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves a token-or-slug to a client and returns the client's active projects.
|
||||
* Called by /c/[token] page to determine: 1 project (direct view) vs 2+ (tabs).
|
||||
* Lookup order: slug first, then token — mirrors middleware order (D-06).
|
||||
*/
|
||||
export async function getClientWithProjectsByToken(
|
||||
tokenOrSlug: string
|
||||
): Promise<ClientProjectSummary | null> {
|
||||
// Try slug first
|
||||
let clientRows = await db
|
||||
.select({
|
||||
id: clients.id,
|
||||
name: clients.name,
|
||||
brand_name: clients.brand_name,
|
||||
token: clients.token,
|
||||
slug: clients.slug,
|
||||
})
|
||||
.from(clients)
|
||||
.where(eq(clients.slug, tokenOrSlug))
|
||||
.limit(1);
|
||||
|
||||
// Fall back to token
|
||||
if (clientRows.length === 0) {
|
||||
clientRows = await db
|
||||
.select({
|
||||
id: clients.id,
|
||||
name: clients.name,
|
||||
brand_name: clients.brand_name,
|
||||
token: clients.token,
|
||||
slug: clients.slug,
|
||||
})
|
||||
.from(clients)
|
||||
.where(eq(clients.token, tokenOrSlug))
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (clientRows.length === 0) return null;
|
||||
const client = clientRows[0];
|
||||
|
||||
const projectRows = await db
|
||||
.select({ id: projects.id, name: projects.name, archived: projects.archived })
|
||||
.from(projects)
|
||||
.where(eq(projects.client_id, client.id))
|
||||
.orderBy(asc(projects.created_at));
|
||||
|
||||
// Only active (non-archived) projects shown to client
|
||||
const activeProjects = projectRows.filter((p) => !p.archived);
|
||||
|
||||
return { client, projects: activeProjects };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns full project data for the client dashboard.
|
||||
* CRITICAL: Does NOT include quote_items — client API never exposes them (CLAUDE.md constraint).
|
||||
* payments include status only, NOT amount or unit_price (DASH-07).
|
||||
*/
|
||||
export async function getProjectView(projectId: string): Promise<ProjectView | null> {
|
||||
const projectRows = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
client_id: projects.client_id,
|
||||
accepted_total: projects.accepted_total,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.limit(1);
|
||||
|
||||
if (projectRows.length === 0) return null;
|
||||
const project = projectRows[0];
|
||||
|
||||
// Phases scoped to THIS project
|
||||
const phasesRows = await db
|
||||
.select()
|
||||
.from(phases)
|
||||
.where(eq(phases.project_id, projectId))
|
||||
.orderBy(asc(phases.sort_order));
|
||||
|
||||
const phaseIds = phasesRows.map((p) => p.id);
|
||||
|
||||
// Tasks scoped to this project's phases
|
||||
const tasksRows = phaseIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(inArray(tasks.phase_id, phaseIds))
|
||||
.orderBy(asc(tasks.sort_order));
|
||||
|
||||
const taskIds = tasksRows.map((t) => t.id);
|
||||
|
||||
// Deliverables — approved_at included (immutable audit trail — CLAUDE.md)
|
||||
const deliverablesRows = taskIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
id: deliverables.id,
|
||||
title: deliverables.title,
|
||||
url: deliverables.url,
|
||||
status: deliverables.status,
|
||||
approved_at: deliverables.approved_at,
|
||||
task_id: deliverables.task_id,
|
||||
})
|
||||
.from(deliverables)
|
||||
.where(inArray(deliverables.task_id, taskIds));
|
||||
|
||||
// Payments — status only, NO amount (D-07 / DASH-07)
|
||||
const paymentsRows = await db
|
||||
.select({
|
||||
id: payments.id,
|
||||
label: payments.label,
|
||||
status: payments.status,
|
||||
// amount intentionally excluded — client sees only status
|
||||
})
|
||||
.from(payments)
|
||||
.where(eq(payments.project_id, projectId));
|
||||
|
||||
// Documents
|
||||
const documentsRows = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
label: documents.label,
|
||||
url: documents.url,
|
||||
created_at: documents.created_at,
|
||||
})
|
||||
.from(documents)
|
||||
.where(eq(documents.project_id, projectId))
|
||||
.orderBy(asc(documents.created_at));
|
||||
|
||||
// Notes (decision log — admin writes, client reads)
|
||||
const notesRows = await db
|
||||
.select({ id: notes.id, body: notes.body, created_at: notes.created_at })
|
||||
.from(notes)
|
||||
.where(eq(notes.project_id, projectId))
|
||||
.orderBy(asc(notes.created_at));
|
||||
|
||||
// Comments (polymorphic — tasks and deliverables for this project)
|
||||
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||
const commentsRows = allEntityIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(inArray(comments.entity_id, allEntityIds))
|
||||
.orderBy(asc(comments.created_at));
|
||||
|
||||
// Rebuild hierarchy + calculate per-phase progress
|
||||
const phasesWithTasks = phasesRows.map((phase) => {
|
||||
const phaseTasks = tasksRows
|
||||
.filter((t) => t.phase_id === phase.id)
|
||||
.map((task) => ({
|
||||
...task,
|
||||
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
|
||||
}));
|
||||
|
||||
const doneCount = phaseTasks.filter((t) => t.status === "done").length;
|
||||
const progress_pct = phaseTasks.length > 0
|
||||
? Math.round((doneCount / phaseTasks.length) * 100)
|
||||
: 0;
|
||||
|
||||
return { ...phase, tasks: phaseTasks, progress_pct };
|
||||
});
|
||||
|
||||
// Global progress across all phases
|
||||
const allTasks = tasksRows;
|
||||
const doneTasks = allTasks.filter((t) => t.status === "done").length;
|
||||
const global_progress_pct = allTasks.length > 0
|
||||
? Math.round((doneTasks / allTasks.length) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
client_id: project.client_id,
|
||||
accepted_total: project.accepted_total ?? "0",
|
||||
},
|
||||
phases: phasesWithTasks,
|
||||
payments: paymentsRows,
|
||||
documents: documentsRows,
|
||||
notes: notesRows,
|
||||
comments: commentsRows,
|
||||
global_progress_pct,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
NOTA CRITICA sulla security: In getProjectView, il select di payments NON include `amount`. Aggiungere un commento esplicito: `// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md constraint + DASH-07)`. Questo è l'invariante principale da non rompere.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- src/app/api/internal/validate-slug/route.ts exists e contains `clients.slug` (grep)
|
||||
- src/proxy.ts contains `validate-slug` (grep — slug check aggiunto)
|
||||
- src/proxy.ts contains slug check BEFORE token check nell'ordine del codice (grep -n "validate-slug\|validate-token" src/proxy.ts — slug deve avere numero di riga inferiore a token)
|
||||
- src/lib/client-view.ts contains `getClientWithProjectsByToken` (grep)
|
||||
- src/lib/client-view.ts contains `getProjectView` (grep)
|
||||
- src/lib/client-view.ts does NOT contain `quote_items` (grep — security invariant)
|
||||
- src/lib/client-view.ts payments select does NOT contain `amount` field (grep: `grep "amount" src/lib/client-view.ts` deve essere assente nel select payments)
|
||||
- TypeScript compila senza errori
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>Slug API route e middleware aggiornato; client-view.ts riscritto per multi-project senza quote_items e senza payment amounts</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Dashboard cliente multi-project (/c/[token]/page.tsx) + slug field in edit cliente</name>
|
||||
<files>
|
||||
src/app/c/[token]/page.tsx
|
||||
src/app/admin/clients/[id]/edit/page.tsx
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- src/app/c/[token]/page.tsx — leggere INTERAMENTE: capire la struttura attuale (ClientView types, componenti usati, come vengono passati i dati ai componenti UI della dashboard)
|
||||
- src/app/admin/clients/[id]/edit/page.tsx — leggere INTERAMENTE: capire il form esistente (campi attuali, actions usate, pattern Zod/form)
|
||||
- src/lib/client-view.ts — appena riscritto in Task 1: capire i tipi ProjectView e ClientProjectSummary
|
||||
- src/components/ui/tabs.tsx — verificare che il componente Tabs sia disponibile e capirne le props (TabsList, TabsTrigger, TabsContent)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**A. Riscrivere src/app/c/[token]/page.tsx**
|
||||
|
||||
Logica D-09/D-10: se 1 progetto → vista diretta; se 2+ → tabs con nomi brand.
|
||||
|
||||
```typescript
|
||||
import { notFound } from "next/navigation";
|
||||
import { getClientWithProjectsByToken, getProjectView } from "@/lib/client-view";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ClientPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
// Resolve token or slug to client + projects list (D-08/D-09)
|
||||
const clientData = await getClientWithProjectsByToken(token);
|
||||
if (!clientData) notFound();
|
||||
|
||||
const { client, projects } = clientData;
|
||||
|
||||
if (projects.length === 0) {
|
||||
// No active projects — show placeholder
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f9f9f9] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-bold text-[#1a1a1a]">{client.name}</h1>
|
||||
<p className="text-sm text-[#71717a] mt-2">Nessun progetto disponibile al momento.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 1) {
|
||||
// D-09: 1 project → direct view without selector
|
||||
const view = await getProjectView(projects[0].id);
|
||||
if (!view) notFound();
|
||||
|
||||
return <ClientDashboardView client={client} view={view} token={token} />;
|
||||
}
|
||||
|
||||
// D-10: 2+ projects → tabs with brand names
|
||||
// Fetch all project views in parallel
|
||||
const projectViews = await Promise.all(
|
||||
projects.map(async (p) => ({
|
||||
project: p,
|
||||
view: await getProjectView(p.id),
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f9f9f9]">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={projects[0].id} className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
{projects.map((p) => (
|
||||
<TabsTrigger key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{projectViews.map(({ project, view }) => (
|
||||
<TabsContent key={project.id} value={project.id}>
|
||||
{view ? (
|
||||
<ClientDashboardView client={client} view={view} token={token} />
|
||||
) : (
|
||||
<p className="text-sm text-[#71717a]">Progetto non disponibile.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Per `ClientDashboardView`: leggere il file attuale di /c/[token]/page.tsx per capire come è strutturata la dashboard corrente. Il componente `ClientDashboardView` è probabilmente già esistente o il rendering è inline. Adattare seguendo ESATTAMENTE la struttura attuale:
|
||||
- Se il file corrente ha un componente separato (es. ClientDashboard o simile) → riutilizzarlo, passando `view` invece di `clientView`
|
||||
- Se il rendering è inline → estrarlo in una funzione helper `ClientDashboardView` nello stesso file
|
||||
- I dati che `ClientDashboardView` riceve vengono ora da `ProjectView` invece di `ClientView` — adattare le prop references
|
||||
|
||||
CRITICO: verificare che `ClientDashboardView` NON abbia accesso a quote_items — deve usare solo i dati di `ProjectView` (phases, payments con solo status, documents, notes, comments).
|
||||
|
||||
Il campo `accepted_total` da mostrare viene da `view.project.accepted_total` (non dal client-level).
|
||||
|
||||
**B. Aggiornare src/app/admin/clients/[id]/edit/page.tsx**
|
||||
|
||||
Aggiungere il campo slug con:
|
||||
1. Input field con label "Slug personalizzato"
|
||||
2. Validazione Zod: `slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.literal(""))` — stringa vuota = nessuno slug
|
||||
3. Preview del link risultante: `/{slug || client.token}`
|
||||
4. Testo help: "Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri."
|
||||
|
||||
Leggere il file per trovare il form attuale e aggiungere il campo slug nel form esistente. L'action di salvataggio deve aggiornare `clients.slug` oltre ai campi esistenti.
|
||||
|
||||
Schema Zod da aggiungere/aggiornare per il campo slug:
|
||||
```typescript
|
||||
const updateClientSchema = z.object({
|
||||
// ... existing fields ...
|
||||
slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.literal("")).transform(v => v === "" ? null : v),
|
||||
});
|
||||
```
|
||||
|
||||
Nel form HTML:
|
||||
```html
|
||||
<div>
|
||||
<label htmlFor="slug">Slug personalizzato (opzionale)</label>
|
||||
<p className="text-xs text-[#71717a] mb-1">Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri.</p>
|
||||
<input
|
||||
id="slug"
|
||||
name="slug"
|
||||
type="text"
|
||||
defaultValue={client.slug ?? ""}
|
||||
pattern="[a-z0-9-]{3,50}"
|
||||
placeholder="mario-rossi"
|
||||
className="..."
|
||||
/>
|
||||
{/* Link preview */}
|
||||
<p className="text-xs text-[#71717a] mt-1">
|
||||
Link cliente: /c/{client.slug || client.token}
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Nella server action che salva, aggiungere l'update di `clients.slug`:
|
||||
```typescript
|
||||
// Se slug è stringa vuota, settarlo a null (rimuove lo slug)
|
||||
await db.update(clients).set({
|
||||
// ...existing fields...
|
||||
slug: parsed.slug ?? null,
|
||||
}).where(eq(clients.id, clientId));
|
||||
```
|
||||
|
||||
Aggiungere anche gestione errore per unique constraint violation (se lo slug è già usato da un altro cliente), mostrando un messaggio user-friendly.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>npm run build 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- src/app/c/[token]/page.tsx contains `getClientWithProjectsByToken` (grep)
|
||||
- src/app/c/[token]/page.tsx contains `projects.length === 1` (grep — single project direct view logic)
|
||||
- src/app/c/[token]/page.tsx contains `Tabs` import (grep — multi-project tabs)
|
||||
- src/app/c/[token]/page.tsx does NOT contain `quote_items` anywhere (grep)
|
||||
- src/app/admin/clients/[id]/edit/page.tsx contains `slug` input field (grep: `grep "name=\"slug\"" src/app/admin/clients/\[id\]/edit/page.tsx`)
|
||||
- src/app/admin/clients/[id]/edit/page.tsx contains `/^[a-z0-9-]{3,50}$/` validation pattern (grep)
|
||||
- `npm run build` completa senza errori TypeScript
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>Dashboard cliente funziona con singolo progetto (vista diretta) e multi-progetto (tabs); slug impostabile dall'admin</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>
|
||||
Funzionalità complete di Phase 04:
|
||||
1. Schema multi-project con FK migrate (04-01)
|
||||
2. Admin projects list + create + client detail con project cards (04-02)
|
||||
3. Admin project workspace con timer project-scoped e analytics profittabilità (04-03)
|
||||
4. Slug resolution middleware + dashboard cliente multi-project + slug edit (questo piano)
|
||||
</what-built>
|
||||
|
||||
<how-to-verify>
|
||||
Eseguire `npm run dev` e verificare manualmente:
|
||||
|
||||
**Test 1 — Admin projects list (/admin/projects)**
|
||||
- Aprire /admin/projects
|
||||
- Verificare che la pagina carichi senza errori
|
||||
- Verificare colonne: Progetto (con nome cliente sotto), Valore, Acconto, Saldo, Timer, €/h
|
||||
|
||||
**Test 2 — Creazione progetto**
|
||||
- Aprire /admin e cliccare su un cliente
|
||||
- Verificare che /admin/clients/[id] mostri project cards (non più il workspace tab)
|
||||
- Cliccare "+ Nuovo Progetto" e creare un progetto
|
||||
- Verificare che il redirect vada a /admin/projects/[id]
|
||||
|
||||
**Test 3 — Workspace progetto (/admin/projects/[id])**
|
||||
- Aprire /admin/projects/[id] per il progetto appena creato
|
||||
- Verificare tutti i tabs: Fasi & Task, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer
|
||||
- Nel tab Timer: verificare play/stop funziona, ProfitabilityCard mostra ore lavorate, €/h, costo ideale, delta
|
||||
|
||||
**Test 4 — Impostazioni (/admin/impostazioni)**
|
||||
- Aprire /admin/impostazioni
|
||||
- Verificare form con campo tariffa oraria target (default 50.00)
|
||||
- Cambiare il valore, salvare, ricaricare — verificare che il nuovo valore sia persistito
|
||||
- Aprire /admin/projects/[id] → tab Timer → verificare che la tariffa target aggiornata appaia nella ProfitabilityCard
|
||||
|
||||
**Test 5 — Slug cliente**
|
||||
- Aprire /admin/clients/[id]/edit per un cliente
|
||||
- Impostare slug "mario-rossi" (o simile)
|
||||
- Salvare e verificare che non ci siano errori
|
||||
- Aprire /c/mario-rossi → verificare che carichi la dashboard del cliente corretto
|
||||
|
||||
**Test 6 — Fallback token**
|
||||
- Con lo stesso cliente che ha lo slug impostato, aprire /c/[token-originale]
|
||||
- Verificare che carichi correttamente (fallback token deve funzionare)
|
||||
|
||||
**Test 7 — Dashboard multi-progetto**
|
||||
- Per il cliente di test, creare un secondo progetto
|
||||
- Aprire /c/[token-o-slug] del cliente
|
||||
- Verificare che appaiano le tabs con i nomi dei due progetti
|
||||
- Cliccare tra i tabs e verificare che i dati siano scoped al progetto corretto
|
||||
|
||||
**Test 8 — Dashboard singolo progetto**
|
||||
- Per un cliente con 1 solo progetto, aprire /c/[token]
|
||||
- Verificare che NON appaiano tabs — la dashboard si apre direttamente sul progetto
|
||||
</how-to-verify>
|
||||
|
||||
<resume-signal>
|
||||
Digitare "approvato" se tutti i test passano, oppure descrivere gli errori trovati per correzione.
|
||||
</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Public internet → /c/[slug-or-token] | Chiunque con il link accede alla dashboard; il middleware valida prima slug poi token — accesso bloccato se entrambi falliscono |
|
||||
| Client dashboard → DB | getProjectView NON espone quote_items né payment amounts — invarianti CLAUDE.md + DASH-07 |
|
||||
| Admin edit → clients.slug | Il campo slug è validato con regex e aggiornato solo in sessione admin autenticata |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-14 | Information Disclosure | getProjectView — payments | mitigate | SELECT include solo id, label, status — amount escluso esplicitamente. Commento nel codice documenta il motivo (DASH-07 + CLAUDE.md). grep di test in acceptance criteria verifica l'assenza di amount |
|
||||
| T-04-15 | Information Disclosure | getProjectView — quote_items | mitigate | quote_items NON importato in client-view.ts. Acceptance criteria include grep check `grep "quote_items" src/lib/client-view.ts` → deve essere assente |
|
||||
| T-04-16 | Tampering | clients.slug — unique constraint | mitigate | DB unique constraint su clients.slug previene slug duplicati; server action cattura unique violation e mostra errore user-friendly |
|
||||
| T-04-17 | Spoofing | Slug collisione con token esistente | accept | Slug regex [a-z0-9-]{3,50} non può collidere con nanoid tokens (che usano anche maiuscole e caratteri speciali); middleware prova prima slug poi token nell'ordine corretto (D-06) |
|
||||
| T-04-18 | Information Disclosure | Dashboard multi-project tabs — dati cross-project | mitigate | Ogni getProjectView(projectId) è scoped con WHERE eq(phases.project_id, projectId) — un cliente non può vedere dati di un altro cliente perché l'accesso è gate-kept dal client.id risolto dal token |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# 1. Slug API route exists
|
||||
ls src/app/api/internal/validate-slug/route.ts
|
||||
|
||||
# 2. Middleware has slug-first
|
||||
grep -n "validate-slug\|validate-token" src/proxy.ts
|
||||
|
||||
# 3. client-view.ts has new functions
|
||||
grep "export async function" src/lib/client-view.ts
|
||||
|
||||
# 4. client-view.ts security invariants
|
||||
grep "quote_items" src/lib/client-view.ts # must be empty
|
||||
grep "amount" src/lib/client-view.ts # must not appear in payments select
|
||||
|
||||
# 5. Dashboard has tabs logic
|
||||
grep "projects.length === 1" src/app/c/\[token\]/page.tsx
|
||||
|
||||
# 6. Edit page has slug field
|
||||
grep "name=\"slug\"" src/app/admin/clients/\[id\]/edit/page.tsx
|
||||
|
||||
# 7. Build clean
|
||||
npm run build
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- /c/[slug] risolve correttamente alla dashboard del cliente → stesso comportamento di /c/[token]
|
||||
- /c/[token] continua a funzionare come fallback per i link esistenti
|
||||
- Dashboard con 1 progetto → nessun selettore/tabs, vista diretta
|
||||
- Dashboard con 2+ progetti → shadcn Tabs con nomi brand, switch funziona
|
||||
- /admin/impostazioni persiste il target_hourly_rate e la ProfitabilityCard nel workspace progetto lo usa
|
||||
- `npm run build` → 0 errori TypeScript
|
||||
- `grep "quote_items" src/lib/client-view.ts` → nessun output (security invariant verificato)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-progetti-multi-project/04-04-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`.
|
||||
|
||||
Key items to document:
|
||||
- Come è stata implementata la logica single/multi-project nella dashboard
|
||||
- Come la edit page gestisce slug vuoto → null (rimozione slug)
|
||||
- Eventuali adattamenti al componente ClientDashboardView per lavorare con ProjectView invece di ClientView
|
||||
- Conferma dei security invariants (no quote_items, no payment amounts in client-view.ts)
|
||||
</output>
|
||||
Reference in New Issue
Block a user