From d1b104736814a47cd2f1ef305dde016552dfbf1c Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sat, 13 Jun 2026 15:07:17 +0200 Subject: [PATCH] =?UTF-8?q?docs(11):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=204=20plans,=204=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalog Database-View UX & Legacy Consolidation (OFFER-07/08/09/10/13) - 11-01: tags table + polymorphic junction + additive legacy migration - 11-02: getAllServices tag join + inline-edit/tag/quick-add server actions - 11-03: EditableCell + TagMultiSelect components - 11-04: database-view ServiceTable + client-side search Verified by gsd-plan-checker (VERIFICATION PASSED). Co-Authored-By: Claude Opus 4.8 --- .planning/STATE.md | 232 ++------ .../11-01-PLAN.md | 462 +++++++++++++++ .../11-02-PLAN.md | 424 ++++++++++++++ .../11-03-PLAN.md | 533 +++++++++++++++++ .../11-04-PLAN.md | 541 ++++++++++++++++++ 5 files changed, 2016 insertions(+), 176 deletions(-) create mode 100644 .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-01-PLAN.md create mode 100644 .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-02-PLAN.md create mode 100644 .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-03-PLAN.md create mode 100644 .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-04-PLAN.md diff --git a/.planning/STATE.md b/.planning/STATE.md index f27c793..ae9314b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,215 +1,95 @@ --- gsd_state_version: 1.0 -milestone: v2.0 -milestone_name: Business Operations Suite +milestone: v2.1 +milestone_name: milestone status: executing -last_updated: "2026-06-11T19:00:00Z" -last_activity: 2026-06-11 +stopped_at: Phase 11 context gathered +last_updated: "2026-06-13T13:00:38.635Z" +last_activity: 2026-06-13 -- Phase 11 planning complete progress: - total_phases: 5 - completed_phases: 4 - total_plans: 11 - completed_plans: 11 - percent: 80 + total_phases: 11 + completed_phases: 8 + total_plans: 37 + completed_plans: 30 + percent: 81 --- # Project State ## Project Reference -See: .planning/PROJECT.md (updated 2026-05-09) +See: .planning/PROJECT.md (updated 2026-06-13) **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 11 — Catalog Database-View UX & Legacy Consolidation + ## Current Position -Phase: 10-crm-pipeline-activity-logging (redo complete, deployed) -Plans: 07 (complete) | 08 (complete) | 09-01..03 (complete) | 10-01..03 (complete, redeployed 2026-06-11) -Status: Phase 7 ✓ | Phase 8 ✓ | Phase 9 ✓ | Phase 10 ✓ (staged redo after rollback) -Last activity: 2026-06-11T19:00:00Z — Phase 10 staged redo deployed; production DB migrations 0001/0003/0004/0005 applied +Phase: 11 of 17 (Catalog Database-View UX & Legacy Consolidation) +Plan: — (not yet planned) +Status: Ready to execute +Last activity: 2026-06-13 -- Phase 11 planning complete -## What Was Built (Phase 10 — CRM Pipeline & Activity Logging, redo) +Progress: [░░░░░░░░░░] 0% -### Root cause of the original Phase 10 production crash (resolved) +## Performance Metrics -Production Postgres (Coolify container) had **no migrations applied after 0000** — `services`, `leads`, `offer_phases`, `offer_phase_services`, `quotes` did not exist. Phase 7/8/9 summaries said "migration pending connectivity" and were never followed up. Any page querying those tables 500'd: catalog (services), quote builder + /quote/[token] (quotes), and after Phase 10 deploy also dashboard (FollowUpWidget → leads). The Phase 10 rollback only masked the dashboard symptom. +**Velocity:** -**Fix (2026-06-11):** migrations 0001+0003+0004+0005 applied atomically (single transaction, additive-only, verified zero DROP/TRUNCATE) via SSH → docker exec psql on container `xwkk0040w0kk0gsgcgog8owk`, db `clienthub`. Protected rows verified intact post-migration (4 clients, 5 projects, 13 payments, 6 phases). `/quote/[token]` 500 → 404 (correct behavior) confirmed. +- Total plans completed: 0 (v2.1) +- Average duration: — +- Total execution time: — -### Delivered (redo commits 5aa6614 + 008a434) +**By Phase:** -- **Stage A** (`5aa6614`): deps `@radix-ui/react-dialog`, `date-fns` + shared `ui/dialog.tsx`, `ui/form.tsx` -- **Stage B**: production DB schema aligned (see above) — script `scripts/push-phase10-migration.ts` for future reuse -- **Stage C** (`008a434`): schema.ts CRM tables (leads expansion, activities, reminders + relations), lead-service/lead-validators, `/admin/leads` list+detail+actions, LeadTable/LeadDetail/LeadForm, LogActivityModal, SendQuoteModal, FollowUpWidget on dashboard, sidebar link +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| - | - | - | - | -## What Was Built (Phase 9 — Quote Builder UI & Public Pages) +**Recent Trend:** -### 09-01: Quote Validators and Service Layer ✓ +- Last 5 plans: — +- Trend: — -- **Validators:** Zod schemas for quote creation (client_id, offer_micro_id, accepted_total) -- **Service Layer:** Public/admin query separation; token-gated access without exposing line items -- **Immutability Guard:** isQuoteAccepted() prevents double-accept of quotes -- **Migration:** 0004_phase-9-quotes-validators.sql (additive-only, backward compatible) -- **Status:** DONE — Schema and validators tested, ready for UI integration +*Updated after each plan completion* -### 09-02: Admin Quote Builder UI ✓ +## Accumulated Context -- **Components:** 4 new components in src/components/admin/quotes/ - - **QuoteBuilderForm.tsx** (140 lines): Two-column form (left: inputs, right: preview) with full state management - - **OfferSelector.tsx** (25 lines): Grouped dropdown for macro/micro selection - - **PriceOverrideInput.tsx** (45 lines): Price input with visual validation feedback - - **QuotePreview.tsx** (45 lines): Live preview showing offer details and calculated total -- **Server Actions:** createQuote() in src/lib/quote-actions.ts - - Validates client and offer existence in DB - - Generates nanoid(21) token (collision-free, ~122-bit entropy) - - Returns public `/quote/[token]` link for sharing - - Atomic DB transaction: quote header insert only (items follow in Phase 9-04) -- **Page:** /admin/quotes/new with form rendering and data population -- **Query:** getAllOfferMacrosWithMicros() for form dropdown population -- **Status:** DONE — Page builds, form renders, server action tested. Ready for public page (Phase 9-03) +### Decisions -### 09-03: Public Quote Page with Token-Gated Access & Rate Limiting ✓ +Decisions are logged in PROJECT.md Key Decisions table. +Recent decisions affecting current work: -- **Rate Limiting:** Enhanced src/proxy.ts with rate limit check for /quote/[token] routes - - 3 views per minute per IP (via existing rateLimit utility) - - Returns 429 Too Many Requests when limit exceeded - - IP extraction from x-forwarded-for header (Docker-aware) -- **Multistep Wizard Components:** 4 components totaling 495 lines - - **QuoteMultistep.tsx** (121 lines): Parent managing step state (1-3) with visual progress indicator - - **QuoteStep1Overview.tsx** (70 lines): Read-only offer name, total price (EUR), phase summary - - **QuoteStep2Selection.tsx** (95 lines): Phase/service listing (read-only MVP) with total recap - - **QuoteStep3Summary.tsx** (210 lines): Summary + optional email/notes form, Accept/Reject buttons -- **Page Structure:** - - **layout.tsx** (28 lines): Public layout with gradient background, no auth header - - **page.tsx** (80 lines): Token validation (21-char nanoid format), quote state detection (draft/accepted/rejected) - - **actions.ts** (108 lines): acceptQuote() and rejectQuote() server actions with immutability enforcement -- **Security:** quote_items never exposed; only accepted_total and phase summary visible to client -- **Status:** DONE — Route visible in build output, 3 commits, all tests passing (npm run build: 0 errors) +- v2.1 roadmap: Offer Studio (Phases 11-15) sequenced before Proposal AI (Phases 16-17) — clean/fast data UX before the AI builder +- Phase 11 bundles catalog database-view UX (OFFER-07..10) with legacy consolidation (OFFER-13) since the new UX should be built on a single unified `services` table, not on top of legacy `service_catalog`/`offer_services` +- Phase 13 (Workspace — Servizi Attivi) is independent of Phases 11/12 — can execute in parallel order if useful, but numbered after for narrative flow +- Phase 15 (Dashboard Revenue Stats / DASH-11) is isolated and BLOCKED on user-provided mockup; no other phase depends on it — can be deferred/skipped without blocking Phase 16/17 +- Phase 16/17 split: schema/automation (payment link field + auto-provisioning) first, then AI builder + public page redesign + email — keeps the AI-dependent work last -## What Was Built (Phase 8 — Quote Builder Schema Foundation) ✓ +### Pending Todos -### 08-01: Offer Phases & Quote Tables Schema ✓ +[From .planning/todos/pending/ — ideas captured during sessions] -- **Schema:** 4 new tables (offer_phases, offer_phase_services, quotes, leads) + 7 columns added to existing tables -- **offer_phases:** Hierarchical breakdown of offer_micros (Discovery → Strategy → Execution phases) -- **offer_phase_services:** Junction linking phases to unified services table (Phase 7) -- **quotes:** Separate quote headers from line items with token-based public access -- **Extensions:** quote_items (quote_id, offer_micro_id, offer_phase_id), projects (offer_id, created_from_lead_id), phases (offer_phase_id) -- **TypeScript:** OfferPhase, OfferPhaseService, Quote, Lead types exported -- **Drizzle Relations:** 6 new relations for proper ORM traversal -- **Status:** Schema complete, migration script ready, validation checks implemented -- **Data Safety:** Additive-only migration (no drops, no truncates) — fully reversible +None yet. -### 08-02: Migration & Validation Scripts ✓ +### Blockers/Concerns -- **Migration:** src/db/migrations/0003_offer_phases_quote_templates.sql (89 lines, additive-only) -- **Indexes:** 11 created for performance (micro_id, service_id, token, client_id, status, etc.) -- **Validation:** scripts/validate-phase8-migration.ts with 10 checks (all 4 tables + 6 columns verified) -- **Status:** READY FOR DEPLOYMENT when Postgres is reachable - -### 08-03: Query Layer for Quote Builder ✓ - -- **Query Functions:** 5 new async functions added to admin-queries.ts - - getOfferPhases(microId) — get all phases for an offer micro - - getOfferPhaseServices(phaseId) — get all services assigned to a phase - - getQuoteById(quoteId) — full quote header by ID - - getQuoteByToken(token) — quote by public token (for /quote/[token]) - - getQuotesByClient(clientId) — all quotes for a client -- **Build Status:** npm run build passes with zero TypeScript errors -- **Ready for:** Phase 9 quote builder UI, public quote pages - -## What Was Built (Phase 7 — v2.0) - -### 07-01: Unified Service Catalog (Expand Phase) ✓ - -- **Schema:** `services` table with migrated_from/migrated_id audit trail for rollback safety -- **TypeScript:** Service and NewService types exported -- **Backfill:** Idempotent migration script (21 service_catalog rows + 35 offer_services rows → services table) -- **Collision Resolution:** ' (Offer)' suffix prevents silent overwrites of duplicate names -- **Validation:** 6-check suite proves zero data loss (row counts, FK integrity, orphaned references, net-new services count) -- **Status:** Code complete; database migration pending connectivity (schema, scripts ready to apply) -- **Data Safety:** Legacy tables (service_catalog, offer_services) untouched — rollback possible by not using services table - -### 07-02: Admin Catalog CRUD + Quote Builder Rewire ✓ - -- **Query Layer:** getAllServices() and activeServices queries updated to read from unified `services` table -- **Admin CRUD:** `/admin/catalog` list/add/edit/soft-delete fully operational on `services` table with category field -- **Service Form:** Added optional category input field for new/edited services -- **Service Table:** Category column rendering; net-new services tracked with migrated_from=null -- **Quote Builder:** QuoteTab service picker reads from `services` via activeServices -- **Backward Compatibility:** Historical quote_items → service_catalog join preserved for existing quote item labels -- **Type Safety:** All components (ServiceTable, ServiceForm, QuoteTab) typed against Service instead of ServiceCatalog -- **Status:** Code complete, TypeScript verified (npm run build passes), manual testing documented -- **ROADMAP Success Criteria:** #3 (/admin/catalog operational on services) and #4 (old catalog queries explicit) satisfied - -## What Was Built (Phase 5) - -- **05-01**: Schema migration — 5 nuove tabelle offer in DB + tipi TypeScript -- **05-02**: `/admin/offers` CRUD — macro, micro, servizi + assegnazione checkbox -- **05-03**: OffersTab nel workspace progetto + `getClientActiveOffers()` + sezione Offerte Attive nel dettaglio cliente -- **05-04**: `OffersSection` nella dashboard cliente (solo `public_name`) + `/admin/forecast` 12 mesi - -## What Was Built (Phase 4) - -- **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 - -## Production State - -- 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) - -## Decisions (All Phases) - -- `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 +- **Phase 15 (DASH-11)**: Blocked pending dashboard mockup from user. Do not start until mockup provided; safe to skip in execution order without impacting Phase 16/17. +- **Migrations**: Per CLAUDE.md, every phase touching schema (11 legacy consolidation, 13 recurring-revenue tracking, 16 payment link + provisioning) MUST have its migration applied to prod via SSH+docker exec BEFORE pushing dependent code. ## Deferred Items -| 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 | +Items acknowledged and carried forward from previous milestone close: + +| Category | Item | Status | Deferred At | +|----------|------|--------|-------------| +| v2 | OFFER-14 — Sezioni analitiche stile Notion (psicologia/rating/performance) | Backlog | v2.1 kickoff | +| v2 | AUTH-OTP-01 — Accesso dashboard cliente via OTP email | Design ready, deferred | v2.1 kickoff | +| v2 | ARCH-01 — Split modulo "compartimento stagno" in deploy separato | Backlog (only if module grows) | v2.1 kickoff | ## Session Continuity -Last session: 2026-06-11T19:00:00Z -Phase 10 staged redo complete: production DB migrations applied (0001/0003/0004/0005), CRM module deployed. -Dangling work recovered from `phase10-wip` branch (anchor of reflog commit 8e2752a — branch can be deleted once redo verified in production). -Lesson recorded: migrations are applied manually in this project — every deploy touching schema MUST apply the migration to prod BEFORE pushing code. - -## Operator Next Steps - -### Immediate - -- Verify in browser (authenticated): /admin (FollowUpWidget), /admin/leads, /admin/catalog, /admin/quotes/new -- Re-run `/gsd-plan-phase 11` and `/gsd-plan-phase 12` (planning folders never committed, must be regenerated) -- Optionally cherry-pick sidebar "App shortcuts" dangling commit 5d75752 if still wanted - -### Phase 10 (Concurrent Activities) - -- Email integration (Resend) for quote acceptance confirmation -- CRM leads table implementation (currently placeholder) -- Activity logging for quote events (viewed, accepted, rejected) -- Auto-provisioning webhook listener (triggers project creation on acceptance) - -### Phase 11 (Auto-Provisioning) - -- Consume quote acceptance event -- Automatically create project phases from offer_phases structure -- Provision services based on accepted quote items +Last session: 2026-06-13T10:02:08.473Z +Stopped at: Phase 11 context gathered +Resume file: .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md diff --git a/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-01-PLAN.md b/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-01-PLAN.md new file mode 100644 index 0000000..6ed46e0 --- /dev/null +++ b/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-01-PLAN.md @@ -0,0 +1,462 @@ +--- +phase: 11-catalog-database-view-ux-legacy-consolidation +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/db/schema.ts + - src/db/migrations/0002_add_tags_table.sql + - src/db/migrations/meta/_journal.json + - scripts/push-11-tags-migration.ts + - scripts/migrate-tags.ts + - scripts/validate-tags-migration.ts +autonomous: true +requirements: [OFFER-13] + +must_haves: + truths: + - "La tabella `tags` esiste nel DB locale con junction polimorfica (entity_type/entity_id)" + - "Le righe storiche di service_catalog e offer_services sono confluite in services (migrated_from popolato), verificabile via conteggio righe" + - "Le righe migrate da offer_services hanno il tag 'Offerta' assegnato in tags" + - "Nessuna riga di clients/projects/payments/phases/service_catalog/offer_services/offer_micro_services è stata droppata o troncata" + artifacts: + - path: "src/db/schema.ts" + provides: "tags pgTable (id, entity_type, entity_id, name, created_at) + Tag/NewTag types" + contains: "export const tags = pgTable(\"tags\"" + - path: "src/db/migrations/0002_add_tags_table.sql" + provides: "Drizzle-generated CREATE TABLE tags migration" + contains: "CREATE TABLE" + - path: "scripts/push-11-tags-migration.ts" + provides: "Idempotent local migration runner for tags table" + contains: "CREATE TABLE IF NOT EXISTS" + - path: "scripts/migrate-tags.ts" + provides: "Assigns 'Offerta' tag to services rows where migrated_from='offer_services'" + contains: "Offerta" + - path: "scripts/validate-tags-migration.ts" + provides: "Row-count + orphan validation for tags + services consolidation (OFFER-13)" + contains: "ALL CHECKS PASSED" + key_links: + - from: "scripts/migrate-tags.ts" + to: "src/db/schema.ts (tags, services)" + via: "drizzle insert/select on tags + services.migrated_from" + pattern: "migrated_from.*offer_services" + - from: "scripts/push-11-tags-migration.ts" + to: "src/db/migrations/0002_add_tags_table.sql" + via: "reads and executes the generated SQL idempotently" + pattern: "0002_add_tags_table" +--- + + +Add the polymorphic `tags` table to the schema (foundation for OFFER-08, reused by Phase 14 for CRM-09), generate and apply its migration to the local dev database, and complete the additive legacy consolidation (OFFER-13) by running the existing Phase 7 migration/validation scripts plus a new tag-assignment script that marks every service migrated from `offer_services` with the "Offerta" tag (D-02). + +Purpose: This is the schema/data foundation Plans 02-04 build on. Without the `tags` table existing locally, the query layer (Plan 02) and UI (Plans 03-04) cannot be typechecked or tested against a live DB. Without the consolidation scripts running, OFFER-13 ("dati storici confluiti senza perdita") is not satisfied. + +Output: +- `tags` table + Drizzle types in `src/db/schema.ts` +- Generated migration `src/db/migrations/0002_add_tags_table.sql` +- `scripts/push-11-tags-migration.ts` (idempotent, additive-only) +- `scripts/migrate-tags.ts` + `scripts/validate-tags-migration.ts` +- Local dev DB updated: `tags` table exists, `services` rows from `service_catalog`/`offer_services` are present with `migrated_from` set, and `migrated_from='offer_services'` rows carry the "Offerta" tag + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md +@.planning/DESIGN-SYSTEM.md + + + + +```typescript +export const comments = pgTable("comments", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + entity_type: text("entity_type").notNull(), // task | deliverable + entity_id: text("entity_id").notNull(), + author: text("author").notNull(), // client | admin + body: text("body").notNull(), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); +``` + + +```typescript +export const services = pgTable("services", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + name: text("name").notNull(), + description: text("description"), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + category: text("category"), + active: boolean("active").notNull().default(true), + migrated_from: text("migrated_from"), // "service_catalog" | "offer_services" | null + migrated_id: text("migrated_id"), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); +``` + + + + + + + + + + Task 1: Add `tags` table to schema, generate migration, push to local DB [BLOCKING] + + src/db/schema.ts + src/db/migrations/0002_add_tags_table.sql (generated by drizzle-kit) + src/db/migrations/meta/_journal.json (updated by drizzle-kit) + scripts/push-11-tags-migration.ts + + + src/db/schema.ts (existing comments table at lines 100-111 for the polymorphic pattern; services table at lines 183-195 for audit-column conventions; end of file for type-export conventions at lines 567-618) + scripts/push-services-migration.ts (idempotent CREATE TABLE IF NOT EXISTS pattern to replicate) + .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-05, D-06, D-07, D-08) + + + 1. In `src/db/schema.ts`, add a new `tags` table immediately after the `comments` table definition (after line 111), following the exact polymorphic pattern of `comments`. Do NOT use a composite primaryKey for `(entity_type, entity_id, id)` — `id` is already the primary key (text, nanoid default). Instead add a **unique index** on `(entity_type, entity_id, name)` to prevent duplicate tag names per entity, using Drizzle's `uniqueIndex` from `drizzle-orm/pg-core`: + + ```typescript + // ============ TAGS (polymorphic — services now, leads in Phase 14) ============ + // entity_type scopes the tag pool (D-06): "services" tags and "leads" tags are + // separate pools even though they share this table. No `color` column — badge + // color is derived deterministically from `name` via hash (D-07). + export const tags = pgTable( + "tags", + { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + entity_type: text("entity_type").notNull(), // "services" | "leads" (Phase 14) + entity_id: text("entity_id").notNull(), + name: text("name").notNull(), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + entityTagUnique: uniqueIndex("tags_entity_name_unique").on( + t.entity_type, + t.entity_id, + t.name + ), + entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id), + }) + ); + ``` + + 2. Add `uniqueIndex` and `index` to the import list at the top of `src/db/schema.ts` (extend the existing `drizzle-orm/pg-core` import that currently includes `pgTable, text, integer, numeric, timestamp, boolean, primaryKey`). + + 3. Add a `tagsRelations` export near the other polymorphic relation (`commentsRelations` at line ~459-461), following the same "no direct FK — entity_type/entity_id at query time" comment pattern: + + ```typescript + export const tagsRelations = relations(tags, (_) => ({ + // Polymorphic: no direct FK relation — entity_type + entity_id used at query time + })); + ``` + + 4. Add `Tag`/`NewTag` TypeScript types at the end of the file alongside the other type exports (after `export type Comment = ...` / `export type NewComment = ...` around line 579-580): + + ```typescript + export type Tag = typeof tags.$inferSelect; + export type NewTag = typeof tags.$inferInsert; + ``` + + 5. Run `npx drizzle-kit generate` from the project root. This produces `src/db/migrations/0002_add_tags_table.sql` (or the next sequential number — check `src/db/migrations/meta/_journal.json` first; existing entries go up to `0001_add_services_table`, but files 0003/0004/0005 already exist on disk without journal entries — drizzle-kit will pick the next number based on the highest existing migration file, likely `0006_*`. Whatever number drizzle-kit generates, use that exact filename for the push script in step 6). Confirm the generated SQL contains `CREATE TABLE "tags"` with columns `id`, `entity_type`, `entity_id`, `name`, `created_at` and a unique index on `(entity_type, entity_id, name)`. + + 6. Create `scripts/push-11-tags-migration.ts` following the exact idempotent pattern of `scripts/push-services-migration.ts` (postgres client, `process.env.DATABASE_URL`, `CREATE TABLE IF NOT EXISTS`, catches "already exists" and exits 0): + + ```typescript + import postgres from "postgres"; + + async function push() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error("DATABASE_URL environment variable is required"); + process.exit(1); + } + + const client = postgres(databaseUrl); + + try { + console.log("Pushing tags table migration..."); + + await client` + CREATE TABLE IF NOT EXISTS tags ( + id text PRIMARY KEY, + entity_type text NOT NULL, + entity_id text NOT NULL, + name text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL + ) + `; + + await client` + CREATE UNIQUE INDEX IF NOT EXISTS tags_entity_name_unique + ON tags (entity_type, entity_id, name) + `; + + await client` + CREATE INDEX IF NOT EXISTS tags_entity_idx + ON tags (entity_type, entity_id) + `; + + console.log("✓ tags table created successfully"); + process.exit(0); + } catch (err: unknown) { + if (err instanceof Error) { + if (err.message.includes("already exists")) { + console.log("✓ tags table already exists (skipped)"); + process.exit(0); + } + console.error("Error pushing migration:", err.message); + } else { + console.error("Unknown error:", err); + } + process.exit(1); + } + } + + push(); + ``` + + 7. Run `npx tsx scripts/push-11-tags-migration.ts` against the local `DATABASE_URL` (dev DB). This is the [BLOCKING] step — Plan 02's query layer and Plan 04's UI require `tags` to exist locally for typecheck/build to reflect reality. + + 8. **Production migration note (do NOT execute):** Per CLAUDE.md Data Safety and project memory (Gitea→Coolify, prod Postgres only via SSH+docker exec), this migration (`CREATE TABLE tags` + 2 indexes, purely additive) MUST be applied to production manually via SSH+docker exec BEFORE the schema-dependent code (Plans 02-04) is deployed. Add a comment block at the top of `scripts/push-11-tags-migration.ts` documenting this: + + ```typescript + // PRODUCTION DEPLOY NOTE: This migration is additive-only (CREATE TABLE IF NOT EXISTS + + // 2 indexes, no drops/truncates). Per CLAUDE.md Data Safety, apply to production via + // SSH+docker exec BEFORE pushing Phase 11 schema-dependent code (Plans 02-04). + // Run: npx tsx scripts/push-11-tags-migration.ts (with prod DATABASE_URL) + ``` + + + npx tsc --noEmit 2>&1 | grep -i "schema.ts" ; echo "exit: $?" + + + - `grep -c "export const tags = pgTable" src/db/schema.ts` returns `1` + - `grep -c "export type Tag = typeof tags" src/db/schema.ts` returns `1` + - `grep -c "export type NewTag = typeof tags" src/db/schema.ts` returns `1` + - A file matching `src/db/migrations/*_*.sql` newly created by this task contains `CREATE TABLE "tags"` (verify via `grep -l "CREATE TABLE \"tags\"" src/db/migrations/*.sql`) + - `src/db/migrations/meta/_journal.json` contains a new entry for the tags migration (check via `grep -c "tags" src/db/migrations/meta/_journal.json` returns >= 1) + - `grep -c "CREATE TABLE IF NOT EXISTS tags" scripts/push-11-tags-migration.ts` returns `1` + - Running `npx tsx scripts/push-11-tags-migration.ts` exits 0 and prints either "✓ tags table created successfully" or "✓ tags table already exists (skipped)" + - After running the push script, querying `information_schema.tables` for `table_name = 'tags'` returns one row (verify via a one-off `psql` or `postgres` client query against `DATABASE_URL`) + - `npx tsc --noEmit` does not report new errors originating from `src/db/schema.ts` + + + `tags` table + `Tag`/`NewTag` types exist in schema.ts, migration SQL generated, push script created and successfully run against local dev DB — `tags` table physically exists with the unique index on (entity_type, entity_id, name). + + + + + Task 2: Run legacy consolidation (OFFER-13) + assign "Offerta" tag to migrated offer_services rows [BLOCKING] + + scripts/migrate-tags.ts + scripts/validate-tags-migration.ts + + + scripts/migrate-services.ts (existing Phase 7 script — already implements the service_catalog + offer_services -> services row-copy with migrated_from/migrated_id; idempotent, skips already-migrated rows) + scripts/validate-services-migration.ts (existing Phase 7 validation — row counts + orphan checks for services consolidation) + src/db/schema.ts (tags table added in Task 1; services.migrated_from column) + .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-01, D-02, D-04) + + + Per D-01, the additive consolidation of `service_catalog`/`offer_services` into `services` is the row-copy logic already written in Phase 7's `scripts/migrate-services.ts` (with `migrated_from`/`migrated_id` audit columns) — that script was written but its execution was deferred to Phase 11 (see ROADMAP.md Phase 7 status: "consolidamento finale rinviato a v2.1 Phase 11"). This task RUNS that existing script (it is idempotent — already-migrated rows are skipped via the `migrated_from`+`migrated_id` existence check), runs its validator, then adds the new "Offerta" tag-assignment script for D-02. + + 1. Run `npx tsx scripts/migrate-services.ts` against the local dev DB. This copies any not-yet-migrated rows from `service_catalog` and `offer_services` into `services` with `migrated_from`/`migrated_id` set (idempotent — already-migrated rows produce "skipped" output, not duplicates). Capture the console output (inserted/skipped counts) for the SUMMARY. + + 2. Run `npx tsx scripts/validate-services-migration.ts` against the local dev DB. Confirm it prints `ALL CHECKS PASSED` (PASS on: service_catalog fully migrated, offer_services fully migrated, no orphaned migrated_id references, quote_items.service_id -> service_catalog FK intact, offer_micro_services.service_id -> offer_services FK intact). If any check fails, STOP and report — do not proceed to step 3, since OFFER-13 depends on this passing first. + + 3. Create `scripts/migrate-tags.ts` implementing D-02 (assign "Offerta" tag to every `services` row where `migrated_from = 'offer_services'`), following the idempotent pattern of `scripts/migrate-services.ts`: + + ```typescript + import { db } from "@/db"; + import { services, tags } from "@/db/schema"; + import { eq, and } from "drizzle-orm"; + + async function migrate() { + console.log("Starting tags migration: assigning 'Offerta' tag to services migrated from offer_services...\n"); + + const offerServices = await db + .select({ id: services.id }) + .from(services) + .where(eq(services.migrated_from, "offer_services")); + + let taggedCount = 0; + let skippedCount = 0; + + for (const service of offerServices) { + const existing = await db + .select() + .from(tags) + .where( + and( + eq(tags.entity_type, "services"), + eq(tags.entity_id, service.id), + eq(tags.name, "Offerta") + ) + ) + .limit(1); + + if (existing.length > 0) { + skippedCount++; + continue; + } + + await db.insert(tags).values({ + entity_type: "services", + entity_id: service.id, + name: "Offerta", + }); + taggedCount++; + } + + console.log(`Assigned 'Offerta' tag: ${taggedCount} services, ${skippedCount} already tagged`); + console.log("\nMigration complete. Run scripts/validate-tags-migration.ts next."); + process.exit(0); + } + + migrate().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); + }); + ``` + + 4. Create `scripts/validate-tags-migration.ts` implementing the row-count + orphan checks for OFFER-13's tag dimension, following the pattern of `scripts/validate-services-migration.ts`: + + ```typescript + import { db } from "@/db"; + import { services, tags } from "@/db/schema"; + import { eq, and, sql } from "drizzle-orm"; + + async function validate() { + let failures = 0; + + const [offerServicesCount] = await db + .select({ n: sql`count(*)::int` }) + .from(services) + .where(eq(services.migrated_from, "offer_services")); + + const [offerTagCount] = await db + .select({ n: sql`count(*)::int` }) + .from(tags) + .where(and(eq(tags.entity_type, "services"), eq(tags.name, "Offerta"))); + + console.log(`offer_services-derived services: ${offerServicesCount.n} | Offerta tags: ${offerTagCount.n}`); + + if (offerServicesCount.n !== offerTagCount.n) { + console.log("FAIL: Offerta tag count does not match offer_services-derived services count"); + failures++; + } else { + console.log("PASS: all offer_services-derived services have the Offerta tag"); + } + + const orphanedTags = await db.execute(sql` + SELECT COUNT(*)::int AS n FROM tags t + WHERE t.entity_type = 'services' + AND NOT EXISTS (SELECT 1 FROM services s WHERE s.id = t.entity_id) + `); + const orphanedTagsCount = (orphanedTags as unknown as Array<{ n: number }>)[0]?.n ?? 0; + if (orphanedTagsCount > 0) { + console.log(`FAIL: ${orphanedTagsCount} tags reference non-existent services`); + failures++; + } else { + console.log("PASS: no orphaned tag references"); + } + + const tagCounts = await db.execute(sql` + SELECT entity_type, COUNT(*)::int AS n FROM tags GROUP BY entity_type + `); + const counts = tagCounts as unknown as Array<{ entity_type: string; n: number }>; + for (const row of counts) { + console.log(`INFO: ${row.n} tags for entity_type=${row.entity_type}`); + } + + console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`); + process.exit(failures === 0 ? 0 : 1); + } + + validate().catch((err) => { + console.error("Validation failed:", err); + process.exit(1); + }); + ``` + + 5. Run `npx tsx scripts/migrate-tags.ts` then `npx tsx scripts/validate-tags-migration.ts` against the local dev DB. Confirm `ALL CHECKS PASSED`. + + 6. **Deferred low-risk note (per CONTEXT.md "Claude's Discretion"):** `src/lib/admin-queries.ts` (lines ~335/343 and ~531/539) still does `leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))` for the `quote_items` label, but `quote_items.service_id` is typed in `schema.ts` (line ~213-214) as `references(() => services.id, ...)` — i.e. it points at `services.id`, not `service_catalog.id`. For any `quote_items` row created after Phase 8 (where `service_id` references a `services.id`), this JOIN will not match and `label` will fall back to `COALESCE(..., quote_items.custom_label)`, which may be `null` for non-custom items. This is a PRE-EXISTING issue, not introduced by Phase 11, and per CONTEXT.md is explicitly NOT blocking for Phase 11's success criteria. Record this in the plan's SUMMARY.md as a "Known issue — deferred" note: "JOIN `quote_items.service_id` -> `service_catalog.id` in admin-queries.ts (lines ~335/343, ~531/539) is stale post-Phase-8 (FK now points to services.id). Low risk, low frequency (admin-only label display in client/project workspace quote_items list). Recommended fix: change `leftJoin(service_catalog, ...)` to `leftJoin(services, eq(quote_items.service_id, services.id))` and `service_catalog.name` to `services.name` in both COALESCE expressions — trivial 4-line change, candidate for Phase 12 cleanup or a standalone hotfix." Do NOT make this code change in Phase 11 — out of scope per CONTEXT.md. + + + npx tsx scripts/validate-services-migration.ts 2>&1 | tail -1 | grep -c "ALL CHECKS PASSED" && npx tsx scripts/validate-tags-migration.ts 2>&1 | tail -1 | grep -c "ALL CHECKS PASSED" + + + - `npx tsx scripts/migrate-services.ts` exits 0 + - `npx tsx scripts/validate-services-migration.ts` exits 0 and output contains `ALL CHECKS PASSED` + - `grep -c "Offerta" scripts/migrate-tags.ts` returns >= 1 + - `grep -c "migrated_from, \"offer_services\"" scripts/migrate-tags.ts` returns >= 1 (or equivalent `eq(services.migrated_from, "offer_services")`) + - `npx tsx scripts/migrate-tags.ts` exits 0 + - `npx tsx scripts/validate-tags-migration.ts` exits 0 and output contains `ALL CHECKS PASSED` + - A direct query of `tags` where `entity_type='services' AND name='Offerta'` returns a row count equal to the count of `services` where `migrated_from='offer_services'` + - No rows were deleted from `service_catalog`, `offer_services`, `offer_micro_services`, `clients`, `projects`, `payments`, or `phases` (spot-check row counts before/after are identical for these tables) + + + `service_catalog` and `offer_services` rows are fully represented in `services` (migrated_from/migrated_id populated, validated via row-count script), and every `services` row with `migrated_from='offer_services'` has the "Offerta" tag in the new `tags` table. Both validation scripts report `ALL CHECKS PASSED`. The stale `quote_items` <-> `service_catalog` JOIN is documented as a deferred, non-blocking known issue. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|--------------| +| One-off scripts -> Postgres | `scripts/*.ts` run with full `DATABASE_URL` credentials, executed manually by the developer (not exposed via HTTP) | +| Drizzle schema -> migration SQL | `drizzle-kit generate` produces SQL applied to a live database; incorrect schema changes could alter production data shape | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-11-01 | Tampering | `scripts/push-11-tags-migration.ts` | mitigate | Use `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` only — no `DROP`/`ALTER ... DROP COLUMN`/`TRUNCATE`. Idempotent: safe to re-run, catches "already exists" and exits 0. | +| T-11-02 | Tampering | `scripts/migrate-tags.ts` / `migrate-services.ts` | mitigate | Both scripts only INSERT new rows; existence checks via `migrated_from`/`migrated_id` (services) and `(entity_type, entity_id, name)` (tags) prevent duplicate inserts on re-run. No UPDATE/DELETE on `service_catalog`, `offer_services`, `offer_micro_services`. | +| T-11-03 | Repudiation | Migration audit trail | accept | `migrated_from`/`migrated_id` on `services` provide traceability for rollback; no separate audit log needed for an additive-only operation. | +| T-11-04 | Information Disclosure | `tags` table polymorphic `entity_id` | mitigate | `entity_type` is constrained at the application layer to a known enum (`"services"`, future `"leads"`) — enforced in Plan 02's server actions (not at DB level in this plan), preventing cross-entity tag pollution. Flagged here for Plan 02 to implement the validation. | +| T-11-05 | Denial of Service | Migration scripts run against prod | accept | Scripts are run manually via SSH+docker exec per project convention, not triggered by any HTTP-reachable endpoint. Out of scope for automated threat mitigation. | + +All HIGH-severity items (T-11-01, T-11-02) are mitigated via idempotent additive-only SQL. No threats in this plan are left unmitigated/unaccepted. + + + +1. `npx tsc --noEmit` passes (no new type errors from schema.ts changes) +2. `tags` table exists in local dev DB with columns `id, entity_type, entity_id, name, created_at` and unique index `tags_entity_name_unique` +3. `scripts/validate-services-migration.ts` and `scripts/validate-tags-migration.ts` both exit 0 with `ALL CHECKS PASSED` +4. Row counts for `clients`, `projects`, `payments`, `phases`, `service_catalog`, `offer_services`, `offer_micro_services` are unchanged before/after running all scripts in this plan + + + +- `tags` table + types committed to `src/db/schema.ts`, migration generated and applied locally +- OFFER-13 satisfied: `service_catalog` + `offer_services` rows fully present in `services` with `migrated_from`/`migrated_id`, validated via row-count script (zero data loss) +- D-02 satisfied: every `services` row with `migrated_from='offer_services'` carries the "Offerta" tag +- Production migration documented as a manual pre-deploy step (not executed by this plan) +- Stale `quote_items` <-> `service_catalog` JOIN documented as a non-blocking known issue in SUMMARY.md + + + +After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-01-SUMMARY.md` + diff --git a/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-02-PLAN.md b/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-02-PLAN.md new file mode 100644 index 0000000..f30e3bf --- /dev/null +++ b/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-02-PLAN.md @@ -0,0 +1,424 @@ +--- +phase: 11-catalog-database-view-ux-legacy-consolidation +plan: 02 +type: execute +wave: 2 +depends_on: ["11-01"] +files_modified: + - src/lib/admin-queries.ts + - src/app/admin/catalog/actions.ts +autonomous: true +requirements: [OFFER-07, OFFER-08, OFFER-09] + +must_haves: + truths: + - "getAllServices() returns each service together with its assigned tag names" + - "An admin-only server action exists to update any single editable field of a service (name, description, category, unit_price, active)" + - "An admin-only server action exists to add/remove a tag from a service, scoped to entity_type='services', creating the tag row if it doesn't exist" + - "An admin-only server action exists to quick-add a new service with unit_price=0 from just a name" + artifacts: + - path: "src/lib/admin-queries.ts" + provides: "ServiceWithTags type + getAllServices() returning Service & { tags: string[] }" + contains: "export type ServiceWithTags" + - path: "src/app/admin/catalog/actions.ts" + provides: "updateServiceField, addTagToService, removeTagFromService, quickAddService server actions" + exports: ["updateServiceField", "addTagToService", "removeTagFromService", "quickAddService", "createService", "updateService", "toggleServiceActive"] + key_links: + - from: "src/app/admin/catalog/actions.ts" + to: "src/db/schema.ts (tags table)" + via: "drizzle insert/delete with entity_type='services'" + pattern: "entity_type.*services" + - from: "src/lib/admin-queries.ts getAllServices" + to: "src/db/schema.ts (tags table)" + via: "leftJoin on tags where entity_type='services' and entity_id=services.id" + pattern: "leftJoin\\(tags" +--- + + +Extend the catalog query layer and server actions to support the database-view UX: `getAllServices()` now returns each service's assigned tags, and four new server actions (`updateServiceField`, `addTagToService`, `removeTagFromService`, `quickAddService`) provide the inline-edit, tag-assignment, and quick-add primitives that Plans 03-04 wire into the UI. + +Purpose: Interface-first foundation — Plan 03 (EditableCell/TagMultiSelect components) and Plan 04 (ServiceTable rewrite) import `ServiceWithTags` and these four actions directly, with no further query-layer changes needed. + +Output: +- `src/lib/admin-queries.ts`: `ServiceWithTags` type + rewritten `getAllServices()` +- `src/app/admin/catalog/actions.ts`: 4 new server actions, all behind `requireAdmin()`, all calling `revalidatePath("/admin/catalog")` + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md +@.planning/DESIGN-SYSTEM.md + + + + +```typescript +export const tags = pgTable("tags", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + entity_type: text("entity_type").notNull(), // "services" | "leads" + entity_id: text("entity_id").notNull(), + name: text("name").notNull(), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); +export type Tag = typeof tags.$inferSelect; +export type NewTag = typeof tags.$inferInsert; + +export const services = pgTable("services", { + id: text("id").primaryKey().$defaultFn(() => nanoid()), + name: text("name").notNull(), + description: text("description"), + unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), + category: text("category"), + active: boolean("active").notNull().default(true), + migrated_from: text("migrated_from"), + migrated_id: text("migrated_id"), + created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); +export type Service = typeof services.$inferSelect; +export type NewService = typeof services.$inferInsert; +``` + + +```typescript +export async function getAllServices(): Promise { + return db + .select() + .from(services) + .orderBy(asc(services.name)); +} +``` + + +```typescript +import { db } from "@/db"; +import { + clients, projects, payments, phases, tasks, deliverables, comments, documents, + notes, time_entries, quote_items, service_catalog, services, settings, + offer_micros, offer_macros, project_offers, offer_phases, offer_phase_services, + quotes, leads, +} from "@/db/schema"; +import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm"; +import type { + Client, Project, Phase, Task, Deliverable, Payment, Document, Note, Comment, + ServiceCatalog, Service, OfferMicro, OfferMacro, ProjectOffer, OfferPhase, + OfferPhaseService, Quote, Lead, +} from "@/db/schema"; +``` + + +```typescript +"use server"; + +import { db } from "@/db"; +import { services } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; + +const serviceSchema = z.object({ + name: z.string().min(1, "Nome richiesto"), + description: z.string().optional(), + unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"), + category: z.string().optional(), +}); + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} + +export async function createService(formData: FormData) { ... } +export async function updateService(serviceId: string, formData: FormData) { ... } +export async function toggleServiceActive(serviceId: string, active: boolean) { ... } +``` + + + + + + Task 1: Extend getAllServices() with tags join (ServiceWithTags) + src/lib/admin-queries.ts + + src/lib/admin-queries.ts (full imports block lines 1-45; getAllServices at lines 359-364; existing leftJoin pattern for quote_items/service_catalog at lines 332-345 as a syntax reference for leftJoin + grouping) + src/db/schema.ts (tags table from Plan 01; services table) + + + - Test 1 (manual/typecheck): `getAllServices()` return type is `Promise` where `ServiceWithTags = Service & { tags: string[] }` + - Test 2: A service with 2 tags ("Offerta", "Premium") returns `tags: ["Offerta", "Premium"]` (order by `tags.name asc`) + - Test 3: A service with 0 tags returns `tags: []` (not `[null]` or `[""]` — left join nulls must be filtered out) + - Test 4: Services remain ordered by `services.name asc` regardless of tag count + + + 1. Add `tags` to the import from `@/db/schema` (extend the existing destructured import on lines 2-24 — add `tags` to the list alongside `services`). + 2. Add `and` is already imported from `drizzle-orm` (line 25) — no change needed there. + 3. Immediately before the existing `getAllServices` function (line 359), add the new type: + + ```typescript + // ── ServiceWithTags — services + assigned tag names (Phase 11 database-view) ── + + export type ServiceWithTags = Service & { tags: string[] }; + ``` + + 4. Replace the body of `getAllServices` (lines 359-364) with: + + ```typescript + export async function getAllServices(): Promise { + const rows = await db + .select({ + id: services.id, + name: services.name, + description: services.description, + unit_price: services.unit_price, + category: services.category, + active: services.active, + migrated_from: services.migrated_from, + migrated_id: services.migrated_id, + created_at: services.created_at, + tag_name: tags.name, + }) + .from(services) + .leftJoin( + tags, + and(eq(tags.entity_type, "services"), eq(tags.entity_id, services.id)) + ) + .orderBy(asc(services.name), asc(tags.name)); + + const serviceMap = new Map(); + for (const row of rows) { + const { tag_name, ...serviceFields } = row; + if (!serviceMap.has(row.id)) { + serviceMap.set(row.id, { ...serviceFields, tags: [] }); + } + if (tag_name) { + serviceMap.get(row.id)!.tags.push(tag_name); + } + } + return Array.from(serviceMap.values()); + } + ``` + + 5. `getClientFullDetail` and `getProjectFullDetail` both have an `activeServices: Service[]` field populated from `db.select().from(services).where(eq(services.active, true))...` (lines ~251-255 and ~542) — these queries are UNCHANGED in this plan (they don't need tags; they feed the project workspace service-assignment dropdown, out of scope for Phase 11 per CONTEXT.md deferred items). Do NOT modify these. + + + npx tsc --noEmit 2>&1 | grep -i "admin-queries" ; echo "exit: $?" + + + - `grep -c "export type ServiceWithTags = Service & { tags: string\[\] }" src/lib/admin-queries.ts` returns `1` + - `grep -c "export async function getAllServices(): Promise" src/lib/admin-queries.ts` returns `1` + - `grep -c "leftJoin(\s*tags" src/lib/admin-queries.ts` (or `leftJoin(\n tags`) returns >= 1 — verify with `grep -A1 "leftJoin(" src/lib/admin-queries.ts | grep -c tags` + - `grep -c "tags," src/lib/admin-queries.ts` >= 1 in the import block (line 2-24 region) + - `npx tsc --noEmit` produces zero errors referencing `src/lib/admin-queries.ts` + - `getClientFullDetail` and `getProjectFullDetail` function bodies are byte-for-byte unchanged except for surrounding context (verify via `git diff src/lib/admin-queries.ts` shows no changes inside those two functions) + + + `getAllServices()` returns `ServiceWithTags[]` with each service's tags as a string array (empty array when no tags), ordered by service name then tag name. Typecheck passes. + + + + + Task 2: Add inline-edit, tag, and quick-add server actions + src/app/admin/catalog/actions.ts + + src/app/admin/catalog/actions.ts (full file — requireAdmin pattern lines 18-21, serviceSchema lines 11-16, existing CRUD actions lines 23-67) + src/db/schema.ts (tags table from Plan 01) + .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-06, D-08, D-12) + + + - Test 1: `updateServiceField(id, "name", "New Name")` updates only the `name` column, calls `revalidatePath("/admin/catalog")` + - Test 2: `updateServiceField(id, "unit_price", "150.5")` validates as a positive number, stores as `"150.50"` (2 decimals) + - Test 3: `updateServiceField(id, "name", "")` throws `"Nome richiesto"` (required field validation) + - Test 4: `updateServiceField(id, "active", false)` updates the boolean `active` column + - Test 5: `addTagToService(serviceId, "Premium")` inserts a row into `tags` with `entity_type="services"`, `entity_id=serviceId`, `name="Premium"` (trimmed); calling it again with the same name does NOT create a duplicate (`onConflictDoNothing`) + - Test 6: `addTagToService(serviceId, " ")` throws (empty/whitespace tag name rejected) + - Test 7: `removeTagFromService(serviceId, "Premium")` deletes the matching row from `tags` scoped to `entity_type="services"` and `entity_id=serviceId` + - Test 8: `quickAddService("Nuovo servizio")` inserts a `services` row with `unit_price="0.00"`, `active=true`, `migrated_from=null` + - Test 9: `quickAddService("")` throws `"Nome richiesto"` + - Test 10: All 4 new actions call `requireAdmin()` first (throw `"Non autorizzato"` when no session) + + + 1. Update imports at the top of `src/app/admin/catalog/actions.ts`: + + ```typescript + "use server"; + + import { db } from "@/db"; + import { services, tags } from "@/db/schema"; + import { revalidatePath } from "next/cache"; + import { eq, and } from "drizzle-orm"; + import { z } from "zod"; + import { getServerSession } from "next-auth"; + import { authOptions } from "@/lib/auth"; + ``` + + 2. Keep the existing `serviceSchema`, `requireAdmin`, `createService`, `updateService`, `toggleServiceActive` UNCHANGED (lines 11-67). + + 3. Append the following 4 new server actions at the end of the file. + + **updateServiceField** — single-field inline edit, used by `EditableCell` (Plan 03/04): + + ```typescript + const EDITABLE_FIELDS = ["name", "description", "category", "unit_price", "active"] as const; + type EditableField = (typeof EDITABLE_FIELDS)[number]; + + export async function updateServiceField( + serviceId: string, + fieldName: EditableField, + value: string | boolean + ) { + await requireAdmin(); + + if (!EDITABLE_FIELDS.includes(fieldName)) { + throw new Error(`Campo non editabile: ${fieldName}`); + } + + if (fieldName === "name") { + const s = String(value).trim(); + if (s.length === 0) throw new Error("Nome richiesto"); + await db.update(services).set({ name: s }).where(eq(services.id, serviceId)); + } else if (fieldName === "description") { + const s = String(value).trim(); + await db.update(services).set({ description: s || null }).where(eq(services.id, serviceId)); + } else if (fieldName === "category") { + const s = String(value).trim(); + await db.update(services).set({ category: s || null }).where(eq(services.id, serviceId)); + } else if (fieldName === "unit_price") { + const num = parseFloat(String(value)); + if (isNaN(num) || num < 0) throw new Error("Prezzo invalido"); + await db.update(services).set({ unit_price: num.toFixed(2) }).where(eq(services.id, serviceId)); + } else if (fieldName === "active") { + const boolValue = typeof value === "boolean" ? value : value === "true"; + await db.update(services).set({ active: boolValue }).where(eq(services.id, serviceId)); + } + + revalidatePath("/admin/catalog"); + } + ``` + + Note: `unit_price` allows `0` here (>= 0, not > 0 like `serviceSchema`) — this is intentional per D-12 (quick-add creates services with `unit_price=0`, and the user must be able to leave it at 0 or correct it inline without hitting the `0.01` minimum from the create form). + + **addTagToService / removeTagFromService** — tag assignment scoped to `entity_type="services"` (D-06), used by `TagMultiSelect` (Plan 03/04): + + ```typescript + export async function addTagToService(serviceId: string, tagName: string) { + await requireAdmin(); + const trimmed = tagName.trim(); + if (trimmed.length === 0) throw new Error("Nome tag richiesto"); + + await db + .insert(tags) + .values({ + entity_type: "services", + entity_id: serviceId, + name: trimmed, + }) + .onConflictDoNothing(); + + revalidatePath("/admin/catalog"); + } + + export async function removeTagFromService(serviceId: string, tagName: string) { + await requireAdmin(); + + await db + .delete(tags) + .where( + and( + eq(tags.entity_type, "services"), + eq(tags.entity_id, serviceId), + eq(tags.name, tagName) + ) + ); + + revalidatePath("/admin/catalog"); + } + ``` + + Note on `onConflictDoNothing()`: this relies on the unique index `tags_entity_name_unique` on `(entity_type, entity_id, name)` created in Plan 01. Drizzle's `.onConflictDoNothing()` without a `target` argument falls back to "do nothing on any conflict" — verify this compiles; if Drizzle requires an explicit target for this version, use `.onConflictDoNothing({ target: [tags.entity_type, tags.entity_id, tags.name] })`. + + **quickAddService** — OFFER-09 quick-add row, used by `ServiceTable` (Plan 04): + + ```typescript + export async function quickAddService(name: string) { + await requireAdmin(); + const trimmed = name.trim(); + if (trimmed.length === 0) throw new Error("Nome richiesto"); + + await db.insert(services).values({ + name: trimmed, + unit_price: "0.00", + active: true, + }); + + revalidatePath("/admin/catalog"); + } + ``` + + Per D-12: `unit_price="0.00"` is intentional — the row becomes a normal editable row immediately after creation, and the user corrects the price inline via `updateServiceField`. + + + npx tsc --noEmit 2>&1 | grep -i "actions.ts" ; echo "exit: $?" + + + - `grep -c "export async function updateServiceField" src/app/admin/catalog/actions.ts` returns `1` + - `grep -c "export async function addTagToService" src/app/admin/catalog/actions.ts` returns `1` + - `grep -c "export async function removeTagFromService" src/app/admin/catalog/actions.ts` returns `1` + - `grep -c "export async function quickAddService" src/app/admin/catalog/actions.ts` returns `1` + - `grep -c "await requireAdmin()" src/app/admin/catalog/actions.ts` returns `7` (3 existing + 4 new) + - `grep -c "entity_type: \"services\"" src/app/admin/catalog/actions.ts` returns `2` (addTagToService insert + removeTagFromService where) + - `grep -c "revalidatePath(\"/admin/catalog\")" src/app/admin/catalog/actions.ts` returns `7` (3 existing + 4 new) + - `npx tsc --noEmit` produces zero errors referencing `src/app/admin/catalog/actions.ts` + - Existing `createService`, `updateService`, `toggleServiceActive` exports remain present and unchanged (`grep -c "export async function createService\|export async function updateService\|export async function toggleServiceActive" src/app/admin/catalog/actions.ts` returns `3`) + + + Four new server actions exist, each calling `requireAdmin()` and `revalidatePath("/admin/catalog")`. Tag operations are scoped to `entity_type="services"`. `updateServiceField` validates per-field (required name, non-negative price). `quickAddService` creates a `unit_price="0.00"` row. Typecheck passes. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|--------------| +| Admin browser -> Server Actions | All 4 new actions are Next.js Server Actions invoked from `/admin/catalog` client components; session-authenticated | +| Server Actions -> Postgres | Drizzle queries with user-supplied field values (tag names, service field values) | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-11-06 | Spoofing/Elevation of Privilege | `updateServiceField`, `addTagToService`, `removeTagFromService`, `quickAddService` | mitigate | Every action calls `await requireAdmin()` as its first statement, throwing `"Non autorizzato"` if no Auth.js session exists — identical to existing `createService`/`updateService`/`toggleServiceActive` pattern. | +| T-11-07 | Tampering | `updateServiceField` field whitelist | mitigate | `EDITABLE_FIELDS` is a closed TypeScript union (`"name" \| "description" \| "category" \| "unit_price" \| "active"`) checked at runtime via `EDITABLE_FIELDS.includes(fieldName)` — prevents arbitrary column writes via a crafted `fieldName` string from a compromised client bundle. | +| T-11-08 | Tampering | `addTagToService`/`removeTagFromService` `entity_type` | mitigate | `entity_type` is hardcoded to the literal `"services"` in both actions — never accepted as a parameter from the client — so a crafted call cannot write/delete tags for `entity_type="leads"` (Phase 14's future pool) via this catalog action set. | +| T-11-09 | Tampering | SQL injection via tag name / field values | accept | All values pass through Drizzle's parameterized query builder (`.values()`, `.set()`, `eq()`) — no raw `sql` string interpolation of user input in this plan's actions. | +| T-11-10 | Denial of Service | Duplicate tag inserts | mitigate | `onConflictDoNothing()` on the `(entity_type, entity_id, name)` unique index (from Plan 01) makes `addTagToService` idempotent — no unbounded duplicate rows from repeated clicks. | + +All HIGH-severity items (T-11-06, T-11-07, T-11-08) are mitigated. + + + +1. `npx tsc --noEmit` passes with zero errors in `src/lib/admin-queries.ts` and `src/app/admin/catalog/actions.ts` +2. `getAllServices()` callable from a server component and returns `ServiceWithTags[]` +3. All 4 new actions present, admin-gated, and exported + + + +- `ServiceWithTags` type + `getAllServices()` join with `tags` implemented and typechecked +- `updateServiceField`, `addTagToService`, `removeTagFromService`, `quickAddService` implemented, admin-gated, revalidating `/admin/catalog` +- Tag operations strictly scoped to `entity_type="services"` (D-06 enforced at the action layer) +- Existing catalog actions (`createService`, `updateService`, `toggleServiceActive`) unchanged + + + +After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-02-SUMMARY.md` + diff --git a/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-03-PLAN.md b/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-03-PLAN.md new file mode 100644 index 0000000..3c030e9 --- /dev/null +++ b/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-03-PLAN.md @@ -0,0 +1,533 @@ +--- +phase: 11-catalog-database-view-ux-legacy-consolidation +plan: 03 +type: execute +wave: 3 +depends_on: ["11-02"] +files_modified: + - src/components/ui/editable-cell.tsx + - src/components/ui/tag-multi-select.tsx +autonomous: true +requirements: [OFFER-07, OFFER-08] + +must_haves: + truths: + - "Clicking a cell in display mode transitions it to an editable input/textarea/checkbox with a visible focus ring" + - "Pressing Enter (non-textarea) or blurring the field saves the value via the onSave callback" + - "Pressing Escape reverts to the previous value without calling onSave" + - "Tag badges display with deterministic colors derived from tag name (same name = same color, no stored color column)" + - "Typing a new tag name and pressing Enter in the TagMultiSelect dropdown calls addTagToService and the badge appears without a full page reload (via revalidation)" + artifacts: + - path: "src/components/ui/editable-cell.tsx" + provides: "EditableCell component — click-to-edit text/number/textarea/toggle cell" + contains: "export function EditableCell" + - path: "src/components/ui/tag-multi-select.tsx" + provides: "TagMultiSelect component — tag badges + add/remove dropdown with deterministic color hashing" + contains: "export function TagMultiSelect" + key_links: + - from: "src/components/ui/tag-multi-select.tsx" + to: "src/app/admin/catalog/actions.ts" + via: "addTagToService / removeTagFromService server action calls" + pattern: "addTagToService|removeTagFromService" + - from: "src/components/ui/editable-cell.tsx" + to: "src/components/ui/input.tsx, src/components/ui/textarea.tsx" + via: "renders Input/Textarea in edit mode with ring-1 ring-primary" + pattern: "ring-1 ring-primary" +--- + + +Build the two new shared UI primitives the database-view table (Plan 04) depends on: `EditableCell` (generic click-to-edit cell for text/number/textarea/toggle, per D-11/D-14 and DESIGN-SYSTEM.md inline-edit pattern) and `TagMultiSelect` (tag badges with deterministic color hashing per D-07, inline "+" dropdown to add/remove tags per D-06/D-08). + +Purpose: Interface-first — these are pure, reusable components with no ServiceTable-specific logic. Plan 04 imports and wires them into `ServiceRow`/`ServiceTable` without needing to touch their internals. + +Output: +- `src/components/ui/editable-cell.tsx` — `EditableCell` component + `EditableCellProps` type +- `src/components/ui/tag-multi-select.tsx` — `TagMultiSelect` component + color-hash utility + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md +@.planning/DESIGN-SYSTEM.md + + + + +```typescript +export async function addTagToService(serviceId: string, tagName: string): Promise; +export async function removeTagFromService(serviceId: string, tagName: string): Promise; +``` + + +```typescript +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + + + + +```typescript +export interface BadgeProps extends React.HTMLAttributes, VariantProps {} +function Badge({ className, variant, ...props }: BadgeProps): JSX.Element; +``` + + + + + + + + Task 1: Build EditableCell component (text/number/textarea/toggle) + src/components/ui/editable-cell.tsx + + src/components/admin/catalog/ServiceTable.tsx (current ServiceRow inline-edit state pattern, lines 11-35, for the useState/useTransition conventions to mirror — NOT the form-based editing, but the local state + save/cancel idiom) + src/components/ui/input.tsx (Input forwardRef signature) + src/components/ui/textarea.tsx (Textarea forwardRef signature) + .planning/DESIGN-SYSTEM.md (inline edit pattern: "click su cella -> diventa input/select borderless con ring-1 ring-primary on focus -> Enter salva, Esc annulla, blur salva") + + + - Test 1 (display mode): renders `value` as plain text (or "—" if empty/falsy for text/textarea; "✓ Attivo"/"✗ Disattivato" for toggle) + - Test 2 (click to edit): clicking the display div sets `isEditing=true` and renders the appropriate input (`Input` for text/number, `Textarea` for textarea, `` for toggle), auto-focused + - Test 3 (Enter saves, non-textarea): pressing Enter in a text/number input calls `onSave(tempValue)` and exits edit mode + - Test 4 (Escape cancels): pressing Escape reverts `tempValue` to the original `value` and exits edit mode WITHOUT calling `onSave` + - Test 5 (blur saves): blurring any input calls `onSave(tempValue)` and exits edit mode + - Test 6 (required validation): if `required=true` and `tempValue.trim() === ""`, `onSave` is NOT called and an inline error message renders + - Test 7 (disabled): if `disabled=true`, clicking the display div does NOT enter edit mode (no `cursor-pointer`, has `cursor-not-allowed opacity-50`) + - Test 8 (toggle type): for `type="toggle"`, `onSave` receives `"true"` or `"false"` as a string based on checkbox state + + + Create `src/components/ui/editable-cell.tsx`: + + ```typescript + "use client"; + + import { useState, useRef, useEffect } from "react"; + import { Input } from "@/components/ui/input"; + import { Textarea } from "@/components/ui/textarea"; + import { cn } from "@/lib/utils"; + + export interface EditableCellProps { + value: string | number | boolean; + type?: "text" | "number" | "textarea" | "toggle"; + onSave: (value: string) => void; + required?: boolean; + placeholder?: string; + disabled?: boolean; + formatDisplay?: (value: string) => string; + } + + export function EditableCell({ + value, + type = "text", + onSave, + required, + placeholder, + disabled, + formatDisplay, + }: EditableCellProps) { + const [isEditing, setIsEditing] = useState(false); + const [tempValue, setTempValue] = useState(String(value)); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + setTempValue(String(value)); + }, [value]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + if ("select" in inputRef.current) { + inputRef.current.select(); + } + } + }, [isEditing]); + + function startEdit() { + if (disabled) return; + setError(null); + setTempValue(String(value)); + setIsEditing(true); + } + + function commit() { + if (required && tempValue.trim().length === 0) { + setError("Campo richiesto"); + return; + } + setError(null); + onSave(tempValue); + setIsEditing(false); + } + + function cancel() { + setTempValue(String(value)); + setError(null); + setIsEditing(false); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && type !== "textarea") { + e.preventDefault(); + commit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + } + + if (!isEditing) { + let display: string; + if (type === "toggle") { + display = tempValue === "true" ? "✓ Attivo" : "✗ Disattivato"; + } else { + const raw = String(value); + display = formatDisplay ? formatDisplay(raw) : raw || "—"; + } + + return ( +
+ {display} +
+ ); + } + + return ( +
+ {type === "textarea" ? ( +