feat(04-04): slug resolution + multi-project client dashboard + slug edit

- validate-slug API route: resolves clients.slug for Edge middleware
- proxy.ts: slug-first resolution (D-06) — tries slug then falls back to token
- client-view.ts: complete rewrite — getClientWithProjectsByToken + getProjectView
  - No quote_items, no payment amounts in client API (CLAUDE.md security invariants)
- client/[token]/page.tsx: multi-project dashboard — 1 project = direct view,
  2+ projects = shadcn Tabs with project names (D-09/D-10)
- edit/page.tsx: slug field with link preview + unique constraint error handling
- actions.ts: updateClient now persists slug, redirects on success/slug_taken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 11:31:32 +02:00
parent 1d9fa10961
commit 5d785a1c1c
8 changed files with 562 additions and 175 deletions
@@ -0,0 +1,50 @@
---
plan: "04-02"
phase: "04-progetti-multi-project"
status: complete
completed_at: "2026-05-22"
---
# Summary: 04-02 — Admin Projects List + Client Detail Project Cards
## What Was Built
**NavBar** — added Progetti (`/admin/projects`) and Impostazioni (`/admin/impostazioni`) links in the correct order: Clienti → Progetti → Statistiche → Catalogo → Impostazioni.
**ProjectRow** (`src/components/admin/ProjectRow.tsx`) — new table row component cloned from ClientRow, adapted for projects. Columns: Nome+Cliente, Valore, Acconto badge, Saldo badge, TimerCell, €/h. The €/h is computed inline: `accepted_total / (totalTrackedSeconds / 3600)`, showing `—` when no hours tracked.
**`/admin/projects`** (`src/app/admin/projects/page.tsx`) — table listing all projects via `getAllProjectsWithPayments()`. Empty state shows helpful message.
**`/admin/projects/new`** (`src/app/admin/projects/new/page.tsx`) — creation form with client `<select>`. Accepts `?client_id` query param to pre-select the client (used when clicking "+ Nuovo Progetto" from a client's detail page). On submit calls `createProject` server action and redirects to the new project's workspace.
**project-actions** (`src/app/admin/projects/project-actions.ts`) — server actions: `createProject`, `archiveProject`, `unarchiveProject`, `updateProjectAcceptedTotal`. Each guards with local `requireAdmin()` (same pattern as other actions files — not imported from `@/lib/auth` which doesn't export it).
**`/admin/clients/[id]`** — fully rewritten from tabbed workspace to project cards grid. Active projects shown as clickable cards linking to `/admin/projects/[id]`. Archived projects shown below with reduced opacity. Header retains "Link cliente →" and `ClientActions` component.
**`getAllClientsWithPayments`** — extended to also fetch project names/amounts per client, computing:
- `projectBrands: string[]` — non-archived project names, used as secondary labels under client name
- `ltv: string` — sum of all projects' `accepted_total` (including archived), replaces single `accepted_total` in the LTV column
**ClientRow** — shows `projectBrands.join(" | ")` under client name (falls back to `brand_name` if no projects). LTV column now uses `client.ltv`.
**`/admin/page.tsx`** — column header renamed from "Totale" to "LTV".
## Key Implementation Decisions
- **`?client_id` pre-selection in new project form**: used `defaultValue={client_id ?? ""}` on the `<select>` — React controlled default, works with server components.
- **No `requireAdmin` import**: all server actions files define it locally with the same `getServerSession(authOptions)` pattern — consistent with existing codebase.
- **`clientProjects` query extended**: instead of a separate parallel query for projectBrands/ltv, the existing `clientProjects` fetch was extended to also select `name`, `accepted_total`, `archived` — avoids an extra DB round-trip.
## Files Modified/Created
| File | Action |
|------|--------|
| `src/components/admin/NavBar.tsx` | Updated — added 2 links |
| `src/components/admin/ProjectRow.tsx` | Created |
| `src/app/admin/projects/page.tsx` | Created |
| `src/app/admin/projects/new/page.tsx` | Created |
| `src/app/admin/projects/project-actions.ts` | Created |
| `src/app/admin/clients/[id]/page.tsx` | Rewritten |
| `src/app/admin/page.tsx` | Updated — column header |
| `src/lib/admin-queries.ts` | Updated — projectBrands + ltv |
| `src/components/admin/ClientRow.tsx` | Updated — projectBrands + ltv |
@@ -0,0 +1,52 @@
---
plan: "04-03"
phase: "04-progetti-multi-project"
status: complete
completed_at: "2026-05-22"
---
# Summary: 04-03 — Project Workspace + Timer Analytics + Settings
## What Was Built
**`/admin/projects/[id]`** — full project workspace with 7 tabs: Fasi & Task, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer. Reuses all existing tab components (PhasesTab, PaymentsTab, DocumentsTab, CommentsTab, QuoteTab) unchanged by passing `projectId` as `clientId`.
**`ProfitabilityCard`** (`src/components/admin/ProfitabilityCard.tsx`) — pure display component (no `"use client"`). Shows: ore lavorate, importo accettato, €/h reale, €/h target, costo ideale, delta (verde = guadagno, rosso = perdita). Delta shown only when both hours > 0 and accepted > 0.
**`TimerTab`** (`src/components/admin/tabs/TimerTab.tsx`) — client component wrapping TimerCell + ProfitabilityCard. Passes `projectId` as `clientId` to TimerCell (prop name is legacy; the underlying startTimer already uses project_id).
**`/admin/impostazioni`** — settings page with target_hourly_rate form. Inline server action calls `updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, ...)`. Default value 50€/h from `getTargetHourlyRate()`.
## Key Architectural Decision: resolveEntity()
The tab components (PhasesTab, PaymentsTab, etc.) hardcode imports from `@/app/admin/clients/[id]/actions`. Rather than duplicating tab components or injecting actions as props, added `resolveEntity(id)` helper to both `actions.ts` and `quote-actions.ts`:
```typescript
async function resolveEntity(id: string): Promise<{ projectId: string | null; path: string }> {
// Checks if id is a projects.id directly (one PK lookup)
// Falls back to client_id lookup if not found
// Returns correct revalidatePath for either context
}
```
This makes all existing tab actions work transparently with both clientId and projectId. Zero changes to tab components.
**`updateAcceptedTotal` in `actions.ts`** — special case: detects project vs client context and updates the correct table (`projects.accepted_total` vs `clients.accepted_total`), with per-project payment stub splitting.
**`timer-actions.ts`** — added `revalidatePath("/admin/projects")` and `revalidatePath(\`/admin/projects/${projectId}\`)` so the project list and workspace refresh after timer start/stop.
## Files Changed
| File | Action |
|------|--------|
| `src/app/admin/clients/[id]/actions.ts` | Updated — added resolveEntity(), all functions now work with projectId |
| `src/app/admin/clients/[id]/quote-actions.ts` | Updated — same resolveEntity pattern |
| `src/app/admin/timer-actions.ts` | Updated — added /admin/projects revalidation |
| `src/components/admin/ProfitabilityCard.tsx` | Created |
| `src/components/admin/tabs/TimerTab.tsx` | Created |
| `src/app/admin/projects/[id]/page.tsx` | Created |
| `src/app/admin/impostazioni/page.tsx` | Created |
## Notes Tab
No `NotesTab` component exists — notes rendered inline in the project workspace page. Simple read-only display (no add/edit/delete — consistent with how notes appear in the client dashboard).
+17 -1
View File
@@ -47,6 +47,12 @@ const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"), name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"), brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(), brief: z.string(),
slug: z
.string()
.regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)")
.optional()
.or(z.literal(""))
.transform((v) => (v === "" || v === undefined ? null : v)),
}); });
export async function updateClient(clientId: string, formData: FormData) { export async function updateClient(clientId: string, formData: FormData) {
@@ -54,11 +60,21 @@ export async function updateClient(clientId: string, formData: FormData) {
name: formData.get("name"), name: formData.get("name"),
brand_name: formData.get("brand_name"), brand_name: formData.get("brand_name"),
brief: formData.get("brief") ?? "", brief: formData.get("brief") ?? "",
slug: formData.get("slug") ?? "",
}); });
if (!parsed.success) throw new Error(parsed.error.issues[0].message); if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId)); try {
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("unique") || msg.includes("duplicate")) {
redirect(`/admin/clients/${clientId}/edit?error=slug_taken`);
}
throw e;
}
revalidatePath(`/admin/clients/${clientId}`); revalidatePath(`/admin/clients/${clientId}`);
revalidatePath("/admin"); revalidatePath("/admin");
redirect(`/admin/clients/${clientId}`);
} }
export async function deleteClient(clientId: string) { export async function deleteClient(clientId: string) {
+30
View File
@@ -11,10 +11,13 @@ import Link from "next/link";
export default async function EditClientPage({ export default async function EditClientPage({
params, params,
searchParams,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) { }) {
const { id } = await params; const { id } = await params;
const { error } = await searchParams;
const [client] = await db.select().from(clients).where(eq(clients.id, id)).limit(1); const [client] = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
if (!client) notFound(); if (!client) notFound();
@@ -31,6 +34,12 @@ export default async function EditClientPage({
<h1 className="text-xl font-bold text-[#1a1a1a] mb-6">Modifica cliente</h1> <h1 className="text-xl font-bold text-[#1a1a1a] mb-6">Modifica cliente</h1>
{error === "slug_taken" && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
Slug già in uso da un altro cliente. Scegline uno diverso.
</div>
)}
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
"use server"; "use server";
@@ -64,6 +73,27 @@ export default async function EditClientPage({
/> />
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="slug">Slug personalizzato (opzionale)</Label>
<p className="text-xs text-[#71717a]">
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"
/>
<p className="text-xs text-[#71717a]">
Link cliente:{" "}
<span className="font-mono text-[#1a1a1a]">
/client/{client.slug || client.token}
</span>
</p>
</div>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<Button type="submit">Salva modifiche</Button> <Button type="submit">Salva modifiche</Button>
<Button asChild variant="ghost"> <Button asChild variant="ghost">
@@ -0,0 +1,28 @@
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 slug = request.nextUrl.searchParams.get("slug");
if (!slug) {
return NextResponse.json({ valid: false }, { status: 400 });
}
try {
const rows = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.slug, slug))
.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 });
}
}
+155 -37
View File
@@ -1,16 +1,73 @@
import { cache } from 'react'; import { cache } from "react";
import { getClientView } from '@/lib/client-view'; import { notFound } from "next/navigation";
import { ClientDashboard } from '@/components/client-dashboard'; import {
import { notFound } from 'next/navigation'; getClientWithProjectsByToken,
import { db } from '@/db'; getProjectView,
import { comments } from '@/db/schema'; type ProjectView,
import { inArray } from 'drizzle-orm'; type ClientView,
import type { Comment } from '@/db/schema'; type ClientProjectSummary,
} from "@/lib/client-view";
import { ClientDashboard } from "@/components/client-dashboard";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Comment } from "@/db/schema";
export const revalidate = 0; // Always revalidate — comments and approvals must be fresh export const revalidate = 0;
// React cache deduplicates DB calls within the same render const getCachedClientData = cache(getClientWithProjectsByToken);
const getCachedClientView = cache(getClientView);
// Adapter: converts ProjectView + client info into ClientView shape for ClientDashboard reuse
function projectViewToClientView(
client: ClientProjectSummary["client"],
view: ProjectView
): ClientView {
return {
client: {
id: view.project.client_id,
name: client.name,
brand_name: client.brand_name,
brief: "",
accepted_total: view.project.accepted_total,
},
phases: view.phases.map((phase) => ({
id: phase.id,
title: phase.title,
status: phase.status as "upcoming" | "active" | "done",
sort_order: phase.sort_order,
tasks: phase.tasks.map((task) => ({
id: task.id,
title: task.title,
description: task.description,
status: task.status as "todo" | "in_progress" | "done",
sort_order: task.sort_order,
deliverables: task.deliverables.map((d) => ({
id: d.id,
title: d.title,
url: d.url,
status: d.status as "pending" | "submitted" | "approved",
// approved_at is immutable once set — CLAUDE.md constraint LOCKED
approved_at: d.approved_at instanceof Date ? d.approved_at.toISOString() : null,
})),
})),
progress_pct: phase.progress_pct,
})),
payments: view.payments.map((p) => ({
id: p.id,
label: p.label,
status: p.status as "da_saldare" | "inviata" | "saldato",
})),
documents: view.documents.map((d) => ({
id: d.id,
label: d.label,
url: d.url,
})),
notes: view.notes.map((n) => ({
id: n.id,
body: n.body,
created_at: n.created_at instanceof Date ? n.created_at.toISOString() : String(n.created_at),
})),
global_progress_pct: view.global_progress_pct,
};
}
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -18,15 +75,11 @@ export async function generateMetadata({
params: Promise<{ token: string }>; params: Promise<{ token: string }>;
}) { }) {
const { token } = await params; const { token } = await params;
const view = await getCachedClientView(token); const clientData = await getCachedClientData(token);
if (!clientData) return { title: "Not Found" };
if (!view) {
return { title: 'Not Found' };
}
return { return {
title: `${view.client.brand_name} — Stato Progetto | iamcavalli`, title: `${clientData.client.brand_name} — Stato Progetto | iamcavalli`,
description: view.client.brief || 'Dashboard stato progetto', description: "Dashboard stato progetto",
}; };
} }
@@ -36,26 +89,91 @@ export default async function ClientPage({
params: Promise<{ token: string }>; params: Promise<{ token: string }>;
}) { }) {
const { token } = await params; const { token } = await params;
const view = await getCachedClientView(token);
if (!view) { const clientData = await getCachedClientData(token);
notFound(); if (!clientData) notFound();
const { client, projects } = clientData;
if (projects.length === 0) {
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>
);
} }
// Fetch comments: tasks, deliverables, and general (entity_id = clientId) if (projects.length === 1) {
const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id)); // D-09: single project → direct view without selector
const allDeliverableIds = view.phases.flatMap((p) => const view = await getProjectView(projects[0].id);
p.tasks.flatMap((t) => t.deliverables.map((d) => d.id)) if (!view) notFound();
return (
<ClientDashboard
view={projectViewToClientView(client, view)}
token={token}
comments={view.comments as unknown as Comment[]}
/>
);
}
// D-10: 2+ projects → tabs with project names
const projectViews = await Promise.all(projects.map((p) => getProjectView(p.id)));
return (
<div className="min-h-screen bg-white">
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
<div className="flex items-center">
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
iamcavalli
</span>
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
{client.brand_name}
</h1>
<div className="w-28 shrink-0" />
</div>
</div>
</header>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<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>
{projects.map((p, i) => {
const view = projectViews[i];
return (
<TabsContent key={p.id} value={p.id}>
{view ? (
<ClientDashboard
view={projectViewToClientView(client, view)}
token={token}
comments={view.comments as unknown as Comment[]}
/>
) : (
<p className="text-sm text-[#71717a]">Progetto non disponibile.</p>
)}
</TabsContent>
);
})}
</Tabs>
</div>
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
<p className="text-xs text-[#999999] text-center">
Questa è la tua dashboard privata non condividere il link.
</p>
</div>
</footer>
</div>
); );
const allEntityIds = [view.client.id, ...allTaskIds, ...allDeliverableIds];
const allComments: Comment[] =
allEntityIds.length > 0
? await db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
: [];
return <ClientDashboard view={view} token={token} comments={allComments} />;
} }
+212 -130
View File
@@ -1,14 +1,11 @@
import { eq, inArray } from 'drizzle-orm'; import { eq, inArray, asc } from "drizzle-orm";
import { db } from '@/db'; import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema'; import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments } from "@/db/schema";
/** /**
* ClientView: The ONLY data shape returned to client-facing routes. * ClientView: Legacy shape used by ClientDashboard component.
* Kept for backward compatibility — new code uses ProjectView.
* Deliberately excludes: quote_items, service_catalog, service prices, payment amounts. * Deliberately excludes: quote_items, service_catalog, service prices, payment amounts.
* Enforced server-side: client API never touches admin data.
*
* Architecture constraint (LOCKED): quote_items are admin-only.
* accepted_total is the only price-related field returned to clients.
*/ */
export interface ClientView { export interface ClientView {
client: { client: {
@@ -16,34 +13,33 @@ export interface ClientView {
name: string; name: string;
brand_name: string; brand_name: string;
brief: string; brief: string;
accepted_total: string; // only total, never breakdown accepted_total: string;
}; };
phases: Array<{ phases: Array<{
id: string; id: string;
title: string; title: string;
status: 'upcoming' | 'active' | 'done'; status: "upcoming" | "active" | "done";
sort_order: number; sort_order: number;
tasks: Array<{ tasks: Array<{
id: string; id: string;
title: string; title: string;
description: string | null; description: string | null;
status: 'todo' | 'in_progress' | 'done'; status: "todo" | "in_progress" | "done";
sort_order: number; sort_order: number;
deliverables: Array<{ deliverables: Array<{
id: string; id: string;
title: string; title: string;
url: string | null; url: string | null;
status: 'pending' | 'submitted' | 'approved'; status: "pending" | "submitted" | "approved";
approved_at: string | null; // ISO timestamp — immutable once set approved_at: string | null;
}>; }>;
}>; }>;
progress_pct: number; // % of tasks done in this phase progress_pct: number;
}>; }>;
payments: Array<{ payments: Array<{
id: string; id: string;
label: string; // "Acconto 50%" | "Saldo 50%" label: string;
status: 'da_saldare' | 'inviata' | 'saldato'; status: "da_saldare" | "inviata" | "saldato";
// NOTE: amount is intentionally omitted — clients see only label and status
}>; }>;
documents: Array<{ documents: Array<{
id: string; id: string;
@@ -53,62 +49,160 @@ export interface ClientView {
notes: Array<{ notes: Array<{
id: string; id: string;
body: string; body: string;
created_at: string; // ISO timestamp created_at: string;
}>;
global_progress_pct: number;
}
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;
task_id: string;
}>;
}>;
progress_pct: number;
}>;
payments: Array<{
id: string;
label: string;
// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07)
status: string;
}>;
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;
}>; }>;
global_progress_pct: number; // % of all tasks done across all phases
} }
/** /**
* getClientView: Fetch all client data and return only the ClientView shape. * Resolves a token-or-slug to a client and returns the client's active projects.
* NEVER queries quote_items, service_catalog, or service prices. * Lookup order: slug first, then token — mirrors middleware order (D-06).
* Aggregates data across all projects for this client.
*/ */
export async function getClientView(token: string): Promise<ClientView | null> { export async function getClientWithProjectsByToken(
// Fetch client by token (Architecture constraint: token is separate from id PK) tokenOrSlug: string
const clientRow = await db ): Promise<ClientProjectSummary | null> {
.select() // 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) .from(clients)
.where(eq(clients.token, token)) .where(eq(clients.slug, tokenOrSlug))
.limit(1); .limit(1);
if (clientRow.length === 0) { // Fall back to token
return null; 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);
} }
const client = clientRow[0]; if (clientRows.length === 0) return null;
const client = clientRows[0];
// Get all projects for this client (data is now project-scoped)
const projectRows = await db const projectRows = await db
.select({ id: projects.id }) .select({ id: projects.id, name: projects.name, archived: projects.archived })
.from(projects) .from(projects)
.where(eq(projects.client_id, client.id)); .where(eq(projects.client_id, client.id))
const projectIds = projectRows.map((p) => p.id); .orderBy(asc(projects.created_at));
if (projectIds.length === 0) { const activeProjects = projectRows.filter((p) => !p.archived);
return {
client: { return { client, projects: activeProjects };
id: client.id, }
name: client.name,
brand_name: client.brand_name, /**
brief: client.brief, * Returns full project data for the client dashboard.
accepted_total: client.accepted_total ?? '0', * 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).
phases: [], */
payments: [], export async function getProjectView(projectId: string): Promise<ProjectView | null> {
documents: [], const projectRows = await db
notes: [], .select({
global_progress_pct: 0, 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];
// Fetch all phases across all projects, ordered by sort_order
const phasesRows = await db const phasesRows = await db
.select() .select()
.from(phases) .from(phases)
.where(inArray(phases.project_id, projectIds)) .where(eq(phases.project_id, projectId))
.orderBy(phases.sort_order); .orderBy(asc(phases.sort_order));
const phaseIds = phasesRows.map((p) => p.id); const phaseIds = phasesRows.map((p) => p.id);
const tasksRows = const tasksRows =
phaseIds.length === 0 phaseIds.length === 0
? [] ? []
@@ -116,108 +210,96 @@ export async function getClientView(token: string): Promise<ClientView | null> {
.select() .select()
.from(tasks) .from(tasks)
.where(inArray(tasks.phase_id, phaseIds)) .where(inArray(tasks.phase_id, phaseIds))
.orderBy(tasks.sort_order); .orderBy(asc(tasks.sort_order));
const taskIds = tasksRows.map((t) => t.id); const taskIds = tasksRows.map((t) => t.id);
// approved_at included — immutable audit trail (CLAUDE.md constraint LOCKED)
const deliverablesRows = const deliverablesRows =
taskIds.length === 0 taskIds.length === 0
? [] ? []
: await db : await db
.select() .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) .from(deliverables)
.where(inArray(deliverables.task_id, taskIds)); .where(inArray(deliverables.task_id, taskIds));
// Fetch payments — label and status only, amount is intentionally excluded from ClientView // Payments — status only, NO amount (DASH-07 / CLAUDE.md)
const paymentsRows = await db const paymentsRows = await db
.select() .select({
id: payments.id,
label: payments.label,
status: payments.status,
// amount intentionally excluded — client API never exposes payment amounts
})
.from(payments) .from(payments)
.where(inArray(payments.project_id, projectIds)); .where(eq(payments.project_id, projectId));
const documentsRows = await db const documentsRows = await db
.select() .select({
id: documents.id,
label: documents.label,
url: documents.url,
created_at: documents.created_at,
})
.from(documents) .from(documents)
.where(inArray(documents.project_id, projectIds)); .where(eq(documents.project_id, projectId))
.orderBy(asc(documents.created_at));
const notesRows = await db const notesRows = await db
.select() .select({ id: notes.id, body: notes.body, created_at: notes.created_at })
.from(notes) .from(notes)
.where(inArray(notes.project_id, projectIds)) .where(eq(notes.project_id, projectId))
.orderBy(notes.created_at); .orderBy(asc(notes.created_at));
// Build hierarchical structure: phases → tasks deliverables // Comments scoped to tasks and deliverables of this project
const phasesList = phasesRows.map((phase) => { const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
const phaseTasksRows = tasksRows.filter((t) => t.phase_id === phase.id); const commentsRows =
allEntityIds.length === 0
? []
: await db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at));
const tasksList = phaseTasksRows.map((task) => { const phasesWithTasks = phasesRows.map((phase) => {
const taskDeliverables = deliverablesRows const phaseTasks = tasksRows
.filter((d) => d.task_id === task.id) .filter((t) => t.phase_id === phase.id)
.map((d) => ({ .map((task) => ({
id: d.id, ...task,
title: d.title, deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
url: d.url, }));
status: d.status as 'pending' | 'submitted' | 'approved',
// approved_at is immutable once set (Architecture constraint LOCKED)
approved_at: d.approved_at ? new Date(d.approved_at).toISOString() : null,
}));
return { const doneCount = phaseTasks.filter((t) => t.status === "done").length;
id: task.id, const progress_pct =
title: task.title, phaseTasks.length > 0 ? Math.round((doneCount / phaseTasks.length) * 100) : 0;
description: task.description,
status: task.status as 'todo' | 'in_progress' | 'done',
sort_order: task.sort_order,
deliverables: taskDeliverables,
};
});
const taskCount = tasksList.length; return { ...phase, tasks: phaseTasks, progress_pct };
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,
};
}); });
const allDoneCount = tasksRows.filter((t) => t.status === 'done').length; const doneTasks = tasksRows.filter((t) => t.status === "done").length;
const globalProgressPct = const global_progress_pct =
tasksRows.length === 0 ? 0 : Math.round((allDoneCount / tasksRows.length) * 100); tasksRows.length > 0 ? Math.round((doneTasks / tasksRows.length) * 100) : 0;
const paymentsList = paymentsRows.map((p) => ({
id: p.id,
label: p.label,
status: p.status as 'da_saldare' | 'inviata' | 'saldato',
}));
const documentsList = documentsRows.map((d) => ({
id: d.id,
label: d.label,
url: d.url,
}));
const notesList = notesRows.map((n) => ({
id: n.id,
body: n.body,
created_at: new Date(n.created_at).toISOString(),
}));
return { return {
client: { project: {
id: client.id, id: project.id,
name: client.name, name: project.name,
brand_name: client.brand_name, client_id: project.client_id,
brief: client.brief, accepted_total: project.accepted_total ?? "0",
accepted_total: client.accepted_total ?? '0',
}, },
phases: phasesList, phases: phasesWithTasks,
payments: paymentsList, payments: paymentsRows,
documents: documentsList, documents: documentsRows,
notes: notesList, notes: notesRows,
global_progress_pct: globalProgressPct, comments: commentsRows,
global_progress_pct,
}; };
} }
+18 -7
View File
@@ -28,23 +28,34 @@ export async function proxy(request: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// ── CLIENT TOKEN GUARD ─────────────────────────────────────────────────── // ── CLIENT TOKEN/SLUG GUARD ──────────────────────────────────────────────
if (pathname.startsWith("/client/")) { if (pathname.startsWith("/client/")) {
const tokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/); const slugOrTokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/);
if (!tokenMatch) { if (!slugOrTokenMatch) {
return NextResponse.rewrite(new URL("/not-found", request.url)); return NextResponse.rewrite(new URL("/not-found", request.url));
} }
const clientToken = tokenMatch[1]; const slugOrToken = slugOrTokenMatch[1];
try { try {
// Call internal Node.js API route — Edge middleware cannot use postgres-js directly // Call internal Node.js API route — Edge middleware cannot use postgres-js directly
// postgres-js requires Node.js net/tls which are unavailable in the Edge runtime // postgres-js requires Node.js net/tls which are unavailable in the Edge runtime
const validateUrl = new URL(
`/api/internal/validate-token?token=${encodeURIComponent(clientToken)}`, // Try slug first (D-06) — user-friendly slugs before token fallback
const validateSlugUrl = new URL(
`/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
request.url request.url
); );
const res = await fetch(validateUrl.toString()); let res = await fetch(validateSlugUrl.toString());
// If slug not found, fall back to token validation (existing links continue to work)
if (!res.ok) {
const validateTokenUrl = new URL(
`/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
request.url
);
res = await fetch(validateTokenUrl.toString());
}
if (!res.ok) { if (!res.ok) {
return NextResponse.rewrite(new URL("/not-found", request.url)); return NextResponse.rewrite(new URL("/not-found", request.url));