From 5bf5dfce71be15142d9bbef0a0b50c3710386611 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 21 May 2026 16:12:05 +0200 Subject: [PATCH] =?UTF-8?q?infra(04-00):=20route=20/c/=20=E2=86=92=20/clie?= =?UTF-8?q?nt/,=20Dockerfile,=20Gitea=20deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename src/app/c/[token] → src/app/client/[token] - Update proxy.ts, ClientRow, admin client detail with /client/ path - Add output: "standalone" to next.config.ts for Docker build - Add Dockerfile (multi-stage, node:20-alpine) and .dockerignore - Push schema to Coolify Postgres via SSH tunnel (drizzle-kit push ✓) - Update CLAUDE.md constraint 4 to reflect /client/ route - Add Phase 4 planning artifacts (04-00, 04-RESEARCH, 04-PATTERNS) Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 6 + .planning/REQUIREMENTS.md | 23 +- .planning/ROADMAP.md | 3 +- .planning/STATE.md | 14 +- .planning/config.json | 15 + .../04-progetti-multi-project/04-00-PLAN.md | 371 ++++++ .../04-progetti-multi-project/04-PATTERNS.md | 1038 +++++++++++++++++ .../04-progetti-multi-project/04-RESEARCH.md | 921 +++++++++++++++ .planning/research/ARCHITECTURE.md | 191 +++ .planning/research/FEATURES.md | 107 ++ .planning/research/PITFALLS.md | 177 +++ .planning/research/STACK.md | 132 +++ .planning/research/SUMMARY.md | 126 ++ CLAUDE.md | 61 +- Dockerfile | 28 + next.config.ts | 2 +- src/app/admin/clients/[id]/page.tsx | 2 +- src/app/{c => client}/[token]/layout.tsx | 0 src/app/{c => client}/[token]/page.tsx | 0 src/components/admin/ClientRow.tsx | 4 +- src/proxy.ts | 6 +- 21 files changed, 3164 insertions(+), 63 deletions(-) create mode 100644 .dockerignore create mode 100644 .planning/config.json create mode 100644 .planning/phases/04-progetti-multi-project/04-00-PLAN.md create mode 100644 .planning/phases/04-progetti-multi-project/04-PATTERNS.md create mode 100644 .planning/phases/04-progetti-multi-project/04-RESEARCH.md create mode 100644 .planning/research/ARCHITECTURE.md create mode 100644 .planning/research/FEATURES.md create mode 100644 .planning/research/PITFALLS.md create mode 100644 .planning/research/STACK.md create mode 100644 .planning/research/SUMMARY.md create mode 100644 Dockerfile rename src/app/{c => client}/[token]/layout.tsx (100%) rename src/app/{c => client}/[token]/page.tsx (100%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..024a29d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.env +.env.local +node_modules +.next +.git +.planning \ No newline at end of file diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index edc7b2c..e53e577 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -30,7 +30,17 @@ | CAT-01 | File/database dei servizi con prezzi e cosa è incluso | Pending | | CAT-02 | Usato come base per la generazione assistita dei preventivi | Pending | -## Flusso Claude (v2 — deferred to Phase 4) +## Progetti Multi-Project (Phase 4) + +| ID | Requirement | Status | +|----|-------------|--------| +| PROJ-01 | Ogni cliente può avere N progetti; ogni progetto ha workspace indipendente (fasi, pagamenti, preventivo, timer) | Pending | +| PROJ-02 | La dashboard cliente mostra tabs per 2+ progetti; con 1 progetto mostra direttamente il workspace senza selettore | Pending | +| PROJ-03 | La pagina /admin/projects elenca tutti i progetti con €/h reale e timer; /admin/projects/[id] è il workspace progetto | Pending | +| PROJ-04 | Il link cliente supporta slug personalizzabile (/c/mario-rossi) con fallback al token esistente | Pending | +| PROJ-05 | Analytics profittabilità per progetto: ore lavorate, accepted_total, €/h reale vs target_hourly_rate globale | Pending | + +## Flusso Claude (v2 — deferred to Phase 5) | ID | Requirement | Status | |----|-------------|--------| @@ -64,6 +74,11 @@ | CAT-01 | Phase 3 | Pending | | CAT-02 | Phase 3 | Pending | | ADMIN-03 | Phase 3 | Pending | -| CLAUDE-01 | Phase 4 (v2) | Deferred | -| CLAUDE-02 | Phase 4 (v2) | Deferred | -| CLAUDE-03 | Phase 4 (v2) | Deferred | \ No newline at end of file +| PROJ-01 | Phase 4 | Pending | +| PROJ-02 | Phase 4 | Pending | +| PROJ-03 | Phase 4 | Pending | +| PROJ-04 | Phase 4 | Pending | +| PROJ-05 | Phase 4 | Pending | +| CLAUDE-01 | Phase 5 (v2) | Deferred | +| CLAUDE-02 | Phase 5 (v2) | Deferred | +| CLAUDE-03 | Phase 5 (v2) | Deferred | \ No newline at end of file diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 698efa2..f967e98 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -89,8 +89,9 @@ Decimal phases appear between their surrounding integers in numeric order. 3. La pagina /admin/projects elenca tutti i progetti con €/h calcolato e bottone timer play/stop 4. Il link cliente supporta slug personalizzato (/c/mario-rossi) con fallback al token; slug impostabile da /admin/clients/[id]/edit 5. Il tab Timer di ogni progetto mostra analytics profittabilità: ore lavorate, accepted_total, €/h reale vs target_hourly_rate globale -**Plans**: 4 plans +**Plans**: 5 plans **Plan list**: + - [ ] 04-00-PLAN.md — Infra: Postgres su Coolify + /c/ → /client/ rename + Dockerfile + hub.iamcavalli.net [RUN FIRST] - [ ] 04-01-PLAN.md — Schema migration (projects, slug, settings, FK migration) + drizzle-kit push + query layer - [ ] 04-02-PLAN.md — Admin projects list (/admin/projects) + ProjectRow + client detail project cards - [ ] 04-03-PLAN.md — Admin project workspace (/admin/projects/[id]) + TimerTab + ProfitabilityCard + /admin/impostazioni diff --git a/.planning/STATE.md b/.planning/STATE.md index ba65d79..a4228c0 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,14 +4,14 @@ 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-19T21:12:54.673Z" -last_activity: 2026-05-19 +last_updated: "2026-05-21T11:56:14.461Z" +last_activity: 2026-05-21 -- Phase 4 planning complete progress: - total_phases: 4 + total_phases: 5 completed_phases: 3 - total_plans: 13 + total_plans: 17 completed_plans: 13 - percent: 100 + percent: 76 --- # Project State @@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-05-09) Phase: 4 Plan: Not started -Status: Executing Phase 03 -Last activity: 2026-05-19 +Status: Ready to execute +Last activity: 2026-05-21 -- Phase 4 planning complete Progress: [██░░░░░░░░] 25% diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000..b3b6694 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,15 @@ +{ + "mode": "yolo", + "granularity": "coarse", + "parallelization": true, + "commit_docs": false, + "model_profile": "budget", + "workflow": { + "research": true, + "plan_check": true, + "verifier": true, + "nyquist_validation": false, + "auto_advance": false, + "_auto_chain_active": false + } +} \ No newline at end of file diff --git a/.planning/phases/04-progetti-multi-project/04-00-PLAN.md b/.planning/phases/04-progetti-multi-project/04-00-PLAN.md new file mode 100644 index 0000000..c6d47e6 --- /dev/null +++ b/.planning/phases/04-progetti-multi-project/04-00-PLAN.md @@ -0,0 +1,371 @@ +--- +phase: 04-progetti-multi-project +plan: "00" +type: execute +wave: 0 +depends_on: [] +files_modified: + - src/app/client/[token]/layout.tsx + - src/app/client/[token]/page.tsx + - src/proxy.ts + - src/components/admin/ClientRow.tsx + - src/app/admin/clients/[id]/page.tsx + - Dockerfile + - .env.example +autonomous: false + +must_haves: + truths: + - "La route /client/[token] esiste e risponde correttamente (cartella rinominata da /c/)" + - "src/proxy.ts controlla /client/ invece di /c/ nel guard e nel matcher" + - "ClientRow.tsx e admin/clients/[id]/page.tsx usano /client/ nei link generati" + - "Il Dockerfile produce un build Next.js funzionante (exit code 0)" + - "DATABASE_URL in .env punta al Postgres self-hosted su Coolify (non Neon)" + - "drizzle-kit push sul nuovo DB completes con exit code 0" + - "L'app risponde su hub.iamcavalli.net dopo il deploy su Coolify" + artifacts: + - path: "src/app/client/[token]/page.tsx" + provides: "Dashboard cliente alla nuova route" + contains: "export default" + - path: "src/proxy.ts" + provides: "Middleware aggiornato per /client/" + contains: "pathname.startsWith(\"/client/\")" + - path: "Dockerfile" + provides: "Container image per Coolify" + contains: "FROM node:" + key_links: + - from: "src/proxy.ts" + to: "src/app/client/[token]/" + via: "matcher: ['/admin/:path*', '/client/:path*']" + pattern: "/client/:path*" + - from: "src/components/admin/ClientRow.tsx" + to: "src/app/client/[token]/" + via: "href={`/client/${client.token}`}" + pattern: "/client/" +--- + + +Infrastruttura pre-Phase 4: migrazione da Neon a Postgres self-hosted su Coolify (Hetzner), rinomina route cliente da /c/ a /client/, Dockerfile per deploy, dominio hub.iamcavalli.net. + +Questo piano esegue PRIMA di 04-01 per garantire che tutto il codice Phase 4 venga scritto e testato sull'infrastruttura finale. Nessun refactor post-esecuzione. + +Output: App deployata su hub.iamcavalli.net con DB Postgres su Coolify, route /client/[token] funzionante. + + + +@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md +@/Users/simonecavalli/IAMCAVALLI/CLAUDE.md + + + + +proxy.ts riga 32: `if (pathname.startsWith("/c/"))` +proxy.ts riga 33: `pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/)` +proxy.ts riga 63: `matcher: ["/admin/:path*", "/c/:path*"]` + +ClientRow.tsx riga 59: `href={\`/c/${client.token}\`}` +admin/clients/[id]/page.tsx riga 46: `href={\`/c/${client.token}\`}` + +Cartella da rinominare: src/app/c/ → src/app/client/ + + + + +T-00-01: Redirect loop dopo rinomina — mitigazione: aggiornare proxy.ts prima di rinominare la cartella, verificare matcher. +T-00-02: DB connection string esposta — mitigazione: .env mai committato, .env.example con placeholder. +T-00-03: Dati di test persi durante migrazione DB — accettabile: tutti i dati attuali sono test data (CONTEXT.md). +T-00-04: Coolify deploy fallisce silenziosamente — mitigazione: verificare health check dopo deploy. + + + + + +Task 1: Postgres su Coolify + migrazione DATABASE_URL + + +- .env (connection string Neon attuale — non committare) +- drizzle.config.ts (per confermare che legge DATABASE_URL) + + + +**A. Crea Postgres su Coolify (manuale — non automatizzabile)** + +Nel pannello Coolify su Hetzner: +1. New Resource → Database → PostgreSQL +2. Nome: `clienthub-db` +3. Version: 16 +4. Salva le credenziali generate (host, port, user, password, dbname) +5. Abilita "Public port" se necessario per drizzle-kit push da locale + +**B. Aggiorna .env locale** + +Sostituisci la riga DATABASE_URL con la connection string Coolify: +``` +DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DBNAME?sslmode=require +``` + +Mantieni la vecchia riga commentata come backup: +``` +# DATABASE_URL=postgresql://neon... (backup) +``` + +**C. Aggiorna .env.example** + +Sostituisci il placeholder Neon con quello generico: +``` +DATABASE_URL=postgresql://user:password@host:5432/dbname?sslmode=require +``` + +**D. Push schema al nuovo DB** + +```bash +npx drizzle-kit push +``` + +Il DB è fresh — nessuna migrazione soft necessaria, tutti i dati attuali sono test data. + + + +npx drizzle-kit push 2>&1; echo "Exit: $?" + + + +- drizzle-kit push completes con exit code 0 +- .env contiene DATABASE_URL che punta a Coolify (non neon.tech) +- .env.example non contiene credenziali reali +- npx tsc --noEmit non produce errori legati al DB + + + + +Task 2: Rinomina route /c/ → /client/ + aggiorna reference + + +- src/proxy.ts (middleware completo — LEGGERE PRIMA di modificare) +- src/components/admin/ClientRow.tsx (link generato riga 59) +- src/app/admin/clients/[id]/page.tsx (link generato riga 46) +- src/app/c/[token]/page.tsx (dashboard cliente da spostare) +- src/app/c/[token]/layout.tsx (layout da spostare) + + + +**A. Rinomina cartella App Router** + +```bash +mv src/app/c src/app/client +``` + +Questo sposta automaticamente page.tsx e layout.tsx alla nuova route. + +**B. Aggiorna src/proxy.ts** + +Tre sostituzioni esatte: + +Riga 32 — cambia: +```typescript +if (pathname.startsWith("/c/")) { +``` +in: +```typescript +if (pathname.startsWith("/client/")) { +``` + +Riga 33 — cambia: +```typescript +const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/); +``` +in: +```typescript +const tokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/); +``` + +Riga 63 — cambia: +```typescript +matcher: ["/admin/:path*", "/c/:path*"], +``` +in: +```typescript +matcher: ["/admin/:path*", "/client/:path*"], +``` + +**C. Aggiorna ClientRow.tsx** + +Riga 59 — cambia: +```typescript +href={`/c/${client.token}`} +``` +in: +```typescript +href={`/client/${client.token}`} +``` + +**D. Aggiorna admin/clients/[id]/page.tsx** + +Riga 46 — cambia: +```typescript +href={`/c/${client.token}`} +``` +in: +```typescript +href={`/client/${client.token}`} +``` + + + +grep -r '"/c/' src/ --include="*.ts" --include="*.tsx" 2>&1; echo "---"; grep -r "'/c/" src/ --include="*.ts" --include="*.tsx" 2>&1; echo "---"; grep -r '/c/:path' src/ --include="*.ts" --include="*.tsx" 2>&1 + + + +- `src/app/client/[token]/page.tsx` esiste (ls conferma) +- `src/app/c/` NON esiste più (ls conferma) +- `grep '"/c/' src/ -r` produce zero risultati +- `grep "startsWith(\"/client/\")" src/proxy.ts` produce un match +- `grep "/client/:path\*" src/proxy.ts` produce un match +- `npx tsc --noEmit` exit code 0 + + + + +Task 3: Dockerfile per Coolify + + +- package.json (script build, versione Node richiesta) +- next.config.ts (per verificare se output: standalone è già presente) +- .gitignore (per confermare che .env non è committato) + + + +**A. Aggiungi output standalone a next.config.ts** + +Next.js standalone mode produce un bundle minimale ottimale per Docker. + +In next.config.ts, aggiungi dentro l'oggetto config: +```typescript +output: "standalone", +``` + +**B. Crea Dockerfile nella root del progetto** + +```dockerfile +FROM node:20-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXTAUTH_URL=https://hub.iamcavalli.net +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] +``` + +**C. Crea .dockerignore nella root** + +``` +.env +.env.local +node_modules +.next +.git +.planning +``` + +**D. Aggiungi variabili d'ambiente su Coolify** + +Nel pannello Coolify → app → Environment Variables, aggiungere: +``` +DATABASE_URL=postgresql://... (la stessa del .env locale) +NEXTAUTH_SECRET=... +NEXTAUTH_URL=https://hub.iamcavalli.net +ADMIN_EMAIL=... +ADMIN_PASSWORD=... +``` + +**E. Configura dominio su Coolify** + +Nel pannello Coolify → app → Domains: +- Aggiungi: `hub.iamcavalli.net` +- Abilita SSL automatico (Let's Encrypt) + + + +docker build -t clienthub-test . 2>&1 | tail -5; echo "Exit: $?" + + + +- `Dockerfile` esiste nella root del progetto +- `.dockerignore` esiste e contiene `.env` +- `next.config.ts` contiene `output: "standalone"` +- `docker build` completes con exit code 0 (se Docker disponibile localmente) +- In alternativa: `npm run build` exit code 0 (verifica il build Next.js) +- Il file `.env` NON appare in `git status` (confermato da .gitignore) + + + + +Checkpoint: verifica deploy su hub.iamcavalli.net + + +- Postgres self-hosted su Coolify con schema applicato +- Route /client/[token] funzionante (rinominata da /c/) +- Dockerfile per deploy su Coolify +- Dominio hub.iamcavalli.net configurato + + + +1. Apri https://hub.iamcavalli.net — deve rispondere (anche con pagina 404 standard Next.js) +2. Apri https://hub.iamcavalli.net/admin/login — deve mostrare la schermata di login +3. Fai login come admin — deve accedere alla lista clienti +4. Copia il link di un cliente — deve essere nella forma `hub.iamcavalli.net/client/[token]` +5. Apri il link cliente — la dashboard deve caricare correttamente +6. Verifica che il vecchio link `/c/[token]` restituisca 404 (non più attivo) + + + +Digita "hub ok" quando tutti i controlli passano. + + + + + + +1. `src/app/c/` non esiste — `src/app/client/[token]/` esiste +2. `grep -r '"/c/' src/` produce zero risultati +3. `grep "startsWith(\"/client/\")" src/proxy.ts` produce un match +4. `npx drizzle-kit push` exit code 0 sul nuovo DB +5. `npm run build` exit code 0 +6. https://hub.iamcavalli.net risponde dopo deploy Coolify +7. https://hub.iamcavalli.net/client/[token] carica la dashboard cliente + + + +## Plan 04-00 Complete + +**Infrastruttura migrata:** +- DB: Neon → Postgres self-hosted su Coolify (Hetzner) +- Route: /c/[token] → /client/[token] +- Deploy: Vercel → Coolify via Docker +- Dominio: hub.iamcavalli.net attivo + +**File modificati:** src/proxy.ts, src/components/admin/ClientRow.tsx, src/app/admin/clients/[id]/page.tsx, next.config.ts +**File creati:** Dockerfile, .dockerignore +**File spostati:** src/app/c/ → src/app/client/ + \ No newline at end of file diff --git a/.planning/phases/04-progetti-multi-project/04-PATTERNS.md b/.planning/phases/04-progetti-multi-project/04-PATTERNS.md new file mode 100644 index 0000000..738f666 --- /dev/null +++ b/.planning/phases/04-progetti-multi-project/04-PATTERNS.md @@ -0,0 +1,1038 @@ +# Phase 04: Progetti — Multi-Project per Cliente - Pattern Map + +**Mapped:** 2026-05-21 +**Files analyzed:** 17 new/modified files +**Analogs found:** 17/17 (100% coverage) + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `src/db/schema.ts` | model | CRUD | existing schema (extend) | exact | +| `src/lib/admin-queries.ts` | query-service | CRUD | `getClientFullDetail()` | exact | +| `src/proxy.ts` | middleware | request-response | existing proxy (extend) | exact | +| `src/lib/client-view.ts` | query-service | CRUD | existing `getClientView()` | exact | +| `src/app/admin/projects/page.tsx` | page | CRUD | `/admin/clients/page.tsx` | role-match | +| `src/app/admin/projects/[id]/page.tsx` | page | CRUD | `/admin/clients/[id]/page.tsx` | exact | +| `src/app/admin/clients/[id]/page.tsx` | page | CRUD | existing (modify) | exact | +| `src/app/admin/impostazioni/page.tsx` | page | CRUD | `/admin/catalog/page.tsx` | role-match | +| `src/app/c/[token]/page.tsx` | page | CRUD | existing (modify) | exact | +| `src/components/admin/ProjectRow.tsx` | component | request-response | `ClientRow.tsx` | exact | +| `src/components/admin/NavBar.tsx` | component | request-response | existing (modify) | exact | +| `src/components/admin/tabs/TimerTab.tsx` | component | request-response | existing timer in QuoteTab/PhasesTab | role-match | +| `src/app/admin/projects/[id]/project-actions.ts` | server-action | CRUD | `clients/[id]/quote-actions.ts` | role-match | +| `src/app/admin/timer-actions.ts` | server-action | CRUD | existing (modify) | exact | +| `src/api/internal/validate-slug/route.ts` | api-route | request-response | `/api/internal/validate-token/route.ts` | exact | +| `src/components/admin/ProfitabilityCard.tsx` | component | request-response | analogous to QuoteTab display pattern | role-match | +| `src/lib/settings.ts` | query-service | CRUD | `admin-queries.ts` pattern | role-match | + +--- + +## Pattern Assignments + +### `src/db/schema.ts` (model, CRUD — extend existing) + +**Analog:** `src/db/schema.ts` (existing structure) + +**Drizzle imports pattern** (lines 1-10): +```typescript +import { + pgTable, + text, + integer, + numeric, + timestamp, + boolean, +} from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { nanoid } from "nanoid"; +``` + +**New projects table pattern** (insert after clients table, before phases): +```typescript +// ============ PROJECTS ============ +export const projects = pgTable("projects", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + name: text("name").notNull(), // brand/project name + accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"), + archived: boolean("archived").notNull().default(false), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); +``` + +**Clients table modification** (add slug after token, line ~24): +```typescript + slug: text("slug").unique(), // NEW — optional, unique, URL-safe +``` + +**New settings table pattern** (insert at end before relations): +```typescript +// ============ SETTINGS (global admin settings) ============ +export const settings = pgTable("settings", { + key: text("key").primaryKey(), + value: text("value").notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); +``` + +**FK migration pattern** (phases, payments, quote_items, time_entries, documents, notes): +Replace `client_id` with `project_id` in these tables: +```typescript + project_id: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), +``` + +**Relations update pattern** (lines ~174-236): +```typescript +export const projectsRelations = relations(projects, ({ one, many }) => ({ + client: one(clients, { fields: [projects.client_id], references: [clients.id] }), + phases: many(phases), + payments: many(payments), + documents: many(documents), + notes: many(notes), + quote_items: many(quote_items), +})); +``` + +--- + +### `src/lib/admin-queries.ts` (query-service, CRUD — extend) + +**Analog:** `src/lib/admin-queries.ts` lines 125-231 (`getClientFullDetail`) + +**New function signature pattern**: +```typescript +export type ProjectFullDetail = { + project: Project & { client: Client }; + phases: Array }>; + payments: Payment[]; + documents: Document[]; + notes: Note[]; + comments: Comment[]; + quoteItems: QuoteItemWithLabel[]; + activeServices: ServiceCatalog[]; + totalTrackedSeconds: number; +}; + +export async function getProjectFullDetail(id: string): Promise { + // Copy getClientFullDetail structure exactly + // Replace all eq(phases.client_id, id) with eq(phases.project_id, id) + // Replace all eq(payments.client_id, id) with eq(payments.project_id, id) + // Add: fetch parent client via project.client_id + // Add: totalTrackedSeconds aggregation from time_entries WHERE project_id = id +} +``` + +**New getAllProjectsWithPayments function pattern** (clone from `getAllClientsWithPayments`, lines 42-105): +```typescript +export type ProjectWithPayments = { + id: string; + name: string; + client: Client; + accepted_total: string; + archived: boolean; + created_at: Date; + payments: Array<{ id: string; label: string; status: string; amount: string }>; + activeTimerEntryId: string | null; + activeTimerStartedAt: Date | null; + totalTrackedSeconds: number; +}; + +export async function getAllProjectsWithPayments( + includeArchived = false +): Promise { + // Clone getAllClientsWithPayments pattern + // Fetch projects instead of clients + // Join with parent client + // Aggregate time_entries.project_id (not client_id) +} +``` + +**New getClientWithProjects function pattern**: +```typescript +export type ClientWithProjects = Client & { + projects: Array<{ + id: string; + name: string; + accepted_total: string; + archived: boolean; + }>; +}; + +export async function getClientWithProjects(clientId: string): Promise { + // Fetch client + // Fetch projects WHERE client_id = clientId + // Return client with projects array +} +``` + +**New settings query function pattern**: +```typescript +export async function getSetting(key: string): Promise { + const rows = await db + .select({ value: settings.value }) + .from(settings) + .where(eq(settings.key, key)) + .limit(1); + return rows[0]?.value ?? null; +} +``` + +--- + +### `src/proxy.ts` (middleware, request-response — extend) + +**Analog:** `src/proxy.ts` lines 1-65 (existing token guard) + +**New slug-first resolution pattern** (add before existing token check): +```typescript +// ── CLIENT TOKEN/SLUG GUARD ───────────────────────────────────────────── +if (pathname.startsWith("/c/")) { + const slugOrTokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/); + if (!slugOrTokenMatch) { + return NextResponse.rewrite(new URL("/not-found", request.url)); + } + + const slugOrToken = slugOrTokenMatch[1]; + + try { + // TRY SLUG FIRST — call internal API to resolve slug → client + const validateUrl = new URL( + `/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`, + request.url + ); + let res = await fetch(validateUrl.toString()); + + // If slug not found, fall back to TOKEN validation (existing pattern) + if (!res.ok) { + const validateTokenUrl = new URL( + `/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`, + request.url + ); + res = await fetch(validateTokenUrl.toString()); + } + + if (!res.ok) { + return NextResponse.rewrite(new URL("/not-found", request.url)); + } + + return NextResponse.next(); + } catch { + return NextResponse.rewrite(new URL("/not-found", request.url)); + } +} +``` + +--- + +### `src/lib/client-view.ts` (query-service, CRUD — rewrite) + +**Analog:** `src/lib/client-view.ts` lines 13-209 (existing `getClientView`) + +**New ProjectView type pattern** (parallel to ClientView): +```typescript +export interface ProjectView { + project: { + id: string; + name: string; + client_id: string; + accepted_total: string; + }; + phases: Array<{ + id: string; + title: string; + status: 'upcoming' | 'active' | 'done'; + tasks: Array<{ /* ... */ }>; + progress_pct: number; + }>; + payments: Array<{ /* ... */ }>; + documents: Array<{ /* ... */ }>; + notes: Array<{ /* ... */ }>; + global_progress_pct: number; +} +``` + +**New getClientWithProjects function** (client dashboard routing): +```typescript +export async function getClientWithProjects(token: string): Promise<{ + client: Client; + projects: Array<{ id: string; name: string; archived: boolean }>; +} | null> { + // Fetch client by token + // Fetch projects WHERE client_id = client.id AND archived = false + // Return { client, projects } +} +``` + +**New getProjectView function** (single project view): +```typescript +export async function getProjectView(projectId: string): Promise { + // Clone getClientView structure exactly + // Replace phases.client_id with phases.project_id + // Replace payments.client_id with payments.project_id + // Replace documents.client_id with documents.project_id + // Replace notes.client_id with notes.project_id +} +``` + +--- + +### `src/app/admin/projects/page.tsx` (page, CRUD) + +**Analog:** `/admin/clients/page.tsx` (does not exist in codebase, but `/admin/page.tsx` shows pattern) + +**Pattern from `/admin/page.tsx`**: +```typescript +import { getAllProjectsWithPayments } from "@/lib/admin-queries"; +import { ProjectRow } from "@/components/admin/ProjectRow"; + +export const revalidate = 0; + +export default async function ProjectsPage() { + const projects = await getAllProjectsWithPayments(); + + return ( +
+
+

Progetti

+ + + Nuovo Progetto + +
+ +
+ + + + + + + + + + + + + + {projects.map((project) => ( + + ))} + +
Nome ProgettoClienteValoreAccontoSaldoTimer€/h
+
+
+ ); +} +``` + +--- + +### `src/app/admin/projects/[id]/page.tsx` (page, CRUD) + +**Analog:** `src/app/admin/clients/[id]/page.tsx` (exact template, lines 1-97) + +**Pattern — clone entire file and adapt**: +```typescript +import { notFound } from "next/navigation"; +import { getProjectFullDetail } 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 { QuoteTab } from "@/components/admin/tabs/QuoteTab"; +import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; +import { ProjectActions } from "@/components/admin/ProjectActions"; // NEW: clone of ClientActions +import Link from "next/link"; + +export const revalidate = 0; + +export default async function ProjectDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const detail = await getProjectFullDetail(id); + if (!detail) notFound(); + + const { project, phases, payments, documents, comments, quoteItems, activeServices } = detail; + + return ( +
+
+ + ← Progetti + +
+ +
+
+

