docs(02-admin-area-interactive-features): complete phase 2 planning with 4-plan structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,9 +50,14 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
2. L'admin può creare un nuovo cliente (con generazione automatica del link segreto), aggiungere fasi, task, documenti e aggiornare lo stato dei pagamenti
|
2. L'admin può creare un nuovo cliente (con generazione automatica del link segreto), aggiungere fasi, task, documenti e aggiornare lo stato dei pagamenti
|
||||||
3. Il cliente può approvare un deliverable dalla sua dashboard; l'approvazione persiste con timestamp immutabile e l'admin la vede
|
3. Il cliente può approvare un deliverable dalla sua dashboard; l'approvazione persiste con timestamp immutabile e l'admin la vede
|
||||||
4. Il cliente può lasciare un commento su un task o deliverable e l'admin vede i commenti nella workspace admin
|
4. Il cliente può lasciare un commento su un task o deliverable e l'admin vede i commenti nella workspace admin
|
||||||
**Plans**: TBD
|
**Plans**: 4 plans
|
||||||
|
**Plan list**:
|
||||||
|
- [ ] 02-01-PLAN.md — Auth.js v4 setup + middleware admin guard (/admin/* session check)
|
||||||
|
- [ ] 02-02-PLAN.md — Admin client list (/admin) + create client form + two payment stubs
|
||||||
|
- [ ] 02-03-PLAN.md — Admin client workspace: tabs for phases/tasks, payments, documents, comments
|
||||||
|
- [ ] 02-04-PLAN.md — Client interactions: deliverable approval + inline comments API + dashboard wiring
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
**Status**: Pending planning
|
**Status**: Planned — ready for execution
|
||||||
|
|
||||||
### Phase 3: Service Catalog & Quote Builder
|
### Phase 3: Service Catalog & Quote Builder
|
||||||
**Goal**: L'admin può costruire un catalogo servizi riutilizzabile e comporre preventivi da esso; il cliente vede solo il totale accettato
|
**Goal**: L'admin può costruire un catalogo servizi riutilizzabile e comporre preventivi da esso; il cliente vede solo il totale accettato
|
||||||
@@ -88,6 +93,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
|
|||||||
| Phase | Plans | Status | Completed |
|
| Phase | Plans | Status | Completed |
|
||||||
|-------|-------|--------|-----------|
|
|-------|-------|--------|-----------|
|
||||||
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
|
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
|
||||||
| 2. Admin Area & Interactive Features | 0/TBD | Planning next | - |
|
| 2. Admin Area & Interactive Features | 4/4 | Planned | - |
|
||||||
| 3. Service Catalog & Quote Builder | 0/TBD | Pending | - |
|
| 3. Service Catalog & Quote Builder | 0/TBD | Pending | - |
|
||||||
| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
||||||
|
|||||||
+20
-2
@@ -1,3 +1,19 @@
|
|||||||
|
---
|
||||||
|
gsd_state_version: 1.0
|
||||||
|
milestone: v1.0
|
||||||
|
milestone_name: milestone
|
||||||
|
status: executing
|
||||||
|
stopped_at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404)
|
||||||
|
last_updated: "2026-05-15T08:30:10.605Z"
|
||||||
|
last_activity: 2026-05-15 -- Phase 02 planning complete
|
||||||
|
progress:
|
||||||
|
total_phases: 4
|
||||||
|
completed_phases: 1
|
||||||
|
total_plans: 9
|
||||||
|
completed_plans: 5
|
||||||
|
percent: 56
|
||||||
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
@@ -11,14 +27,15 @@ See: .planning/PROJECT.md (updated 2026-05-09)
|
|||||||
|
|
||||||
Phase: 2 of 4 (Admin Area & Interactive Features)
|
Phase: 2 of 4 (Admin Area & Interactive Features)
|
||||||
Plan: 0 of TBD in current phase
|
Plan: 0 of TBD in current phase
|
||||||
Status: Phase 1 complete — ready for `/gsd-plan-phase 2`
|
Status: Ready to execute
|
||||||
Last activity: 2026-05-14 — Phase 1 execution complete (all 5 plans, E2E verified)
|
Last activity: 2026-05-15 -- Phase 02 planning complete
|
||||||
|
|
||||||
Progress: [██░░░░░░░░] 25%
|
Progress: [██░░░░░░░░] 25%
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
|
|
||||||
- Total plans completed: 5
|
- Total plans completed: 5
|
||||||
- Average duration: ~1 session each
|
- Average duration: ~1 session each
|
||||||
- Total execution time: ~2 sessions (May 13–14)
|
- Total execution time: ~2 sessions (May 13–14)
|
||||||
@@ -30,6 +47,7 @@ Progress: [██░░░░░░░░] 25%
|
|||||||
| 1. Foundation & Client Dashboard | 5 | 2 sessions | ~0.4 sessions |
|
| 1. Foundation & Client Dashboard | 5 | 2 sessions | ~0.4 sessions |
|
||||||
|
|
||||||
**Recent Trend:**
|
**Recent Trend:**
|
||||||
|
|
||||||
- Last 5 plans: 01-01, 01-02, 01-03, 01-04, 01-05
|
- Last 5 plans: 01-01, 01-02, 01-03, 01-04, 01-05
|
||||||
- Trend: Steady, one blocker fixed mid-execution (Tailwind scanning external projects)
|
- Trend: Steady, one blocker fixed mid-execution (Tailwind scanning external projects)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
---
|
||||||
|
phase: "02-admin-area-interactive-features"
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- package.json
|
||||||
|
- src/proxy.ts
|
||||||
|
- src/app/api/auth/[...nextauth]/route.ts
|
||||||
|
- src/app/admin/login/page.tsx
|
||||||
|
- src/app/admin/login/actions.ts
|
||||||
|
- src/lib/auth.ts
|
||||||
|
- .env.local
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- ADMIN-01
|
||||||
|
- ADMIN-02
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Admin can POST /admin/login with ADMIN_EMAIL + ADMIN_PASSWORD and receive a session JWT cookie"
|
||||||
|
- "Visiting /admin/* without a valid session redirects to /admin/login"
|
||||||
|
- "Visiting /c/[token]/* still validates token at edge (proxy.ts unchanged for client routes)"
|
||||||
|
- "Session is JWT-based (stateless) — no DB users table involved"
|
||||||
|
- "ADMIN_EMAIL and ADMIN_PASSWORD are read from env vars, never hardcoded"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/lib/auth.ts"
|
||||||
|
provides: "NextAuth config — CredentialsProvider validating against ADMIN_EMAIL/ADMIN_PASSWORD env vars"
|
||||||
|
contains: "CredentialsProvider"
|
||||||
|
- path: "src/app/api/auth/[...nextauth]/route.ts"
|
||||||
|
provides: "NextAuth catch-all route handler"
|
||||||
|
contains: "NextAuth"
|
||||||
|
- path: "src/app/admin/login/page.tsx"
|
||||||
|
provides: "Admin login form UI (email + password, submit)"
|
||||||
|
min_lines: 30
|
||||||
|
- path: "src/proxy.ts"
|
||||||
|
provides: "Updated proxy: /c/* token validation + /admin/* session guard"
|
||||||
|
contains: "getToken"
|
||||||
|
key_links:
|
||||||
|
- from: "src/proxy.ts"
|
||||||
|
to: "src/app/api/auth/[...nextauth]/route.ts"
|
||||||
|
via: "getToken({ req, secret: process.env.NEXTAUTH_SECRET })"
|
||||||
|
pattern: "getToken"
|
||||||
|
- from: "src/app/admin/login/page.tsx"
|
||||||
|
to: "/api/auth/callback/credentials"
|
||||||
|
via: "signIn('credentials', { email, password })"
|
||||||
|
pattern: "signIn"
|
||||||
|
- from: "ADMIN_EMAIL + ADMIN_PASSWORD"
|
||||||
|
to: "CredentialsProvider authorize()"
|
||||||
|
via: "process.env.ADMIN_EMAIL"
|
||||||
|
pattern: "ADMIN_EMAIL"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
**Auth.js Admin Session + Proxy Guard:** Install next-auth@4, configure a CredentialsProvider that validates against ADMIN_EMAIL/ADMIN_PASSWORD env vars, wire the catch-all API route, build the login page, and extend the existing src/proxy.ts to guard /admin/* routes with a session check.
|
||||||
|
|
||||||
|
Purpose: Gate the entire admin area behind Auth.js JWT session before any admin UI is built. Two independent auth paths are enforced: /c/[token]/* uses edge token validation (unchanged from Phase 1), /admin/* uses getToken() from next-auth/jwt. No DB users table — single admin, env-var credentials only (per D-01, D-02, D-03, D-04).
|
||||||
|
|
||||||
|
Output: Working /admin/login page, session cookie on successful login, automatic redirect to /admin/login for unauthenticated access to any /admin/* route.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
||||||
|
@.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Existing proxy from Phase 1 (src/proxy.ts) — EXTEND this file, do not create src/middleware.ts -->
|
||||||
|
<!-- Current structure: named export `proxy(request)` + config.matcher = ['/c/:path*'] -->
|
||||||
|
<!-- Next.js requires the export to be named `middleware` — rename proxy→middleware in this task -->
|
||||||
|
<!-- Phase 2 extends it to also handle /admin/:path* session guard using getToken() from next-auth/jwt -->
|
||||||
|
|
||||||
|
Current src/proxy.ts content:
|
||||||
|
```typescript
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
const pathname = request.nextUrl.pathname;
|
||||||
|
|
||||||
|
// Extract token from path: /c/[token]/...
|
||||||
|
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||||
|
if (!tokenMatch) {
|
||||||
|
return NextResponse.rewrite(new URL('/not-found', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = tokenMatch[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validateUrl = new URL(
|
||||||
|
`/api/internal/validate-token?token=${encodeURIComponent(token)}`,
|
||||||
|
request.url
|
||||||
|
);
|
||||||
|
const res = await fetch(validateUrl.toString());
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.rewrite(new URL('/not-found', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.rewrite(new URL('/not-found', request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/c/:path*'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Next.js middleware MUST be exported as `middleware`, not `proxy`. The Phase 1 file uses `proxy` — this plan must rename it to `middleware` while extending it with /admin/* guard. No src/middleware.ts should ever be created.
|
||||||
|
|
||||||
|
From src/db/index.ts:
|
||||||
|
```typescript
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
export const db = drizzle(client);
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/db/schema.ts (types needed in this plan):
|
||||||
|
```typescript
|
||||||
|
// No schema changes needed — no users table. Auth is env-var only.
|
||||||
|
export type Client = typeof clients.$inferSelect;
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Install next-auth@4, create src/lib/auth.ts and NextAuth catch-all route</name>
|
||||||
|
<files>
|
||||||
|
package.json
|
||||||
|
src/lib/auth.ts
|
||||||
|
src/app/api/auth/[...nextauth]/route.ts
|
||||||
|
.env.local
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Install next-auth v4 (stable — v5 is still beta RC as of 2026-05-15, per D-01):
|
||||||
|
```
|
||||||
|
npm install next-auth@4
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to .env.local (generate NEXTAUTH_SECRET with: `openssl rand -base64 32`):
|
||||||
|
```
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
NEXTAUTH_SECRET=<generated-32-byte-base64-string>
|
||||||
|
ADMIN_EMAIL=simone.cavalli.gestione@gmail.com
|
||||||
|
ADMIN_PASSWORD=<choose-a-strong-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/lib/auth.ts` — NextAuth config, no DB adapter (per D-03):
|
||||||
|
```typescript
|
||||||
|
import type { NextAuthOptions } from "next-auth";
|
||||||
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: "credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null;
|
||||||
|
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (!adminEmail || !adminPassword) {
|
||||||
|
throw new Error("ADMIN_EMAIL and ADMIN_PASSWORD env vars must be set");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
credentials.email === adminEmail &&
|
||||||
|
credentials.password === adminPassword
|
||||||
|
) {
|
||||||
|
// Return minimal session user — no DB lookup needed
|
||||||
|
return { id: "admin", email: adminEmail, name: "Admin" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // null = unauthorized (NextAuth returns 401)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
session: {
|
||||||
|
strategy: "jwt", // stateless JWT — no DB session table (per D-03)
|
||||||
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/admin/login", // custom login page (per D-07)
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
(session.user as { id?: string }).id = token.id as string;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/app/api/auth/[...nextauth]/route.ts` — NextAuth catch-all:
|
||||||
|
```typescript
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: next-auth@4 with App Router uses this export pattern. The handler handles
|
||||||
|
GET (session fetch, CSRF) and POST (sign in, sign out).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q '"next-auth"' package.json && echo "next-auth installed"</automated>
|
||||||
|
<automated>test -f src/lib/auth.ts && grep -q "CredentialsProvider" src/lib/auth.ts && echo "CredentialsProvider configured"</automated>
|
||||||
|
<automated>grep -q "strategy.*jwt" src/lib/auth.ts && echo "JWT session strategy set"</automated>
|
||||||
|
<automated>grep -q "ADMIN_EMAIL" src/lib/auth.ts && echo "ADMIN_EMAIL env var referenced"</automated>
|
||||||
|
<automated>test -f src/app/api/auth/\[...nextauth\]/route.ts && grep -q "NextAuth" src/app/api/auth/\[...nextauth\]/route.ts && echo "NextAuth route created"</automated>
|
||||||
|
<automated>grep -q "NEXTAUTH_SECRET" .env.local && echo "NEXTAUTH_SECRET in .env.local"</automated>
|
||||||
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- next-auth@4 is in package.json
|
||||||
|
- src/lib/auth.ts exports authOptions with CredentialsProvider using env vars
|
||||||
|
- src/app/api/auth/[...nextauth]/route.ts exports GET and POST handlers
|
||||||
|
- NEXTAUTH_SECRET, ADMIN_EMAIL, ADMIN_PASSWORD are set in .env.local
|
||||||
|
- npm run build passes without errors
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Extend src/proxy.ts to guard /admin/* with session check; create /admin/login page</name>
|
||||||
|
<files>
|
||||||
|
src/proxy.ts
|
||||||
|
src/app/admin/login/page.tsx
|
||||||
|
src/app/admin/login/actions.ts
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
**Replace** `src/proxy.ts` entirely. NEVER create src/middleware.ts — it would be a dead file ignored by Next.js since this project uses src/proxy.ts as the middleware entry point. The new file renames the export from `proxy` to `middleware` (required by Next.js) and adds the /admin/* guard alongside the existing /c/* token validation logic (per D-04):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const pathname = request.nextUrl.pathname;
|
||||||
|
|
||||||
|
// ── ADMIN GUARD ──────────────────────────────────────────────────────────
|
||||||
|
if (pathname.startsWith("/admin")) {
|
||||||
|
// Allow the login page and NextAuth API routes through without session check
|
||||||
|
if (
|
||||||
|
pathname === "/admin/login" ||
|
||||||
|
pathname.startsWith("/api/auth")
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getToken({
|
||||||
|
req: request,
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
const loginUrl = new URL("/admin/login", request.url);
|
||||||
|
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLIENT TOKEN GUARD ───────────────────────────────────────────────────
|
||||||
|
if (pathname.startsWith("/c/")) {
|
||||||
|
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||||
|
if (!tokenMatch) {
|
||||||
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientToken = tokenMatch[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validateUrl = new URL(
|
||||||
|
`/api/internal/validate-token?token=${encodeURIComponent(clientToken)}`,
|
||||||
|
request.url
|
||||||
|
);
|
||||||
|
const res = await fetch(validateUrl.toString());
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/admin/:path*", "/c/:path*"],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The export is renamed from `proxy` to `middleware`. This is the only correct Next.js middleware export name. The /c/* logic is preserved verbatim from Phase 1.
|
||||||
|
|
||||||
|
Create `src/app/admin/login/page.tsx` — login form as Client Component:
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const callbackUrl = searchParams.get("callbackUrl") ?? "/admin";
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false, // handle redirect manually to show errors
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError("Email o password non corretti.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace(callbackUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Admin — ClientHub</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Accesso in corso..." : "Accedi"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT create src/app/admin/login/actions.ts — the login is handled
|
||||||
|
client-side via signIn(). No Server Action file is needed.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/proxy.ts && grep -q "getToken" src/proxy.ts && echo "getToken imported in proxy.ts"</automated>
|
||||||
|
<automated>grep -q "export async function middleware" src/proxy.ts && echo "export named middleware (not proxy)"</automated>
|
||||||
|
<automated>grep -q '"/admin/:path\*"' src/proxy.ts && echo "admin matcher configured"</automated>
|
||||||
|
<automated>grep -q '"/c/:path\*"' src/proxy.ts && echo "client matcher still present"</automated>
|
||||||
|
<automated>grep -q "pathname === \"/admin/login\"" src/proxy.ts && echo "login page exempted from auth guard"</automated>
|
||||||
|
<automated>test -f src/app/admin/login/page.tsx && grep -q "signIn" src/app/admin/login/page.tsx && echo "login page uses signIn"</automated>
|
||||||
|
<automated>grep -q '"use client"' src/app/admin/login/page.tsx && echo "login page is Client Component"</automated>
|
||||||
|
<automated>test ! -f src/middleware.ts && echo "src/middleware.ts does NOT exist (correct)"</automated>
|
||||||
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- src/proxy.ts guards /admin/* routes: unauthenticated requests redirect to /admin/login?callbackUrl=...
|
||||||
|
- Export renamed from proxy to middleware (required by Next.js)
|
||||||
|
- /admin/login and /api/auth/* are exempt from the session guard
|
||||||
|
- /c/:path* token validation is unchanged
|
||||||
|
- /admin/login page renders email+password form, calls signIn('credentials'), shows error on failure, redirects on success
|
||||||
|
- src/middleware.ts does NOT exist
|
||||||
|
- npm run build passes
|
||||||
|
- Manual verification: visiting http://localhost:3000/admin redirects to /admin/login; successful login redirects back to /admin
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → /admin/* | All admin routes gated by JWT session cookie; proxy.ts rejects unauthenticated requests before any page code runs |
|
||||||
|
| Login form → CredentialsProvider | Email + password transmitted over HTTPS; validated in server-side authorize() only |
|
||||||
|
| NEXTAUTH_SECRET → JWT signing | All session tokens are HMAC-signed; tampering is detectable |
|
||||||
|
| ADMIN_EMAIL/ADMIN_PASSWORD → env vars | Credentials never in source code; must be in .env.local and Vercel environment |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-01 | Spoofing | Admin login | mitigate | CredentialsProvider validates against env vars server-side; password never logged; JWT signed with NEXTAUTH_SECRET |
|
||||||
|
| T-02-02 | Tampering | JWT session cookie | mitigate | next-auth signs JWT with NEXTAUTH_SECRET (HMAC-SHA256); proxy.ts verifies signature on every /admin request via getToken() |
|
||||||
|
| T-02-03 | Information Disclosure | ADMIN_PASSWORD in env | mitigate | Stored only in .env.local (gitignored) and Vercel environment secrets; never returned in API responses |
|
||||||
|
| T-02-04 | Elevation of Privilege | /api/auth/* exemption | accept | NextAuth API routes are exempt from session guard by design; they perform their own CSRF and credential validation internally |
|
||||||
|
| T-02-05 | Denial of Service | Brute-force login | accept | Single admin, not a public product; no rate limiting in v1. If needed in v2, add next-auth rate limit middleware. |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After plan execution:
|
||||||
|
1. `npm run build` — no TypeScript errors
|
||||||
|
2. `npm run dev`, visit http://localhost:3000/admin → redirects to /admin/login
|
||||||
|
3. Submit wrong credentials → error message "Email o password non corretti." appears
|
||||||
|
4. Submit correct ADMIN_EMAIL + ADMIN_PASSWORD → redirects to /admin (200, even if page is blank)
|
||||||
|
5. Visit http://localhost:3000/c/any-token → still validates token (client path unchanged)
|
||||||
|
6. Visit http://localhost:3000/api/auth/session after login → returns `{ user: { email, id: "admin" } }`
|
||||||
|
7. Confirm src/middleware.ts does not exist in the repo
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Admin can log in at /admin/login with env-var credentials and receive a JWT session cookie
|
||||||
|
- All /admin/* routes (except /admin/login and /api/auth/*) redirect unauthenticated visitors to /admin/login
|
||||||
|
- Client token route /c/:path* is unaffected
|
||||||
|
- No DB users table exists or is needed
|
||||||
|
- src/proxy.ts is the middleware file — src/middleware.ts never created
|
||||||
|
- npm run build passes cleanly
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
---
|
||||||
|
phase: "02-admin-area-interactive-features"
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- "02-01"
|
||||||
|
files_modified:
|
||||||
|
- src/app/admin/page.tsx
|
||||||
|
- src/app/admin/layout.tsx
|
||||||
|
- src/app/admin/clients/new/page.tsx
|
||||||
|
- src/app/admin/clients/new/actions.ts
|
||||||
|
- src/lib/admin-queries.ts
|
||||||
|
- src/components/admin/ClientRow.tsx
|
||||||
|
- src/components/admin/NavBar.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- ADMIN-01
|
||||||
|
- ADMIN-02
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Admin can see a list of all clients at /admin with name, brand, and payment status badges"
|
||||||
|
- "Admin can create a new client via /admin/clients/new form; on submit the client row + two payment rows are inserted and the secret link (token) is auto-generated"
|
||||||
|
- "After creating a client, admin is redirected to /admin (or /admin/clients/[id] for detail)"
|
||||||
|
- "The new client's shareable link /c/[token] is visible to the admin immediately after creation"
|
||||||
|
- "Payment status badges for Acconto and Saldo are visible in the client list row"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/app/admin/page.tsx"
|
||||||
|
provides: "Admin client list — Server Component fetching all clients with payments"
|
||||||
|
contains: "export default async function"
|
||||||
|
- path: "src/app/admin/layout.tsx"
|
||||||
|
provides: "Admin layout with minimal NavBar (logo + Clienti link + logout button)"
|
||||||
|
contains: "NavBar"
|
||||||
|
- path: "src/app/admin/clients/new/page.tsx"
|
||||||
|
provides: "New client form page"
|
||||||
|
min_lines: 30
|
||||||
|
- path: "src/app/admin/clients/new/actions.ts"
|
||||||
|
provides: "Server Action: createClient() — inserts client + 2 payment rows"
|
||||||
|
contains: "createClient"
|
||||||
|
- path: "src/lib/admin-queries.ts"
|
||||||
|
provides: "Admin-side DB query functions (getAllClientsWithPayments)"
|
||||||
|
contains: "getAllClientsWithPayments"
|
||||||
|
key_links:
|
||||||
|
- from: "src/app/admin/page.tsx"
|
||||||
|
to: "src/lib/admin-queries.ts"
|
||||||
|
via: "getAllClientsWithPayments()"
|
||||||
|
pattern: "getAllClientsWithPayments"
|
||||||
|
- from: "src/app/admin/clients/new/page.tsx"
|
||||||
|
to: "src/app/admin/clients/new/actions.ts"
|
||||||
|
via: "createClient Server Action"
|
||||||
|
pattern: "createClient"
|
||||||
|
- from: "createClient action"
|
||||||
|
to: "clients + payments tables"
|
||||||
|
via: "db.insert(clients) + db.insert(payments) x2"
|
||||||
|
pattern: "db.insert"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
**Admin Client List + Create Client:** Build the admin home page (client list with payment badges) and the new client creation form. The create form auto-generates the nanoid secret token, inserts the client row, and creates two payment rows (Acconto 50% / Saldo 50%) in a single Server Action.
|
||||||
|
|
||||||
|
Purpose: Deliver the first end-to-end admin capability — admin can enter a client's details and immediately get a shareable /c/[token] link. Implements ADMIN-01 (client list with status) and the creation half of ADMIN-02 (per D-05 Server Actions, D-07 list→detail layout, D-09 minimal nav).
|
||||||
|
|
||||||
|
Output: /admin shows all clients with payment badges; /admin/clients/new creates a client and two payment stubs.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Exact schema exports from src/db/schema.ts (do not re-read the file) -->
|
||||||
|
```typescript
|
||||||
|
export const clients = pgTable("clients", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
brand_name: text("brand_name").notNull(),
|
||||||
|
brief: text("brief").notNull(),
|
||||||
|
token: text("token").notNull().unique().$defaultFn(() => nanoid()),
|
||||||
|
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"),
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const payments = pgTable("payments", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||||
|
client_id: text("client_id").notNull().references(() => clients.id, { onDelete: "cascade" }),
|
||||||
|
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
|
||||||
|
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
||||||
|
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
|
||||||
|
paid_at: timestamp("paid_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Client = typeof clients.$inferSelect;
|
||||||
|
export type NewClient = typeof clients.$inferInsert;
|
||||||
|
export type Payment = typeof payments.$inferSelect;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/db/index.ts:
|
||||||
|
```typescript
|
||||||
|
export const db = drizzle(client); // drizzle-orm/postgres-js
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create src/lib/admin-queries.ts and admin layout + NavBar component</name>
|
||||||
|
<files>
|
||||||
|
src/lib/admin-queries.ts
|
||||||
|
src/app/admin/layout.tsx
|
||||||
|
src/components/admin/NavBar.tsx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Create `src/lib/admin-queries.ts` — all admin-side DB reads live here:
|
||||||
|
```typescript
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { clients, payments } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type ClientWithPayments = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand_name: string;
|
||||||
|
token: string;
|
||||||
|
accepted_total: string;
|
||||||
|
created_at: Date;
|
||||||
|
payments: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
amount: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAllClientsWithPayments(): Promise<ClientWithPayments[]> {
|
||||||
|
const allClients = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.orderBy(clients.created_at);
|
||||||
|
|
||||||
|
if (allClients.length === 0) return [];
|
||||||
|
|
||||||
|
const allPayments = await db
|
||||||
|
.select()
|
||||||
|
.from(payments);
|
||||||
|
|
||||||
|
return allClients.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
brand_name: c.brand_name,
|
||||||
|
token: c.token,
|
||||||
|
accepted_total: c.accepted_total ?? "0",
|
||||||
|
created_at: c.created_at,
|
||||||
|
payments: allPayments
|
||||||
|
.filter((p) => p.client_id === c.id)
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
status: p.status,
|
||||||
|
amount: p.amount,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClientById(id: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/components/admin/NavBar.tsx` — minimal nav per D-09 (no sidebar):
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function NavBar() {
|
||||||
|
return (
|
||||||
|
<nav className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<span className="font-semibold text-gray-900">ClientHub</span>
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Clienti
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
||||||
|
className="text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
Esci
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/app/admin/layout.tsx` — wraps all /admin/* pages:
|
||||||
|
```typescript
|
||||||
|
import { NavBar } from "@/components/admin/NavBar";
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<NavBar />
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/lib/admin-queries.ts && grep -q "getAllClientsWithPayments" src/lib/admin-queries.ts && echo "admin-queries.ts created"</automated>
|
||||||
|
<automated>grep -q "getClientById" src/lib/admin-queries.ts && echo "getClientById exported"</automated>
|
||||||
|
<automated>test -f src/components/admin/NavBar.tsx && grep -q "signOut" src/components/admin/NavBar.tsx && echo "NavBar with logout"</automated>
|
||||||
|
<automated>test -f src/app/admin/layout.tsx && grep -q "NavBar" src/app/admin/layout.tsx && echo "Admin layout wraps NavBar"</automated>
|
||||||
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- src/lib/admin-queries.ts exports getAllClientsWithPayments() and getClientById()
|
||||||
|
- NavBar renders with "Clienti" link and "Esci" button
|
||||||
|
- Admin layout wraps all /admin/* pages with NavBar + centered main content area
|
||||||
|
- npm run build passes
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Build /admin client list page and /admin/clients/new create-client flow</name>
|
||||||
|
<files>
|
||||||
|
src/app/admin/page.tsx
|
||||||
|
src/components/admin/ClientRow.tsx
|
||||||
|
src/app/admin/clients/new/page.tsx
|
||||||
|
src/app/admin/clients/new/actions.ts
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Create `src/components/admin/ClientRow.tsx` — single row in client list table:
|
||||||
|
```typescript
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { ClientWithPayments } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
da_saldare: { label: "Da saldare", variant: "destructive" },
|
||||||
|
inviata: { label: "Inviata", variant: "secondary" },
|
||||||
|
saldato: { label: "Saldato", variant: "default" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClientRow({ client }: { client: ClientWithPayments }) {
|
||||||
|
const acconto = client.payments.find((p) => p.label.includes("Acconto"));
|
||||||
|
const saldo = client.payments.find((p) => p.label.includes("Saldo"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<Link
|
||||||
|
href={`/admin/clients/${client.id}`}
|
||||||
|
className="font-medium text-gray-900 hover:underline"
|
||||||
|
>
|
||||||
|
{client.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-gray-400">{client.brand_name}</p>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-600">
|
||||||
|
€ {parseFloat(client.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
{acconto && (
|
||||||
|
<Badge variant={statusConfig[acconto.status]?.variant ?? "outline"}>
|
||||||
|
Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
{saldo && (
|
||||||
|
<Badge variant={statusConfig[saldo.status]?.variant ?? "outline"}>
|
||||||
|
Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<a
|
||||||
|
href={`/c/${client.token}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-600 hover:underline font-mono"
|
||||||
|
>
|
||||||
|
/c/{client.token.slice(0, 10)}…
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/app/admin/page.tsx` — Server Component, no client state:
|
||||||
|
```typescript
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getAllClientsWithPayments } from "@/lib/admin-queries";
|
||||||
|
import { ClientRow } from "@/components/admin/ClientRow";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export const revalidate = 0; // always fresh — admin needs real-time data
|
||||||
|
|
||||||
|
export default async function AdminDashboard() {
|
||||||
|
const clients = await getAllClientsWithPayments();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Clienti</h1>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clients.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-gray-400">
|
||||||
|
<p>Nessun cliente ancora.</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
<Link href="/admin/clients/new" className="text-blue-600 hover:underline">
|
||||||
|
Crea il primo cliente
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-gray-600">Cliente</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-gray-600">Totale</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-gray-600">Acconto</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-gray-600">Saldo</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-gray-600">Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{clients.map((client) => (
|
||||||
|
<ClientRow key={client.id} client={client} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/app/admin/clients/new/actions.ts` — Server Action (per D-05):
|
||||||
|
```typescript
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { clients, payments } from "@/db/schema";
|
||||||
|
|
||||||
|
const createClientSchema = z.object({
|
||||||
|
name: z.string().min(1, "Nome richiesto"),
|
||||||
|
brand_name: z.string().min(1, "Nome brand richiesto"),
|
||||||
|
brief: z.string().min(1, "Brief richiesto"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createClient(formData: FormData) {
|
||||||
|
const raw = {
|
||||||
|
name: formData.get("name") as string,
|
||||||
|
brand_name: formData.get("brand_name") as string,
|
||||||
|
brief: formData.get("brief") as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = createClientSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
// In v1 return errors as thrown string — form displays validation inline
|
||||||
|
throw new Error(parsed.error.issues.map((i) => i.message).join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
|
||||||
|
const [newClient] = await db
|
||||||
|
.insert(clients)
|
||||||
|
.values({
|
||||||
|
name: parsed.data.name,
|
||||||
|
brand_name: parsed.data.brand_name,
|
||||||
|
brief: parsed.data.brief,
|
||||||
|
})
|
||||||
|
.returning({ id: clients.id, token: clients.token });
|
||||||
|
|
||||||
|
// Always create two payment stubs per client — Acconto 50% and Saldo 50%
|
||||||
|
// Amounts default to 0 until admin sets accepted_total; admin updates separately
|
||||||
|
await db.insert(payments).values([
|
||||||
|
{
|
||||||
|
client_id: newClient.id,
|
||||||
|
label: "Acconto 50%",
|
||||||
|
amount: "0",
|
||||||
|
status: "da_saldare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
client_id: newClient.id,
|
||||||
|
label: "Saldo 50%",
|
||||||
|
amount: "0",
|
||||||
|
status: "da_saldare",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
revalidatePath("/admin");
|
||||||
|
redirect(`/admin/clients/${newClient.id}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/app/admin/clients/new/page.tsx` — form using the Server Action:
|
||||||
|
```typescript
|
||||||
|
import { createClient } from "./actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NewClientPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
← Clienti
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Nuovo cliente</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={createClient} className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="name">Nome cliente</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="es. Marco Rossi"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="brand_name">Nome brand</Label>
|
||||||
|
<Input
|
||||||
|
id="brand_name"
|
||||||
|
name="brand_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="es. Rossi Studio"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="brief">Brief del progetto</Label>
|
||||||
|
<Textarea
|
||||||
|
id="brief"
|
||||||
|
name="brief"
|
||||||
|
placeholder="Descrizione del progetto e degli obiettivi..."
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button type="submit">Crea cliente</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/admin">Annulla</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/app/admin/page.tsx && grep -q "getAllClientsWithPayments" src/app/admin/page.tsx && echo "Admin page fetches clients"</automated>
|
||||||
|
<automated>grep -q "ClientRow" src/app/admin/page.tsx && echo "ClientRow used in table"</automated>
|
||||||
|
<automated>test -f src/app/admin/clients/new/actions.ts && grep -q '"use server"' src/app/admin/clients/new/actions.ts && echo "Server Action directive present"</automated>
|
||||||
|
<automated>grep -q "db.insert(clients)" src/app/admin/clients/new/actions.ts && echo "client insert present"</automated>
|
||||||
|
<automated>grep -q "Acconto 50%" src/app/admin/clients/new/actions.ts && grep -q "Saldo 50%" src/app/admin/clients/new/actions.ts && echo "both payment stubs inserted"</automated>
|
||||||
|
<automated>grep -q "createClientSchema" src/app/admin/clients/new/actions.ts && echo "Zod validation present"</automated>
|
||||||
|
<automated>test -f src/app/admin/clients/new/page.tsx && grep -q "action={createClient}" src/app/admin/clients/new/page.tsx && echo "form wired to Server Action"</automated>
|
||||||
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- /admin shows table of clients with name, brand, totale, acconto badge, saldo badge, client link
|
||||||
|
- Empty state shows "Nessun cliente ancora" with link to create
|
||||||
|
- /admin/clients/new shows form with name, brand_name, brief fields
|
||||||
|
- Submitting the form inserts client row (token auto-generated by nanoid) + 2 payment stubs
|
||||||
|
- After creation, admin is redirected to /admin/clients/[id] (detail page — stub until Plan 03)
|
||||||
|
- npm run build passes
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Admin browser → Server Action | createClient() runs server-side; input validated with Zod before any DB write |
|
||||||
|
| Admin browser → /admin/* | Middleware session guard (02-01) prevents unauthenticated access to all admin pages and Server Actions |
|
||||||
|
| Client token → DB | Token generated server-side by nanoid(), never user-supplied; cannot be guessed |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-06 | Tampering | createClient Server Action | mitigate | Zod validates all input fields before DB insert; malformed input throws, no partial writes |
|
||||||
|
| T-02-07 | Information Disclosure | Client list page | mitigate | /admin/* protected by middleware session guard from 02-01; unauthenticated requests never reach this Server Component |
|
||||||
|
| T-02-08 | Tampering | token generation | mitigate | token is $defaultFn(() => nanoid()) — server-generated, cryptographically random, never derived from user input |
|
||||||
|
| T-02-09 | Information Disclosure | ClientRow renders full token | accept | Token is shown truncated in UI for usability; full token accessible via /c/[token] link only — acceptable since admin has session auth |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After plan execution:
|
||||||
|
1. `npm run build` — no errors
|
||||||
|
2. Log in as admin, visit /admin — client table renders (empty or with seeded data)
|
||||||
|
3. Click "+ Nuovo cliente" → /admin/clients/new loads with form
|
||||||
|
4. Submit form with valid data → redirects to /admin/clients/[id] (stub page acceptable at this point)
|
||||||
|
5. Return to /admin → new client appears in table with "Da saldare" badges for Acconto and Saldo
|
||||||
|
6. Click the /c/[token] link in the table → opens client dashboard (Phase 1 output)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- /admin shows all clients with payment status badges; empty state handled gracefully
|
||||||
|
- New client can be created via form; token is auto-generated server-side
|
||||||
|
- Two payment stubs (Acconto 50% / Saldo 50%) are created automatically on client creation
|
||||||
|
- Admin can immediately share /c/[token] after creating a client
|
||||||
|
- npm run build passes cleanly
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
---
|
||||||
|
phase: "02-admin-area-interactive-features"
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on:
|
||||||
|
- "02-02"
|
||||||
|
files_modified:
|
||||||
|
- src/app/admin/clients/[id]/page.tsx
|
||||||
|
- src/app/admin/clients/[id]/actions.ts
|
||||||
|
- src/components/admin/tabs/PhasesTab.tsx
|
||||||
|
- src/components/admin/tabs/PaymentsTab.tsx
|
||||||
|
- src/components/admin/tabs/DocumentsTab.tsx
|
||||||
|
- src/components/admin/tabs/CommentsTab.tsx
|
||||||
|
- src/lib/admin-queries.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- ADMIN-02
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Admin can open /admin/clients/[id] and see all client data in tabs: Panoramica, Fasi & Task, Documenti, Pagamenti, Commenti"
|
||||||
|
- "Admin can add a phase to a client, add a task to a phase, and change task status — all via Server Actions"
|
||||||
|
- "Admin can add a document (label + URL) and delete it"
|
||||||
|
- "Admin can change the payment status (da_saldare / inviata / saldato) and update the accepted_total on the client row"
|
||||||
|
- "Admin can see all comments left by the client (read-only in this tab) and post a reply as 'admin'"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/app/admin/clients/[id]/page.tsx"
|
||||||
|
provides: "Client workspace with tabbed layout using @radix-ui/react-tabs"
|
||||||
|
contains: "Tabs"
|
||||||
|
- path: "src/app/admin/clients/[id]/actions.ts"
|
||||||
|
provides: "Server Actions: addPhase, addTask, updateTaskStatus, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment"
|
||||||
|
contains: "addPhase"
|
||||||
|
- path: "src/components/admin/tabs/PhasesTab.tsx"
|
||||||
|
provides: "Fasi & Task tab — list phases with tasks, add-phase form, add-task form, task status selector"
|
||||||
|
min_lines: 60
|
||||||
|
- path: "src/components/admin/tabs/PaymentsTab.tsx"
|
||||||
|
provides: "Pagamenti tab — accepted_total field + two payment rows with status selects"
|
||||||
|
min_lines: 40
|
||||||
|
key_links:
|
||||||
|
- from: "src/app/admin/clients/[id]/page.tsx"
|
||||||
|
to: "src/lib/admin-queries.ts"
|
||||||
|
via: "getClientFullDetail(id)"
|
||||||
|
pattern: "getClientFullDetail"
|
||||||
|
- from: "PhasesTab, PaymentsTab, DocumentsTab"
|
||||||
|
to: "src/app/admin/clients/[id]/actions.ts"
|
||||||
|
via: "Server Actions bound to form action={}"
|
||||||
|
pattern: "action={"
|
||||||
|
- from: "updatePaymentStatus / updateAcceptedTotal"
|
||||||
|
to: "payments / clients tables"
|
||||||
|
via: "db.update().set().where()"
|
||||||
|
pattern: "db.update"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
**Admin Client Workspace (tabs):** Build the full /admin/clients/[id] detail page with Radix Tabs. Each tab covers one concern: Panoramica (overview), Fasi & Task (add phases/tasks, update status), Documenti (add/delete document links), Pagamenti (update payment status + accepted_total), Commenti (read client comments, post admin reply). All mutations use Server Actions (per D-05). Tabs use @radix-ui/react-tabs + shadcn tabs component (per D-08).
|
||||||
|
|
||||||
|
Purpose: Deliver ADMIN-02 — complete management of every client's data from a single authenticated workspace.
|
||||||
|
|
||||||
|
Output: Admin can fully manage a client's project lifecycle without leaving the detail page.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From src/db/schema.ts — all types relevant to this plan -->
|
||||||
|
```typescript
|
||||||
|
export type Client = typeof clients.$inferSelect;
|
||||||
|
export type Phase = typeof phases.$inferSelect;
|
||||||
|
export type Task = typeof tasks.$inferSelect;
|
||||||
|
export type Deliverable = typeof deliverables.$inferSelect;
|
||||||
|
export type Comment = typeof comments.$inferSelect;
|
||||||
|
export type Payment = typeof payments.$inferSelect;
|
||||||
|
export type Document = typeof documents.$inferSelect;
|
||||||
|
export type Note = typeof notes.$inferSelect;
|
||||||
|
|
||||||
|
// phases columns: id, client_id, title, sort_order, status (upcoming|active|done)
|
||||||
|
// tasks columns: id, phase_id, title, description, status (todo|in_progress|done), sort_order
|
||||||
|
// comments columns: id, entity_type (task|deliverable), entity_id, author (client|admin), body, created_at
|
||||||
|
// payments columns: id, client_id, label, amount, status (da_saldare|inviata|saldato), paid_at
|
||||||
|
// documents columns: id, client_id, label, url, created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From src/lib/admin-queries.ts (02-02 output) -->
|
||||||
|
```typescript
|
||||||
|
export async function getClientById(id: string): Promise<Client | null>;
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- New function to add to admin-queries.ts in this plan -->
|
||||||
|
<!-- getClientFullDetail(id) must return client + phases + tasks + deliverables + payments + documents + notes + comments -->
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Install @radix-ui/react-tabs + shadcn tabs; add getClientFullDetail() to admin-queries; create Server Actions</name>
|
||||||
|
<files>
|
||||||
|
package.json
|
||||||
|
src/components/ui/tabs.tsx
|
||||||
|
src/lib/admin-queries.ts
|
||||||
|
src/app/admin/clients/[id]/actions.ts
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Install Radix tabs and add shadcn tabs component (per D-08):
|
||||||
|
```
|
||||||
|
npx shadcn@latest add tabs
|
||||||
|
```
|
||||||
|
This installs @radix-ui/react-tabs and creates src/components/ui/tabs.tsx.
|
||||||
|
|
||||||
|
Extend `src/lib/admin-queries.ts` — add getClientFullDetail() below existing functions.
|
||||||
|
Read the current file first to append without overwriting.
|
||||||
|
|
||||||
|
Add this function:
|
||||||
|
```typescript
|
||||||
|
import { clients, phases, tasks, deliverables, comments, payments, documents, notes } from "@/db/schema";
|
||||||
|
import { eq, inArray, asc } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type ClientFullDetail = {
|
||||||
|
client: Client;
|
||||||
|
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||||
|
payments: Payment[];
|
||||||
|
documents: Document[];
|
||||||
|
notes: Note[];
|
||||||
|
comments: Comment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
|
||||||
|
const clientRows = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
|
||||||
|
if (clientRows.length === 0) return null;
|
||||||
|
const client = clientRows[0];
|
||||||
|
|
||||||
|
const phasesRows = await db
|
||||||
|
.select()
|
||||||
|
.from(phases)
|
||||||
|
.where(eq(phases.client_id, id))
|
||||||
|
.orderBy(asc(phases.sort_order));
|
||||||
|
|
||||||
|
const phaseIds = phasesRows.map((p) => p.id);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const deliverablesRows = taskIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds));
|
||||||
|
|
||||||
|
const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, id));
|
||||||
|
const documentsRows = await db.select().from(documents).where(eq(documents.client_id, id)).orderBy(asc(documents.created_at));
|
||||||
|
const notesRows = await db.select().from(notes).where(eq(notes.client_id, id)).orderBy(asc(notes.created_at));
|
||||||
|
|
||||||
|
// Fetch all comments for this client's tasks and deliverables
|
||||||
|
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 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 { ...phase, tasks: phaseTasks };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
phases: phasesWithTasks,
|
||||||
|
payments: paymentsRows,
|
||||||
|
documents: documentsRows,
|
||||||
|
notes: notesRows,
|
||||||
|
comments: commentsRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/app/admin/clients/[id]/actions.ts` — all mutations for the workspace:
|
||||||
|
```typescript
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { phases, tasks, deliverables, documents, payments, clients, comments } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ── PHASES ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function addPhase(clientId: string, formData: FormData) {
|
||||||
|
const title = (formData.get("title") as string)?.trim();
|
||||||
|
if (!title) throw new Error("Titolo fase richiesto");
|
||||||
|
|
||||||
|
// Determine next sort_order
|
||||||
|
const existingPhases = await db.select({ sort_order: phases.sort_order })
|
||||||
|
.from(phases).where(eq(phases.client_id, clientId));
|
||||||
|
const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1);
|
||||||
|
|
||||||
|
await db.insert(phases).values({
|
||||||
|
client_id: clientId,
|
||||||
|
title,
|
||||||
|
sort_order: maxOrder + 1,
|
||||||
|
status: "upcoming",
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePhaseStatus(phaseId: string, clientId: string, status: string) {
|
||||||
|
const allowed = ["upcoming", "active", "done"];
|
||||||
|
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||||
|
await db.update(phases).set({ status }).where(eq(phases.id, phaseId));
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TASKS ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function addTask(phaseId: string, clientId: string, formData: FormData) {
|
||||||
|
const title = (formData.get("title") as string)?.trim();
|
||||||
|
if (!title) throw new Error("Titolo task richiesto");
|
||||||
|
|
||||||
|
const existingTasks = await db.select({ sort_order: tasks.sort_order })
|
||||||
|
.from(tasks).where(eq(tasks.phase_id, phaseId));
|
||||||
|
const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.sort_order), -1);
|
||||||
|
|
||||||
|
await db.insert(tasks).values({
|
||||||
|
phase_id: phaseId,
|
||||||
|
title,
|
||||||
|
description: (formData.get("description") as string)?.trim() || null,
|
||||||
|
sort_order: maxOrder + 1,
|
||||||
|
status: "todo",
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTaskStatus(taskId: string, clientId: string, status: string) {
|
||||||
|
const allowed = ["todo", "in_progress", "done"];
|
||||||
|
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||||
|
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELIVERABLES ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function addDeliverable(taskId: string, clientId: string, formData: FormData) {
|
||||||
|
const title = (formData.get("title") as string)?.trim();
|
||||||
|
const url = (formData.get("url") as string)?.trim() || null;
|
||||||
|
if (!title) throw new Error("Titolo deliverable richiesto");
|
||||||
|
await db.insert(deliverables).values({ task_id: taskId, title, url, status: "pending" });
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOCUMENTS ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const docSchema = z.object({
|
||||||
|
label: z.string().min(1),
|
||||||
|
url: z.string().url("URL non valido"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addDocument(clientId: string, formData: FormData) {
|
||||||
|
const parsed = docSchema.safeParse({
|
||||||
|
label: formData.get("label"),
|
||||||
|
url: formData.get("url"),
|
||||||
|
});
|
||||||
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
await db.insert(documents).values({ client_id: clientId, ...parsed.data });
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDocument(documentId: string, clientId: string) {
|
||||||
|
await db.delete(documents).where(eq(documents.id, documentId));
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PAYMENTS ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function updatePaymentStatus(paymentId: string, clientId: string, status: string) {
|
||||||
|
const allowed = ["da_saldare", "inviata", "saldato"];
|
||||||
|
if (!allowed.includes(status)) throw new Error("Stato pagamento non valido");
|
||||||
|
const paid_at = status === "saldato" ? new Date() : null;
|
||||||
|
await db.update(payments).set({ status, paid_at }).where(eq(payments.id, paymentId));
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
|
||||||
|
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||||
|
const val = parseFloat(raw);
|
||||||
|
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
|
||||||
|
// Update accepted_total on client row
|
||||||
|
await db.update(clients).set({ accepted_total: raw }).where(eq(clients.id, clientId));
|
||||||
|
// Update payment amounts to 50% each
|
||||||
|
const half = (val / 2).toFixed(2);
|
||||||
|
const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, clientId));
|
||||||
|
for (const p of paymentsRows) {
|
||||||
|
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
|
||||||
|
}
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── COMMENTS (admin reply) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function postAdminComment(clientId: string, formData: FormData) {
|
||||||
|
const entity = formData.get("entity") as string;
|
||||||
|
const body = (formData.get("body") as string)?.trim();
|
||||||
|
if (!body || !entity) throw new Error("Dati mancanti");
|
||||||
|
const [entity_type, entity_id] = entity.split(":");
|
||||||
|
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
|
||||||
|
if (!["task", "deliverable"].includes(entity_type)) throw new Error("entity_type non valido");
|
||||||
|
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/components/ui/tabs.tsx && echo "shadcn tabs component installed"</automated>
|
||||||
|
<automated>grep -q "getClientFullDetail" src/lib/admin-queries.ts && echo "getClientFullDetail added to admin-queries"</automated>
|
||||||
|
<automated>test -f src/app/admin/clients/\[id\]/actions.ts && grep -q '"use server"' src/app/admin/clients/\[id\]/actions.ts && echo "actions.ts is Server Action file"</automated>
|
||||||
|
<automated>grep -q "addPhase\|addTask\|updatePaymentStatus\|updateAcceptedTotal\|postAdminComment" src/app/admin/clients/\[id\]/actions.ts && echo "all major actions present"</automated>
|
||||||
|
<automated>grep -q "revalidatePath" src/app/admin/clients/\[id\]/actions.ts && echo "revalidatePath called in actions"</automated>
|
||||||
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- src/components/ui/tabs.tsx exists (shadcn tabs installed)
|
||||||
|
- getClientFullDetail(id) added to admin-queries.ts, returns all nested client data
|
||||||
|
- actions.ts contains addPhase, addTask, updateTaskStatus, addDeliverable, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment — all with revalidatePath
|
||||||
|
- npm run build passes
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Build /admin/clients/[id] detail page with all four tab components</name>
|
||||||
|
<files>
|
||||||
|
src/app/admin/clients/[id]/page.tsx
|
||||||
|
src/components/admin/tabs/PhasesTab.tsx
|
||||||
|
src/components/admin/tabs/PaymentsTab.tsx
|
||||||
|
src/components/admin/tabs/DocumentsTab.tsx
|
||||||
|
src/components/admin/tabs/CommentsTab.tsx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Create `src/app/admin/clients/[id]/page.tsx` — Server Component, tab container:
|
||||||
|
```typescript
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getClientFullDetail } from "@/lib/admin-queries";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||||
|
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||||
|
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||||
|
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function ClientDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const detail = await getClientFullDetail(params.id);
|
||||||
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
const { client, phases, payments, documents, notes, comments } = detail;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
← Clienti
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{client.name}</h1>
|
||||||
|
<p className="text-sm text-gray-500">{client.brand_name}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/c/${client.token}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-600 hover:underline font-mono bg-blue-50 px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
Link cliente →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="phases" className="w-full">
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
||||||
|
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||||
|
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||||
|
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="phases">
|
||||||
|
<PhasesTab phases={phases} clientId={client.id} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="payments">
|
||||||
|
<PaymentsTab
|
||||||
|
payments={payments}
|
||||||
|
acceptedTotal={client.accepted_total ?? "0"}
|
||||||
|
clientId={client.id}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="documents">
|
||||||
|
<DocumentsTab documents={documents} clientId={client.id} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="comments">
|
||||||
|
<CommentsTab comments={comments} phases={phases} clientId={client.id} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/components/admin/tabs/PhasesTab.tsx`:
|
||||||
|
```typescript
|
||||||
|
import { addPhase, addTask, updateTaskStatus, updatePhaseStatus } from "@/app/admin/clients/[id]/actions";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
phases: ClientFullDetail["phases"];
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskStatusOptions = [
|
||||||
|
{ value: "todo", label: "Da fare" },
|
||||||
|
{ value: "in_progress", label: "In corso" },
|
||||||
|
{ value: "done", label: "Fatto" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const phaseStatusOptions = [
|
||||||
|
{ value: "upcoming", label: "In arrivo" },
|
||||||
|
{ value: "active", label: "Attiva" },
|
||||||
|
{ value: "done", label: "Completata" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PhasesTab({ phases, clientId }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Add phase form */}
|
||||||
|
<form
|
||||||
|
action={async (fd) => { "use server"; await addPhase(clientId, fd); }}
|
||||||
|
className="flex gap-2"
|
||||||
|
>
|
||||||
|
<Input name="title" placeholder="Nome nuova fase..." className="max-w-xs" required />
|
||||||
|
<Button type="submit" variant="outline" size="sm">+ Fase</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Phases list */}
|
||||||
|
{phases.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">Nessuna fase ancora.</p>
|
||||||
|
)}
|
||||||
|
{phases.map((phase) => (
|
||||||
|
<div key={phase.id} className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
|
||||||
|
<form
|
||||||
|
action={async (fd) => {
|
||||||
|
"use server";
|
||||||
|
await updatePhaseStatus(phase.id, clientId, fd.get("status") as string);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={phase.status}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||||
|
>
|
||||||
|
{phaseStatusOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button type="submit" variant="ghost" size="sm" className="text-xs">Salva</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
{phase.tasks.map((task) => (
|
||||||
|
<div key={task.id} className="flex items-center justify-between pl-3 border-l-2 border-gray-100">
|
||||||
|
<span className="text-sm text-gray-800">{task.title}</span>
|
||||||
|
<form
|
||||||
|
action={async (fd) => {
|
||||||
|
"use server";
|
||||||
|
await updateTaskStatus(task.id, clientId, fd.get("status") as string);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={task.status}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||||
|
>
|
||||||
|
{taskStatusOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button type="submit" variant="ghost" size="sm" className="text-xs px-1">✓</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add task form */}
|
||||||
|
<form
|
||||||
|
action={async (fd) => { "use server"; await addTask(phase.id, clientId, fd); }}
|
||||||
|
className="flex gap-2 mt-2"
|
||||||
|
>
|
||||||
|
<Input name="title" placeholder="Nuovo task..." className="text-sm max-w-xs" required />
|
||||||
|
<Button type="submit" variant="ghost" size="sm" className="text-xs">+ Task</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/components/admin/tabs/PaymentsTab.tsx`:
|
||||||
|
```typescript
|
||||||
|
import { updatePaymentStatus, updateAcceptedTotal } from "@/app/admin/clients/[id]/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { Payment } from "@/db/schema";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
payments: Payment[];
|
||||||
|
acceptedTotal: string;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
da_saldare: "Da saldare",
|
||||||
|
inviata: "Inviata",
|
||||||
|
saldato: "Saldato",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-md">
|
||||||
|
{/* Accepted total */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
|
||||||
|
<form
|
||||||
|
action={async (fd) => { "use server"; await updateAcceptedTotal(clientId, fd); }}
|
||||||
|
className="flex items-end gap-3"
|
||||||
|
>
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||||
|
<Input
|
||||||
|
id="accepted_total"
|
||||||
|
name="accepted_total"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
defaultValue={acceptedTotal}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">Salva</Button>
|
||||||
|
</form>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment rows */}
|
||||||
|
{payments.map((p) => (
|
||||||
|
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
€ {parseFloat(p.amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
action={async (fd) => {
|
||||||
|
"use server";
|
||||||
|
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={p.status}
|
||||||
|
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
|
||||||
|
>
|
||||||
|
{Object.entries(statusLabels).map(([val, label]) => (
|
||||||
|
<option key={val} value={val}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button type="submit" size="sm" variant="outline">Aggiorna</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/components/admin/tabs/DocumentsTab.tsx`:
|
||||||
|
```typescript
|
||||||
|
import { addDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { Document } from "@/db/schema";
|
||||||
|
|
||||||
|
type Props = { documents: Document[]; clientId: string };
|
||||||
|
|
||||||
|
export function DocumentsTab({ documents, clientId }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-lg">
|
||||||
|
<form
|
||||||
|
action={async (fd) => { "use server"; await addDocument(clientId, fd); }}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium text-gray-900">Aggiungi documento</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="doc-label">Nome / etichetta</Label>
|
||||||
|
<Input id="doc-label" name="label" placeholder="es. Brief progetto" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="doc-url">URL (Google Drive, PDF...)</Label>
|
||||||
|
<Input id="doc-url" name="url" type="url" placeholder="https://drive.google.com/..." required />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">Aggiungi</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{documents.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">Nessun documento ancora.</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{documents.map((doc) => (
|
||||||
|
<div key={doc.id} className="flex items-center justify-between bg-white border border-gray-200 rounded-lg px-4 py-3">
|
||||||
|
<a
|
||||||
|
href={doc.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{doc.label}
|
||||||
|
</a>
|
||||||
|
<form action={async () => { "use server"; await deleteDocument(doc.id, clientId); }}>
|
||||||
|
<Button type="submit" variant="ghost" size="sm" className="text-red-500 hover:text-red-700 text-xs">
|
||||||
|
Rimuovi
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/components/admin/tabs/CommentsTab.tsx`:
|
||||||
|
```typescript
|
||||||
|
import { postAdminComment } from "@/app/admin/clients/[id]/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type { Comment } from "@/db/schema";
|
||||||
|
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
comments: Comment[];
|
||||||
|
phases: ClientFullDetail["phases"];
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommentsTab({ comments, phases, clientId }: Props) {
|
||||||
|
// Build entity label map for display
|
||||||
|
const entityLabels: Record<string, string> = {};
|
||||||
|
for (const phase of phases) {
|
||||||
|
for (const task of phase.tasks) {
|
||||||
|
entityLabels[task.id] = `Task: ${task.title}`;
|
||||||
|
for (const d of task.deliverables) {
|
||||||
|
entityLabels[d.id] = `Deliverable: ${d.title}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build list of entities the admin can reply on
|
||||||
|
const entities: Array<{ id: string; type: string; label: string }> = [];
|
||||||
|
for (const phase of phases) {
|
||||||
|
for (const task of phase.tasks) {
|
||||||
|
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
|
||||||
|
for (const d of task.deliverables) {
|
||||||
|
entities.push({ id: d.id, type: "deliverable", label: `Deliverable: ${d.title}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-lg">
|
||||||
|
{/* Comment list */}
|
||||||
|
{comments.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">Nessun commento ancora.</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{comments.map((c) => (
|
||||||
|
<div key={c.id} className={`flex gap-3 ${c.author === "admin" ? "flex-row-reverse" : ""}`}>
|
||||||
|
<div
|
||||||
|
className={`rounded-lg px-3 py-2 text-sm max-w-xs ${
|
||||||
|
c.author === "admin"
|
||||||
|
? "bg-gray-900 text-white"
|
||||||
|
: "bg-white border border-gray-200 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium mb-1 opacity-60">
|
||||||
|
{c.author === "admin" ? "iamcavalli" : "Cliente"} — {entityLabels[c.entity_id] ?? c.entity_id}
|
||||||
|
</p>
|
||||||
|
<p>{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin reply form */}
|
||||||
|
{entities.length > 0 && (
|
||||||
|
<form
|
||||||
|
action={async (fd) => { "use server"; await postAdminComment(clientId, fd); }}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium text-gray-900 text-sm">Rispondi come admin</h3>
|
||||||
|
<select name="entity" className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white" required>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<option key={e.id} value={`${e.type}:${e.id}`}>{e.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Textarea name="body" placeholder="Scrivi un commento..." rows={3} required />
|
||||||
|
<Button type="submit" size="sm">Invia risposta</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/app/admin/clients/\[id\]/page.tsx && grep -q "Tabs" src/app/admin/clients/\[id\]/page.tsx && echo "Tabs imported in detail page"</automated>
|
||||||
|
<automated>grep -q "getClientFullDetail" src/app/admin/clients/\[id\]/page.tsx && echo "getClientFullDetail called"</automated>
|
||||||
|
<automated>test -f src/components/admin/tabs/PhasesTab.tsx && echo "PhasesTab exists"</automated>
|
||||||
|
<automated>test -f src/components/admin/tabs/PaymentsTab.tsx && echo "PaymentsTab exists"</automated>
|
||||||
|
<automated>test -f src/components/admin/tabs/DocumentsTab.tsx && echo "DocumentsTab exists"</automated>
|
||||||
|
<automated>test -f src/components/admin/tabs/CommentsTab.tsx && echo "CommentsTab exists"</automated>
|
||||||
|
<automated>grep -q "updateAcceptedTotal" src/components/admin/tabs/PaymentsTab.tsx && echo "Payment total update wired"</automated>
|
||||||
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- /admin/clients/[id] renders with Radix Tabs: Fasi & Task, Pagamenti, Documenti, Commenti
|
||||||
|
- PhasesTab: shows phases with task lists; add phase form and add task form work; task status updates work
|
||||||
|
- PaymentsTab: accepted_total editable; payment status selects update and set paid_at on saldato
|
||||||
|
- DocumentsTab: add document (label + URL) and delete document work
|
||||||
|
- CommentsTab: displays all comments chronologically; admin can post reply on any task/deliverable
|
||||||
|
- npm run build passes cleanly
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Admin browser → Server Actions | All mutations server-side; session guard in middleware ensures only authenticated admin reaches these |
|
||||||
|
| Server Actions → DB | Input validated with Zod or allowlist checks before any write |
|
||||||
|
| approved_at field | Not touched by any admin action in this plan — immutability enforced by omission |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-10 | Tampering | updateTaskStatus / updatePaymentStatus | mitigate | Server-side allowlist check on status value before db.update(); invalid values throw before any write |
|
||||||
|
| T-02-11 | Tampering | deleteDocument | mitigate | Admin-only route protected by middleware session; no client can call deleteDocument without a valid JWT |
|
||||||
|
| T-02-12 | Information Disclosure | getClientFullDetail fetches comments | accept | Comments are fetched only by admin in this plan; client reads own comments only via client-facing API (Plan 04) |
|
||||||
|
| T-02-13 | Tampering | postAdminComment entity_type parsing | mitigate | entity_type parsed from "type:id" composite value; only "task" and "deliverable" are valid; invalid type rejected in action |
|
||||||
|
| T-02-14 | Elevation of Privilege | Server Action inline "use server" | mitigate | Inline "use server" directives in RSC props are Next.js 15 pattern; each closure captures clientId from Server Component scope, preventing cross-client pollution |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After plan execution:
|
||||||
|
1. `npm run build` — no errors
|
||||||
|
2. Log in, open /admin/clients/[id] → tabs render
|
||||||
|
3. Add a phase → appears in Fasi & Task tab after submit
|
||||||
|
4. Add a task to the phase → appears nested under phase
|
||||||
|
5. Change task status → badge updates
|
||||||
|
6. Set accepted_total to 1000 → both payments show 500.00
|
||||||
|
7. Change payment status to "saldato" → status updates
|
||||||
|
8. Add a document with URL → appears in list; delete it → removed
|
||||||
|
9. If comments exist (from Phase 1 seed), they appear in Commenti tab
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Admin can fully manage client phases, tasks, documents, and payments from the detail page
|
||||||
|
- All mutations use Server Actions with revalidatePath — no client-side fetch or state
|
||||||
|
- accepted_total update correctly sets both payment amounts to 50% each
|
||||||
|
- Payment status "saldato" sets paid_at timestamp
|
||||||
|
- Tab layout renders correctly with @radix-ui/react-tabs
|
||||||
|
- npm run build passes cleanly
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
---
|
||||||
|
phase: "02-admin-area-interactive-features"
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 4
|
||||||
|
depends_on:
|
||||||
|
- "02-03"
|
||||||
|
files_modified:
|
||||||
|
- src/app/api/client/approve/route.ts
|
||||||
|
- src/app/api/client/comment/route.ts
|
||||||
|
- src/app/c/[token]/page.tsx
|
||||||
|
- src/components/client/ApproveButton.tsx
|
||||||
|
- src/components/client/CommentForm.tsx
|
||||||
|
- src/components/client/CommentList.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- DASH-05
|
||||||
|
- DASH-06
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Client can click 'Approva' on a deliverable and the approved_at timestamp is set immutably in DB"
|
||||||
|
- "The Approva button is hidden once approved_at is set — the approved state shows a timestamp instead"
|
||||||
|
- "Client can submit a comment on a task or deliverable; it appears in the list on reload"
|
||||||
|
- "Comment author is 'client'; admin comments show as 'iamcavalli', client comments show as 'Tu'"
|
||||||
|
- "Both API routes validate the client token from the request body against the DB before writing"
|
||||||
|
- "quote_items is never queried or returned by either API route"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/app/api/client/approve/route.ts"
|
||||||
|
provides: "POST — validates client token, sets deliverable status=approved + approved_at=now() if not already approved"
|
||||||
|
contains: "approved_at"
|
||||||
|
- path: "src/app/api/client/comment/route.ts"
|
||||||
|
provides: "POST — validates client token, inserts comment with author='client'"
|
||||||
|
contains: "author.*client"
|
||||||
|
- path: "src/components/client/ApproveButton.tsx"
|
||||||
|
provides: "Client Component: Approva button that POSTs to /api/client/approve and refreshes the page"
|
||||||
|
contains: "useRouter"
|
||||||
|
- path: "src/components/client/CommentForm.tsx"
|
||||||
|
provides: "Client Component: textarea + submit that POSTs to /api/client/comment"
|
||||||
|
contains: "api/client/comment"
|
||||||
|
- path: "src/app/c/[token]/page.tsx"
|
||||||
|
provides: "Updated client dashboard wiring ApproveButton and CommentForm into deliverable/task sections"
|
||||||
|
contains: "ApproveButton"
|
||||||
|
key_links:
|
||||||
|
- from: "ApproveButton"
|
||||||
|
to: "POST /api/client/approve"
|
||||||
|
via: "fetch('/api/client/approve', { body: JSON.stringify({ token, deliverableId }) })"
|
||||||
|
pattern: "api/client/approve"
|
||||||
|
- from: "POST /api/client/approve"
|
||||||
|
to: "deliverables table"
|
||||||
|
via: "db.update(deliverables).set({ status: 'approved', approved_at: new Date() })"
|
||||||
|
pattern: "approved_at"
|
||||||
|
- from: "CommentForm"
|
||||||
|
to: "POST /api/client/comment"
|
||||||
|
via: "fetch('/api/client/comment', { body: JSON.stringify({ token, entity_type, entity_id, body }) })"
|
||||||
|
pattern: "api/client/comment"
|
||||||
|
- from: "POST /api/client/comment"
|
||||||
|
to: "comments table"
|
||||||
|
via: "db.insert(comments).values({ author: 'client', ... })"
|
||||||
|
pattern: "author.*client"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
**Client Interactions — Approvals + Comments:** Add two API routes for client-side mutations (per D-06 — not Server Actions, because the client has no admin session), then update the client dashboard UI to render ApproveButton on pending/submitted deliverables and CommentForm + CommentList on every task and deliverable. Token is validated server-side in each API route against the clients table before any write.
|
||||||
|
|
||||||
|
Purpose: Deliver DASH-05 (deliverable approval with immutable approved_at) and DASH-06 (inline comments). The approved_at immutability rule from CLAUDE.md is enforced in the API route: if approved_at is already set, the request is a no-op (returns 200 but does not overwrite).
|
||||||
|
|
||||||
|
Output: Clients can approve deliverables and leave comments from their dashboard; admin sees both in the workspace (Plan 03 CommentsTab).
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From src/db/schema.ts — types used in this plan -->
|
||||||
|
```typescript
|
||||||
|
export const deliverables = pgTable("deliverables", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
task_id: text("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
url: text("url"),
|
||||||
|
status: text("status").notNull().default("pending"), // pending | submitted | approved
|
||||||
|
approved_at: timestamp("approved_at", { withTimezone: true }), // IMMUTABLE once set
|
||||||
|
});
|
||||||
|
|
||||||
|
export const comments = pgTable("comments", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
entity_type: text("entity_type").notNull(), // task | deliverable
|
||||||
|
entity_id: text("entity_id").notNull(),
|
||||||
|
author: text("author").notNull(), // client | admin
|
||||||
|
body: text("body").notNull(),
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clients = pgTable("clients", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
// ... other fields
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Deliverable = typeof deliverables.$inferSelect;
|
||||||
|
export type Comment = typeof comments.$inferSelect;
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From src/lib/client-view.ts (Phase 1) — ClientView shape for reference -->
|
||||||
|
```typescript
|
||||||
|
export interface ClientView {
|
||||||
|
client: { id: string; name: string; brand_name: string; brief: string; accepted_total: string; };
|
||||||
|
phases: Array<{
|
||||||
|
id: string; title: string; status: string; sort_order: number; progress_pct: 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: 'pending' | 'submitted' | 'approved';
|
||||||
|
approved_at: string | null;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
payments: Array<{ id: string; label: string; status: string; }>;
|
||||||
|
documents: Array<{ id: string; label: string; url: string; }>;
|
||||||
|
notes: Array<{ id: string; body: string; created_at: string; }>;
|
||||||
|
global_progress_pct: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- ClientView does NOT include comments — they are fetched separately in the page -->
|
||||||
|
<!-- The page must fetch comments independently using db query on entity_ids from the view -->
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create POST /api/client/approve and POST /api/client/comment API routes</name>
|
||||||
|
<files>
|
||||||
|
src/app/api/client/approve/route.ts
|
||||||
|
src/app/api/client/comment/route.ts
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Both routes validate the client's token against the DB before any mutation (per D-06).
|
||||||
|
Token comes from the request body JSON. Neither route uses Auth.js — client has no session.
|
||||||
|
Neither route ever queries quote_items (per CLAUDE.md architecture constraint).
|
||||||
|
|
||||||
|
Create `src/app/api/client/approve/route.ts`:
|
||||||
|
```typescript
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { clients, deliverables, tasks, phases } from "@/db/schema";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { token, deliverableId } = body as { token?: string; deliverableId?: string };
|
||||||
|
|
||||||
|
if (!token || !deliverableId) {
|
||||||
|
return NextResponse.json({ error: "token e deliverableId richiesti" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token — find the client
|
||||||
|
const clientRows = await db
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.token, token))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (clientRows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Token non valido" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = clientRows[0].id;
|
||||||
|
|
||||||
|
// Verify deliverable belongs to this client (prevents cross-client approval)
|
||||||
|
// deliverable → task → phase → client
|
||||||
|
const ownershipCheck = await db
|
||||||
|
.select({ deliverable_id: deliverables.id, approved_at: deliverables.approved_at })
|
||||||
|
.from(deliverables)
|
||||||
|
.innerJoin(tasks, eq(deliverables.task_id, tasks.id))
|
||||||
|
.innerJoin(phases, and(eq(tasks.phase_id, phases.id), eq(phases.client_id, clientId)))
|
||||||
|
.where(eq(deliverables.id, deliverableId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (ownershipCheck.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMMUTABILITY RULE (CLAUDE.md): if approved_at is already set, this is a no-op
|
||||||
|
if (ownershipCheck[0].approved_at !== null) {
|
||||||
|
return NextResponse.json({ approved: true, message: "Già approvato" }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set approved — approved_at is immutable once set, client cannot unset it
|
||||||
|
await db
|
||||||
|
.update(deliverables)
|
||||||
|
.set({ status: "approved", approved_at: new Date() })
|
||||||
|
.where(eq(deliverables.id, deliverableId));
|
||||||
|
|
||||||
|
return NextResponse.json({ approved: true }, { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/api/client/approve error:", err);
|
||||||
|
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/app/api/client/comment/route.ts`:
|
||||||
|
```typescript
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { clients, comments, tasks, phases, deliverables } from "@/db/schema";
|
||||||
|
|
||||||
|
const commentSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
entity_type: z.enum(["task", "deliverable"]),
|
||||||
|
entity_id: z.string().min(1),
|
||||||
|
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = commentSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0].message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, entity_type, entity_id, body: commentBody } = parsed.data;
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
const clientRows = await db
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.token, token))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (clientRows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Token non valido" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = clientRows[0].id;
|
||||||
|
|
||||||
|
// Verify entity belongs to this client (prevent cross-client comment injection)
|
||||||
|
if (entity_type === "task") {
|
||||||
|
const phasesForClient = await db
|
||||||
|
.select({ id: phases.id })
|
||||||
|
.from(phases)
|
||||||
|
.where(eq(phases.client_id, clientId));
|
||||||
|
const phaseIds = phasesForClient.map((p) => p.id);
|
||||||
|
|
||||||
|
if (phaseIds.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskCheck = await db
|
||||||
|
.select({ id: tasks.id })
|
||||||
|
.from(tasks)
|
||||||
|
.where(
|
||||||
|
inArray(tasks.phase_id, phaseIds)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.find((r) => r.id === entity_id));
|
||||||
|
|
||||||
|
if (!taskCheck) {
|
||||||
|
return NextResponse.json({ error: "Task non trovato" }, { status: 404 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// deliverable — verify via task → phase → client chain
|
||||||
|
const phasesForClient = await db
|
||||||
|
.select({ id: phases.id })
|
||||||
|
.from(phases)
|
||||||
|
.where(eq(phases.client_id, clientId));
|
||||||
|
const phaseIds = phasesForClient.map((p) => p.id);
|
||||||
|
|
||||||
|
if (phaseIds.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskIds = await db
|
||||||
|
.select({ id: tasks.id })
|
||||||
|
.from(tasks)
|
||||||
|
.where(inArray(tasks.phase_id, phaseIds))
|
||||||
|
.then((rows) => rows.map((r) => r.id));
|
||||||
|
|
||||||
|
if (taskIds.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const delivCheck = await db
|
||||||
|
.select({ id: deliverables.id })
|
||||||
|
.from(deliverables)
|
||||||
|
.where(inArray(deliverables.task_id, taskIds))
|
||||||
|
.then((rows) => rows.find((r) => r.id === entity_id));
|
||||||
|
|
||||||
|
if (!delivCheck) {
|
||||||
|
return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(comments).values({
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
author: "client",
|
||||||
|
body: commentBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/api/client/comment error:", err);
|
||||||
|
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/app/api/client/approve/route.ts && echo "approve route exists"</automated>
|
||||||
|
<automated>grep -q "approved_at.*null" src/app/api/client/approve/route.ts && echo "immutability check present"</automated>
|
||||||
|
<automated>grep -q "phases.client_id.*clientId\|clientId.*phases.client_id" src/app/api/client/approve/route.ts && echo "ownership verification present"</automated>
|
||||||
|
<automated>test -f src/app/api/client/comment/route.ts && echo "comment route exists"</automated>
|
||||||
|
<automated>grep -q "author.*client" src/app/api/client/comment/route.ts && echo "author set to client"</automated>
|
||||||
|
<automated>grep -v '^#' src/app/api/client/approve/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in approve route"</automated>
|
||||||
|
<automated>grep -v '^#' src/app/api/client/comment/route.ts | grep -c "quote_items" | grep -q "^0$" && echo "quote_items not referenced in comment route"</automated>
|
||||||
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- POST /api/client/approve: validates token, verifies deliverable ownership via phase→client chain, sets status=approved + approved_at=now() only if approved_at is currently null
|
||||||
|
- POST /api/client/comment: validates token, validates entity ownership, inserts comment with author='client'
|
||||||
|
- Both routes return 404 on invalid token or missing entity
|
||||||
|
- Neither route references quote_items
|
||||||
|
- npm run build passes
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Build ApproveButton + CommentForm/List Client Components; wire into client dashboard page</name>
|
||||||
|
<files>
|
||||||
|
src/components/client/ApproveButton.tsx
|
||||||
|
src/components/client/CommentForm.tsx
|
||||||
|
src/components/client/CommentList.tsx
|
||||||
|
src/app/c/[token]/page.tsx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Create `src/components/client/ApproveButton.tsx` — Client Component (per D-10, no confirm modal):
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
deliverableId: string;
|
||||||
|
token: string;
|
||||||
|
approvedAt: string | null; // ISO timestamp or null
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Already approved — show immutable confirmation, no button
|
||||||
|
if (approvedAt) {
|
||||||
|
const date = new Date(approvedAt).toLocaleDateString("it-IT", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-green-700 bg-green-50 border border-green-200 px-2 py-1 rounded">
|
||||||
|
Approvato il {date}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApprove() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/client/approve", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, deliverableId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error ?? "Errore durante l'approvazione");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh(); // Re-fetch Server Component data — approved_at now set
|
||||||
|
} catch {
|
||||||
|
setError("Errore di rete");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-xs text-green-700 border-green-300 hover:bg-green-50"
|
||||||
|
>
|
||||||
|
{loading ? "Approvazione..." : "Approva"}
|
||||||
|
</Button>
|
||||||
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/components/client/CommentList.tsx` — pure presentational:
|
||||||
|
```typescript
|
||||||
|
import type { Comment } from "@/db/schema";
|
||||||
|
|
||||||
|
type Props = { comments: Comment[] };
|
||||||
|
|
||||||
|
export function CommentList({ comments }: Props) {
|
||||||
|
if (comments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{comments.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`flex gap-2 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-lg px-3 py-2 text-xs max-w-xs ${
|
||||||
|
c.author === "admin"
|
||||||
|
? "bg-gray-900 text-white"
|
||||||
|
: "bg-gray-100 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium mb-0.5 opacity-60">
|
||||||
|
{c.author === "admin" ? "iamcavalli" : "Tu"}
|
||||||
|
</p>
|
||||||
|
<p>{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/components/client/CommentForm.tsx` — Client Component (per D-11):
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
token: string;
|
||||||
|
entityType: "task" | "deliverable";
|
||||||
|
entityId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommentForm({ token, entityType, entityId }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!body.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/client/comment", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, entity_type: entityType, entity_id: entityId, body }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error ?? "Errore durante l'invio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBody("");
|
||||||
|
router.refresh(); // Re-fetch Server Component to show new comment
|
||||||
|
} catch {
|
||||||
|
setError("Errore di rete");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-3 flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="Lascia un commento..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm resize-none flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading || !body.trim()}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{loading ? "Invio..." : "Invia"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `src/app/c/[token]/page.tsx` — extend the existing Phase 1 dashboard to:
|
||||||
|
1. Fetch comments for all task/deliverable ids in this client's data
|
||||||
|
2. Render ApproveButton on each deliverable (pending or submitted)
|
||||||
|
3. Render CommentList + CommentForm below each task and deliverable
|
||||||
|
|
||||||
|
Read the existing page first, then extend it. The page must remain a Server Component.
|
||||||
|
ApproveButton and CommentForm are Client Components embedded within it.
|
||||||
|
|
||||||
|
Key additions to the existing page (add these imports and sections):
|
||||||
|
```typescript
|
||||||
|
// New imports to add:
|
||||||
|
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||||
|
import { CommentForm } from "@/components/client/CommentForm";
|
||||||
|
import { CommentList } from "@/components/client/CommentList";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { comments } from "@/db/schema";
|
||||||
|
import { inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
// After getClientView(), fetch comments:
|
||||||
|
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))
|
||||||
|
);
|
||||||
|
const allEntityIds = [...allTaskIds, ...allDeliverableIds];
|
||||||
|
|
||||||
|
const allComments = allEntityIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select()
|
||||||
|
.from(comments)
|
||||||
|
.where(inArray(comments.entity_id, allEntityIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Helper to get comments for a specific entity:
|
||||||
|
const commentsFor = (entityId: string) =>
|
||||||
|
allComments.filter((c) => c.entity_id === entityId);
|
||||||
|
```
|
||||||
|
|
||||||
|
Within the task/deliverable render loop, add below each deliverable:
|
||||||
|
```typescript
|
||||||
|
// Within deliverable rendering:
|
||||||
|
<ApproveButton
|
||||||
|
deliverableId={deliverable.id}
|
||||||
|
token={params.token}
|
||||||
|
approvedAt={deliverable.approved_at}
|
||||||
|
/>
|
||||||
|
<CommentList comments={commentsFor(deliverable.id)} />
|
||||||
|
<CommentForm token={params.token} entityType="deliverable" entityId={deliverable.id} />
|
||||||
|
```
|
||||||
|
|
||||||
|
And below each task:
|
||||||
|
```typescript
|
||||||
|
// Within task rendering (after deliverables):
|
||||||
|
<CommentList comments={commentsFor(task.id)} />
|
||||||
|
<CommentForm token={params.token} entityType="task" entityId={task.id} />
|
||||||
|
```
|
||||||
|
|
||||||
|
The page must read the full Phase 1 client dashboard before modifying it.
|
||||||
|
Preserve all existing Phase 1 UI sections. Only add the interactive elements.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/components/client/ApproveButton.tsx && grep -q '"use client"' src/components/client/ApproveButton.tsx && echo "ApproveButton is Client Component"</automated>
|
||||||
|
<automated>grep -q "router.refresh" src/components/client/ApproveButton.tsx && echo "router.refresh on approval"</automated>
|
||||||
|
<automated>grep -q "approvedAt.*null" src/components/client/ApproveButton.tsx && echo "approved_at check present in button"</automated>
|
||||||
|
<automated>test -f src/components/client/CommentForm.tsx && grep -q '"use client"' src/components/client/CommentForm.tsx && echo "CommentForm is Client Component"</automated>
|
||||||
|
<automated>grep -q "api/client/comment" src/components/client/CommentForm.tsx && echo "CommentForm posts to correct route"</automated>
|
||||||
|
<automated>test -f src/components/client/CommentList.tsx && grep -q "iamcavalli" src/components/client/CommentList.tsx && echo "admin author label present"</automated>
|
||||||
|
<automated>grep -q "ApproveButton" src/app/c/\[token\]/page.tsx && echo "ApproveButton imported in dashboard page"</automated>
|
||||||
|
<automated>grep -q "CommentForm" src/app/c/\[token\]/page.tsx && echo "CommentForm imported in dashboard page"</automated>
|
||||||
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- ApproveButton renders on deliverables with approved_at=null; shows immutable "Approvato il [date]" once set
|
||||||
|
- CommentForm posts to /api/client/comment and calls router.refresh() on success
|
||||||
|
- CommentList shows client comments as "Tu", admin comments as "iamcavalli"
|
||||||
|
- Client dashboard page fetches comments server-side and renders all three components inline
|
||||||
|
- Phase 1 existing UI is preserved — only interactive elements are added
|
||||||
|
- npm run build passes cleanly
|
||||||
|
- Manual verification: approve a deliverable → refreshed page shows date badge; submit comment → refreshed page shows comment in list
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Client browser → POST /api/client/approve | Unauthenticated client route; token in request body is the only credential |
|
||||||
|
| Client browser → POST /api/client/comment | Same — token in body is the only credential |
|
||||||
|
| Token → client ownership | Each API route validates token → client, then verifies entity belongs to that client via DB join |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-15 | Spoofing | /api/client/approve token validation | mitigate | Token validated server-side via DB lookup before any mutation; expired or rotated tokens return 404 |
|
||||||
|
| T-02-16 | Elevation of Privilege | Cross-client approval | mitigate | Ownership check: deliverable → task → phase → client_id match enforced via innerJoin before update; a client cannot approve another client's deliverable |
|
||||||
|
| T-02-17 | Tampering | approved_at immutability | mitigate | API route checks approved_at !== null before running UPDATE; once set, the field cannot be overwritten via this route — enforced at application layer |
|
||||||
|
| T-02-18 | Tampering | Comment injection across clients | mitigate | entity ownership verified via DB join (task/deliverable → phase → client_id) before insert; client can only comment on their own entities |
|
||||||
|
| T-02-19 | Information Disclosure | CommentList renders all comments | accept | Comments are scoped to entity_ids belonging to the validated client; server-side filtering before rendering |
|
||||||
|
| T-02-20 | Denial of Service | POST /api/client/comment body length | mitigate | Zod schema enforces max 2000 chars on body; requests exceeding this return 400 |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After plan execution:
|
||||||
|
1. `npm run build` — no errors
|
||||||
|
2. Open client dashboard at /c/[valid-token]
|
||||||
|
3. Locate a deliverable with status=pending or status=submitted → "Approva" button visible
|
||||||
|
4. Click Approva → page refreshes → button replaced with "Approvato il [date]"
|
||||||
|
5. Refresh page again → approval still shows (persisted in DB)
|
||||||
|
6. In admin workspace → CommentsTab shows the approved deliverable's state
|
||||||
|
7. Open CommentForm under a task → type a message → click Invia
|
||||||
|
8. Page refreshes → comment appears as "Tu" in the list
|
||||||
|
9. In admin workspace → CommentsTab shows the comment with author "Cliente"
|
||||||
|
10. Test invalid token: POST /api/client/approve with wrong token → 404
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Client can approve deliverables; approved_at is set once and immutable (CLAUDE.md constraint enforced)
|
||||||
|
- Client can submit comments on tasks and deliverables
|
||||||
|
- Both API routes validate token and verify entity ownership before writing
|
||||||
|
- CommentList shows author correctly: "Tu" for client, "iamcavalli" for admin
|
||||||
|
- Phase 1 client dashboard UI is fully preserved; interactive elements are additive
|
||||||
|
- npm run build passes cleanly
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-admin-area-interactive-features/02-04-SUMMARY.md`
|
||||||
|
</output>
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user