Files
simone 5bf5dfce71 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>
2026-05-21 16:12:05 +02:00

921 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 13 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 45 plans for vertical-slice execution (Schema Wave 0 → Core Routing → Admin UI → Client UI + Analytics).