infra(04-00): route /c/ → /client/, Dockerfile, Gitea deploy
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.planning
|
||||||
@@ -30,7 +30,17 @@
|
|||||||
| CAT-01 | File/database dei servizi con prezzi e cosa è incluso | Pending |
|
| CAT-01 | File/database dei servizi con prezzi e cosa è incluso | Pending |
|
||||||
| CAT-02 | Usato come base per la generazione assistita dei preventivi | 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 |
|
| ID | Requirement | Status |
|
||||||
|----|-------------|--------|
|
|----|-------------|--------|
|
||||||
@@ -64,6 +74,11 @@
|
|||||||
| CAT-01 | Phase 3 | Pending |
|
| CAT-01 | Phase 3 | Pending |
|
||||||
| CAT-02 | Phase 3 | Pending |
|
| CAT-02 | Phase 3 | Pending |
|
||||||
| ADMIN-03 | Phase 3 | Pending |
|
| ADMIN-03 | Phase 3 | Pending |
|
||||||
| CLAUDE-01 | Phase 4 (v2) | Deferred |
|
| PROJ-01 | Phase 4 | Pending |
|
||||||
| CLAUDE-02 | Phase 4 (v2) | Deferred |
|
| PROJ-02 | Phase 4 | Pending |
|
||||||
| CLAUDE-03 | Phase 4 (v2) | Deferred |
|
| 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 |
|
||||||
@@ -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
|
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
|
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
|
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**:
|
**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-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-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
|
- [ ] 04-03-PLAN.md — Admin project workspace (/admin/projects/[id]) + TimerTab + ProfitabilityCard + /admin/impostazioni
|
||||||
|
|||||||
+7
-7
@@ -4,14 +4,14 @@ milestone: v1.0
|
|||||||
milestone_name: milestone
|
milestone_name: milestone
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404)
|
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_updated: "2026-05-21T11:56:14.461Z"
|
||||||
last_activity: 2026-05-19
|
last_activity: 2026-05-21 -- Phase 4 planning complete
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 5
|
||||||
completed_phases: 3
|
completed_phases: 3
|
||||||
total_plans: 13
|
total_plans: 17
|
||||||
completed_plans: 13
|
completed_plans: 13
|
||||||
percent: 100
|
percent: 76
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-05-09)
|
|||||||
|
|
||||||
Phase: 4
|
Phase: 4
|
||||||
Plan: Not started
|
Plan: Not started
|
||||||
Status: Executing Phase 03
|
Status: Ready to execute
|
||||||
Last activity: 2026-05-19
|
Last activity: 2026-05-21 -- Phase 4 planning complete
|
||||||
|
|
||||||
Progress: [██░░░░░░░░] 25%
|
Progress: [██░░░░░░░░] 25%
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
|
||||||
|
@/Users/simonecavalli/IAMCAVALLI/CLAUDE.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Riferimenti correnti da aggiornare -->
|
||||||
|
|
||||||
|
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/
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
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.
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task id="1" type="execute" autonomous="false">
|
||||||
|
<name>Task 1: Postgres su Coolify + migrazione DATABASE_URL</name>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- .env (connection string Neon attuale — non committare)
|
||||||
|
- drizzle.config.ts (per confermare che legge DATABASE_URL)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**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.
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>npx drizzle-kit push 2>&1; echo "Exit: $?"</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- 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
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task id="2" type="execute" autonomous="true">
|
||||||
|
<name>Task 2: Rinomina route /c/ → /client/ + aggiorna reference</name>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**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}`}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>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</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task id="3" type="execute" autonomous="true">
|
||||||
|
<name>Task 3: Dockerfile per Coolify</name>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- package.json (script build, versione Node richiesta)
|
||||||
|
- next.config.ts (per verificare se output: standalone è già presente)
|
||||||
|
- .gitignore (per confermare che .env non è committato)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**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)
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>docker build -t clienthub-test . 2>&1 | tail -5; echo "Exit: $?"</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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)
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task id="4" type="checkpoint" subtype="human-verify" autonomous="false">
|
||||||
|
<name>Checkpoint: verifica deploy su hub.iamcavalli.net</name>
|
||||||
|
|
||||||
|
<what_was_built>
|
||||||
|
- 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
|
||||||
|
</what_was_built>
|
||||||
|
|
||||||
|
<how_to_verify>
|
||||||
|
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)
|
||||||
|
</how_to_verify>
|
||||||
|
|
||||||
|
<resume_signal>
|
||||||
|
Digita "hub ok" quando tutti i controlli passano.
|
||||||
|
</resume_signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
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
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<summary_template>
|
||||||
|
## 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/
|
||||||
|
</summary_template>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||||
|
payments: Payment[];
|
||||||
|
documents: Document[];
|
||||||
|
notes: Note[];
|
||||||
|
comments: Comment[];
|
||||||
|
quoteItems: QuoteItemWithLabel[];
|
||||||
|
activeServices: ServiceCatalog[];
|
||||||
|
totalTrackedSeconds: number; // for profitability calc
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
|
||||||
|
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<string>`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<string>`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<void> {
|
||||||
|
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 (
|
||||||
|
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3">
|
||||||
|
<h3 className="font-medium text-[#1a1a1a]">Profittabilità</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-[#71717a] text-xs">Ore lavorate</p>
|
||||||
|
<p className="font-mono font-semibold text-[#1a1a1a]">{hours.toFixed(1)}h</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[#71717a] text-xs">Importo accettato</p>
|
||||||
|
<p className="font-mono font-semibold text-[#1a1a1a]">€{acceptedTotal.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#f4f4f5] pt-3 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#71717a]">€/h reale</span>
|
||||||
|
<span className="font-mono font-semibold text-[#1a1a1a]">€{realHourlyRate.toFixed(2)}/h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#71717a]">€/h target</span>
|
||||||
|
<span className="font-mono font-semibold text-[#71717a]">€{targetHourlyRate.toFixed(2)}/h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-[#71717a]">Costo ideale</span>
|
||||||
|
<span className="font-mono font-semibold text-[#1a1a1a]">€{idealCost.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`border-t border-[#f4f4f5] pt-3 flex justify-between items-center`}>
|
||||||
|
<span className="text-[#71717a]">Delta (guadagno/perdita)</span>
|
||||||
|
<span className={`font-mono font-bold ${deltaIsProfit ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
{deltaIsProfit ? "+" : ""}€{delta.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 `<select>` dropdown, but Tabs is already in use |
|
||||||
|
|
||||||
|
**Missing dependencies with no fallback:** None.
|
||||||
|
|
||||||
|
**Missing dependencies with fallback:** Tabs component could be replaced with a `<select>` 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).
|
||||||
@@ -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
|
||||||
@@ -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)?
|
||||||
@@ -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 |
|
||||||
@@ -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 `<form>` + 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 |
|
||||||
@@ -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ì*
|
||||||
@@ -1,50 +1,23 @@
|
|||||||
# ClientHub — Project Instructions
|
# ClientHub
|
||||||
|
|
||||||
## Project
|
Portale clienti per consulente di personal branding. Admin area + dashboard cliente via link segreto.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
## Stack
|
## 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
|
## Security
|
||||||
|
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE, infra changes)
|
||||||
- Client tokens: cryptographically random via `nanoid` (21 chars, ~126 bit entropy). Never derived from client name or sequential ID.
|
- Never read/expose .env or credentials without explicit request
|
||||||
- Admin area: protected by Auth.js session before Phase 2 ships to production.
|
- Don't install packages without showing name + registry + version first
|
||||||
- Payment privacy: `quote_items` never returned by client-facing API routes.
|
- Don't push to main or create PRs without explicit confirmation
|
||||||
|
- Any change to this section: propose full new version, get approval before applying
|
||||||
+28
@@ -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"]
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default async function ClientDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<a
|
<a
|
||||||
href={`/c/${client.token}`}
|
href={`/client/${client.token}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-[#1A463C] hover:underline font-mono bg-[#1A463C]/5 px-2 py-1 rounded"
|
className="text-xs text-[#1A463C] hover:underline font-mono bg-[#1A463C]/5 px-2 py-1 rounded"
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<a
|
<a
|
||||||
href={`/c/${client.token}`}
|
href={`/client/${client.token}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-[#1A463C] hover:underline font-mono"
|
className="text-xs text-[#1A463C] hover:underline font-mono"
|
||||||
>
|
>
|
||||||
/c/{client.token.slice(0, 8)}…
|
/client/{client.token.slice(0, 8)}…
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+3
-3
@@ -29,8 +29,8 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── CLIENT TOKEN GUARD ───────────────────────────────────────────────────
|
// ── CLIENT TOKEN GUARD ───────────────────────────────────────────────────
|
||||||
if (pathname.startsWith("/c/")) {
|
if (pathname.startsWith("/client/")) {
|
||||||
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
const tokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/);
|
||||||
if (!tokenMatch) {
|
if (!tokenMatch) {
|
||||||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||||
}
|
}
|
||||||
@@ -60,5 +60,5 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/admin/:path*", "/c/:path*"],
|
matcher: ["/admin/:path*", "/client/:path*"],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user