diff --git a/.planning/phases/05-offer-system/05-01-PLAN.md b/.planning/phases/05-offer-system/05-01-PLAN.md index b9be6f5..a56a995 100644 --- a/.planning/phases/05-offer-system/05-01-PLAN.md +++ b/.planning/phases/05-offer-system/05-01-PLAN.md @@ -241,7 +241,7 @@ If push fails with "relation does not exist", the definition order in schema.ts - cd /Users/simonecavalli/IAMCAVALLI && node -e "const { db } = require('./src/db/index.ts'); console.log('db ok')" 2>&1 || npx drizzle-kit push --dry-run 2>&1 | tail -10 + npx tsc --noEmit 2>&1 | head -5 diff --git a/.planning/phases/05-offer-system/05-03-PLAN.md b/.planning/phases/05-offer-system/05-03-PLAN.md index 9518fed..f0b5828 100644 --- a/.planning/phases/05-offer-system/05-03-PLAN.md +++ b/.planning/phases/05-offer-system/05-03-PLAN.md @@ -10,6 +10,7 @@ files_modified: - src/app/admin/projects/[id]/page.tsx - src/app/admin/projects/project-actions.ts - src/components/admin/tabs/OffersTab.tsx + - src/app/admin/clients/[id]/page.tsx requirements_addressed: [OFFER-04] autonomous: true must_haves: @@ -18,6 +19,7 @@ must_haves: - "The Offerte tab shows all active offers assigned to a project with micro name, duration, and accepted_total" - "Admin can set the accepted_total on a project offer assignment" - "ProjectFullDetail type includes projectOffers array" + - "Client detail page /admin/clients/[id] shows active offers summary (offer public_name + project name) for all of that client's projects" artifacts: - path: "src/components/admin/tabs/OffersTab.tsx" provides: "Client component for assigning and viewing project offers" @@ -25,7 +27,14 @@ must_haves: - path: "src/app/admin/projects/project-actions.ts" provides: "Server actions for project offer assignment mutations" contains: "assignOfferToProject, removeProjectOffer, updateProjectOfferTotal" + - path: "src/app/admin/clients/[id]/page.tsx" + provides: "Client detail page extended with active offers summary section" + contains: "active offers per project listed with public_name and project name" key_links: + - from: "src/app/admin/clients/[id]/page.tsx" + to: "src/lib/admin-queries.ts" + via: "getClientWithProjectsAndOffers() or extended getClientWithProjects()" + pattern: "activeOffers" - from: "src/app/admin/projects/[id]/page.tsx" to: "src/lib/admin-queries.ts" via: "getProjectFullDetail() — extended to include projectOffers" @@ -517,6 +526,131 @@ And after the last ``: OffersTab created; project workspace page has Offerte tab; admin can assign micro-offers and set accepted_total; TypeScript compiles + + + Task 3: Add getClientActiveOffers query + client detail page active offers summary + + src/lib/admin-queries.ts + src/app/admin/clients/[id]/page.tsx + + + + - src/lib/admin-queries.ts — read the getClientWithProjects function and ClientWithProjects type (to extend alongside, not replace) + - src/app/admin/clients/[id]/page.tsx — read the FULL file; understand the current project card layout + - src/db/schema.ts — confirm project_offers.micro_id, offer_micros.public_name, project_offers.project_id column names (after 05-01) + + + +**Add new query to `src/lib/admin-queries.ts`:** + +Add `offer_micros` and `project_offers` to the existing schema imports at the top (they were added in Task 1 of this plan). Then add the following new exported type and function after `getClientWithProjects()`: + +```typescript +export type ClientActiveOfferSummary = { + offer_id: string; + project_id: string; + project_name: string; + public_name: string; // offer_micros.public_name — NEVER internal_name + accepted_total: string | null; +}; + +export async function getClientActiveOffers(clientId: string): Promise { + const projectRows = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.client_id, clientId)); + + if (projectRows.length === 0) return []; + + const projectIds = projectRows.map((p) => p.id); + const projectNameMap = new Map(projectRows.map((p) => [p.id, p.name])); + + // Explicit column selection — public_name only, NEVER internal_name + const rows = await db + .select({ + offer_id: project_offers.id, + project_id: project_offers.project_id, + public_name: offer_micros.public_name, + accepted_total: project_offers.accepted_total, + }) + .from(project_offers) + .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)) + .where(inArray(project_offers.project_id, projectIds)) + .orderBy(asc(project_offers.created_at)); + + return rows.map((r) => ({ + offer_id: r.offer_id, + project_id: r.project_id, + project_name: projectNameMap.get(r.project_id) ?? "—", + public_name: r.public_name, + accepted_total: r.accepted_total ? String(r.accepted_total) : null, + })); +} +``` + +Note: `inArray`, `asc`, `eq` are already imported. `project_offers` and `offer_micros` schema imports were added in Task 1 of this plan. + +**Extend `src/app/admin/clients/[id]/page.tsx`:** + +Read the full file. It currently calls `getClientWithProjects(id)` and awaits a single result. Make these three targeted changes: + +1. Add `getClientActiveOffers` to the existing import: +```typescript +import { getClientWithProjects, getClientActiveOffers } from "@/lib/admin-queries"; +``` + +2. Replace the single `await getClientWithProjects(id)` call with a parallel fetch: +```typescript +const [data, activeOffers] = await Promise.all([ + getClientWithProjects(id), + getClientActiveOffers(id), +]); +if (!data) notFound(); +``` + +3. Add the "Offerte Attive" summary section at the bottom of the JSX return, after the `archivedProjects` block and before the closing `` of the root element: +```tsx +{activeOffers.length > 0 && ( +
+

+ Offerte Attive ({activeOffers.length}) +

+
+ {activeOffers.map((offer) => ( +
+
+

{offer.public_name}

+

{offer.project_name}

+
+ {offer.accepted_total && ( + + €{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })} + + )} +
+ ))} +
+
+)} +``` +
+ + + npx tsc --noEmit 2>&1 | head -20 + + + + - `npx tsc --noEmit` exits 0 + - `grep "getClientActiveOffers" src/lib/admin-queries.ts` matches (new function exported) + - `grep "ClientActiveOfferSummary" src/lib/admin-queries.ts` matches (type exported) + - `grep "getClientActiveOffers" "src/app/admin/clients/[id]/page.tsx"` matches (function called on page) + - `grep "activeOffers" "src/app/admin/clients/[id]/page.tsx"` returns at least 2 matches (fetch + render) + - `grep "internal_name" src/lib/admin-queries.ts` — must NOT appear in the new getClientActiveOffers query block + + + getClientActiveOffers() exported from admin-queries.ts; /admin/clients/[id] page shows active offers summary with public_name and project name; TypeScript compiles +
+ @@ -537,13 +671,15 @@ And after the last ``: -After both tasks complete: +After all three tasks complete: - `npx tsc --noEmit` exits 0 - Visit `/admin/projects/[id]` → Offerte tab is visible - Offerte tab shows empty state when no offers assigned - Assign a micro-offer → appears in the active list - Set accepted_total by blurring the input → value persists on refresh - Remove an assignment → disappears from list +- Visit `/admin/clients/[id]` for a client with active project offers → "Offerte Attive" section appears at the bottom with public_name and project name +- Client with no active offers → no Offerte Attive section visible @@ -552,6 +688,7 @@ After both tasks complete: 3. Admin can update the accepted_total inline (onBlur save) 4. Admin can remove a project offer assignment 5. All changes revalidate /admin/forecast path +6. Client detail page /admin/clients/[id] shows active offers summary (public_name + project name) for all projects belonging to that client diff --git a/.planning/phases/05-offer-system/05-04-PLAN.md b/.planning/phases/05-offer-system/05-04-PLAN.md index 243ae86..c7adfc5 100644 --- a/.planning/phases/05-offer-system/05-04-PLAN.md +++ b/.planning/phases/05-offer-system/05-04-PLAN.md @@ -1,7 +1,7 @@ --- plan_id: 05-04 phase: 5 -wave: 3 +wave: 4 title: "Client dashboard offer card + /admin/forecast revenue forecast page (12 months)" type: execute depends_on: [05-01, 05-03] diff --git a/.planning/phases/05-offer-system/05-RESEARCH.md b/.planning/phases/05-offer-system/05-RESEARCH.md new file mode 100644 index 0000000..f6ae1ec --- /dev/null +++ b/.planning/phases/05-offer-system/05-RESEARCH.md @@ -0,0 +1,624 @@ +# Phase 5: Offer System — Research + +**Researched:** 2026-05-30 +**Domain:** Drizzle ORM schema design, Next.js 16 App Router server actions, shadcn/ui multi-select pattern, revenue forecast algorithm +**Confidence:** HIGH (codebase fully inspected; all patterns verified against existing code) + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| OFFER-01 | Admin can create macro-offers with internal name, public name, macro transformation promise | New table `offer_macros`; CRUD follows catalog/actions.ts pattern | +| OFFER-02 | Each macro-offer has child micro-offers with internal name, public name, micro transformation promise, duration in months | New table `offer_micros` with FK to `offer_macros`; `duration_months` integer column | +| OFFER-03 | Admin can create services with name, price, transformation description; each service assignable to multiple micro-offers via multi-select (many-to-many) | New table `offer_services` (separate from `service_catalog`) + junction table `offer_micro_services`; multi-select via checkbox list pattern | +| OFFER-04 | Admin can assign one or more micro-offers to a project; admin sees active offers per project/client | New table `project_offers` with `project_id`, `micro_offer_id`, `start_date`, `accepted_total` columns | +| OFFER-05 | Client dashboard shows active offers with public name, cumulative service price, accepted final price; multiple offers shown | Extend `getProjectView()` to include offer data; render in `ClientDashboard` component; NO internal names or individual prices | +| OFFER-06 | Admin revenue forecast for next 12 months based on active offers, duration, accepted_total; monthly breakdown | Pure JS computation in a new query function; no new DB tables required | + + +--- + +## Summary + +Phase 5 adds an Offer System on top of the existing multi-project structure. The work divides cleanly into three layers: (1) a new schema with four tables, (2) admin CRUD UI for the offers catalog and project assignment, and (3) two read surfaces — the client dashboard and an admin revenue forecast page. + +The most important architectural decision is that Offer Services (`offer_services`) are a NEW entity, distinct from the existing `service_catalog`. The existing `service_catalog` is used for quote line items (internal pricing). Offer services carry a "transformation description" for marketing language and are bundled into micro-offers. The two entities serve different purposes and must not be merged. + +The revenue forecast algorithm (OFFER-06) requires no extra DB tables: given `project_offers.start_date` and `offer_micros.duration_months`, each project_offer generates revenue in months `[start_month, start_month + duration_months)`. The `accepted_total` from the `project_offers` row (not from the project) drives the monthly amount — this is the "offer-level accepted total", distinct from `projects.accepted_total`. + +**Primary recommendation:** Follow the established pattern — `"use server"` actions + `revalidatePath` + `router.refresh()` in client components. Add no new libraries for this phase; the existing shadcn/ui `select.tsx` is sufficient for the single-select assignment form, and a controlled checkbox list covers the multi-select service assignment. Radix `@radix-ui/react-checkbox` (v1.3.3 available) is the only optional new install if a styled checkbox component is desired. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Offer catalog CRUD (macro/micro) | API / Backend (Server Actions) | Admin Frontend (RSC + Client form) | All writes go through `"use server"` actions with session guard | +| Offer services CRUD | API / Backend (Server Actions) | Admin Frontend | Same as service catalog pattern | +| Service-to-micro assignment (M2M) | API / Backend (Server Actions) | Admin Frontend (checkbox list) | Junction table writes in single server action | +| Project offer assignment | API / Backend (Server Actions) | Admin Frontend | Extends project workspace tab | +| Client dashboard offer display | Frontend Server (RSC) | — | `getProjectView()` extension; never exposes internal names or prices | +| Revenue forecast computation | API / Backend (query function) | Admin Frontend (RSC) | Pure JS over DB query result; no client-side computation | + +--- + +## Standard Stack + +### Core (already installed — no new installs required) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| drizzle-orm | 0.45.2 | Schema definition, queries, relations | Project ORM — all schema work follows this | +| drizzle-kit | 0.31.10 | `drizzle-kit push` migration | Existing migration workflow | +| next | 16.2.6 | App Router, Server Actions | Project framework | +| zod | 4.4.3 | Server action input validation | Used in every existing action | +| nanoid | 5.1.11 | ID generation | Used for all PKs | +| @radix-ui/react-select | 2.2.6 | Single-select dropdown | Already installed | +| lucide-react | 1.14.0 | Icons | Already installed | + +[VERIFIED: /Users/simonecavalli/IAMCAVALLI/package.json] + +### Optional (multi-select checkbox styling only) + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| @radix-ui/react-checkbox | 1.3.3 | Styled checkbox primitive | Only if the bare HTML `` looks too unstyled; the project uses Radix primitives for all interactive elements | + +[VERIFIED: npm registry] + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Checkbox list for multi-select | `cmdk` Command palette | Command palette adds a dependency and is overkill for a 5-20 item list; checkbox list is simpler and consistent with project style | +| Separate offer_services table | Re-use service_catalog | Wrong: service_catalog is for quoted line-item prices; offer_services carry marketing transformation descriptions — different semantics | + +**Installation (if checkbox component needed):** +```bash +npm install @radix-ui/react-checkbox@1.3.3 +``` + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +Admin Browser + | + | Server Action (POST) + v +offer-actions.ts ─────────────────────────────────────┐ + | | + | db.insert / db.update / db.delete | + v | +Postgres (Neon/Coolify) | + offer_macros | + offer_micros ──FK──> offer_macros | + offer_services | + offer_micro_services ──FK──> offer_micros | + └──FK──> offer_services | + project_offers ──FK──> projects | + └──FK──> offer_micros | + | +Admin RSC Pages | + /admin/offers ← catalog management | + /admin/projects/[id] ← OffersTab (new tab) ──────┘ + /admin/clients/[id] ← active offers badge + /admin/forecast ← revenue forecast + | +Client RSC Page + /client/[token] ← OffersSection (read-only, public names only) + | + | getProjectView() extended — no internal names, no unit prices + v + ClientDashboard component +``` + +### Recommended Project Structure + +``` +src/ +├── app/ +│ └── admin/ +│ ├── offers/ +│ │ └── page.tsx # Offer catalog (macro + micro + services) +│ ├── forecast/ +│ │ └── page.tsx # Revenue forecast 12 months +│ └── projects/[id]/page.tsx # Add OffersTab here +├── components/ +│ └── admin/ +│ ├── tabs/ +│ │ └── OffersTab.tsx # Assign micro-offers to project + list +│ └── offers/ +│ ├── MacroOfferForm.tsx # Create/edit macro-offer +│ ├── MicroOfferForm.tsx # Create/edit micro-offer (child of macro) +│ ├── OfferServiceForm.tsx # Create/edit offer service +│ ├── ServiceAssignForm.tsx # Multi-select checkbox list for micro→services +│ └── ForecastTable.tsx # 12-month breakdown table +└── lib/ + ├── offer-queries.ts # All offer-related read queries + └── forecast-queries.ts # Revenue forecast computation +``` + +[VERIFIED: mirrors structure of existing catalog/, tabs/, components/admin/] + +### Pattern 1: Schema — Four New Tables + +```typescript +// Source: /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (existing pattern) + +export const offer_macros = pgTable("offer_macros", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + internal_name: text("internal_name").notNull(), + public_name: text("public_name").notNull(), + transformation_promise: text("transformation_promise"), + sort_order: integer("sort_order").notNull().default(0), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const offer_micros = pgTable("offer_micros", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + macro_id: text("macro_id").notNull().references(() => offer_macros.id, { onDelete: "cascade" }), + internal_name: text("internal_name").notNull(), + public_name: text("public_name").notNull(), + transformation_promise: text("transformation_promise"), + duration_months: integer("duration_months").notNull().default(1), + sort_order: integer("sort_order").notNull().default(0), +}); + +export const offer_services = pgTable("offer_services", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + name: text("name").notNull(), + price: numeric("price", { precision: 10, scale: 2 }).notNull(), + transformation_description: text("transformation_description"), + active: boolean("active").notNull().default(true), +}); + +// Junction table — many micro-offers <-> many services +export const offer_micro_services = pgTable("offer_micro_services", { + micro_id: text("micro_id").notNull().references(() => offer_micros.id, { onDelete: "cascade" }), + service_id: text("service_id").notNull().references(() => offer_services.id, { onDelete: "cascade" }), +}, (t) => ({ + pk: primaryKey({ columns: [t.micro_id, t.service_id] }), +})); + +// Project assignment +export const project_offers = pgTable("project_offers", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), + micro_id: text("micro_id").notNull().references(() => offer_micros.id, { onDelete: "restrict" }), + start_date: timestamp("start_date", { withTimezone: true }).notNull().defaultNow(), + // Offer-level accepted total — separate from projects.accepted_total + // This is what the client pays for THIS specific offer bundle + accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); +``` + +[VERIFIED: schema.ts naming conventions, nanoid PK pattern, pgTable from drizzle-orm/pg-core] + +**Important:** `offer_micro_services` uses a composite primary key — Drizzle `primaryKey()` import comes from `drizzle-orm/pg-core`. [VERIFIED: Drizzle ORM docs — composite PK syntax] + +### Pattern 2: Server Action Guard (existing pattern) + +```typescript +// Source: /Users/simonecavalli/IAMCAVALLI/src/app/admin/catalog/actions.ts +"use server"; + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} +``` + +All offer server actions must call `requireAdmin()` as first line. [VERIFIED: catalog/actions.ts, project-actions.ts] + +### Pattern 3: Multi-Select Service Assignment (checkbox list) + +No `cmdk` or `Combobox` needed. The project has at most ~20 offer services. Use a controlled checkbox list: + +```tsx +// Source: pattern derived from ServiceTable.tsx + QuoteTab.tsx interaction model +// "use client" +function ServiceCheckboxList({ + allServices, + assignedIds, + microId, +}: { + allServices: OfferService[]; + assignedIds: string[]; + microId: string; +}) { + const [selected, setSelected] = useState(new Set(assignedIds)); + const [, startTransition] = useTransition(); + const router = useRouter(); + + function toggle(serviceId: string) { + const next = new Set(selected); + if (next.has(serviceId)) next.delete(serviceId); + else next.add(serviceId); + setSelected(next); + startTransition(async () => { + await updateMicroOfferServices(microId, [...next]); + router.refresh(); + }); + } + + return ( +
+ {allServices.map((svc) => ( + + ))} +
+ ); +} +``` + +[VERIFIED: pattern matches existing QuoteTab useTransition + router.refresh() approach] + +### Pattern 4: Revenue Forecast Algorithm + +```typescript +// Source: analytics-queries.ts existing SQL+JS pattern +// No new DB tables needed — pure computation from project_offers + +export type ForecastMonth = { + year: number; + month: number; // 1-12 + label: string; // "Giu 2026" + total: number; // sum of offer revenue expected in that month +}; + +export async function getRevenueForecast12Months(): Promise { + // Load all active project_offers with micro duration + const offers = await db + .select({ + project_id: project_offers.project_id, + start_date: project_offers.start_date, + duration_months: offer_micros.duration_months, + accepted_total: project_offers.accepted_total, + }) + .from(project_offers) + .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)); + + // Build 12-month bucket array starting from current month + const now = new Date(); + const buckets: ForecastMonth[] = Array.from({ length: 12 }, (_, i) => { + const d = new Date(now.getFullYear(), now.getMonth() + i, 1); + return { + year: d.getFullYear(), + month: d.getMonth() + 1, + label: d.toLocaleDateString("it-IT", { month: "short", year: "numeric" }), + total: 0, + }; + }); + + for (const offer of offers) { + if (!offer.accepted_total) continue; + const total = parseFloat(offer.accepted_total); + const perMonth = total / offer.duration_months; + const start = new Date(offer.start_date); + + for (let m = 0; m < offer.duration_months; m++) { + const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1); + const bucket = buckets.find( + (b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1 + ); + if (bucket) bucket.total += perMonth; + } + } + + return buckets; +} +``` + +[VERIFIED: pattern mirrors getMonthlyCollected() from analytics-queries.ts; no SQL GROUP BY needed since computation is JS-side] + +### Pattern 5: Client Dashboard Offer Section (safe exposure) + +The `getProjectView()` function in `client-view.ts` must be extended with a new field `activeOffers`. This field exposes ONLY: +- `public_name` (from `offer_micros.public_name`) — NOT `internal_name` +- `cumulative_price` (sum of `offer_services.price` for services assigned to that micro) — NOT individual service prices +- `accepted_total` (from `project_offers.accepted_total`) — already how payments work + +```typescript +// Extension to ProjectView interface in client-view.ts +activeOffers?: Array<{ + id: string; + public_name: string; // micro offer public name only + cumulative_price: string; // sum of all service prices in the micro-offer + accepted_total: string | null; // offer-level accepted total, shown prominently +}>; +``` + +[VERIFIED: client-view.ts security pattern — amount intentionally excluded from payments, same discipline applied here] + +### Pattern 6: Admin Project Page — Adding an Offers Tab + +`/admin/projects/[id]/page.tsx` uses ``. Adding an "Offerte" tab follows the exact same structure as the existing tabs: + +```tsx +// In /admin/projects/[id]/page.tsx +Offerte +// ... + + + +``` + +The `getProjectFullDetail()` query needs one additional parallel query for `project_offers`. [VERIFIED: existing tab pattern in /admin/projects/[id]/page.tsx] + +### Anti-Patterns to Avoid + +- **Re-using `service_catalog` for offer services:** `service_catalog` has `unit_price` semantics for quote line items. Offer services have a `transformation_description` for marketing copy and are bundled into packages. Different entities, different tables. +- **Storing cumulative price in DB:** Compute `cumulative_price` at query time (SUM of `offer_services.price` joined via `offer_micro_services`). Never denormalize into `offer_micros` — it would go stale when services are edited. +- **Exposing `internal_name` to client API:** Every client-side query must select `public_name` explicitly, never `SELECT *` on offer tables. +- **Computing forecast in the browser:** The `getRevenueForecast12Months()` function runs server-side as a Server Component prop. No client-side fetch or useEffect. +- **Using `onDelete: "cascade"` on `project_offers.micro_id`:** Use `onDelete: "restrict"` — if an admin tries to delete a micro-offer that is actively assigned to projects, the DB should reject it with an error rather than silently destroying assignment history. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Multi-select UI | Custom dropdown with search | Checkbox list (plain HTML or Radix Checkbox) | 5-20 items max; no search needed at this scale | +| Composite PK in junction table | Separate `id` column + unique constraint | Drizzle `primaryKey({ columns: [t.micro_id, t.service_id] })` | Drizzle natively supports composite PKs | +| Revenue forecast | Complex SQL window functions | JS loop over query results | The dataset is tiny (< 100 active offers); analytics-queries.ts already uses this pattern | +| Relation definitions | Raw SQL joins | Drizzle `relations()` | Existing pattern; enables type-safe `.with()` queries | + +**Key insight:** This phase is data-model heavy but UI-light. The hardest part is the schema design (junction table, composite PK, the `start_date` + `duration_months` forecast model), not the UI. + +--- + +## Common Pitfalls + +### Pitfall 1: Drizzle Composite PK import +**What goes wrong:** `primaryKey` is not imported from `drizzle-orm` — it must be imported from `drizzle-orm/pg-core`. +**Why it happens:** `drizzle-orm` re-exports many things but `primaryKey` for table definitions is from the pg-core package. +**How to avoid:** `import { pgTable, primaryKey, text, ... } from "drizzle-orm/pg-core"` — add `primaryKey` to the existing import in schema.ts. +**Warning signs:** TypeScript error "primaryKey is not exported from drizzle-orm". + +### Pitfall 2: Offer accepted_total vs. project accepted_total +**What goes wrong:** Revenue forecast uses `projects.accepted_total` instead of `project_offers.accepted_total`. +**Why it happens:** `projects.accepted_total` is the quote-builder total (from Phase 3 CRUD). Offer assignments have their own accepted totals. +**How to avoid:** `project_offers` table has its own `accepted_total` column. Forecast queries MUST join to `project_offers.accepted_total`, not `projects.accepted_total`. +**Warning signs:** Forecast totals match the quote totals instead of offer totals. + +### Pitfall 3: start_date = NULL breaks forecast +**What goes wrong:** `project_offers.start_date` nullable + forecast query silently drops those rows. +**Why it happens:** If `start_date` is nullable, active offers with no start date contribute nothing to forecast. +**How to avoid:** Make `start_date` NOT NULL with `.defaultNow()` — admin sets it at assignment time. If needed, allow admin to edit it after assignment. +**Warning signs:** Forecast shows 0 even with active offers. + +### Pitfall 4: Client API security — internal names leaking +**What goes wrong:** A query accidentally includes `internal_name` in the client-side response. +**Why it happens:** Using `...spread` or `SELECT *` on offer tables in `getProjectView()`. +**How to avoid:** In `getProjectView()`, always use explicit column selection: `offer_micros.public_name`, `project_offers.accepted_total` — never `SELECT *` on tables with both internal and public name columns. +**Warning signs:** Client dashboard shows internal names like "Entry A" instead of "Entry Offer — Starter". + +### Pitfall 5: Drizzle push order — tables with circular deps +**What goes wrong:** `drizzle-kit push` fails if tables reference each other out of order. +**Why it happens:** `offer_micro_services` references both `offer_micros` and `offer_services` — both must exist first. +**How to avoid:** Define in schema.ts in this order: `offer_macros` → `offer_micros` → `offer_services` → `offer_micro_services` → `project_offers`. drizzle-kit push respects definition order. +**Warning signs:** `relation "offer_micros" does not exist` error during push. + +### Pitfall 6: Forecast page route collision with existing /admin/analytics +**What goes wrong:** Naming the forecast page `/admin/analytics` when that route already exists. +**Why it happens:** Existing `/admin/analytics/page.tsx` is the financial statistics page. +**How to avoid:** Use `/admin/forecast` or add a tab to the existing analytics page. Recommended: new route `/admin/forecast` to keep concerns separated. +**Warning signs:** The analytics page gets replaced. + +--- + +## Code Examples + +### Drizzle composite PK (junction table) + +```typescript +// Source: Drizzle ORM docs — https://orm.drizzle.team/docs/indexes-constraints#composite-primary-key +import { pgTable, primaryKey, text } from "drizzle-orm/pg-core"; + +export const offer_micro_services = pgTable( + "offer_micro_services", + { + micro_id: text("micro_id").notNull().references(() => offer_micros.id, { onDelete: "cascade" }), + service_id: text("service_id").notNull().references(() => offer_services.id, { onDelete: "cascade" }), + }, + (t) => ({ + pk: primaryKey({ columns: [t.micro_id, t.service_id] }), + }) +); +``` + +[CITED: https://orm.drizzle.team/docs/indexes-constraints#composite-primary-key] + +### Cumulative price query (server-side) + +```typescript +// Sum of all services assigned to a micro-offer +const rows = await db + .select({ + micro_id: offer_micro_services.micro_id, + cumulative_price: sql`coalesce(sum(${offer_services.price}::numeric), 0)`, + }) + .from(offer_micro_services) + .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) + .where(inArray(offer_micro_services.micro_id, microIds)) + .groupBy(offer_micro_services.micro_id); +``` + +[VERIFIED: mirrors analytics-queries.ts SQL aggregate patterns] + +### revalidatePath strategy for offer mutations + +```typescript +// After any offer catalog mutation: +revalidatePath("/admin/offers"); + +// After project offer assignment: +revalidatePath(`/admin/projects/${projectId}`); +revalidatePath("/admin/forecast"); +// Do NOT revalidate /client/[token] — client pages use revalidate = 0 +``` + +[VERIFIED: existing revalidatePath usage in catalog/actions.ts, project-actions.ts] + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `quote_items` exposed to client | `accepted_total` only | Phase 1 (locked) | Offer display must follow same discipline | +| Single project per client | Multi-project (projects table) | Phase 4 | `project_offers` links to `projects.id`, not `clients.id` | +| Timer/payments on clients | Timer/payments on projects | Phase 4 | Offers also scope to projects, not clients | + +--- + +## Schema Migration Strategy + +The four new tables are purely additive. The migration: +1. Adds `offer_macros`, `offer_micros`, `offer_services`, `offer_micro_services`, `project_offers` +2. Does NOT modify any existing table +3. Does NOT drop or truncate any existing data + +[VERIFIED: CLAUDE.md Data Safety constraint — migrations must only add columns/tables, never drop] + +Command after schema.ts is updated: +```bash +npx drizzle-kit push +``` + +--- + +## Navigation Integration + +**Current NavBar links:** Clienti | Progetti | Statistiche | Catalogo | Impostazioni + +**Recommended addition:** Add "Offerte" between "Catalogo" and "Impostazioni", and "Forecast" after "Statistiche". This keeps catalog-type items together. + +Final NavBar: `Clienti | Progetti | Statistiche | Forecast | Catalogo | Offerte | Impostazioni` + +Alternatively, "Forecast" can live under "Statistiche" as a tab, avoiding NavBar clutter. Since the analytics page already uses year-based filtering, adding a "Forecast" tab to `/admin/analytics` is a viable option. + +[ASSUMED] — Which navigation placement is preferable is a product decision. Both options work technically. + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | Offer-level `accepted_total` on `project_offers` is separate from `projects.accepted_total` | Schema Design | If the intent is that `projects.accepted_total` also covers offers, the schema design changes; the revenue forecast would use a different source | +| A2 | "Forecast" gets its own page at `/admin/forecast` rather than a tab in `/admin/analytics` | Navigation Integration | If admin prefers a tab, the page structure changes but not the algorithm | +| A3 | Offer services have a flat price (not quantity × unit_price like quote_items) | Schema Design | If offer services need quantity support, `offer_micro_services` needs a `quantity` column | + +--- + +## Open Questions (RESOLVED) + +1. **Should `project_offers.accepted_total` be mandatory or optional?** + - What we know: `projects.accepted_total` defaults to "0"; offer assignment may happen before price negotiation + - What's unclear: Can admin assign a micro-offer with no accepted total yet? Does it appear in forecast as 0? + - RESOLVED: nullable — exclude from forecast if null + - Recommendation: Make it nullable (`accepted_total: numeric(...)`), exclude null rows from forecast computation + +2. **Can a project have the same micro-offer assigned twice (e.g., a retainer renewed)?** + - What we know: The `project_offers` table as designed allows duplicate (`project_id`, `micro_id`) combinations + - What's unclear: Is renewal a new row (different `start_date`) or an update? + - RESOLVED: allowed — differentiated by start_date, no unique constraint + - Recommendation: Allow duplicate rows (multiple assignments of same micro to same project), differentiated by `start_date`; no unique constraint on (`project_id`, `micro_id`) + +3. **Where does the "Offerte" catalog page live — merged with existing /admin/catalog, or separate?** + - What we know: `/admin/catalog` handles `service_catalog`; offer entities are distinct + - What's unclear: Admin preference for navigation + - RESOLVED: separate /admin/offers page + - Recommendation: Separate page `/admin/offers` keeps concerns clean; existing `/admin/catalog` stays for quote service catalog + +--- + +## Environment Availability + +Step 2.6: SKIPPED — no new external dependencies identified. All required tools (Node.js, npm, Postgres, drizzle-kit) are already verified operational from Phase 4. + +--- + +## Validation Architecture + +`nyquist_validation: false` in `.planning/config.json` — section omitted per config. + +[VERIFIED: /Users/simonecavalli/IAMCAVALLI/.planning/config.json] + +--- + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | `requireAdmin()` guard in every server action — already established pattern | +| V3 Session Management | yes | Auth.js v4 session; no changes needed | +| V4 Access Control | yes | Client API (`getProjectView`) must never return `internal_name` or individual `offer_services.price` | +| V5 Input Validation | yes | zod schemas in all server actions (established pattern) | +| V6 Cryptography | no | No new cryptographic operations | + +### Known Threat Patterns + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Client reads internal offer names via /api/client/* | Information Disclosure | Explicit column selection in `getProjectView()` — never `SELECT *` on offer tables | +| Admin accesses offer CRUD without session | Elevation of Privilege | `requireAdmin()` as first call in every server action | +| Cascade delete of micro-offer deletes project_offer history | Tampering | `onDelete: "restrict"` on `project_offers.micro_id` — prevents deletion of in-use micros | + +--- + +## Sources + +### Primary (HIGH confidence) +- `/Users/simonecavalli/IAMCAVALLI/src/db/schema.ts` — full schema inspected +- `/Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts` — all query patterns verified +- `/Users/simonecavalli/IAMCAVALLI/src/lib/client-view.ts` — client API security model verified +- `/Users/simonecavalli/IAMCAVALLI/src/app/admin/catalog/actions.ts` — server action pattern verified +- `/Users/simonecavalli/IAMCAVALLI/src/lib/analytics-queries.ts` — SQL aggregate + JS computation pattern verified +- `/Users/simonecavalli/IAMCAVALLI/package.json` — dependencies and versions verified +- `/Users/simonecavalli/IAMCAVALLI/.planning/config.json` — workflow config verified + +### Secondary (MEDIUM confidence) +- Drizzle ORM composite primary key syntax — `https://orm.drizzle.team/docs/indexes-constraints#composite-primary-key` [CITED] +- npm registry — `@radix-ui/react-checkbox@1.3.3`, `@radix-ui/react-dialog@1.1.15`, `@radix-ui/react-popover@1.1.15` versions [VERIFIED] + +### Tertiary (LOW confidence) +- None + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all packages verified from package.json +- Schema design: HIGH — derived directly from existing schema.ts patterns and Drizzle docs +- Architecture patterns: HIGH — derived from direct codebase inspection +- Revenue forecast algorithm: HIGH — mirrors existing analytics-queries.ts pattern +- Client security model: HIGH — directly reading client-view.ts with explicit column exclusions + +**Research date:** 2026-05-30 +**Valid until:** 2026-06-30 (stable stack — Next.js 16, Drizzle, Radix; no fast-moving dependencies)