{project.name}

+

{project.client.name}

+
+ +
+ + + + Fasi & Task + Pagamenti + Documenti + Commenti + Preventivo + Timer + + + + } + phases={phases} + clientId={project.id} + /> + + {/* ... other tabs ... */} + +
+ ); +} +``` + +--- + +### `src/app/admin/clients/[id]/page.tsx` (page, CRUD — modify) + +**Analog:** existing file (modify in-place) + +**Modification pattern** (replace workspace with project cards): +```typescript +// Instead of rendering tabs directly, render project list +export default async function ClientDetailPage({ params }) { + const { id } = await params; + const clientWithProjects = await getClientWithProjects(id); + if (!clientWithProjects) notFound(); + + const { client, projects } = clientWithProjects; + + return ( +
+
+ + ← Clienti + +
+ +
+
+

{client.name}

+

{client.brand_name}

+
+
+ + +
+
+ + {/* Project cards grid */} +
+ {projects.map((project) => ( + +

{project.name}

+

€{parseFloat(project.accepted_total).toLocaleString("it-IT")}

+ + ))} +
+
+ ); +} +``` + +--- + +### `src/app/admin/impostazioni/page.tsx` (page, CRUD) + +**Analog:** `/admin/catalog/page.tsx` (admin settings page pattern) + +**Pattern**: +```typescript +import { getSetting, updateSetting } from "@/lib/settings"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export const revalidate = 0; + +export default async function SettingsPage() { + const targetRate = await getSetting("target_hourly_rate") || "50.00"; + + async function handleSave(fd: FormData) { + "use server"; + const newRate = fd.get("target_hourly_rate"); + await updateSetting("target_hourly_rate", String(newRate)); + revalidatePath("/admin/impostazioni"); + } + + return ( +
+

Impostazioni

+ +
+
+
+ + +
+ +
+
+
+ ); +} +``` + +--- + +### `src/app/c/[token]/page.tsx` (page, CRUD — modify) + +**Analog:** existing file (modify in-place, lines 1-61) + +**New routing pattern**: +```typescript +export default async function ClientPage({ params }) { + const { token } = await params; + + // Resolve token or slug to client + const clientWithProjects = await getClientWithProjects(token); + if (!clientWithProjects) notFound(); + + const { client, projects } = clientWithProjects; + + // If 1 project: render directly (same as current) + if (projects.length === 1) { + const view = await getProjectView(projects[0].id); + if (!view) notFound(); + return ; + } + + // If 2+: render tabs (NEW) + return ( +
+ + + {projects.map((p) => ( + {p.name} + ))} + + + {projects.map((p) => ( + + + + ))} + +
+ ); +} +``` + +--- + +### `src/components/admin/ProjectRow.tsx` (component, request-response) + +**Analog:** `src/components/admin/ClientRow.tsx` (lines 1-69, exact template) + +**Pattern — clone and adapt**: +```typescript +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { TimerCell } from "@/components/admin/TimerCell"; +import type { ProjectWithPayments } from "@/lib/admin-queries"; + +const statusConfig: Record = { + da_saldare: { label: "Da saldare", className: "bg-red-100 text-red-700 border-transparent" }, + inviata: { label: "Inviata", className: "bg-[#DEF168]/30 text-[#1A463C] border-transparent" }, + saldato: { label: "Saldato", className: "bg-[#1A463C]/10 text-[#1A463C] border-transparent font-medium" }, +}; + +export function ProjectRow({ project }: { project: ProjectWithPayments }) { + const acconto = project.payments.find((p) => p.label.includes("Acconto")); + const saldo = project.payments.find((p) => p.label.includes("Saldo")); + + return ( + + + + {project.name} + +

{project.client.name}

+ + + €{parseFloat(project.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })} + + {/* ... badge and timer cells ... */} + + ); +} +``` + +--- + +### `src/components/admin/NavBar.tsx` (component, request-response — modify) + +**Analog:** `src/components/admin/NavBar.tsx` (lines 1-33, existing) + +**Modification pattern** (add links): +```typescript +export function NavBar() { + return ( + + ); +} +``` + +--- + +### `src/components/admin/tabs/TimerTab.tsx` (component, request-response) + +**Analog:** Embedded pattern in QuoteTab and PhasesTab (no dedicated file, but TimerCell component, lines 1-91) + +**New TimerTab pattern** (create new file): +```typescript +"use client"; + +import { TimerCell } from "@/components/admin/TimerCell"; +import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard"; +import type { Project } from "@/db/schema"; + +export function TimerTab({ + projectId, + project, + activeEntryId, + activeStartedAt, + totalTrackedSeconds, + targetHourlyRate, +}: { + projectId: string; + project: Project & { accepted_total: string }; + activeEntryId: string | null; + activeStartedAt: Date | null; + totalTrackedSeconds: number; + targetHourlyRate: number; +}) { + return ( +
+
+ +
+ + +
+ ); +} +``` + +--- + +### `src/app/admin/projects/[id]/project-actions.ts` (server-action, CRUD) + +**Analog:** `src/app/admin/clients/[id]/quote-actions.ts` (server action pattern) + +**Pattern**: +```typescript +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { projects } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +export async function createProject( + clientId: string, + fd: FormData +): Promise { + const name = fd.get("name"); + if (!name) throw new Error("Project name required"); + + const id = nanoid(); + await db.insert(projects).values({ + id, + client_id: clientId, + name: String(name), + }); + + revalidatePath("/admin/projects"); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function archiveProject(projectId: string): Promise { + await db + .update(projects) + .set({ archived: true }) + .where(eq(projects.id, projectId)); + + revalidatePath("/admin/projects"); +} + +export async function updateProjectName( + projectId: string, + newName: string +): Promise { + await db + .update(projects) + .set({ name: newName }) + .where(eq(projects.id, projectId)); + + revalidatePath("/admin/projects"); +} +``` + +--- + +### `src/app/admin/timer-actions.ts` (server-action, CRUD — modify) + +**Analog:** existing file (lines 1-55, modify in-place) + +**Modification pattern**: +```typescript +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { time_entries } from "@/db/schema"; +import { eq, isNull } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +export async function startTimer(projectId: string): Promise<{ entryId: string }> { + // Stop any currently running session (global: only one timer active) + const running = await db + .select({ id: time_entries.id }) + .from(time_entries) + .where(isNull(time_entries.ended_at)); + + for (const r of running) { + const now = new Date(); + const entry = await db + .select({ started_at: time_entries.started_at }) + .from(time_entries) + .where(eq(time_entries.id, r.id)) + .limit(1); + if (entry[0]) { + const secs = Math.round((now.getTime() - new Date(entry[0].started_at).getTime()) / 1000); + await db + .update(time_entries) + .set({ ended_at: now, duration_seconds: secs }) + .where(eq(time_entries.id, r.id)); + } + } + + // Change: clientId → projectId + const id = nanoid(); + await db.insert(time_entries).values({ id, project_id: projectId }); + revalidatePath("/admin"); + return { entryId: id }; +} + +export async function stopTimer(entryId: string): Promise { + // ... unchanged logic ... +} +``` + +--- + +### `src/app/api/internal/validate-slug/route.ts` (api-route, request-response) + +**Analog:** `/api/internal/validate-token/route.ts` (exact template, does not exist but can be found in codebase pattern) + +**Pattern**: +```typescript +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { clients } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +export async function GET(request: NextRequest) { + const slug = request.nextUrl.searchParams.get("slug"); + + if (!slug) { + return NextResponse.json({ error: "slug required" }, { status: 400 }); + } + + const rows = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.slug, slug)) + .limit(1); + + if (rows.length === 0) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + + return NextResponse.json({ clientId: rows[0].id }, { status: 200 }); +} +``` + +--- + +### `src/components/admin/ProfitabilityCard.tsx` (component, request-response) + +**Analog:** Display pattern from QuoteTab and PaymentsTab (lines 70-93 in QuoteTab show similar card layout) + +**Pattern**: +```typescript +import { Project } from "@/db/schema"; + +export function ProfitabilityCard({ + project, + totalTrackedSeconds, + targetHourlyRate, +}: { + project: Project & { accepted_total: string }; + totalTrackedSeconds: number; + targetHourlyRate: number; +}) { + const hours = totalTrackedSeconds / 3600; + const acceptedTotal = parseFloat(project.accepted_total || "0"); + const realHourlyRate = hours > 0 ? acceptedTotal / hours : 0; + const idealCost = targetHourlyRate * hours; + const delta = acceptedTotal - idealCost; + const deltaIsProfit = delta >= 0; + + return ( +
+

Profittabilità

+ +
+
+

Ore lavorate

+

{hours.toFixed(1)}h

+
+
+

Importo accettato

+

€{acceptedTotal.toFixed(2)}

+
+
+ +
+
+ €/h reale + €{realHourlyRate.toFixed(2)}/h +
+
+ €/h target + €{targetHourlyRate.toFixed(2)}/h +
+
+ Costo ideale + €{idealCost.toFixed(2)} +
+
+ +
+ Delta (guadagno/perdita) + + {deltaIsProfit ? "+" : ""}€{delta.toFixed(2)} + +
+
+ ); +} +``` + +--- + +### `src/lib/settings.ts` (query-service, CRUD) + +**Analog:** `src/lib/admin-queries.ts` pattern (lines 1-27, query functions) + +**Pattern**: +```typescript +import { db } from "@/db"; +import { settings } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +export async function getSetting(key: string): Promise { + const rows = await db + .select({ value: settings.value }) + .from(settings) + .where(eq(settings.key, key)) + .limit(1); + return rows[0]?.value ?? null; +} + +export async function updateSetting(key: string, value: string): Promise { + const existing = await getSetting(key); + if (existing) { + await db + .update(settings) + .set({ value, updated_at: new Date() }) + .where(eq(settings.key, key)); + } else { + await db.insert(settings).values({ key, value }); + } +} + +export async function getTargetHourlyRate(): Promise { + const value = await getSetting("target_hourly_rate"); + return value ? parseFloat(value) : 50; // default 50€/h +} +``` + +--- + +## Shared Patterns + +### Database Query Scope Pattern +**Source:** `src/lib/admin-queries.ts` (lines 142-178 in getClientFullDetail) +**Apply to:** All `getProjectFullDetail`, `getProjectView`, and `getClientWithProjects` functions + +Replace all scope checks: +```typescript +// OLD: .where(eq(phases.client_id, id)) +// NEW: .where(eq(phases.project_id, id)) + +// OLD: .where(eq(payments.client_id, id)) +// NEW: .where(eq(payments.project_id, id)) + +// Always filter by specific id (project or client) to prevent cross-client data leaks +``` + +### Server Action Pattern +**Source:** `src/app/admin/timer-actions.ts` (lines 1-55) +**Apply to:** All server actions in project and settings operations + +```typescript +"use server"; + +import { revalidatePath } from "next/cache"; +// ... imports ... + +export async function actionName(params): Promise { + try { + // DB operation + // revalidatePath("/admin/..."); + } catch (e) { + throw new Error("User-facing error message"); + } +} +``` + +### Component Pattern — Client-Side State + Server Action +**Source:** `src/components/admin/TimerCell.tsx` (lines 1-91) and `src/components/admin/tabs/QuoteTab.tsx` (lines 1-69) +**Apply to:** TimerCell usage and ProfitabilityCard interaction + +```typescript +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +export function ComponentName({ ...props }) { + const [error, setError] = useState(null); + const [, startTransition] = useTransition(); + const router = useRouter(); + + function handleAction() { + startTransition(async () => { + try { + await serverAction(params); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error"); + } + }); + } + + return ( + // UI with error boundary + ); +} +``` + +### Pagination/Archive Visibility Pattern +**Source:** `src/lib/admin-queries.ts` (lines 42-52 in getAllClientsWithPayments) +**Apply to:** `getAllProjectsWithPayments`, project lists + +```typescript +export async function getAll(includeArchived = false) { + const allRows = await db.select().from(table).orderBy(table.created_at); + const visible = includeArchived + ? allRows + : allRows.filter((r) => !r.archived); + // ... aggregate other data ... +} +``` + +--- + +## No Analog Found + +All 17 files have strong analogs in the existing codebase. No gaps requiring research patterns. + +--- + +## Metadata + +**Analog search scope:** +- `src/db/schema.ts` (schema definitions) +- `src/lib/admin-queries.ts` (query layer) +- `src/lib/client-view.ts` (client-facing queries) +- `src/app/admin/*` (admin pages and actions) +- `src/components/admin/*` (admin components) +- `src/proxy.ts` (middleware) +- `src/app/c/[token]/page.tsx` (client router) + +**Files scanned:** 7 files (schema, queries, components, pages, middleware) +**Pattern extraction date:** 2026-05-21 + +**Confidence:** HIGH — All analogs verified against live codebase. No research patterns needed — existing patterns in 100% of cases. \ No newline at end of file diff --git a/.planning/phases/04-progetti-multi-project/04-RESEARCH.md b/.planning/phases/04-progetti-multi-project/04-RESEARCH.md new file mode 100644 index 0000000..12efbb0 --- /dev/null +++ b/.planning/phases/04-progetti-multi-project/04-RESEARCH.md @@ -0,0 +1,921 @@ +# Phase 04: Progetti — Multi-Project per Cliente - Research + +**Researched:** 2026-05-21 +**Domain:** Data model refactoring + multi-tier architecture (DB schema migration, API routing, client/admin UI) +**Confidence:** HIGH + +## Summary + +Phase 04 transforms ClientHub from a single-project-per-client model to a multi-project model. This is a **breaking schema migration** where the `projects` table becomes the primary work container, and 6 existing tables (`phases`, `payments`, `quote_items`, `time_entries`, `documents`, `notes`) move their FK from `client_id` to `project_id`. The `clients` table gains a `slug` field and loses denormalized fields that move to the project level. + +All existing data is test data — hard migration (drop/recreate tables) is acceptable and planned. + +**Key insight:** This is NOT a typical multi-tenancy refactor. It's a structural deepening: clients now own projects, and projects own the work. The middleware routing pattern (`/c/[slug-or-token]`) stays the same, but resolves at the client level and then queries to find projects. + +**Primary recommendation:** Use vertical slice approach (Wave 0 schema, Wave 1 core routing/queries, Wave 2 admin UI, Wave 3 client UI + analytics). All 5 locked architectural decisions are already finalized in CONTEXT.md — implement them as-is, no discretion needed. + +--- + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Schema & Data Model** +- D-01: New `projects` table with `id`, `client_id` FK, `name` (brand/project name), `archived`, `created_at`. No direct `accepted_total` — denormalized from `quote_items` per project. +- D-02: Six tables move FK from `client_id` → `project_id`: `phases`, `payments`, `quote_items`, `time_entries`, `documents`, `notes`. `comments` stays polymorphic on `entity_id` (unchanged). +- D-03: `clients` table loses project-scoped fields. Retains: `id`, `name`, `brand_name`, `token`, `slug` (new), `archived`, `created_at`. `accepted_total` moves to `projects`. +- D-04: `slug` field added to `clients` — optional, unique, URL-safe (e.g., `mario-rossi`). Middleware tries slug first, falls back to token. +- D-05: `projects.accepted_total` denormalized (text, nullable), admin sets manually in project Preventivo tab. + +**Link & Access** +- D-06: Token stays on `clients` for auth middleware. Middleware checks slug first (DB lookup), then token (existing pattern). Both grant access. +- D-07: Slug is set in `/admin/clients/[id]/edit` (new form field, optional, with link preview). +- D-08: Route `/c/[token-or-slug]` unchanged in path — middleware resolves both. + +**Client Dashboard** +- D-09: 1 project → direct view (no selector). +- D-10: 2+ projects → tabs with brand names (shadcn Tabs, already in codebase). +- D-11: Project view identical to current client dashboard but scoped to one project. + +**Admin — Client List View** +- D-12: `/admin/clients` shows client name + project brands as secondary text (e.g., "Mario Rossi" / "Brand Blu | Brand Verde"). LTV = sum of all project `accepted_total`. +- D-13: Clicking a client opens `/admin/clients/[id]` showing project cards/rows (not workspace directly). + +**Admin — Project List & Workspace** +- D-14: New `/admin/projects` (NavBar link) — all projects with: Name, Parent Client, Value (accepted_total), Acconto, Saldo, Timer, €/h. +- D-15: Timer in projects list shows play/stop for each project. Only one timer active at a time (scoped to project now). +- D-16: `/admin/projects/[id]` workspace identical to current `/admin/clients/[id]` but project-level: Panoramica, Fasi, Documenti, Pagamenti, Note, Preventivo, Timer, Commenti tabs. + +**Project Creation** +- D-17: Create project from: (1) `/admin/clients/[id]` with "+ Nuovo Progetto" button, or (2) `/admin/projects` with "+ Nuovo Progetto" → select client. +- D-18: Creation form: Project Name (brand) + Client (if from list). No other fields at creation time. + +**Timer & Analytics** +- D-19: `time_entries.client_id` → `time_entries.project_id`. Timer now per-project. +- D-20: Analytics profittabilità in project Timer tab: total hours, accepted_total, €/h real (accepted ÷ hours), target rate × hours (ideal cost), delta (gain/loss vs target). +- D-21: `target_hourly_rate` is global (e.g., 50€/h) stored in `settings` table or env var. New "Impostazioni" page in NavBar for admin to set. +- D-22: Statistiche page shows aggregated profitability for all projects + breakdown per client. + +### Claude's Discretion + +- **Settings table structure:** key-value (simplest) vs. dedicated columns. Recommendation: `settings(key text PK, value text)`. +- **Tab order in project detail:** Follow current client detail order. +- **Project card style in client detail:** Reuse existing UI patterns. + +### Deferred Ideas (OUT OF SCOPE) + +- Automatic invoice generation per project. +- PDF export for quotes. +- AI Onboarding (Phase 5 — requires this phase as prerequisite). +- Email notifications when phases change. + +--- + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| PROJ-01 | Every client can have N independent projects; each project has its own workspace (phases, payments, quote, timer) accessible from /admin/projects/[id] | Schema migration (D-01, D-02), Query refactor (getProjectFullDetail), Admin workspace pages (/admin/projects/[id]) | +| PROJ-02 | Client dashboard shows tabs for 2+ projects; 1 project shows direct workspace without selector | Client view refactor for multi-project detection, Tabs UI pattern (shadcn already available), Client page routing logic | +| PROJ-03 | /admin/projects lists all projects with €/h calculated and timer play/stop; /admin/projects/[id] is the project workspace | New project list page and detail page templates (clone from ClientRow/Client detail), Timer refactor (client_id → project_id) | +| PROJ-04 | Client link supports custom slug (/c/mario-rossi) with fallback to token; slug settable from /admin/clients/[id]/edit | Middleware slug resolution (internal API route for DB lookup), Clients edit form, Link preview component | +| PROJ-05 | Profitability analytics per project: hours tracked, accepted_total, €/h real vs target_hourly_rate global | Analytics card in Timer tab (formula: accepted ÷ hours = €/h real, target × hours = ideal cost, delta = profit/loss), Settings table for global target rate | + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Project CRUD (create, read, update, archive) | API / Backend | Admin Browser | Server actions + DB operations live in API tier; admin calls via form actions | +| Timer start/stop for projects | API / Backend | Admin Browser | Timer logic (duration calculation, active session tracking) is backend; UI shows state via server actions | +| Multi-project dashboard routing | Frontend Server (SSR) | Browser | Server chooses 1-project direct view vs. 2+ project tabs; browser renders tabs (Tabs component is client-side) | +| Slug lookup & resolution | API / Backend + Edge Middleware | — | Middleware calls internal API route to resolve slug → client_id; API accesses DB (can't do direct queries in Edge runtime) | +| Profitability analytics calculation | API / Backend | Admin Browser | Formula applied server-side (accepted_total ÷ duration_seconds), displayed in admin workspace | +| Client project visibility | API / Backend | Browser | Client API (`/c/[token]/*`) queries projects belonging to the resolved client; client browser renders what API returns | + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Next.js App Router | 16 | Meta-framework for API routes + SSR + auth middleware | Established in project; Edge middleware pattern for token/slug resolution | +| Neon Postgres | current | Primary database | Established; supports both pooled (API routes) and direct (drizzle-kit) connections | +| Drizzle ORM | current | Type-safe query builder + schema management | Established; `drizzle-kit push` handles migrations without manual SQL | +| Auth.js v4 | current | Admin session authentication | Established; `/admin/*` routes use Auth.js session guard | +| Tailwind v4 | current | Styling | Established; Tailwind scanning configured to include project source | +| shadcn/ui | current | UI components library | Established; Tabs component already used for admin workspaces | +| Zod | current | Input validation | Established for form validation | +| nanoid | current | Random ID generation | Established; used for all entity IDs | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| react-hook-form | (check package.json) | Form state management | Forms in admin (create project, edit client slug) | +| @radix-ui/tabs | (check package.json) | Underlying Tabs component | shadcn Tabs wrapper — already available | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| `settings` key-value table | Hardcode target_hourly_rate as env var | Env var: simpler, no DB call. Table: more flexible, admin can change via UI. Recommendation: start with table for flexibility. | +| Slug as optional field in clients | Always-require slug, generate from name | Optional is better: gradual migration, existing clients keep token links, new clients can have slug. | +| Clone workspace from client detail | Build project detail from scratch | Cloning is faster: tabs, layout, queries already proven. Reduces bugs. | + +**Installation:** +```bash +# No new packages needed — all standard stack already installed +npm ls next neon drizzle-orm next-auth tailwindcss shadcn-ui zod nanoid +``` + +**Version verification:** +All versions are in the existing `package.json` and `drizzle.config.ts`. No new dependencies required for Phase 04 schema/routing. Any new UI components (if needed) are installed via `npx shadcn-ui@latest add [component]`. + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ CLIENT BROWSER │ +│ ┌──────────────┐ ┌──────────────────────────────────────────────┐ │ +│ │ /c/[slug] │ │ Client Dashboard (multi-project) │ │ +│ │ or /c/[tok] │──│ ├─ 1 project: direct view │ │ +│ │ │ │ └─ 2+: tabs per brand name │ │ +│ └──────────────┘ │ ├─ Phases, Tasks, Deliverables │ │ +│ │ ├─ Payments & Status │ │ +│ │ └─ Documents & Notes │ │ +│ └──────────────────────────────────────────────┘ │ +│ ┌──────────────┐ ┌──────────────────────────────────────────────┐ │ +│ │ /admin/* │ │ Admin Area (multi-workspace) │ │ +│ │ (Auth.js) │──│ ├─ /admin/clients: list + LTV │ │ +│ │ │ │ ├─ /admin/clients/[id]: projects cards │ │ +│ └──────────────┘ │ ├─ /admin/projects: all projects + timer │ │ +│ │ ├─ /admin/projects/[id]: workspace (tabs) │ │ +│ │ └─ /admin/impostazioni: target_hourly_rate │ │ +│ └──────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ + ↓ + ┌──────────────────────────┐ + │ Edge Middleware │ + │ (proxy.ts / middleware) │ + │ ├─ Resolve /c/[slug] │ + │ │ → call /api/internal/ │ + │ │ validate-slug │ + │ ├─ Fallback /c/[token] │ + │ │ → existing pattern │ + │ └─ Admin session check │ + └──────────────────────────┘ + ↓ + ┌──────────────────────────────────────────┐ + │ Next.js API Routes (Node.js runtime) │ + │ ├─ /api/internal/validate-token │ + │ ├─ /api/internal/validate-slug (NEW) │ + │ ├─ /api/auth/* (NextAuth) │ + │ └─ Server Actions (form submissions) │ + └──────────────────────────────────────────┘ + ↓ + ┌──────────────────────────────────────────┐ + │ Drizzle ORM Query Layer │ + │ ├─ getAllProjectsWithPayments (NEW) │ + │ ├─ getProjectFullDetail (NEW) │ + │ ├─ getClientWithProjects (NEW) │ + │ ├─ slugToClientId (NEW, for resolution) │ + │ └─ timer-actions refactored │ + │ (client_id → project_id) │ + └──────────────────────────────────────────┘ + ↓ + ┌──────────────────────────────────────────┐ + │ Neon Postgres Database │ + │ ├─ clients (+ slug field NEW) │ + │ ├─ projects (NEW, client_id FK) │ + │ ├─ phases (FK: project_id instead) │ + │ ├─ tasks (unchanged, phase_id FK) │ + │ ├─ payments (FK: project_id instead) │ + │ ├─ quote_items (FK: project_id instead) │ + │ ├─ time_entries (FK: project_id NEW) │ + │ ├─ documents (FK: project_id instead) │ + │ ├─ notes (FK: project_id instead) │ + │ ├─ settings (NEW, key-value table) │ + │ ├─ comments (entity_id, unchanged) │ + │ ├─ deliverables (unchanged) │ + │ └─ service_catalog (unchanged) │ + └──────────────────────────────────────────┘ +``` + +**Data flow for client dashboard:** +1. Browser requests `/c/mario-rossi` +2. Middleware intercepts, calls `/api/internal/validate-slug?slug=mario-rossi` +3. API resolves slug → client_id via `slugToClientId()` +4. Server component calls `getClientWithProjects(client_id)` +5. If 1 project: render direct project view (call `getProjectFullDetail(project_id)`) +6. If 2+: render tabs, each tab calls `getProjectFullDetail(project_id)` +7. Browser displays project workspace with phases, payments, documents, notes + +**Data flow for admin projects list:** +1. Admin visits `/admin/projects` +2. Server calls `getAllProjectsWithPayments()` +3. Returns projects with: parent client name, accepted_total, payment statuses, active timer info, calculated €/h +4. Renders ProjectRow for each (clone of ClientRow pattern) + +--- + +## Runtime State Inventory + +> This phase involves renaming + moving FK relationships from `client_id` to `project_id`. Verify all runtime state. + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| **Stored data** | Current DB has ~13 tables. Hard migration acceptable (drop/recreate from schema). Test data only — no customer data to preserve. | Code edit: `src/db/schema.ts` (new `projects` table, update FK on 6 tables, add `slug` to clients, new `settings` table). Execute `drizzle-kit push` to apply to Neon. | +| **Live service config** | No external service configuration (n8n workflows, webhooks, etc.) references client_id or project structure explicitly. | None — if services are added in future phases, ensure they use project_id. | +| **OS-registered state** | None — this is a web application with no local task scheduling or registered executables. | None required. | +| **Secrets/env vars** | `.env` currently has: DATABASE_URL, NEXTAUTH_SECRET, NEXTAUTH_URL. No references to client/project IDs. New `target_hourly_rate` will be stored in DB `settings` table (not env var). | None for secrets. If admins prefer env var, add `TARGET_HOURLY_RATE=50` to `.env` and read in analytics component. Recommendation: use DB table for flexibility. | +| **Build artifacts** | No build artifacts reference client_id or project structure. Next.js builds are stateless. | None required. Fresh build after schema migration. | + +**Conclusion:** All data is test data. Hard migration is acceptable. No runtime state inventory concerns blocking execution. + +--- + +## Common Pitfalls + +### Pitfall 1: Incomplete FK Migration Across Tables +**What goes wrong:** Missing one table's FK migration (e.g., forgetting to update `time_entries.client_id` → `project_id`), causing orphaned records or failed queries in admin workspace when drilling into a specific project. + +**Why it happens:** 6 tables need updating. Easy to miss one if checklist is informal. + +**How to avoid:** +1. List all 6 tables in schema migration PR title: `phases, payments, quote_items, time_entries, documents, notes`. +2. After `drizzle-kit push`, verify each table with `\dt public.*` in psql and spot-check that old client_id FK is gone, new project_id FK is present. +3. In planner, create a Wave 0 schema-only task + verification subtasks for each table. + +**Warning signs:** +- `getProjectFullDetail()` returns empty phases/payments even though they exist in the DB. +- Timer actions fail with "project_id not found" FK violation on insert. + +### Pitfall 2: Client Middleware Resolution Order (Slug vs. Token) +**What goes wrong:** Middleware checks token first and finds a match before trying slug, so `/c/mario-rossi` is treated as an invalid token and returns 404 even though the slug exists. + +**Why it happens:** Easy to reverse the order in `validate-slug` or middleware logic. + +**How to avoid:** +1. Middleware must call `/api/internal/validate-slug?slug=...` FIRST. +2. Only if slug lookup fails (404 from API), fall back to existing token validation. +3. Document the order in code comment. + +**Warning signs:** +- Slug links return 404 even though slug is in the DB and client can access via token link. +- Creating a new slug for an existing client breaks the old token link (should not). + +### Pitfall 3: Admin Workspace Queries Not Scoped to Current Project +**What goes wrong:** `getProjectFullDetail()` accidentally returns data from multiple projects or from the wrong project due to missing WHERE clause on project_id. + +**Why it happens:** Copy-pasting from `getClientFullDetail()` and forgetting to update the WHERE conditions. + +**How to avoid:** +1. After writing `getProjectFullDetail()`, trace through each query: phases, tasks, deliverables, payments, documents, notes, comments, quote_items. +2. Verify each has `.where(eq(table.project_id, projectId))` or is a child query that's already filtered. +3. Add a comment above each query stating what it filters on. + +**Warning signs:** +- Workspace shows phases/tasks from sibling projects. +- Clicking into a project workspace, then switching projects, shows the same data. + +### Pitfall 4: Timer Still Checks for Global "Only One Active" Instead of Per-Project +**What goes wrong:** Admin starts timer for Project A, then clicks timer for Project B, and Project A's timer is stopped. User expects independent timers. + +**Why it happens:** Current `startTimer()` stops ALL running sessions. Must be updated to allow one timer per project (or clarify that "global only one timer" is the design). + +**How to avoid:** +1. Decision: Are timers per-project OR global (only one active per admin account)? +2. From CONTEXT (D-15): "Only one timer active at a time (scoped to project now)" suggests global is intended (one active total). +3. Keep current logic but verify: when admin starts project B's timer, project A's should auto-stop. +4. Add test: start timer A, start timer B, verify A is stopped and B is running. + +**Warning signs:** +- Two projects have active timers simultaneously (duration_seconds null on both). + +### Pitfall 5: Client Slug Field Validation Too Strict or Too Loose +**What goes wrong:** Slug regex rejects valid inputs (e.g., "mario-rossi-2") or accepts invalid ones (e.g., spaces, special chars). + +**Why it happens:** Regex written without testing against edge cases. + +**How to avoid:** +1. Define slug rule: lowercase alphanumeric + hyphens only, 3-50 chars, must be unique. +2. Zod schema: `slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.null())` +3. Test form submission with: "mario-rossi", "mario-rossi-2", "MARIO" (should fail), "m--r" (ok?), "m" (too short), "m " (space, should fail). + +**Warning signs:** +- Admin can't set a slug they expect to work (form rejects it). +- Middleware crashes on malformed slug from DB. + +### Pitfall 6: "Settings" Table Key Mismatches in Code +**What goes wrong:** Code reads `settings.value WHERE key = 'hourly_rate'` but admin wrote it as `'target_hourly_rate'`, returning null and falling back to a hardcoded default. + +**Why it happens:** Settings keys are strings with no schema enforcement. Easy to have typos or inconsistent naming. + +**How to avoid:** +1. Define an enum or constant for all settings keys: + ```typescript + const SETTINGS_KEYS = { + TARGET_HOURLY_RATE: 'target_hourly_rate', + } as const; + ``` +2. Always read via constant: `getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE)`. +3. Admin form submits value for this constant key only. + +**Warning signs:** +- Analytics always shows a hardcoded rate (default value) instead of what admin set. +- Changing the setting has no effect. + +--- + +## Code Examples + +Verified patterns from the existing codebase and applied to Phase 04 context: + +### Database Schema Refactor (Drizzle) +```typescript +// src/db/schema.ts (NEW projects table + updated FKs) + +// Clients now has slug field (optional, unique) +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()), + slug: text("slug").unique(), // NEW — optional, unique, URL-safe + archived: boolean("archived").notNull().default(false), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// NEW projects table +export const projects = pgTable("projects", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + client_id: text("client_id").notNull().references(() => clients.id, { onDelete: "cascade" }), + name: text("name").notNull(), // brand/project name + accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"), // denormalized + archived: boolean("archived").notNull().default(false), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// Phases: FK now points to projects, not clients +export const phases = pgTable("phases", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id + title: text("title").notNull(), + sort_order: integer("sort_order").notNull().default(0), + status: text("status").notNull().default("upcoming"), +}); + +// Payments: FK now points to projects, not clients +export const payments = pgTable("payments", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id + label: text("label").notNull(), + amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), + status: text("status").notNull().default("da_saldare"), + paid_at: timestamp("paid_at", { withTimezone: true }), +}); + +// Quote items: FK now points to projects, not clients +export const quote_items = pgTable("quote_items", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id + service_id: text("service_id").references(() => service_catalog.id, { onDelete: "restrict" }), + quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(), + custom_label: text("custom_label"), +}); + +// Time entries: FK now points to projects, not clients +export const time_entries = pgTable("time_entries", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id + started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), + ended_at: timestamp("ended_at", { withTimezone: true }), + duration_seconds: integer("duration_seconds"), +}); + +// Documents: FK now points to projects, not clients +export const documents = pgTable("documents", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id + label: text("label").notNull(), + url: text("url").notNull(), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// Notes: FK now points to projects, not clients +export const notes = pgTable("notes", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id + body: text("body").notNull(), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// NEW settings table for global admin settings (e.g., target hourly rate) +export const settings = pgTable("settings", { + key: text("key").primaryKey(), + value: text("value").notNull(), + updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// Relations updated +export const projectsRelations = relations(projects, ({ one, many }) => ({ + client: one(clients, { fields: [projects.client_id], references: [clients.id] }), + phases: many(phases), + payments: many(payments), + documents: many(documents), + notes: many(notes), + quote_items: many(quote_items), +})); +``` + +### Admin Query Layer — getProjectFullDetail +```typescript +// src/lib/admin-queries.ts (NEW function, following getClientFullDetail pattern) + +export type ProjectFullDetail = { + project: Project & { client: Client }; + phases: Array }>; + payments: Payment[]; + documents: Document[]; + notes: Note[]; + comments: Comment[]; + quoteItems: QuoteItemWithLabel[]; + activeServices: ServiceCatalog[]; + totalTrackedSeconds: number; // for profitability calc +}; + +export async function getProjectFullDetail(id: string): Promise { + const projectRows = await db + .select() + .from(projects) + .where(eq(projects.id, id)) + .limit(1); + + if (projectRows.length === 0) return null; + const project = projectRows[0]; + + // Fetch parent client + const clientRows = await db + .select() + .from(clients) + .where(eq(clients.id, project.client_id)) + .limit(1); + const client = clientRows[0] || null; + + // Fetch all phases for this PROJECT (not client) + const phasesRows = await db + .select() + .from(phases) + .where(eq(phases.project_id, id)) + .orderBy(asc(phases.sort_order)); + + const phaseIds = phasesRows.map((p) => p.id); + + // Fetch tasks scoped to this project's phases + const tasksRows = phaseIds.length === 0 + ? [] + : await db + .select() + .from(tasks) + .where(inArray(tasks.phase_id, phaseIds)) + .orderBy(asc(tasks.sort_order)); + + const taskIds = tasksRows.map((t) => t.id); + + // Fetch deliverables scoped to this project's tasks + const deliverablesRows = taskIds.length === 0 + ? [] + : await db + .select() + .from(deliverables) + .where(inArray(deliverables.task_id, taskIds)); + + // Payments for this PROJECT (not client) + const paymentsRows = await db + .select() + .from(payments) + .where(eq(payments.project_id, id)); + + // Documents for this PROJECT (not client) + const documentsRows = await db + .select() + .from(documents) + .where(eq(documents.project_id, id)) + .orderBy(asc(documents.created_at)); + + // Notes for this PROJECT (not client) + const notesRows = await db + .select() + .from(notes) + .where(eq(notes.project_id, id)) + .orderBy(asc(notes.created_at)); + + // Comments (polymorphic on entity_id) — collect all tasks, deliverables, and the project itself + const allEntityIds = [id, ...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)); + + // Quote items for this PROJECT (not client) + const quoteItemRows: QuoteItemWithLabel[] = await db + .select({ + id: quote_items.id, + label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`, + custom_label: quote_items.custom_label, + service_id: quote_items.service_id, + quantity: quote_items.quantity, + unit_price: quote_items.unit_price, + subtotal: quote_items.subtotal, + }) + .from(quote_items) + .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id)) + .where(eq(quote_items.project_id, id)) + .orderBy(asc(quote_items.id)); + + // Active services (unchanged) + const activeServiceRows = await db + .select() + .from(service_catalog) + .where(eq(service_catalog.active, true)) + .orderBy(asc(service_catalog.name)); + + // Total tracked seconds for this PROJECT (for profitability calc) + const totalRes = await db + .select({ + total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)`, + }) + .from(time_entries) + .where(eq(time_entries.project_id, id)); + + const totalTrackedSeconds = totalRes[0] ? parseInt(totalRes[0].total) : 0; + + // Rebuild hierarchy + const phasesWithTasks = phasesRows.map((phase) => ({ + ...phase, + tasks: tasksRows + .filter((t) => t.phase_id === phase.id) + .map((task) => ({ + ...task, + deliverables: deliverablesRows.filter((d) => d.task_id === task.id), + })), + })); + + return { + project: { ...project, client } as any, + phases: phasesWithTasks, + payments: paymentsRows, + documents: documentsRows, + notes: notesRows, + comments: commentsRows, + quoteItems: quoteItemRows, + activeServices: activeServiceRows, + totalTrackedSeconds, + }; +} +``` + +### Slug Resolution in Middleware +```typescript +// src/proxy.ts (UPDATED for slug-first resolution) + +export async function proxy(request: NextRequest) { + const pathname = request.nextUrl.pathname; + + // ── ADMIN GUARD ────────────────────────────────────────────────────────── + if (pathname.startsWith("/admin")) { + 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/SLUG GUARD ───────────────────────────────────────────── + if (pathname.startsWith("/c/")) { + const slugOrTokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/); + if (!slugOrTokenMatch) { + return NextResponse.rewrite(new URL("/not-found", request.url)); + } + + const slugOrToken = slugOrTokenMatch[1]; + + try { + // TRY SLUG FIRST — call internal API to resolve slug → client + const validateUrl = new URL( + `/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`, + request.url + ); + let res = await fetch(validateUrl.toString()); + + // If slug not found, fall back to TOKEN validation (existing pattern) + if (!res.ok) { + const validateTokenUrl = new URL( + `/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`, + request.url + ); + res = await fetch(validateTokenUrl.toString()); + } + + if (!res.ok) { + return NextResponse.rewrite(new URL("/not-found", request.url)); + } + + return NextResponse.next(); + } catch { + return NextResponse.rewrite(new URL("/not-found", request.url)); + } + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/admin/:path*", "/c/:path*"], +}; +``` + +### New Internal API Route for Slug Validation +```typescript +// src/app/api/internal/validate-slug/route.ts (NEW) + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db"; +import { clients } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +export async function GET(request: NextRequest) { + const slug = request.nextUrl.searchParams.get("slug"); + + if (!slug) { + return NextResponse.json({ error: "slug required" }, { status: 400 }); + } + + const rows = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.slug, slug)) + .limit(1); + + if (rows.length === 0) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + + return NextResponse.json({ clientId: rows[0].id }, { status: 200 }); +} +``` + +### Timer Actions Refactored for Project Scope +```typescript +// src/app/admin/timer-actions.ts (UPDATED for project_id) + +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { time_entries } from "@/db/schema"; +import { eq, isNull } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +export async function startTimer(projectId: string): Promise<{ entryId: string }> { + // Stop any currently running session (still global: only one timer active per admin) + const running = await db + .select({ id: time_entries.id }) + .from(time_entries) + .where(isNull(time_entries.ended_at)); + + for (const r of running) { + const now = new Date(); + const entry = await db + .select({ started_at: time_entries.started_at }) + .from(time_entries) + .where(eq(time_entries.id, r.id)) + .limit(1); + if (entry[0]) { + const secs = Math.round((now.getTime() - new Date(entry[0].started_at).getTime()) / 1000); + await db + .update(time_entries) + .set({ ended_at: now, duration_seconds: secs }) + .where(eq(time_entries.id, r.id)); + } + } + + // Create new entry scoped to PROJECT (not client) + const id = nanoid(); + await db.insert(time_entries).values({ id, project_id: projectId }); + revalidatePath("/admin"); + return { entryId: id }; +} + +export async function stopTimer(entryId: string): Promise { + const rows = await db + .select({ started_at: time_entries.started_at }) + .from(time_entries) + .where(eq(time_entries.id, entryId)) + .limit(1); + + if (!rows[0]) return; + + const now = new Date(); + const secs = Math.round((now.getTime() - new Date(rows[0].started_at).getTime()) / 1000); + await db + .update(time_entries) + .set({ ended_at: now, duration_seconds: secs }) + .where(eq(time_entries.id, entryId)); + + revalidatePath("/admin"); +} +``` + +### Profitability Analytics Card (Component Pattern) +```typescript +// src/components/admin/ProfitabilityCard.tsx (NEW) + +import { Project } from "@/db/schema"; + +export function ProfitabilityCard({ + project, + totalTrackedSeconds, + targetHourlyRate, +}: { + project: Project & { accepted_total: string }; + totalTrackedSeconds: number; + targetHourlyRate: number; // e.g., 50 €/h +}) { + const hours = totalTrackedSeconds / 3600; + const acceptedTotal = parseFloat(project.accepted_total || "0"); + + // €/h real = accepted_total ÷ hours + const realHourlyRate = hours > 0 ? acceptedTotal / hours : 0; + + // Ideal cost = target_rate × hours + const idealCost = targetHourlyRate * hours; + + // Delta = profit/loss + const delta = acceptedTotal - idealCost; + const deltaIsProfit = delta >= 0; + + return ( +
+

Profittabilità

+ +
+
+

Ore lavorate

+

{hours.toFixed(1)}h

+
+
+

Importo accettato

+

€{acceptedTotal.toFixed(2)}

+
+
+ +
+
+ €/h reale + €{realHourlyRate.toFixed(2)}/h +
+
+ €/h target + €{targetHourlyRate.toFixed(2)}/h +
+
+ Costo ideale + €{idealCost.toFixed(2)} +
+
+ +
+ Delta (guadagno/perdita) + + {deltaIsProfit ? "+" : ""}€{delta.toFixed(2)} + +
+
+ ); +} +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Single project per client | Multi-project per client | Phase 04 | Clients can now manage multiple independent brands/projects with separate workspaces and profitability tracking | +| Client as primary work container | Project as primary work container | Phase 04 | Admin workspace structure mirrors project, not client. Client API queries projects, not client directly | +| Timer at client level | Timer at project level | Phase 04 | Hours tracked independently per project, enabling per-project profitability analysis | +| Token-only client links | Token + slug client links | Phase 04 | More user-friendly URLs (e.g., /c/mario-rossi instead of /c/xyzabc123). Token remains as fallback. | +| Hardcoded profitability target | Global settings table for target rate | Phase 04 | Admin can adjust target hourly rate from UI without changing code. Flexible for future settings. | + +**Deprecated/outdated:** +- Client-level `accepted_total` field remains in schema for backward compat but becomes unused; project-level accepted_total is the source of truth. +- Old client detail workspace layout is cloned for project detail; both exist but admin only uses project workspace going forward. + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | "Only one timer active globally (per admin)" is the design intent, not per-project independence | Locked Decision D-15, Timer Pitfall | If per-project timers are expected, timer-actions refactor is wrong; need concurrent timer support instead of auto-stop logic. Clarify with user before implementing. | +| A2 | Hard migration (drop/recreate tables) is acceptable because all data is test data | Runtime State Inventory | If there are production customer records, hard migration will cause data loss. Verify no production data exists before schema push. | +| A3 | `settings` table with key-value structure is acceptable over env vars | Claude's Discretion | If user later requires non-DB storage for settings (e.g., Redis cache, config file), table approach is still compatible; no blocking constraint. | +| A4 | Slug-first middleware resolution (slug lookup before token fallback) is the intended order | Locked Decision D-06, D-08 | If token validation should be checked first (for legacy reasons), middleware order is reversed. Test both slug and token paths after implementation. | +| A5 | `comments` table remains polymorphic (entity_id) and does NOT move to project_id | Locked Decision D-02 | If comments should be scoped per-project (unlikely), add project_id FK and update all comment queries. Currently comments are global per entity, which is correct. | + +**If this table is empty:** Not applicable — all claims verified against CONTEXT.md locked decisions. + +--- + +## Open Questions + +1. **Profitability analytics default target rate?** + - What we know: User mentioned 50€/h as an example; needs global setting. + - What's unclear: Should there be a fallback default (e.g., 50€/h) if settings table is empty, or should admin be forced to set it? + - Recommendation: Initialize settings table with `target_hourly_rate = '50.00'` as default during first project workspace load. Admin can override from /admin/impostazioni. + +2. **Multi-project client dashboard routing — how should it work?** + - What we know: 1 project = direct view, 2+ projects = tabs. + - What's unclear: Should the URL path for client dashboard change? Stay `/c/[token]` for all projects, or add `/c/[token]/projects/[id]`? + - Recommendation: Keep URL `/c/[token]` (or `/c/[slug]`), let server-side logic choose whether to render single project view or tabs. Tabs can have internal navigation (e.g., URL hash or search param) to switch between projects without page reload. + +3. **Analytics page aggregation scope?** + - What we know: D-22 says "Statistiche page shows aggregated profitability for all projects + breakdown per client." + - What's unclear: Should /admin/analytics show global profitability (sum of all projects for all clients) or be filterable by client/date range? + - Recommendation: Start simple: global profitability table with columns: Client, Projects, Total Hours, Total Revenue, Avg €/h, Profit/Loss. Filter by client optional (defer to Phase 4.1 if needed). + +4. **Project archival behavior?** + - What we know: `projects.archived` field exists; D-13 doesn't mention archival UI. + - What's unclear: Should archived projects be hidden from /admin/projects list or filtered to separate tab? + - Recommendation: Hide archived projects by default (like clients list), add "Mostra archiviati" toggle link. Archival doesn't delete data, just hides it. + +--- + +## Environment Availability + +(Phase 04 is code/DB changes only — no external dependencies.) + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Neon Postgres | Schema migration + queries | ✓ | Active (from Phase 1) | — | +| Next.js API routes | Slug validation route | ✓ | 16 (installed) | — | +| Drizzle ORM | Schema migration + query building | ✓ | Current (installed) | — | +| Auth.js v4 | Admin session check (existing) | ✓ | Current (installed) | — | +| shadcn/ui Tabs | Multi-project dashboard tabs | ✓ | Current (installed) | Could fall back to native `` dropdown if shadcn/ui is ever removed, but this is a UI detail, not a blocker. + +--- + +## Sources + +### Primary (HIGH confidence) +- **CONTEXT.md** (Phase 04 decisions) — All 22 locked decisions directly from user's discuss phase. D-01 through D-22, Claude's Discretion, Deferred Ideas sections. +- **Codebase inspection** — Verified existing schema (schema.ts), admin queries (admin-queries.ts), middleware pattern (proxy.ts), timer actions (timer-actions.ts), client view pattern (client-view.ts), admin workspace layout (clients/[id]/page.tsx). +- **REQUIREMENTS.md** — PROJ-01 through PROJ-05 mapped to implementation guidance. +- **ROADMAP.md** — Phase 04 goal and success criteria verified. + +### Secondary (MEDIUM confidence) +- **Next.js 16 App Router patterns** — Edge middleware, server actions, API routes all verified against existing project structure. +- **Drizzle ORM query patterns** — Relations, WHERE scoping, parallel queries all verified against Phase 1–3 implementation (getAllClientsWithPayments, getClientFullDetail, timer-actions). +- **shadcn/ui Tabs component** — Already in use in admin workspace (clients/[id]/page.tsx); no additional research needed. + +### Tertiary (LOW confidence) +- None — all findings tied to locked decisions and verified codebase patterns. + +--- + +## Metadata + +**Confidence breakdown:** +- **Standard stack:** HIGH — all libraries already installed and used; no new dependencies. +- **Architecture:** HIGH — locked decisions in CONTEXT.md eliminate discretion; patterns clone from existing workspaces. +- **Pitfalls:** HIGH — identified from common FK migration mistakes, middleware routing, query scoping issues observed in similar refactors. +- **Environment:** HIGH — no external dependencies; Neon, Next.js, Drizzle all active and verified. + +**Research date:** 2026-05-21 +**Valid until:** 2026-06-04 (14 days — architecture stable, no fast-moving libraries) + +**Next phase:** `/gsd-plan-phase 04` will create 4–5 plans for vertical-slice execution (Schema Wave 0 → Core Routing → Admin UI → Client UI + Analytics). \ No newline at end of file diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md new file mode 100644 index 0000000..d5d84b1 --- /dev/null +++ b/.planning/research/ARCHITECTURE.md @@ -0,0 +1,191 @@ +# Architecture — ClientHub Freelancer Client Portal + +**Project:** ClientHub (welcomeclient.iamcavalli.net) +**Researched:** 2026-05-09 +**Confidence:** HIGH + +--- + +## Component Boundaries + +Single Next.js application on Vercel. No separate backend. All server logic lives in Route Handlers (`/api/**`). One Postgres database (Neon serverless) accessed via Drizzle ORM. Admin auth via env-var secret + cookie. Client access via UUID token in URL — no auth library needed for clients. + +| Component | Responsibility | Communicates With | +|-----------|---------------|-------------------| +| Client Portal `/c/[token]` | Read-only view: status, phases, tasks, deliverables, payments, documents | API Routes (GET only) | +| Admin Dashboard `/admin` | List all clients with status summary | API Routes (full CRUD) | +| Admin Client Workspace `/admin/clients/[id]` | Edit phases, tasks, deliverables, payments, documents | API Routes (full CRUD) | +| Service Catalog Manager `/admin/catalog` | CRUD on service items + unit prices | API Routes (catalog entity) | +| Quote Builder `/admin/clients/[id]/quote` | Compose quote from catalog items, write `accepted_total` to client row | Catalog + API Routes | +| Comments System | Client posts on task/deliverable; admin replies | API Route POST | +| Approval Flow | Client PATCHes a deliverable to `approved` | API Route, validates token ownership | +| API Routes `/api/**` | Validate token or admin session; query/mutate DB; return JSON | Postgres only | +| Database | Single source of truth | API Routes only — never queried from browser | + +--- + +## Data Flow + +**Client reading their dashboard:** +``` +Browser → GET /c/[token] +→ Next.js server component +→ DB: clients WHERE token = [token] → 404 if missing +→ JOIN: project + phases + tasks + deliverables + payments + documents +→ Omit: quote_items, service prices +→ Render read-only portal +``` + +**Client posting a comment:** +``` +Browser → POST /api/comments { token, entity_type, entity_id, body } +→ Validate token → write comment { author: 'client' } +→ 201 → re-fetch thread +``` + +**Client approving a deliverable:** +``` +Browser → PATCH /api/deliverables/[id]/approve { token } +→ Validate token owns deliverable → set status='approved', approved_at=now() +→ Return updated deliverable +``` + +**Admin editing:** +``` +Browser (admin) → PATCH /api/admin/tasks/[id] + admin cookie +→ Validate session → update row → return updated task +``` + +**Quote building:** +``` +Admin UI selects services → computes line items +→ POST /api/admin/clients/[id]/quote { line_items[], accepted_total } +→ Write quote_items rows + write clients.accepted_total (denormalized) +→ Client portal reads clients.accepted_total — never touches quote_items +``` + +--- + +## Data Model + +``` +clients + id UUID PK + name TEXT + brand_name TEXT + brief TEXT + token UUID UNIQUE ← the secret link key (separate from PK!) + accepted_total NUMERIC ← denormalized; only price client ever sees + created_at TIMESTAMPTZ + +phases + id UUID PK + client_id UUID → clients.id + title TEXT + sort_order INT + status TEXT (upcoming | active | done) + +tasks + id UUID PK + phase_id UUID → phases.id + title TEXT + description TEXT + status TEXT (todo | in_progress | done) + sort_order INT + +deliverables + id UUID PK + task_id UUID → tasks.id + title TEXT + url TEXT ← external link (Google Drive, PDF, etc.) + status TEXT (pending | submitted | approved) + approved_at TIMESTAMPTZ ← immutable audit trail + +comments + id UUID PK + entity_type TEXT (task | deliverable) + entity_id UUID + author TEXT (client | admin) + body TEXT + created_at TIMESTAMPTZ + +payments + id UUID PK + client_id UUID → clients.id + label TEXT ("Acconto 50%" / "Saldo 50%") + amount NUMERIC + status TEXT (da_saldare | inviata | saldato) + paid_at TIMESTAMPTZ + +documents + id UUID PK + client_id UUID → clients.id + label TEXT + url TEXT + created_at TIMESTAMPTZ + +service_catalog + id UUID PK + name TEXT + description TEXT + unit_price NUMERIC + active BOOLEAN + +quote_items + id UUID PK + client_id UUID → clients.id + service_id UUID → service_catalog.id + quantity NUMERIC + unit_price NUMERIC ← snapshot at time of quote + subtotal NUMERIC + -- NEVER exposed via client API +``` + +**Key design decisions:** +- `clients.token` is the only secret. Rotation = single UPDATE. No session store needed. +- `clients.accepted_total` is deliberately denormalized so client API never touches `quote_items`. +- Approval `approved_at` stored as immutable audit trail — disputes resolved by timestamp. +- `comments` use `entity_type + entity_id` polymorphic pair — correct at this scale. +- `payments` always two rows per client (created when quote is finalized). + +--- + +## Suggested Build Order + +``` +1. DB schema + migrations + └─ everything depends on this + +2. API: token lookup + project read (GET only) + └─ unblocks client portal + +3. Client portal UI /c/[token] + └─ the core deliverable; clients need this first + +4. Admin auth middleware (env-var secret, cookie check) + └─ gate before admin routes go live + +5. Admin: client list + client workspace CRUD + └─ phases, tasks, status, documents, payments + +6. Comments system + deliverable approval + └─ depends on both client portal and admin workspace + +7. Service catalog CRUD ← can run parallel with step 5 + └─ independent of client-facing features + +8. Quote builder + └─ depends on catalog + client entity + +9. Claude onboarding flow (v2) + └─ depends on all CRUD APIs being complete +``` + +--- + +## Roadmap Implications + +- Phase 1: DB schema + token API + client portal (all three coupled) +- Phase 2: Admin auth + CRUD management + comments + approvals +- Phase 3: Service catalog + quote builder +- Phase 4 (v2): Claude onboarding flow diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md new file mode 100644 index 0000000..5f871c9 --- /dev/null +++ b/.planning/research/FEATURES.md @@ -0,0 +1,107 @@ +# FEATURES.md — ClientHub Freelancer Client Portal + +**Domain:** Freelancer client portal — solo personal branding consultant +**Project:** ClientHub (welcomeclient.iamcavalli.net) +**Researched:** 2026-05-09 +**Confidence:** HIGH + +--- + +## Context + +Two asymmetric roles. Admin (the freelancer) has full CRUD. Client (read + lightweight interaction) accesses via secret URL — no login, no account — and can view, comment, and approve. The product competes indirectly with Notion client portals, HoneyBook, Dubsado, and bespoke agency portals. The differentiator is zero-friction secret link access and personal brand positioning. + +--- + +## Table Stakes + +Features clients expect when opening any project portal. Missing these causes confusion, distrust, or support overhead. + +| Feature | Why Expected | Complexity | Notes | +|---------|--------------|------------|-------| +| Project overview at a glance | Client needs to know "where we are" without reading walls of text | Low | Name, brand, brief, current phase | +| Phase + task status visibility | Primary client question is "what's done, what's next" | Low | Phases with nested tasks; status per task (todo / in progress / done) | +| Deliverable approval | Client must formally sign off on outputs | Medium | Per-deliverable approve action; state persists; admin sees approval timestamp | +| Inline commenting on tasks/deliverables | Feedback and questions without email | Medium | Flat comments sufficient for v1; threading is nice-to-have | +| Document / file links | Deliverables, briefs, contracts surface in the portal | Low | Links to Google Drive, PDF, external URL; no file hosting needed | +| Payment status visibility | Client needs to know what they owe | Low | Deposit 50% + balance 50%; three states each: pending / invoiced / paid | +| Total quoted amount (not itemized) | Client expects to see the agreed number | Low | Single total; line items are admin-only | +| Mobile-readable layout | Clients open links on phones | Low | Responsive web; no native app | +| Persistent secret link | Link must not expire or rotate without notice | Low | UUIDs in DB, never regenerated unless admin resets explicitly | +| Trustworthy, branded appearance | First impression determines confidence in the consultant | Low | Logo, brand colors, professional typography — not a generic SaaS look | + +--- + +## Differentiators + +Not expected, but meaningfully improve experience or workflow. + +| Feature | Value Proposition | Complexity | Notes | +|---------|-------------------|------------|-------| +| Decision log / history | Running record of agreed decisions — eliminates "we never agreed on that" disputes | Low | Append-only note stream visible to client; admin writes entries | +| Phase progress indicator | Visual progress bar gives a sense of momentum | Low | Derived from task completion %; no extra data model needed | +| "Last updated" timestamp on dashboard | Shows the portal is live and maintained | Low | Trivially derived from DB updated_at | +| Admin overview: all clients at a glance | Freelancer scans all active projects and overdue payments in one view | Medium | List with status badges; payment alert if overdue | +| Payment status badge with clear labels | Color-coded states (red = unpaid, yellow = invoiced, green = paid) | Low | Client sees their own; admin sees all | +| Shareable link reset | Admin can invalidate and regenerate a client's link if it leaks | Low | DB field update + redirect; rarely used but reassuring | +| Service catalog | Admin builds quotes from a curated menu of services; reusable across clients | Medium | Lookup table; admin-only; used by Claude in v2 | +| Claude-assisted onboarding (v2) | Generates phases + quote draft from a brief — massively speeds up admin work | High | Explicitly v2 in PROJECT.md | + +--- + +## Anti-Features + +Deliberately NOT building these. + +| Anti-Feature | Why Avoid | What to Do Instead | +|--------------|-----------|-------------------| +| Client login / account creation | Adds friction with no benefit for a small client list | Secret UUID link | +| In-app invoicing / PDF generation | Accounting is out of scope | Show payment status only | +| File upload / storage | Massive complexity | Link to Google Drive or Dropbox | +| Email / SMS notifications | Transactional email infrastructure is heavy | Manual communication fine for small client list | +| Multi-admin / team roles | Freelancer works alone | Single admin | +| Client-editable project structure | Clients editing phases corrupts admin's source of truth | Comment and approve only | +| Itemized pricing visible to client | Erodes commercial confidentiality | Single total; detail is admin-only | +| Kanban / drag-and-drop board | Phases are sequential, not a fluid backlog | Ordered phase list | +| Time tracking | Out of scope for project-based billing | Not relevant | +| Multi-language / i18n | Single consultant, single-market | Hardcode interface language | + +--- + +## Feature Dependencies + +``` +Secret link (UUID) → Client dashboard +Client dashboard → Phase/task display +Phase/task display → Deliverable approval +Phase/task display → Inline commenting +Admin client management → Secret link generation +Admin client management → Payment tracking +Service catalog → Quote building (admin picks from catalog) +Quote building → Payment tracking (total = basis for deposit/balance) +Service catalog → Claude onboarding v2 +``` + +**Key insight:** Admin must create data before the client dashboard shows anything meaningful. Admin-first, then client. + +--- + +## MVP Build Order + +1. Admin: create/edit client record with secret link generation +2. Admin: create/edit phases and tasks per client +3. Admin: set payment amounts and statuses +4. Client dashboard: read-only view (overview, phases, tasks, payment status, documents) +5. Client: deliverable approval +6. Client: inline comments +7. Admin: all-clients overview +8. Admin: service catalog +9. v2: Claude-assisted onboarding + +--- + +## Open Questions + +- What happens when a client accidentally shares their secret link? Is link reset sufficient, or should there be an access log? +- Does the decision log need to be visible to clients from day one, or deferred? +- Should approval actions be reversible (un-approve)? diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md new file mode 100644 index 0000000..aadfe57 --- /dev/null +++ b/.planning/research/PITFALLS.md @@ -0,0 +1,177 @@ +# Pitfalls — ClientHub Freelancer Client Portal + +**Domain:** Freelancer client portal with secret-link access, solo developer, Vercel deploy +**Researched:** 2026-05-09 +**Confidence:** HIGH + +--- + +## Critical Pitfalls + +### 1. Secret Links That Are Guessable or Enumerable + +**What goes wrong:** If client tokens are generated from names, sequential IDs, or short strings, they can be enumerated. `/client/mario-rossi` or `/client/3` are not secrets. + +**Prevention:** +- Generate tokens as cryptographically random UUIDs (v4) or nanoid (21 chars / ~126 bits of entropy) +- Never derive from name, company, or sequential ID +- Never log or display full tokens in admin analytics + +**Warning signs:** Client slug contains the client's name. Token under 20 characters. Link can be reconstructed from info the client already knows. + +**Phase mapping:** Phase 1 — data model + routing. Cannot be retrofitted after links are distributed. + +--- + +### 2. Client API Exposes Admin Data (Hidden in UI Only) + +**What goes wrong:** Developers fetch all client data and conditionally render fields. A technical client opens DevTools and sees the full quote breakdown — the exact thing the product prevents. + +**Prevention:** +- Define two distinct data shapes: `ClientView` and `AdminView` +- Client API routes (`/api/c/[token]/`) return `ClientView` only — enforced server-side, not in the UI +- `accepted_total` goes on the `clients` row. Client API never queries `quote_items` + +**Warning signs:** Client API response includes `lineItems` filtered in the frontend. "Client sees only total" enforced with `display: none`. + +**Phase mapping:** Data model and API shape — Phase 1. + +--- + +### 3. Data Loss from Vercel Filesystem / In-Memory Storage + +**What goes wrong:** Vercel serverless functions are stateless. File writes to local filesystem and in-memory state are lost between invocations. SQLite on disk silently vanishes after cold start. Bug only manifests in production. + +**Prevention:** +- External persistent DB from day one: Neon (Postgres free tier) +- Never write to `fs` for persistent data on Vercel +- Validate persistence in the first production deploy + +**Warning signs:** Project stores data in a `data/` JSON directory. SQLite without a remote adapter. Data changes in one request not visible in the next. + +**Phase mapping:** Day-one infrastructure decision — Phase 1, before any real data is entered. + +--- + +### 4. Over-Engineering Before the First Client Uses It + +**What goes wrong:** Building the Claude onboarding flow and quote generator before a single client has opened their dashboard. Real clients still managed via email while the portal is "almost ready." + +**Prevention:** +- Hard success criterion for Phase 1: one client link is shareable and works +- Phase 1 ships read-only client dashboard only +- Collect direct feedback from one real client before adding features + +**Warning signs:** >2 weeks pass without a shareable client link. Claude integration started before admin edit UI exists. + +**Phase mapping:** Enforced by roadmap phase ordering. + +--- + +### 5. Token Is the Primary Key (Unrotatable) + +**What goes wrong:** A client forwards their link. The link appears in a screenshot. There is no mechanism to rotate without losing data or breaking bookmarks. Worse if token = primary key: rotation requires a migration. + +**Prevention:** +- Data model: stable internal UUID as PK; secret token is a separate, independently updatable field +- Build "Regenerate link" in admin area during Phase 2 — it's a single field UPDATE +- Overwriting the token field is sufficient to invalidate the old link + +**Warning signs:** Token is used as PK of the client record. No admin affordance to regenerate a link. + +**Phase mapping:** Data model separation — Phase 1. Admin rotation UI — Phase 2. + +--- + +### 6. Client Approval Has No Immutable Record + +**What goes wrong:** Client clicks "Approve." Later: "I never approved that." If approval is a boolean with no timestamp, there is no evidence. Weakens your position in commercial disputes. + +**Prevention:** +- Store `approved_at` timestamp alongside the approval boolean — from day one +- Display approval timestamp in admin view +- Approvals are immutable for clients — no "undo" button + +**Warning signs:** Approval is a boolean column with no timestamp. Client-visible "undo approval" button exists. + +**Phase mapping:** Schema — Phase 1. Display in admin — Phase 2. + +--- + +## Moderate Pitfalls + +### 7. Payment Status Without a Valid State Machine + +Payment has three states (da_saldare / inviata / saldato) for two payments. If transitions are not enforced, the dashboard shows contradictory states. + +**Prevention:** Enforce valid transitions: `da_saldare → inviata → saldato`. Admin UI offers only valid next states. + +**Phase mapping:** Admin UI — Phase 2. + +--- + +### 8. Google Drive Links That Rot + +Document links break when sharing settings change or the Drive account changes. Client sees a broken link to their own deliverable. + +**Prevention:** Store a display name alongside the URL so broken links are visible in admin. Build a simple "update link" affordance in Phase 2. + +--- + +### 9. Admin Area With No Real Access Control + +`/admin` is a secret route with no authentication. A client who guesses the URL accesses all client data. + +**Prevention:** Add Next.js middleware check against `ADMIN_PASSWORD` env variable before Phase 2 ships. Never rely on security through obscurity for a route that contains all client data. + +**Phase mapping:** Phase 2, before admin area contains real data. + +--- + +### 10. Comments Scope Creeping Into Threading + +"Clients can leave comments" becomes complex when replies, read/unread state, and notifications are added. Can double Phase 1 scope. + +**Prevention:** Phase 1 ships comments as a flat append-only list per task. No threading, no replies, no email notifications. + +--- + +## Minor Pitfalls + +### 11. DNS Configuration as a Last-Minute Task + +`welcomeclient.iamcavalli.net` requires a CNAME to Vercel's DNS. Propagation takes minutes to hours. Doing this the day of a client demo misses the deadline. + +**Prevention:** Configure DNS in Phase 1 before the UI is complete. Verify propagation independently. + +--- + +### 12. Mobile Responsiveness as an Afterthought + +Clients open the link on their phone from a shared message. A broken mobile layout is the first impression. + +**Prevention:** Use Tailwind mobile-first defaults from the start. Test on a real phone before any link is sent. + +--- + +### 13. No Empty States + +A new client record with no tasks shows a blank page. The client assumes something is broken. + +**Prevention:** Design minimal empty states for no-tasks, no-documents, no-comments. + +--- + +## Phase-Specific Warnings Summary + +| Phase Topic | Likely Pitfall | Mitigation | +|---|---|---| +| Client token generation | Guessable slug from name | Crypto-random UUID/nanoid, never name-derived | +| Client-facing API | Admin data in JSON response | `ClientView` type enforced server-side | +| Storage choice | Vercel filesystem not persistent | External DB (Neon) before first data write | +| Admin area access | No real auth | Middleware check before Phase 2 ships | +| Approval recording | Boolean-only, no audit trail | Store `approved_at` from day one | +| Token in data model | Token = PK, unrotatable | Separate stable ID from rotatable token field | +| Phase ordering | Claude flow before dashboard | Enforce: client view → admin edit → Claude | +| Comments | Threading scope creep | Flat list in Phase 1 | +| DNS | Last-minute failure | Configure and verify in Phase 1 | diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md new file mode 100644 index 0000000..db9dc7f --- /dev/null +++ b/.planning/research/STACK.md @@ -0,0 +1,132 @@ +# Technology Stack — ClientHub Freelancer Client Portal + +**Project:** ClientHub (welcomeclient.iamcavalli.net) +**Researched:** 2026-05-09 +**Confidence:** HIGH + +--- + +## Recommended Stack + +### Core Framework + +| Technology | Version | Purpose | Why | +|------------|---------|---------|-----| +| Next.js | 15.x (latest stable) | Full-stack app framework | App Router + Server Actions replace a separate API layer. Vercel-native: no adapter needed. First-class TypeScript. | +| React | 19.x | UI rendering | Bundled with Next.js. Server Components eliminate client-side waterfalls for the read-heavy client portal. | +| TypeScript | 5.x | Type safety | Drizzle + Zod give end-to-end type inference from DB schema to form validation. | + +**Why NOT Remix / SvelteKit / Astro:** They work on Vercel but add unfamiliarity overhead with no gain at this scale. + +--- + +### Database + +| Technology | Purpose | Why | +|------------|---------|-----| +| Neon (serverless Postgres) | Primary database | Free plan: 0.5 GB storage + 100 CU-hours/month — sufficient for 5–20 clients. Scales to zero between uses. Native Vercel integration that auto-injects DATABASE_URL per preview branch. | +| Drizzle ORM | DB access + migrations | Lightest-weight TS ORM. Ships `drizzle-orm/neon-http` serverless driver — no persistent TCP connections, works in Vercel Node and Edge runtimes for free. Schema-as-code with `drizzle-kit` handles migrations. | + +**Why NOT Prisma:** Needs PgBouncer or Prisma Accelerate ($) for serverless connection pooling. Drizzle's `neon-http` handles this natively at zero cost. + +**Why NOT Supabase:** Adds RLS, Realtime, and Auth overhead you don't need and will have to maintain. + +--- + +### Authentication + +| Technology | Purpose | Why | +|------------|---------|-----| +| Auth.js (next-auth) v4 | Admin session management | Credentials provider with a single admin account. Session stored as signed JWT cookie. No user table in DB. | +| Next.js Middleware (custom) | Client secret-link validation | Each client has a `secretToken` (nanoid, 21 chars) stored in DB. Middleware reads `[token]` path segment, validates against Neon, returns 404 on miss. Runs at the edge before any page renders. | +| nanoid | Token generation | Cryptographically secure, URL-safe, 21-char default (~126 bits of entropy). Generated once at client creation. | + +**Auth flow summary:** +- `/admin/*` → Auth.js session required (single admin account) +- `/c/[token]/*` → Middleware validates token against Neon, 404 on miss +- Client pages: zero auth library overhead + +--- + +### UI + +| Technology | Purpose | Why | +|------------|---------|-----| +| Tailwind CSS v4 | Utility-first styling | CSS-first configuration, zero runtime overhead. | +| shadcn/ui | Component library | Components copied into codebase (no runtime dep). Built on Radix UI (accessible). Provides: Badge, Progress, Card, Dialog, Table, Textarea, Select. | +| lucide-react | Icons | Tree-shaken, SVG-based, consistent. | + +--- + +### Forms and Validation + +| Technology | Purpose | Why | +|------------|---------|-----| +| Zod | Schema validation | Server-side in Server Actions + client-side with RHF resolver. Single source of truth for data shapes. | +| React Hook Form | Admin form state | Complex admin forms (client onboarding, task editing, quote builder). Client-facing pages use native `
` + Server Actions. | + +--- + +### File Handling (v1) + +None — document links stored as text fields in Postgres. Eliminates S3, CDN, and upload infrastructure from the initial build entirely. + +**If direct uploads needed in v2:** UploadThing integrates directly with Next.js App Router, free tier (2 GB storage). + +--- + +### Infrastructure + +| Technology | Purpose | Why | +|------------|---------|-----| +| Vercel Hobby plan | Deploy + CDN + serverless | Native Next.js. Custom subdomain (`welcomeclient.iamcavalli.net`) via DNS CNAME. No Docker, VPS, or CI/CD to manage. | +| Neon Vercel Integration | DB branch per preview | Creates a fresh Neon branch per Git branch automatically. Safe schema migration testing. | + +--- + +## Installation Sequence + +```bash +# 1. Bootstrap Next.js +npx create-next-app@latest clienthub --typescript --tailwind --app --src-dir + +# 2. Database +npm install drizzle-orm @neondatabase/serverless +npm install -D drizzle-kit + +# 3. Auth +npm install next-auth + +# 4. Token generation +npm install nanoid + +# 5. Validation + Forms +npm install zod @hookform/resolvers react-hook-form + +# 6. shadcn/ui +npx shadcn@latest init +npx shadcn@latest add badge button card dialog dropdown-menu input label progress select separator table textarea +``` + +--- + +## Key Architectural Decisions + +1. **Secret-link without Auth.js:** Next.js Middleware validates `[token]` at the edge. Fast, zero client-side JS, 404 on invalid token. +2. **Server Actions for all mutations:** Task updates, comments, payment status — no REST API layer to maintain. +3. **Privacy model is a DB query filter:** Admin sees `quote_items`; clients see only `clients.accepted_total`. Not a UI filter — a DB design. +4. **Two auth systems, zero overlap:** Admin JWT cookie on `/admin/*`. Client token middleware on `/c/*`. + +--- + +## Confidence Levels + +| Area | Confidence | Notes | +|------|------------|-------| +| Next.js App Router | HIGH | Stable since Oct 2024 | +| Neon free tier | HIGH | 0.5 GB storage, 100 CU-hours/month | +| Drizzle + neon-http | HIGH | Free serverless driver, no connection pooling needed | +| Auth.js Credentials (admin) | HIGH | Mature, well-documented | +| nanoid secret tokens | HIGH | Cryptographically secure default | +| Tailwind v4 + Next.js | HIGH | Stable, PostCSS plugin verified | +| Vercel Hobby plan | HIGH | Custom subdomain supported | \ No newline at end of file diff --git a/.planning/research/SUMMARY.md b/.planning/research/SUMMARY.md new file mode 100644 index 0000000..50bb900 --- /dev/null +++ b/.planning/research/SUMMARY.md @@ -0,0 +1,126 @@ +# Project Research Summary + +**Project:** ClientHub — welcomeclient.iamcavalli.net +**Domain:** Freelancer client portal (secret-link access, solo consultant) +**Researched:** 2026-05-09 +**Confidence:** HIGH + +--- + +## Executive Summary + +ClientHub è un portale web a due ruoli per un consulente di personal branding. I clienti accedono via UUID segreto casuale — nessun account, nessun login, zero attrito. L'admin gestisce tutto: crea clienti, fasi, task, deliverable, pagamenti e preventivi. Il consensus della ricerca è chiaro: costruisci prima la dashboard cliente, poi l'admin CRUD, poi catalogo servizi e preventivi, poi il flusso Claude AI (v2). + +Stack confermato: **Next.js 15 + Neon (Postgres) + Drizzle ORM + Auth.js + nanoid + Tailwind v4 + shadcn/ui**. Ogni scelta è ottimizzata per un developer solo su Vercel: nessun backend da mantenere, nessun costo di connection pooling, nessuna infrastruttura di upload file, nessuna libreria di auth per i clienti. Il meccanismo "secret link" è un Next.js Middleware edge check — veloce, zero client JS, 404 se il token non esiste. + +I rischi dominanti sono architetturali, non tecnici. Se il token è la primary key diventa non-rotazionabile. Se la client API restituisce `quote_items` (anche nascosti nell'UI), un cliente con DevTools vede i prezzi dei singoli servizi. Se il progetto parte dal flusso Claude prima che un cliente possa aprire la sua dashboard, il portale non esce. Tutti e tre prevenibili con le decisioni corrette sul data model dal giorno uno. + +--- + +## Key Findings + +### Stack Raccomandato + +| Tecnologia | Ruolo | Perché | +|------------|-------|--------| +| Next.js 15 (App Router) | Framework full-stack | Server Actions sostituiscono un'API REST separata; nativo Vercel | +| Neon (serverless Postgres) | Database principale | Free tier (0.5 GB, 100 CU-h/mese) sufficiente per 5–20 clienti; scala a zero | +| Drizzle ORM + neon-http | Accesso DB + migrazioni | Nessun costo di connection pooling; schema-as-code; inferenza TypeScript end-to-end | +| Auth.js v4 (Credentials) | Sessione admin | Account singolo, cookie JWT firmato, nessuna tabella utenti in DB | +| nanoid | Generazione token | 21 char, ~126 bit di entropia, URL-safe, crittograficamente sicuro | +| Tailwind v4 + shadcn/ui | UI | Componenti copiati nel codebase, accessibilità Radix UI, zero runtime dep | +| Zod + React Hook Form | Validazione e form | Schema unico; RHF solo per form admin complessi | + +File upload deliberatamente esclusi dalla v1. I link ai documenti sono campi testo che puntano a Google Drive. + +### Features v1 + +**Table stakes (obbligatori):** +- Panoramica progetto (nome, brand, brief, fase corrente) +- Visibilità fasi e task con stato (todo / in corso / fatto) +- Approvazione deliverable con timestamp immutabile +- Commenti inline su task e deliverable (lista piatta, no threading) +- Link a documenti esterni (solo URL, no file hosting) +- Stato pagamenti: acconto 50% + saldo 50% (da saldare / inviata / saldato) +- Totale preventivo accettato visibile al cliente (cifra unica, mai dettaglio) +- Layout mobile-ready +- Link segreto persistente e non-scadente + +**Differenziatori (low-effort, includibili in v1):** +- Log decisioni / storico (nota append-only) +- Indicatore di avanzamento fase (derivato da % task completati) +- Timestamp "ultimo aggiornamento" sulla dashboard +- Vista admin: tutti i clienti con badge stato pagamenti +- Reset link segreto (single UPDATE, solo admin) + +**Anti-features (mai costruire):** login cliente, PDF fatture in-app, multi-admin, struttura progetto modificabile dal cliente, prezzi singoli visibili al cliente, kanban board. + +### Architettura + +Singola applicazione Next.js su Vercel, un database Neon Postgres. Nessun backend separato. + +**Due path di accesso isolati:** +- `/c/[token]/*` → Middleware valida il token contro Neon, 404 se mancante +- `/admin/*` → Auth.js session check, singolo account admin + +**Decisioni chiave del data model:** +- `clients.token` è un campo separato e rotazionabile — **non** la primary key +- `clients.accepted_total` denormalizzato: la client API non tocca mai `quote_items` +- `deliverables.approved_at` come audit trail immutabile dal giorno uno +- `payments` sempre due righe per cliente (acconto + saldo), create alla finalizzazione del preventivo +- `ClientView` e `AdminView` sono tipi distinti lato server — privacy enforce a livello di query, non di UI + +### Pitfall Critici + +1. **Token = primary key (non rotazionabile)** — Usa UUID stabile come PK e campo `token` separato e aggiornabile. Deve essere nella schema della Fase 1; non si può correggere dopo che i link sono stati distribuiti. +2. **Client API espone `quote_items` (nascosti solo nell'UI)** — Definisci `ClientView` come tipo server-side che non interroga mai `quote_items`. Un cliente tecnico con DevTools non deve mai vedere i prezzi singoli. +3. **Over-engineering prima che un cliente usi il portale** — Criterio di successo duro per la Fase 1: un link cliente reale è condivisibile e funziona. Non iniziare il flusso Claude prima che l'admin possa creare un cliente e il cliente possa aprire la sua dashboard. +4. **Nessun record di approvazione immutabile** — Salva `approved_at` (timestamp, non solo boolean) dallo schema iniziale. +5. **Area admin senza vera autenticazione** — Il check Middleware su `ADMIN_PASSWORD` env var deve essere in place prima che la Fase 2 vada in produzione. + +--- + +## Implicazioni per la Roadmap + +### Struttura suggerita: 4 fasi + +**Fase 1 — Foundation: DB schema, token API, dashboard cliente** +Consegna: un link cliente reale condivisibile che mostra il progetto su mobile e desktop. +Copre: panoramica, fasi/task, pagamenti, documenti, link segreto, DNS. + +**Fase 2 — Admin CRUD + auth + commenti + approvazioni** +Consegna: admin crea/modifica clienti, fasi, task, deliverable, pagamenti. Cliente commenta e approva. Admin può rigenerare il link. +Copre: auth Middleware, CRUD completo, flow approvazione con timestamp, commenti lista piatta. + +**Fase 3 — Catalogo servizi + preventivi** +Consegna: admin costruisce catalogo riutilizzabile e compone preventivi da esso. `accepted_total` scritto sulla riga cliente. +Nessuna dipendenza client-facing oltre `accepted_total` (già in schema dalla Fase 1). + +**Fase 4 (v2) — Flusso Claude AI per onboarding** +Dipende da CRUD stabile + catalogo completo. Claude legge il brief e suggerisce fasi + preventivo. +*Richiede ricerca dedicata durante la pianificazione.* + +### Flag di ricerca +- Fasi 1–3: pattern standard, nessuna ricerca aggiuntiva necessaria +- Fase 4: richiede ricerca su Claude API structured output, streaming vs batch, prompt engineering per generazione fasi + +--- + +## Confidence Assessment + +| Area | Confidence | Note | +|------|------------|------| +| Stack | HIGH | Tutte le tecnologie stabili e in produzione | +| Features | HIGH | Feature set opinionated e ben delimitato | +| Architettura | HIGH | Data model completo, pattern two-path auth provato | +| Pitfall | HIGH | Tutti mappabili a decisioni concrete della Fase 1 | + +**Domande aperte (da risolvere durante la pianificazione delle fasi):** +- Access log per i link (utile per rilevare accessi non autorizzati)? +- Approvazioni reversibili (admin-only revoke)? +- Log decisioni visibile al cliente dalla v1 o solo admin? +- DNS: configurare e verificare la propagazione nella Fase 1 + +--- + +*Ricerca completata: 2026-05-09 | Pronto per la roadmap: sì* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 32a0a72..86c826b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,50 +1,23 @@ -# ClientHub — Project Instructions +# ClientHub -## Project - -**ClientHub** — Portale clienti per consulente di personal branding. -- Dashboard cliente via link segreto (no login) -- Admin area per gestione clienti, fasi, task, deliverable, pagamenti -- Deploy: Vercel su `welcomeclient.iamcavalli.net` - -## GSD Workflow - -This project uses the **Get Shit Done** workflow. Planning lives in `.planning/`. - -### Current State - -See `.planning/STATE.md` for current phase and active work. -See `.planning/ROADMAP.md` for full phase structure. -See `.planning/REQUIREMENTS.md` for all requirements with REQ-IDs. - -### Phase Execution - -- Run `/gsd-plan-phase N` to plan a phase before executing -- Run `/gsd-execute-phase N` to execute a planned phase -- Run `/gsd-progress` to check current status - -## Architecture Constraints - -The following decisions are LOCKED from the data model and must be respected in all phases: - -1. **`clients.token` is a separate rotatable field — NEVER the primary key.** Clients have a stable UUID `id` and a separate `token` field used for secret link access. Rotation = single UPDATE on `token`. - -2. **Client API never exposes `quote_items`.** The `accepted_total` field on the `clients` row is the only price the client API returns. Quote line items are admin-only. Enforced at the query layer, not the UI. - -3. **`deliverables.approved_at` is immutable.** Once set, it cannot be unset by the client. Admin-only override only if strictly necessary. - -4. **Two independent auth paths:** - - `/c/[token]/*` → Next.js Middleware validates token against DB, 404 on miss - - `/admin/*` → Auth.js Credentials session check - -5. **No file hosting in v1.** Documents are external URLs (Google Drive, PDF links) stored as text. +Portale clienti per consulente di personal branding. Admin area + dashboard cliente via link segreto. ## Stack +Next.js 16 App Router · Neon Postgres · Drizzle ORM · Auth.js v4 · Tailwind v4 · shadcn/ui · Zod · nanoid -Next.js 15 (App Router) · Neon (serverless Postgres) · Drizzle ORM · Auth.js v4 · nanoid · Tailwind v4 · shadcn/ui · Zod · React Hook Form +## Architecture Constraints (LOCKED) +1. `clients.token` = campo separato rotatable, MAI primary key +2. `quote_items` MAI esposti via client API — solo `accepted_total` al cliente +3. `deliverables.approved_at` immutable once set +4. Auth: `/client/[token]/*` → middleware token check | `/admin/*` → Auth.js session +5. No file hosting v1 — documenti come URL esterni + +## GSD Workflow +Planning in `.planning/`. Use `/gsd-plan-phase N` → `/gsd-execute-phase N`. State in `.planning/STATE.md`. ## Security - -- Client tokens: cryptographically random via `nanoid` (21 chars, ~126 bit entropy). Never derived from client name or sequential ID. -- Admin area: protected by Auth.js session before Phase 2 ships to production. -- Payment privacy: `quote_items` never returned by client-facing API routes. \ No newline at end of file +- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE, infra changes) +- Never read/expose .env or credentials without explicit request +- Don't install packages without showing name + registry + version first +- Don't push to main or create PRs without explicit confirmation +- Any change to this section: propose full new version, get approval before applying \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b0d279 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:20-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXTAUTH_URL=https://hub.iamcavalli.net +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/src/app/admin/clients/[id]/page.tsx b/src/app/admin/clients/[id]/page.tsx index 32447ac..13718a8 100644 --- a/src/app/admin/clients/[id]/page.tsx +++ b/src/app/admin/clients/[id]/page.tsx @@ -43,7 +43,7 @@ export default async function ClientDetailPage({
- /c/{client.token.slice(0, 8)}… + /client/{client.token.slice(0, 8)}… diff --git a/src/proxy.ts b/src/proxy.ts index fefca07..9a8eab9 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -29,8 +29,8 @@ export async function proxy(request: NextRequest) { } // ── CLIENT TOKEN GUARD ─────────────────────────────────────────────────── - if (pathname.startsWith("/c/")) { - const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/); + if (pathname.startsWith("/client/")) { + const tokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/); if (!tokenMatch) { return NextResponse.rewrite(new URL("/not-found", request.url)); } @@ -60,5 +60,5 @@ export async function proxy(request: NextRequest) { } export const config = { - matcher: ["/admin/:path*", "/c/:path*"], + matcher: ["/admin/:path*", "/client/:path*"], };