From cd55cba56c82b1cacc68f90a254e422bc61266fc Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sat, 30 May 2026 15:07:52 +0200 Subject: [PATCH] =?UTF-8?q?docs(05):=20create=20Phase=205=20Offer=20System?= =?UTF-8?q?=20plans=20=E2=80=94=204=20plans=20in=203=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1: 05-01 schema migration (5 new tables, additive only) Wave 2: 05-02 offer catalog CRUD (/admin/offers + NavBar) Wave 3: 05-03 project offer assignment (OffersTab) + 05-04 client dashboard offers + /admin/forecast Co-Authored-By: Claude Sonnet 4.6 --- .planning/REQUIREMENTS.md | 25 +- .planning/ROADMAP.md | 36 +- .planning/STATE.md | 104 +-- .../phases/05-offer-system/05-01-PLAN.md | 290 +++++++ .../phases/05-offer-system/05-02-PLAN.md | 713 ++++++++++++++++++ .../phases/05-offer-system/05-03-PLAN.md | 559 ++++++++++++++ .../phases/05-offer-system/05-04-PLAN.md | 530 +++++++++++++ 7 files changed, 2183 insertions(+), 74 deletions(-) create mode 100644 .planning/phases/05-offer-system/05-01-PLAN.md create mode 100644 .planning/phases/05-offer-system/05-02-PLAN.md create mode 100644 .planning/phases/05-offer-system/05-03-PLAN.md create mode 100644 .planning/phases/05-offer-system/05-04-PLAN.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index e53e577..fab552e 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -40,7 +40,18 @@ | 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) +## Offer System (Phase 5) + +| ID | Requirement | Status | +|----|-------------|--------| +| OFFER-01 | Admin può creare macro-offerte (es. Entry Offer, Signature Offer, Retainer) con nome interno, nome pubblico e promessa di trasformazione macro | Pending | +| OFFER-02 | Ogni macro-offerta ha micro-offerte figlie (es. Entry A/B/C) con nome interno, nome pubblico, promessa di trasformazione micro e durata in mesi | Pending | +| OFFER-03 | Admin può creare servizi con nome, prezzo e descrizione della trasformazione; ogni servizio è assegnabile a più micro-offerte via multi-select (many-to-many) | Pending | +| OFFER-04 | Admin può assegnare una o più micro-offerte a un progetto; admin vede le offerte attive per ogni progetto e cliente | Pending | +| OFFER-05 | La dashboard cliente mostra le offerte attive con nome pubblico, prezzo cumulativo dei servizi inclusi e prezzo finale accettato in evidenza; se ha più offerte le vede tutte | Pending | +| OFFER-06 | Admin ha una vista revenue forecast dei 12 mesi successivi basata su offerte attive, durata e accepted_total; breakdown mensile con totale fatturato per mese | Pending | + +## Flusso Claude (v2 — deferred to Phase 6) | ID | Requirement | Status | |----|-------------|--------| @@ -79,6 +90,12 @@ | PROJ-03 | Phase 4 | Pending | | PROJ-04 | Phase 4 | Pending | | PROJ-05 | Phase 4 | Pending | -| CLAUDE-01 | Phase 5 (v2) | Deferred | -| CLAUDE-02 | Phase 5 (v2) | Deferred | -| CLAUDE-03 | Phase 5 (v2) | Deferred | \ No newline at end of file +| OFFER-01 | Phase 5 | Pending | +| OFFER-02 | Phase 5 | Pending | +| OFFER-03 | Phase 5 | Pending | +| OFFER-04 | Phase 5 | Pending | +| OFFER-05 | Phase 5 | Pending | +| OFFER-06 | Phase 5 | Pending | +| CLAUDE-01 | Phase 6 (v2) | Deferred | +| CLAUDE-02 | Phase 6 (v2) | Deferred | +| CLAUDE-03 | Phase 6 (v2) | Deferred | \ No newline at end of file diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f967e98..8ebf321 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -16,7 +16,8 @@ Decimal phases appear between their surrounding integers in numeric order. - [ ] **Phase 2: Admin Area & Interactive Features** - Auth admin, CRUD completo clienti/fasi/task/deliverable/pagamenti, approvazioni e commenti - [ ] **Phase 3: Service Catalog & Quote Builder** - Catalogo servizi riutilizzabile e costruttore preventivi (admin-only, cliente vede solo il totale) - [ ] **Phase 4: Progetti — Multi-Project per Cliente** - Modello dati multi-progetto per cliente; dashboard con tabs; /admin/projects; slug link; analytics €/h -- [ ] **Phase 5: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo +- [ ] **Phase 5: Offer System** - Catalogo macro/micro offerte con servizi assegnabili, assegnazione offerte a progetti, dashboard cliente con offerte attive e revenue forecast 12 mesi per l'admin +- [ ] **Phase 6: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo ## Phase Details @@ -99,10 +100,32 @@ Decimal phases appear between their surrounding integers in numeric order. **UI hint**: yes **Status**: Planning complete -### Phase 5: Claude AI Onboarding (v2) -**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude +### Phase 5: Offer System + +**Goal**: L'admin può gestire un catalogo di macro/micro offerte con servizi assegnabili; le offerte vengono assegnate ai progetti; il cliente vede le sue offerte attive; l'admin ha un revenue forecast 12 mesi **Mode:** mvp **Depends on**: Phase 4 +**Requirements**: OFFER-01, OFFER-02, OFFER-03, OFFER-04, OFFER-05, OFFER-06 +**Success Criteria** (what must be TRUE): + + 1. L'admin può creare macro-offerte (es. Entry Offer) con micro-offerte figlie (es. Entry A/B/C) — nome interno, nome pubblico, promessa di trasformazione, durata in mesi + 2. L'admin può creare servizi con nome, prezzo e descrizione trasformazione; ogni servizio è assegnabile a più micro-offerte via multi-select + 3. L'admin può assegnare una micro-offerta a un progetto; la lista clienti/progetti mostra le offerte attive + 4. Il cliente vede nella dashboard le sue offerte attive (nome pubblico), il prezzo cumulativo dei servizi e il prezzo finale accettato in evidenza + 5. L'admin ha una pagina revenue forecast con breakdown mensile per i 12 mesi successivi basato su offerte attive e durate +**Plans**: 4 plans +**Plan list**: + - [ ] 05-01-PLAN.md — Schema migration: 5 new offer tables (offer_macros, offer_micros, offer_services, offer_micro_services, project_offers) + drizzle-kit push [BLOCKING] + - [ ] 05-02-PLAN.md — Offer catalog admin CRUD: /admin/offers page + offer-queries.ts + actions.ts + ServiceCheckboxList + NavBar update + - [ ] 05-03-PLAN.md — Project offer assignment: OffersTab in /admin/projects/[id] + admin-queries extension + project-actions extension + - [ ] 05-04-PLAN.md — Client dashboard OffersSection + /admin/forecast revenue forecast page (12 months) +**UI hint**: yes +**Status**: Planning complete + +### Phase 6: Claude AI Onboarding (v2) +**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude +**Mode:** mvp +**Depends on**: Phase 5 **Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03 **Success Criteria** (what must be TRUE): 1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie @@ -115,12 +138,13 @@ Decimal phases appear between their surrounding integers in numeric order. ## Progress **Execution Order:** -Phases execute in numeric order: 1 → 2 → 3 → 4 +Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 | Phase | Plans | Status | Completed | |-------|-------|--------|-----------| | 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 | | 2. Admin Area & Interactive Features | 4/4 | ✅ Done | 2026-05-15 | | 3. Service Catalog & Quote Builder | 4/4 | ✅ Done | 2026-05-19 | -| 4. Progetti — Multi-Project per Cliente | 0/TBD | Pending | - | -| 5. Claude AI Onboarding (v2) | 0/TBD | Pending | - | \ No newline at end of file +| 4. Progetti — Multi-Project per Cliente | 5/5 | ✅ Done | 2026-05-23 | +| 5. Offer System | 0/4 | Pending | - | +| 6. Claude AI Onboarding (v2) | 0/TBD | Pending | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index a4228c0..ef2e7d2 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,16 +2,16 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone -status: executing -stopped_at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404) -last_updated: "2026-05-21T11:56:14.461Z" -last_activity: 2026-05-21 -- Phase 4 planning complete +status: completed +stopped_at: Phase 4 fully executed — payments, multi-project, slug, deploy on hub.iamcavalli.net +last_updated: "2026-05-27" +last_activity: 2026-05-27 -- Phase 4 confirmed complete (04-00 through 04-07 + payments) progress: total_phases: 5 - completed_phases: 3 + completed_phases: 4 total_plans: 17 - completed_plans: 13 - percent: 76 + completed_plans: 17 + percent: 100 --- # Project State @@ -21,75 +21,51 @@ progress: See: .planning/PROJECT.md (updated 2026-05-09) **Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti. -**Current focus:** Phase 03 — service-catalog-quote-builder ## Current Position -Phase: 4 -Plan: Not started -Status: Ready to execute -Last activity: 2026-05-21 -- Phase 4 planning complete +Phase: 4 — COMPLETE +Plan: All plans done (04-00 through 04-07) +Status: Production deployed on hub.iamcavalli.net -Progress: [██░░░░░░░░] 25% +## What Was Built (Phase 4) -## Performance Metrics +- **04-00**: Infra — route /c/ → /client/, Dockerfile, deploy Gitea → Coolify +- **04-01**: DB schema migration — projects, settings, FK pivot +- **04-02**: Admin projects list + client detail project cards +- **04-03**: Project workspace + timer analytics + settings page +- **04-04**: Slug resolution + multi-project client dashboard + slug edit +- **04-05/06**: Post-deploy bug fixes +- **04-07**: Debug code removed, public page restored +- **security**: Full hardening — auth guards, rate limiting, security headers +- **payments**: Init payments + split payment in project workspace -**Velocity:** +## Production State -- Total plans completed: 13 -- Average duration: ~1 session each -- Total execution time: ~2 sessions (May 13–14) +- App: hub.iamcavalli.net (Coolify, Hetzner) +- DB: Postgres self-hosted on Coolify +- Auth: Auth.js (admin) + token middleware (clients) +- Routes: /admin/* (admin), /client/[token]/* (clients) -**By Phase:** +## Decisions (All Phases) -| Phase | Plans | Total | Avg/Plan | -|-------|-------|-------|----------| -| 1. Foundation & Client Dashboard | 5 | 2 sessions | ~0.4 sessions | -| 02 | 4 | - | - | -| 03 | 4 | - | - | - -**Recent Trend:** - -- Last 5 plans: 01-01, 01-02, 01-03, 01-04, 01-05 -- Trend: Steady, one blocker fixed mid-execution (Tailwind scanning external projects) - -*Updated after each plan completion* - -## Accumulated Context - -### Decisions - -Decisions are logged in PROJECT.md Key Decisions table. -Recent decisions affecting current work: - -- Phase 1: `clients.token` è campo separato (non la PK) — rotazionabile via single UPDATE -- Phase 1: `clients.accepted_total` denormalizzato — client API non tocca mai `quote_items` -- Phase 1: `deliverables.approved_at` immutabile — audit trail dal giorno uno -- Phase 1: Edge middleware (`proxy.ts`) usa fetch() a route interna — postgres-js non può girare nell'Edge runtime -- Phase 1: Tailwind v4 auto-detection allargata — aggiunto `@source not` per escludere `.01_projects/` e `.claude/` -- Phase 1: DNS `welcomeclient.iamcavalli.net` → PENDING (richiede Vercel deploy prima) - -### Pending Todos - -- [ ] Vercel deploy: `vercel --prod` e aggiunta dominio `welcomeclient.iamcavalli.net` -- [ ] DNS CNAME: `welcomeclient → cname.vercel-dns.com` al registrar `iamcavalli.net` -- [ ] `DATABASE_URL` env var in Vercel project settings - -### Blockers/Concerns - -None. +- `clients.token` è campo separato (non la PK) — rotazionabile via single UPDATE +- `clients.accepted_total` denormalizzato — client API non tocca mai `quote_items` +- `deliverables.approved_at` immutabile — audit trail dal giorno uno +- Edge middleware (`proxy.ts`) usa fetch() a route interna — postgres-js non può girare nell'Edge runtime +- Tailwind v4 auto-detection allargata — aggiunto `@source not` per escludere `.01_projects/` e `.claude/` +- Authelia va solo davanti ad /admin — /client/[token] deve bypassare per accesso via link token ## Deferred Items -| Category | Item | Status | Deferred At | -|----------|------|--------|-------------| -| v2 | Claude AI onboarding (CLAUDE-01, CLAUDE-02, CLAUDE-03) | Phase 4 | Roadmap init | -| Post-Phase 1 | DNS CNAME + Vercel deploy | Before prod launch | 2026-05-14 | +| Category | Item | Status | +|----------|------|--------| +| v2 | Claude AI onboarding (CLAUDE-01, CLAUDE-02, CLAUDE-03) | Backlog | +| Infrastructure | Authelia SSO davanti ad /admin | After Phase 4 (now ready) | +| Infrastructure | Higgsfield clone deploy (nuovo progetto Coolify) | Pending | +| SEO | WordPress auto-publish articoli | Mentioned, not planned | ## Session Continuity -Last session: 2026-05-14 -Stopped at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404) -Resume with: `/gsd-plan-phase 2` — Admin Area & Interactive Features - - +Last session: 2026-05-27 +All Phase 4 plans complete. Next: Authelia infra or new feature scope. diff --git a/.planning/phases/05-offer-system/05-01-PLAN.md b/.planning/phases/05-offer-system/05-01-PLAN.md new file mode 100644 index 0000000..b9be6f5 --- /dev/null +++ b/.planning/phases/05-offer-system/05-01-PLAN.md @@ -0,0 +1,290 @@ +--- +plan_id: 05-01 +phase: 5 +wave: 1 +title: "Schema migration — 5 new offer tables + Drizzle relations" +type: execute +depends_on: [] +files_modified: + - src/db/schema.ts +requirements_addressed: [OFFER-01, OFFER-02, OFFER-03, OFFER-04] +autonomous: true +must_haves: + truths: + - "Five new tables exist in the database: offer_macros, offer_micros, offer_services, offer_micro_services, project_offers" + - "No existing table (clients, projects, payments, phases) is modified, dropped, or truncated" + - "Drizzle relations are defined for all new tables" + - "TypeScript types are exported for all new tables" + artifacts: + - path: "src/db/schema.ts" + provides: "Five new pgTable definitions appended after existing tables" + contains: "offer_macros, offer_micros, offer_services, offer_micro_services, project_offers" + key_links: + - from: "src/db/schema.ts" + to: "Postgres DB" + via: "npx drizzle-kit push" + pattern: "offer_macros|offer_micros|offer_services|offer_micro_services|project_offers" +--- + + +Add five new tables for the Offer System to the Drizzle schema and push to the database. + +Purpose: Every subsequent plan in Phase 5 reads from or writes to these tables. This plan is the blocking dependency for all other Phase 5 work. +Output: Updated `src/db/schema.ts` with five new table definitions, Drizzle relations, exported TypeScript types, and a successful `drizzle-kit push`. + + + +@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md +@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md + + + +@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md +@/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md +@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-RESEARCH.md + + + + + + Task 1: Append five new table definitions and relations to schema.ts + src/db/schema.ts + + + - src/db/schema.ts — read the FULL file before touching it (existing tables, import block, relations block, TypeScript types block) + + + +Open `src/db/schema.ts`. At the top of the file, the existing import from `"drizzle-orm/pg-core"` is: + +```typescript +import { + pgTable, + text, + integer, + numeric, + timestamp, + boolean, +} from "drizzle-orm/pg-core"; +``` + +Add `primaryKey, date` to this import (needed for `offer_micro_services` composite PK and `project_offers.start_date`). + +Append the following five table definitions AFTER the `settings` table definition and BEFORE the `// ============ RELATIONS ============` section. Use the exact column names from the research (not the phase_scope variant — research is authoritative): + +```typescript +// ============ OFFER MACROS ============ +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(), +}); + +// ============ OFFER MICROS ============ +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), +}); + +// ============ OFFER SERVICES (distinct from service_catalog — marketing/transformation semantics) ============ +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), +}); + +// ============ OFFER MICRO SERVICES (junction: offer_micros <-> offer_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 OFFERS (assignment of micro-offer to project) ============ +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" }), + // NOT NULL with defaultNow — required for forecast computation (null start_date breaks forecast) + start_date: timestamp("start_date", { withTimezone: true }).notNull().defaultNow(), + // Offer-level accepted total — separate from projects.accepted_total (quote builder total) + accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); +``` + +Then add Drizzle relations at the end of the `// ============ RELATIONS ============` section (after `serviceCatalogRelations`): + +```typescript +export const offerMacrosRelations = relations(offer_macros, ({ many }) => ({ + micros: many(offer_micros), +})); + +export const offerMicrosRelations = relations(offer_micros, ({ one, many }) => ({ + macro: one(offer_macros, { fields: [offer_micros.macro_id], references: [offer_macros.id] }), + services: many(offer_micro_services), + projectOffers: many(project_offers), +})); + +export const offerServicesRelations = relations(offer_services, ({ many }) => ({ + microAssignments: many(offer_micro_services), +})); + +export const offerMicroServicesRelations = relations(offer_micro_services, ({ one }) => ({ + micro: one(offer_micros, { fields: [offer_micro_services.micro_id], references: [offer_micros.id] }), + service: one(offer_services, { fields: [offer_micro_services.service_id], references: [offer_services.id] }), +})); + +export const projectOffersRelations = relations(project_offers, ({ one }) => ({ + project: one(projects, { fields: [project_offers.project_id], references: [projects.id] }), + micro: one(offer_micros, { fields: [project_offers.micro_id], references: [offer_micros.id] }), +})); +``` + +Also add the new tables to `projectsRelations`: +```typescript +// Add to the existing projectsRelations many() block: +projectOffers: many(project_offers), +``` + +Finally, append TypeScript types at the end of the `// ============ TYPESCRIPT TYPES ============` section: + +```typescript +export type OfferMacro = typeof offer_macros.$inferSelect; +export type NewOfferMacro = typeof offer_macros.$inferInsert; +export type OfferMicro = typeof offer_micros.$inferSelect; +export type NewOfferMicro = typeof offer_micros.$inferInsert; +export type OfferService = typeof offer_services.$inferSelect; +export type NewOfferService = typeof offer_services.$inferInsert; +export type OfferMicroService = typeof offer_micro_services.$inferSelect; +export type NewOfferMicroService = typeof offer_micro_services.$inferInsert; +export type ProjectOffer = typeof project_offers.$inferSelect; +export type NewProjectOffer = typeof project_offers.$inferInsert; +``` + +**Data Safety check before push:** Confirm the migration only adds tables — grep the generated SQL for DROP or TRUNCATE before accepting. + + + + cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 + + + + - `grep -c "offer_macros\|offer_micros\|offer_services\|offer_micro_services\|project_offers" src/db/schema.ts` returns 5 or more (table definitions present) + - `npx tsc --noEmit` exits 0 (no TypeScript errors) + - `grep "primaryKey" src/db/schema.ts` matches (composite PK import present) + - `grep "onDelete.*restrict" src/db/schema.ts` matches (project_offers.micro_id FK is restrict, not cascade) + + + schema.ts compiles without errors; five new table definitions present; relations defined; TypeScript types exported + + + + Task 2: Run drizzle-kit push — create tables in database + src/db/schema.ts + + + - src/db/schema.ts — verify Task 1 output before pushing + - .env (existence check only — do NOT log contents) + + + +Run the migration command. Before accepting the push, read the SQL preview that drizzle-kit outputs and verify it contains only CREATE TABLE statements — NO DROP TABLE, NO ALTER TABLE DROP COLUMN, NO TRUNCATE. + +```bash +npx drizzle-kit push +``` + +When drizzle-kit shows the diff, confirm only additions are shown. If any destructive statement appears, ABORT and report to the user immediately. + +Expected tables created: +1. `offer_macros` +2. `offer_micros` +3. `offer_services` +4. `offer_micro_services` (composite PK: micro_id + service_id) +5. `project_offers` + +If push fails with "relation does not exist", the definition order in schema.ts is wrong. Fix order: `offer_macros` → `offer_micros` → `offer_services` → `offer_micro_services` → `project_offers` (each references only tables defined before it). + + + + 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 drizzle-kit push` exits without error + - No DROP TABLE or TRUNCATE appears in the migration output + - All five table names appear in the drizzle-kit push output as CREATE TABLE operations + + + All five tables created in database; no existing data modified + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| schema.ts → DB | Schema changes are applied via drizzle-kit push — only additive changes allowed (CLAUDE.md Data Safety) | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-05-01 | Tampering | drizzle-kit push | mitigate | Read migration preview before accepting; abort if DROP TABLE or TRUNCATE appears | +| T-05-02 | Tampering | project_offers.micro_id FK | mitigate | Use `onDelete: "restrict"` — prevents silent history destruction when a micro-offer is deleted while assigned to projects | + + + +After both tasks complete: +- `grep -c "offer_macros" src/db/schema.ts` → at least 3 (table def + relation + type) +- `grep "primaryKey" src/db/schema.ts` → matches (composite PK import added) +- `npx tsc --noEmit` → exits 0 +- DB tables exist (confirmed by drizzle-kit push output) + + + +1. Five new tables in database: offer_macros, offer_micros, offer_services, offer_micro_services, project_offers +2. Zero modifications to existing tables (clients, projects, payments, phases untouched) +3. TypeScript compiles without errors +4. Drizzle relations defined for all five tables + + + +After completion, create `.planning/phases/05-offer-system/05-01-SUMMARY.md` using the summary template. + diff --git a/.planning/phases/05-offer-system/05-02-PLAN.md b/.planning/phases/05-offer-system/05-02-PLAN.md new file mode 100644 index 0000000..3e835ea --- /dev/null +++ b/.planning/phases/05-offer-system/05-02-PLAN.md @@ -0,0 +1,713 @@ +--- +plan_id: 05-02 +phase: 5 +wave: 2 +title: "Offer catalog admin CRUD — /admin/offers (macro + micro + services + multi-select assignment)" +type: execute +depends_on: [05-01] +files_modified: + - src/app/admin/offers/page.tsx + - src/app/admin/offers/actions.ts + - src/lib/offer-queries.ts + - src/components/admin/NavBar.tsx +requirements_addressed: [OFFER-01, OFFER-02, OFFER-03] +autonomous: true +must_haves: + truths: + - "Admin can create a macro-offer with internal_name, public_name, transformation_promise" + - "Admin can create a micro-offer as a child of a macro, with internal_name, public_name, transformation_promise, duration_months" + - "Admin can create offer services with name, price, transformation_description" + - "Admin can assign services to a micro-offer via a checkbox list (many-to-many)" + - "NavBar has an 'Offerte' link pointing to /admin/offers" + artifacts: + - path: "src/app/admin/offers/page.tsx" + provides: "RSC page listing macros, their micros, and offer services" + contains: "OfferMacroForm, OfferServiceForm" + - path: "src/app/admin/offers/actions.ts" + provides: "Server actions for all offer catalog mutations" + contains: "createMacro, createMicro, createOfferService, updateMicroServices" + - path: "src/lib/offer-queries.ts" + provides: "Read queries for offer catalog" + contains: "getCatalogWithMicros, getAllOfferServices" + - path: "src/components/admin/NavBar.tsx" + provides: "NavBar with Offerte link" + contains: "/admin/offers" + key_links: + - from: "src/app/admin/offers/page.tsx" + to: "src/lib/offer-queries.ts" + via: "getCatalogWithMicros() server import" + pattern: "getCatalogWithMicros" + - from: "src/app/admin/offers/actions.ts" + to: "src/db/schema.ts" + via: "offer_macros, offer_micros, offer_services, offer_micro_services imports" + pattern: "offer_macros|offer_micro_services" +--- + + +Build the full admin offer catalog at `/admin/offers`: create/list macro-offers, create/list micro-offers (children of macros), create/list offer services, and assign services to micro-offers via a checkbox list. + +Purpose: This is the data entry point for the entire offer system. Without catalog data, Plans 03 and 04 have nothing to assign or display. +Output: `/admin/offers` page fully functional; server actions for all mutations; query layer in `offer-queries.ts`; NavBar updated. + + + +@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md +@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md + + + +@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md +@/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md +@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-RESEARCH.md +@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-01-SUMMARY.md + + + +```typescript +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), +}); + +export const offer_micro_services = pgTable("offer_micro_services", { + micro_id: text("micro_id").notNull(), + service_id: text("service_id").notNull(), +}, (t) => ({ pk: primaryKey({ columns: [t.micro_id, t.service_id] }) })); + +export type OfferMacro = typeof offer_macros.$inferSelect; +export type OfferMicro = typeof offer_micros.$inferSelect; +export type OfferService = typeof offer_services.$inferSelect; +``` + + +```typescript +"use server"; +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} +// All actions: call requireAdmin() first, then zod parse, then db mutation, then revalidatePath("/admin/offers") +``` + + +```typescript +// Add between Catalogo and Impostazioni: + + Offerte + +``` + + + + + + + Task 1: offer-queries.ts + server actions (actions.ts) + + src/lib/offer-queries.ts + src/app/admin/offers/actions.ts + + + + - src/db/schema.ts — confirm column names for offer_macros, offer_micros, offer_services, offer_micro_services (after 05-01) + - src/app/admin/catalog/actions.ts — copy requireAdmin() pattern, zod schema pattern, revalidatePath usage + - src/lib/admin-queries.ts — copy import style (db, eq, inArray, asc, sql from drizzle-orm) + + + +**Create `src/lib/offer-queries.ts`:** + +```typescript +import { db } from "@/db"; +import { + offer_macros, offer_micros, offer_services, offer_micro_services, +} from "@/db/schema"; +import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema"; +import { eq, asc, inArray, sql } from "drizzle-orm"; + +export type MicroWithServices = OfferMicro & { + services: Array<{ id: string; name: string; price: string }>; + cumulative_price: string; +}; + +export type MacroWithMicros = OfferMacro & { + micros: MicroWithServices[]; +}; + +export async function getCatalogWithMicros(): Promise { + const macros = await db + .select() + .from(offer_macros) + .orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at)); + + if (macros.length === 0) return []; + + const macroIds = macros.map((m) => m.id); + + const micros = await db + .select() + .from(offer_micros) + .where(inArray(offer_micros.macro_id, macroIds)) + .orderBy(asc(offer_micros.sort_order)); + + const microIds = micros.map((m) => m.id); + + if (microIds.length === 0) { + return macros.map((m) => ({ ...m, micros: [] })); + } + + // Fetch junction rows + service data in one query + const assignments = await db + .select({ + micro_id: offer_micro_services.micro_id, + service_id: offer_services.id, + name: offer_services.name, + price: offer_services.price, + }) + .from(offer_micro_services) + .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) + .where(inArray(offer_micro_services.micro_id, microIds)); + + // Cumulative price per micro + const cumulRows = 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); + + const cumulMap = new Map(cumulRows.map((r) => [r.micro_id, r.cumulative_price])); + + const microsWithServices: MicroWithServices[] = micros.map((micro) => ({ + ...micro, + services: assignments + .filter((a) => a.micro_id === micro.id) + .map((a) => ({ id: a.service_id, name: a.name, price: String(a.price) })), + cumulative_price: cumulMap.get(micro.id) ?? "0", + })); + + return macros.map((macro) => ({ + ...macro, + micros: microsWithServices.filter((m) => m.macro_id === macro.id), + })); +} + +export async function getAllOfferServices(): Promise { + return db + .select() + .from(offer_services) + .where(eq(offer_services.active, true)) + .orderBy(asc(offer_services.name)); +} + +// Returns assigned service IDs for a given micro-offer (used by ServiceCheckboxList) +export async function getMicroAssignedServiceIds(microId: string): Promise { + const rows = await db + .select({ service_id: offer_micro_services.service_id }) + .from(offer_micro_services) + .where(eq(offer_micro_services.micro_id, microId)); + return rows.map((r) => r.service_id); +} +``` + +**Create `src/app/admin/offers/actions.ts`:** + +```typescript +"use server"; + +import { db } from "@/db"; +import { + offer_macros, offer_micros, offer_services, offer_micro_services, +} from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { eq, inArray } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} + +// ── Macro-offer CRUD ───────────────────────────────────────────────────────── + +const macroSchema = z.object({ + internal_name: z.string().min(1, "Nome interno richiesto"), + public_name: z.string().min(1, "Nome pubblico richiesto"), + transformation_promise: z.string().optional(), +}); + +export async function createMacro(formData: FormData) { + await requireAdmin(); + const parsed = macroSchema.safeParse({ + internal_name: formData.get("internal_name"), + public_name: formData.get("public_name"), + transformation_promise: formData.get("transformation_promise") ?? "", + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.insert(offer_macros).values({ + internal_name: parsed.data.internal_name, + public_name: parsed.data.public_name, + transformation_promise: parsed.data.transformation_promise || null, + }); + revalidatePath("/admin/offers"); +} + +export async function deleteMacro(macroId: string) { + await requireAdmin(); + await db.delete(offer_macros).where(eq(offer_macros.id, macroId)); + revalidatePath("/admin/offers"); +} + +// ── Micro-offer CRUD ───────────────────────────────────────────────────────── + +const microSchema = z.object({ + macro_id: z.string().min(1, "Macro offerta richiesta"), + internal_name: z.string().min(1, "Nome interno richiesto"), + public_name: z.string().min(1, "Nome pubblico richiesto"), + transformation_promise: z.string().optional(), + duration_months: z.coerce.number().int().min(1, "Durata minima 1 mese"), +}); + +export async function createMicro(formData: FormData) { + await requireAdmin(); + const parsed = microSchema.safeParse({ + macro_id: formData.get("macro_id"), + internal_name: formData.get("internal_name"), + public_name: formData.get("public_name"), + transformation_promise: formData.get("transformation_promise") ?? "", + duration_months: formData.get("duration_months"), + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.insert(offer_micros).values({ + macro_id: parsed.data.macro_id, + internal_name: parsed.data.internal_name, + public_name: parsed.data.public_name, + transformation_promise: parsed.data.transformation_promise || null, + duration_months: parsed.data.duration_months, + }); + revalidatePath("/admin/offers"); +} + +export async function deleteMicro(microId: string) { + await requireAdmin(); + // offer_micro_services will cascade; project_offers will restrict (DB constraint) + await db.delete(offer_micros).where(eq(offer_micros.id, microId)); + revalidatePath("/admin/offers"); +} + +// ── Offer service CRUD ─────────────────────────────────────────────────────── + +const offerServiceSchema = z.object({ + name: z.string().min(1, "Nome richiesto"), + price: z.coerce.number().min(0, "Prezzo non può essere negativo"), + transformation_description: z.string().optional(), +}); + +export async function createOfferService(formData: FormData) { + await requireAdmin(); + const parsed = offerServiceSchema.safeParse({ + name: formData.get("name"), + price: formData.get("price"), + transformation_description: formData.get("transformation_description") ?? "", + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.insert(offer_services).values({ + name: parsed.data.name, + price: parsed.data.price.toFixed(2), + transformation_description: parsed.data.transformation_description || null, + }); + revalidatePath("/admin/offers"); +} + +export async function toggleOfferServiceActive(serviceId: string, active: boolean) { + await requireAdmin(); + await db.update(offer_services).set({ active }).where(eq(offer_services.id, serviceId)); + revalidatePath("/admin/offers"); +} + +// ── Multi-select service assignment ───────────────────────────────────────── + +// Replaces all assignments for a micro with the provided serviceIds (upsert-replace pattern) +export async function updateMicroOfferServices(microId: string, serviceIds: string[]) { + await requireAdmin(); + // Delete existing assignments for this micro + await db.delete(offer_micro_services).where(eq(offer_micro_services.micro_id, microId)); + // Re-insert with new set (empty array = unassign all) + if (serviceIds.length > 0) { + await db.insert(offer_micro_services).values( + serviceIds.map((serviceId) => ({ micro_id: microId, service_id: serviceId })) + ); + } + revalidatePath("/admin/offers"); +} +``` + + + + cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 + + + + - `npx tsc --noEmit` exits 0 + - `grep -c "requireAdmin" src/app/admin/offers/actions.ts` returns 5 or more (every exported action calls it) + - `grep "revalidatePath" src/app/admin/offers/actions.ts` returns at least 5 matches (every mutating action revalidates) + - `grep "getCatalogWithMicros\|getAllOfferServices\|getMicroAssignedServiceIds" src/lib/offer-queries.ts` returns 3 matches + + + offer-queries.ts and actions.ts created; TypeScript compiles; every action guards with requireAdmin() + + + + Task 2: /admin/offers RSC page + NavBar update + + src/app/admin/offers/page.tsx + src/components/admin/NavBar.tsx + + + + - src/app/admin/catalog/page.tsx — read for the admin page structure pattern (RSC, revalidate = 0, form patterns) + - src/components/admin/NavBar.tsx — read full file before editing (must preserve all existing links) + - src/lib/offer-queries.ts — confirm getCatalogWithMicros and getAllOfferServices signatures (just created in Task 1) + - src/app/admin/offers/actions.ts — confirm action names (just created in Task 1) + + + +**Create `src/app/admin/offers/page.tsx`:** + +This is an RSC page. No `"use client"` at the top level. Inline client sub-components that need interactivity are permissible as separate files or as local `"use client"` components in the same file using the pattern established by existing admin pages. + +The page has three sections: +1. **Macro-offers** — list with "create" form inline; each macro shows its micro-offer children +2. **Micro-offers** per macro — create form inside each macro card; each micro shows assigned services and a `ServiceCheckboxList` +3. **Offer Services catalog** — list all offer services + create form at bottom + +For `ServiceCheckboxList`: this is a `"use client"` component. Create it as a named export in the same page file or as a separate file at `src/components/admin/offers/ServiceCheckboxList.tsx`. It uses `useState(Set)` + `useTransition` + `router.refresh()` pattern from the research (Pattern 3). + +```typescript +// src/components/admin/offers/ServiceCheckboxList.tsx +"use client"; +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { updateMicroOfferServices } from "@/app/admin/offers/actions"; + +export function ServiceCheckboxList({ + allServices, + assignedIds, + microId, +}: { + allServices: Array<{ id: string; name: string; price: string }>; + assignedIds: string[]; + microId: string; +}) { + const [selected, setSelected] = useState(new Set(assignedIds)); + const [isPending, 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) => ( + + ))} + {allServices.length === 0 && ( +

Nessun servizio nel catalogo ancora.

+ )} +
+ ); +} +``` + +**Page structure for `src/app/admin/offers/page.tsx`:** + +```typescript +import { getCatalogWithMicros, getAllOfferServices } from "@/lib/offer-queries"; +import { createMacro, createMicro, createOfferService, deleteMacro, deleteMicro, toggleOfferServiceActive } from "./actions"; +import { ServiceCheckboxList } from "@/components/admin/offers/ServiceCheckboxList"; + +export const revalidate = 0; + +export default async function OffersPage() { + const [catalog, allServices] = await Promise.all([ + getCatalogWithMicros(), + getAllOfferServices(), + ]); + + return ( +
+

Catalogo Offerte

+ + {/* ── Sezione macro-offerte ── */} +
+

Macro-Offerte

+ + {/* Create macro form */} +
+

Nuova Macro-Offerta

+ + +