d210cf6202
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
830 lines
31 KiB
Markdown
830 lines
31 KiB
Markdown
---
|
|
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> |