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:
@@ -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).
|
||||
@@ -47,6 +47,12 @@ const clientSchema = z.object({
|
||||
name: z.string().min(1, "Nome richiesto"),
|
||||
brand_name: z.string().min(1, "Brand name richiesto"),
|
||||
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) {
|
||||
@@ -54,11 +60,21 @@ export async function updateClient(clientId: string, formData: FormData) {
|
||||
name: formData.get("name"),
|
||||
brand_name: formData.get("brand_name"),
|
||||
brief: formData.get("brief") ?? "",
|
||||
slug: formData.get("slug") ?? "",
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
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");
|
||||
redirect(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function deleteClient(clientId: string) {
|
||||
|
||||
@@ -11,10 +11,13 @@ import Link from "next/link";
|
||||
|
||||
export default async function EditClientPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { error } = await searchParams;
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
|
||||
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>
|
||||
|
||||
{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
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
@@ -64,6 +73,27 @@ export default async function EditClientPage({
|
||||
/>
|
||||
</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">
|
||||
<Button type="submit">Salva modifiche</Button>
|
||||
<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 });
|
||||
}
|
||||
}
|
||||
+153
-35
@@ -1,16 +1,73 @@
|
||||
import { cache } from 'react';
|
||||
import { getClientView } from '@/lib/client-view';
|
||||
import { ClientDashboard } from '@/components/client-dashboard';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { db } from '@/db';
|
||||
import { comments } from '@/db/schema';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import type { Comment } from '@/db/schema';
|
||||
import { cache } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getClientWithProjectsByToken,
|
||||
getProjectView,
|
||||
type ProjectView,
|
||||
type ClientView,
|
||||
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 getCachedClientView = cache(getClientView);
|
||||
const getCachedClientData = cache(getClientWithProjectsByToken);
|
||||
|
||||
// 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({
|
||||
params,
|
||||
@@ -18,15 +75,11 @@ export async function generateMetadata({
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const view = await getCachedClientView(token);
|
||||
|
||||
if (!view) {
|
||||
return { title: 'Not Found' };
|
||||
}
|
||||
|
||||
const clientData = await getCachedClientData(token);
|
||||
if (!clientData) return { title: "Not Found" };
|
||||
return {
|
||||
title: `${view.client.brand_name} — Stato Progetto | iamcavalli`,
|
||||
description: view.client.brief || 'Dashboard stato progetto',
|
||||
title: `${clientData.client.brand_name} — Stato Progetto | iamcavalli`,
|
||||
description: "Dashboard stato progetto",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,26 +89,91 @@ export default async function ClientPage({
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const view = await getCachedClientView(token);
|
||||
|
||||
if (!view) {
|
||||
notFound();
|
||||
const clientData = await getCachedClientData(token);
|
||||
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)
|
||||
const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id));
|
||||
const allDeliverableIds = view.phases.flatMap((p) =>
|
||||
p.tasks.flatMap((t) => t.deliverables.map((d) => d.id))
|
||||
if (projects.length === 1) {
|
||||
// D-09: single project → direct view without selector
|
||||
const view = await getProjectView(projects[0].id);
|
||||
if (!view) notFound();
|
||||
return (
|
||||
<ClientDashboard
|
||||
view={projectViewToClientView(client, view)}
|
||||
token={token}
|
||||
comments={view.comments as unknown as Comment[]}
|
||||
/>
|
||||
);
|
||||
const allEntityIds = [view.client.id, ...allTaskIds, ...allDeliverableIds];
|
||||
}
|
||||
|
||||
const allComments: Comment[] =
|
||||
allEntityIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(inArray(comments.entity_id, allEntityIds))
|
||||
: [];
|
||||
// D-10: 2+ projects → tabs with project names
|
||||
const projectViews = await Promise.all(projects.map((p) => getProjectView(p.id)));
|
||||
|
||||
return <ClientDashboard view={view} token={token} comments={allComments} />;
|
||||
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>
|
||||
);
|
||||
}
|
||||
+212
-130
@@ -1,14 +1,11 @@
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { clients, projects, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema';
|
||||
import { eq, inArray, asc } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
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.
|
||||
* 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 {
|
||||
client: {
|
||||
@@ -16,34 +13,33 @@ export interface ClientView {
|
||||
name: string;
|
||||
brand_name: string;
|
||||
brief: string;
|
||||
accepted_total: string; // only total, never breakdown
|
||||
accepted_total: string;
|
||||
};
|
||||
phases: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'upcoming' | 'active' | 'done';
|
||||
status: "upcoming" | "active" | "done";
|
||||
sort_order: number;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
status: "todo" | "in_progress" | "done";
|
||||
sort_order: number;
|
||||
deliverables: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
url: string | null;
|
||||
status: 'pending' | 'submitted' | 'approved';
|
||||
approved_at: string | null; // ISO timestamp — immutable once set
|
||||
status: "pending" | "submitted" | "approved";
|
||||
approved_at: string | null;
|
||||
}>;
|
||||
}>;
|
||||
progress_pct: number; // % of tasks done in this phase
|
||||
progress_pct: number;
|
||||
}>;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
label: string; // "Acconto 50%" | "Saldo 50%"
|
||||
status: 'da_saldare' | 'inviata' | 'saldato';
|
||||
// NOTE: amount is intentionally omitted — clients see only label and status
|
||||
label: string;
|
||||
status: "da_saldare" | "inviata" | "saldato";
|
||||
}>;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
@@ -53,62 +49,160 @@ export interface ClientView {
|
||||
notes: Array<{
|
||||
id: 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.
|
||||
* NEVER queries quote_items, service_catalog, or service prices.
|
||||
* Aggregates data across all projects for this client.
|
||||
* Resolves a token-or-slug to a client and returns the client's active projects.
|
||||
* Lookup order: slug first, then token — mirrors middleware order (D-06).
|
||||
*/
|
||||
export async function getClientView(token: string): Promise<ClientView | null> {
|
||||
// Fetch client by token (Architecture constraint: token is separate from id PK)
|
||||
const clientRow = await db
|
||||
.select()
|
||||
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.token, token))
|
||||
.where(eq(clients.slug, tokenOrSlug))
|
||||
.limit(1);
|
||||
|
||||
if (clientRow.length === 0) {
|
||||
return null;
|
||||
// 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);
|
||||
}
|
||||
|
||||
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
|
||||
.select({ id: projects.id })
|
||||
.select({ id: projects.id, name: projects.name, archived: projects.archived })
|
||||
.from(projects)
|
||||
.where(eq(projects.client_id, client.id));
|
||||
const projectIds = projectRows.map((p) => p.id);
|
||||
.where(eq(projects.client_id, client.id))
|
||||
.orderBy(asc(projects.created_at));
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return {
|
||||
client: {
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
brand_name: client.brand_name,
|
||||
brief: client.brief,
|
||||
accepted_total: client.accepted_total ?? '0',
|
||||
},
|
||||
phases: [],
|
||||
payments: [],
|
||||
documents: [],
|
||||
notes: [],
|
||||
global_progress_pct: 0,
|
||||
};
|
||||
}
|
||||
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];
|
||||
|
||||
// Fetch all phases across all projects, ordered by sort_order
|
||||
const phasesRows = await db
|
||||
.select()
|
||||
.from(phases)
|
||||
.where(inArray(phases.project_id, projectIds))
|
||||
.orderBy(phases.sort_order);
|
||||
.where(eq(phases.project_id, projectId))
|
||||
.orderBy(asc(phases.sort_order));
|
||||
|
||||
const phaseIds = phasesRows.map((p) => p.id);
|
||||
|
||||
const tasksRows =
|
||||
phaseIds.length === 0
|
||||
? []
|
||||
@@ -116,108 +210,96 @@ export async function getClientView(token: string): Promise<ClientView | null> {
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(inArray(tasks.phase_id, phaseIds))
|
||||
.orderBy(tasks.sort_order);
|
||||
.orderBy(asc(tasks.sort_order));
|
||||
|
||||
const taskIds = tasksRows.map((t) => t.id);
|
||||
|
||||
// approved_at included — immutable audit trail (CLAUDE.md constraint LOCKED)
|
||||
const deliverablesRows =
|
||||
taskIds.length === 0
|
||||
? []
|
||||
: 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)
|
||||
.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
|
||||
.select()
|
||||
.select({
|
||||
id: payments.id,
|
||||
label: payments.label,
|
||||
status: payments.status,
|
||||
// amount intentionally excluded — client API never exposes payment amounts
|
||||
})
|
||||
.from(payments)
|
||||
.where(inArray(payments.project_id, projectIds));
|
||||
.where(eq(payments.project_id, projectId));
|
||||
|
||||
const documentsRows = await db
|
||||
.select()
|
||||
.select({
|
||||
id: documents.id,
|
||||
label: documents.label,
|
||||
url: documents.url,
|
||||
created_at: documents.created_at,
|
||||
})
|
||||
.from(documents)
|
||||
.where(inArray(documents.project_id, projectIds));
|
||||
.where(eq(documents.project_id, projectId))
|
||||
.orderBy(asc(documents.created_at));
|
||||
|
||||
const notesRows = await db
|
||||
.select()
|
||||
.select({ id: notes.id, body: notes.body, created_at: notes.created_at })
|
||||
.from(notes)
|
||||
.where(inArray(notes.project_id, projectIds))
|
||||
.orderBy(notes.created_at);
|
||||
.where(eq(notes.project_id, projectId))
|
||||
.orderBy(asc(notes.created_at));
|
||||
|
||||
// Build hierarchical structure: phases → tasks → deliverables
|
||||
const phasesList = phasesRows.map((phase) => {
|
||||
const phaseTasksRows = tasksRows.filter((t) => t.phase_id === phase.id);
|
||||
// Comments scoped to tasks and deliverables of 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));
|
||||
|
||||
const tasksList = phaseTasksRows.map((task) => {
|
||||
const taskDeliverables = deliverablesRows
|
||||
.filter((d) => d.task_id === task.id)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
url: d.url,
|
||||
status: d.status as 'pending' | 'submitted' | 'approved',
|
||||
// approved_at is immutable once set (Architecture constraint LOCKED)
|
||||
approved_at: d.approved_at ? new Date(d.approved_at).toISOString() : null,
|
||||
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),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status as 'todo' | 'in_progress' | 'done',
|
||||
sort_order: task.sort_order,
|
||||
deliverables: taskDeliverables,
|
||||
};
|
||||
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 };
|
||||
});
|
||||
|
||||
const taskCount = tasksList.length;
|
||||
const doneCount = tasksList.filter((t) => t.status === 'done').length;
|
||||
const progress_pct = taskCount === 0 ? 0 : Math.round((doneCount / taskCount) * 100);
|
||||
const doneTasks = tasksRows.filter((t) => t.status === "done").length;
|
||||
const global_progress_pct =
|
||||
tasksRows.length > 0 ? Math.round((doneTasks / tasksRows.length) * 100) : 0;
|
||||
|
||||
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 globalProgressPct =
|
||||
tasksRows.length === 0 ? 0 : Math.round((allDoneCount / tasksRows.length) * 100);
|
||||
|
||||
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 {
|
||||
client: {
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
brand_name: client.brand_name,
|
||||
brief: client.brief,
|
||||
accepted_total: client.accepted_total ?? '0',
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
client_id: project.client_id,
|
||||
accepted_total: project.accepted_total ?? "0",
|
||||
},
|
||||
phases: phasesList,
|
||||
payments: paymentsList,
|
||||
documents: documentsList,
|
||||
notes: notesList,
|
||||
global_progress_pct: globalProgressPct,
|
||||
phases: phasesWithTasks,
|
||||
payments: paymentsRows,
|
||||
documents: documentsRows,
|
||||
notes: notesRows,
|
||||
comments: commentsRows,
|
||||
global_progress_pct,
|
||||
};
|
||||
}
|
||||
+18
-7
@@ -28,23 +28,34 @@ export async function proxy(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// ── CLIENT TOKEN GUARD ───────────────────────────────────────────────────
|
||||
// ── CLIENT TOKEN/SLUG GUARD ──────────────────────────────────────────────
|
||||
if (pathname.startsWith("/client/")) {
|
||||
const tokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/);
|
||||
if (!tokenMatch) {
|
||||
const slugOrTokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/);
|
||||
if (!slugOrTokenMatch) {
|
||||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||
}
|
||||
|
||||
const clientToken = tokenMatch[1];
|
||||
const slugOrToken = slugOrTokenMatch[1];
|
||||
|
||||
try {
|
||||
// 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
|
||||
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
|
||||
);
|
||||
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) {
|
||||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||
|
||||
Reference in New Issue
Block a user