From 5b583bcc5e9d154d4638dde0ccc34958a0243f71 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 14 Jun 2026 12:38:09 +0200 Subject: [PATCH 1/4] feat(14-01): add getLeadsWithTags and getLeadFieldOptions to admin-queries - LeadWithTags type + getLeadsWithTags() left-joins leads with tags scoped to entity_type="leads", Map-reduce pattern mirroring getAllServices (Phase 11) - LeadFieldOptions type + getLeadFieldOptions() returns fixed LEAD_STAGES for status and distinct sorted lead tag names - desc import added to drizzle-orm import; LEAD_STAGES imported from lib/lead-validators --- src/lib/admin-queries.ts | 64 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index 0fb11a0..7e86f3c 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -23,7 +23,8 @@ import { leads, tags, } from "@/db/schema"; -import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm"; +import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm"; +import { LEAD_STAGES } from "@/lib/lead-validators"; import type { Client, Project, @@ -878,4 +879,65 @@ export async function getAllOfferMacrosWithMicros(): Promise< .filter((micro) => micro.macro_id === macro.id) .sort((a, b) => a.sort_order - b.sort_order), })); +} + +// ── LeadWithTags — leads + tags (Phase 14 database-view) ───────────────────── +// Lead tags are stored in the polymorphic `tags` table, scoped by +// entity_type="leads". `status` is a fixed enum (LEAD_STAGES), not a +// dynamic pool — exposed via getLeadFieldOptions for the OptionSelect UI. + +const LEADS_TAG_ENTITY = "leads"; + +export type LeadWithTags = Lead & { tags: string[] }; + +export async function getLeadsWithTags(): Promise { + const rows = await db + .select({ + id: leads.id, + name: leads.name, + email: leads.email, + phone: leads.phone, + company: leads.company, + status: leads.status, + last_contact_date: leads.last_contact_date, + next_action: leads.next_action, + next_action_date: leads.next_action_date, + notes: leads.notes, + created_at: leads.created_at, + updated_at: leads.updated_at, + tag_name: tags.name, + }) + .from(leads) + .leftJoin( + tags, + and( + eq(tags.entity_id, leads.id), + eq(tags.entity_type, LEADS_TAG_ENTITY) + ) + ) + .orderBy(desc(leads.updated_at), asc(tags.name)); + + const leadMap = new Map(); + for (const row of rows) { + const { tag_name, ...leadFields } = row; + if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] }); + if (tag_name) leadMap.get(row.id)!.tags.push(tag_name); + } + return Array.from(leadMap.values()); +} + +export type LeadFieldOptions = { status: string[]; tags: string[] }; + +export async function getLeadFieldOptions(): Promise { + const tagRows = await db + .selectDistinct({ name: tags.name }) + .from(tags) + .where(eq(tags.entity_type, LEADS_TAG_ENTITY)); + + return { + status: [...LEAD_STAGES], + tags: tagRows + .map((r) => r.name) + .sort((a, b) => a.localeCompare(b, "it")), + }; } \ No newline at end of file From 7d98b27e7560db696732cd170ed54d696b84f608 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 14 Jun 2026 12:41:36 +0200 Subject: [PATCH 2/4] feat(14-01): add updateLeadField and lead tag CRUD server actions - requireAdmin() guard (mirrors catalog/actions.ts pattern), throws "Non autorizzato" before any DB access - updateLeadField: EDITABLE_FIELDS allowlist, name/status validated server-side (LEAD_STAGES.includes), nullable fields cleared on empty - addLeadTag/removeLeadTag/renameLeadTag: polymorphic tags table scoped to entity_type="leads" (LEADS_TAG_ENTITY), never touches entity_type="services" rows - All four actions call revalidatePath for /admin/leads (+ detail page where a single leadId applies) - Log pre-existing lint issues (unrelated to this task) to deferred-items.md per scope-boundary rule --- .../14-crm-attio-style-fix/deferred-items.md | 20 ++++ src/app/admin/leads/actions.ts | 105 +++++++++++++++++- 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/14-crm-attio-style-fix/deferred-items.md diff --git a/.planning/phases/14-crm-attio-style-fix/deferred-items.md b/.planning/phases/14-crm-attio-style-fix/deferred-items.md new file mode 100644 index 0000000..8e0d583 --- /dev/null +++ b/.planning/phases/14-crm-attio-style-fix/deferred-items.md @@ -0,0 +1,20 @@ +# Deferred Items — Phase 14 + +Items discovered during execution that are out of scope for the current task +(per Scope Boundary rule — pre-existing issues in unrelated code paths). + +## 14-01 + +- **`src/app/admin/leads/actions.ts:54`** — pre-existing `@typescript-eslint/no-explicit-any` + error on `const updateData: Record = { updated_at: new Date() };` inside + `updateLead()`. This function predates Phase 14 and is unchanged by 14-01 (Task 2 only + appends `updateLeadField`/`addLeadTag`/`removeLeadTag`/`renameLeadTag` after it). + `npx eslint src/app/admin/leads/actions.ts` exits 1 solely due to this pre-existing line; + the four new actions added in 14-01 introduce zero new lint errors/warnings. + Fix: replace `Record` with a properly typed partial update object + (e.g. `Partial` or an explicit interface). + +- **`src/lib/admin-queries.ts`** — pre-existing `@typescript-eslint/no-unused-vars` warnings + (4x) for imports `settings`, `ServiceCatalog`, `ProjectOffer`, `OfferPhaseService` — these + predate Phase 14 and are unrelated to the `getLeadsWithTags`/`getLeadFieldOptions` additions + in Task 1. diff --git a/src/app/admin/leads/actions.ts b/src/app/admin/leads/actions.ts index 3c48fe6..78ff5ce 100644 --- a/src/app/admin/leads/actions.ts +++ b/src/app/admin/leads/actions.ts @@ -2,11 +2,18 @@ import { z } from "zod"; import { db } from "@/db"; -import { leads, quotes } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { createLeadSchema, updateLeadSchema, createActivitySchema } from "@/lib/lead-validators"; +import { leads, quotes, tags } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import { + createLeadSchema, + updateLeadSchema, + createActivitySchema, + LEAD_STAGES, +} from "@/lib/lead-validators"; import { revalidatePath } from "next/cache"; import { createActivity, updateLeadStage } from "@/lib/lead-service"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; export async function createLead(data: z.infer) { const parsed = createLeadSchema.safeParse(data); @@ -159,3 +166,95 @@ export async function assignQuoteToLead(data: z.infer) return { success: false, error: "Errore nell'assegnazione" }; } } + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} + +// ── Inline-edit field update (Phase 14 database-view) ─────────────────────── + +const EDITABLE_FIELDS = ["name", "email", "phone", "company", "status", "next_action"] as const; +type EditableField = (typeof EDITABLE_FIELDS)[number]; + +export async function updateLeadField( + leadId: string, + fieldName: EditableField, + value: string +) { + await requireAdmin(); + + if (!EDITABLE_FIELDS.includes(fieldName)) { + throw new Error(`Campo non editabile: ${fieldName}`); + } + + if (fieldName === "name") { + const s = value.trim(); + if (s.length === 0) throw new Error("Nome richiesto"); + await db.update(leads).set({ name: s, updated_at: new Date() }).where(eq(leads.id, leadId)); + } else if (fieldName === "status") { + if (!LEAD_STAGES.includes(value as (typeof LEAD_STAGES)[number])) { + throw new Error("Stato non valido"); + } + await db.update(leads).set({ status: value, updated_at: new Date() }).where(eq(leads.id, leadId)); + } else { + // email | phone | company | next_action — nullable text fields, empty clears + const s = value.trim(); + await db + .update(leads) + .set({ [fieldName]: s || null, updated_at: new Date() }) + .where(eq(leads.id, leadId)); + } + + revalidatePath("/admin/leads"); + revalidatePath(`/admin/leads/${leadId}`); +} + +// ── Lead tags (Phase 14, CRM-09) — polymorphic `tags` table, entity_type="leads" ─ + +const LEADS_TAG_ENTITY = "leads"; + +export async function addLeadTag(leadId: string, value: string) { + await requireAdmin(); + const trimmed = value.trim(); + if (trimmed.length === 0) throw new Error("Valore richiesto"); + + await db + .insert(tags) + .values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed }) + .onConflictDoNothing(); + + revalidatePath("/admin/leads"); + revalidatePath(`/admin/leads/${leadId}`); +} + +export async function removeLeadTag(leadId: string, value: string) { + await requireAdmin(); + + await db + .delete(tags) + .where( + and( + eq(tags.entity_type, LEADS_TAG_ENTITY), + eq(tags.entity_id, leadId), + eq(tags.name, value) + ) + ); + + revalidatePath("/admin/leads"); + revalidatePath(`/admin/leads/${leadId}`); +} + +export async function renameLeadTag(oldValue: string, newValue: string) { + await requireAdmin(); + const next = newValue.trim(); + if (next.length === 0) throw new Error("Nuovo nome richiesto"); + if (next === oldValue) return; + + await db + .update(tags) + .set({ name: next }) + .where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.name, oldValue))); + + revalidatePath("/admin/leads"); +} From d1acfe912702d99750dfcbccb06acc70147c28e8 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 14 Jun 2026 12:43:21 +0200 Subject: [PATCH 3/4] docs(14-01): complete lead data-layer foundation plan - SUMMARY documents getLeadsWithTags/getLeadFieldOptions and updateLeadField/addLeadTag/removeLeadTag/renameLeadTag additions - Logs 2 pre-existing lint issues (deferred-items.md) unrelated to this plan's changes --- .../14-crm-attio-style-fix/14-01-SUMMARY.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 .planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md diff --git a/.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md b/.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md new file mode 100644 index 0000000..8373c65 --- /dev/null +++ b/.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md @@ -0,0 +1,121 @@ +--- +phase: 14-crm-attio-style-fix +plan: 01 +subsystem: database +tags: [drizzle, postgres, server-actions, leads, tags, crm] + +# Dependency graph +requires: + - phase: 11-catalog-database-view + provides: "Polymorphic `tags` table + getAllServices/getCatalogFieldOptions left-join + Map-reduce pattern, requireAdmin() convention in catalog/actions.ts" +provides: + - "LeadWithTags type + getLeadsWithTags() — leads augmented with tags:string[] from polymorphic tags table (entity_type='leads')" + - "LeadFieldOptions type + getLeadFieldOptions() — fixed LEAD_STAGES for status, distinct sorted lead tag names for tags" + - "updateLeadField server action — allowlisted inline-edit for name/email/phone/company/status/next_action, server-side LEAD_STAGES validation" + - "addLeadTag/removeLeadTag/renameLeadTag server actions — polymorphic tags CRUD scoped to entity_type='leads'" +affects: [14-02-leadtable-rewrite] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Left-join + Map-reduce pattern for polymorphic tags table (reused from Phase 11 getAllServices/getCatalogFieldOptions)" + - "requireAdmin() session guard as first statement in every new server action (reused from Phase 11 catalog/actions.ts)" + - "EDITABLE_FIELDS allowlist gating which columns a generic field-update action can write" + +key-files: + created: [] + modified: + - src/lib/admin-queries.ts + - src/app/admin/leads/actions.ts + +key-decisions: + - "Logged 2 pre-existing lint issues (Record in updateLead, 4 unused imports in admin-queries.ts) to deferred-items.md per Scope Boundary rule rather than fixing — both predate this plan and are unrelated to the new code added" + +patterns-established: + - "Lead tag mutations always scope tags table queries with eq(tags.entity_type, LEADS_TAG_ENTITY) to prevent cross-entity pollution with entity_type='services' rows" + +requirements-completed: [CRM-08, CRM-09] + +# Metrics +duration: 12min +completed: 2026-06-14 +--- + +# Phase 14 Plan 01: Lead Data-Layer Foundation Summary + +**Added `getLeadsWithTags()`/`getLeadFieldOptions()` query functions and `updateLeadField`/`addLeadTag`/`removeLeadTag`/`renameLeadTag` server actions, mirroring Phase 11's catalog database-view pattern for the polymorphic `tags` table scoped to `entity_type="leads"`.** + +## Performance + +- **Duration:** 12 min +- **Started:** 2026-06-14T10:30:00Z +- **Completed:** 2026-06-14T10:42:03Z +- **Tasks:** 2 +- **Files modified:** 2 + +## Accomplishments +- `LeadWithTags` type + `getLeadsWithTags()` left-joins `leads` with `tags` (scoped to `entity_type="leads"`), returning every lead row with a `tags: string[]` array, ordered `desc(leads.updated_at), asc(tags.name)` +- `LeadFieldOptions` type + `getLeadFieldOptions()` returns the fixed `LEAD_STAGES` list for `status` and the distinct sorted set of lead tag names for `tags` +- `updateLeadField(leadId, fieldName, value)` — allowlisted inline-edit action (`name`, `email`, `phone`, `company`, `status`, `next_action`), with server-side `LEAD_STAGES.includes()` validation for `status` and trim/null-clear handling for nullable text fields +- `addLeadTag`/`removeLeadTag`/`renameLeadTag` — polymorphic `tags` table CRUD scoped to `LEADS_TAG_ENTITY = "leads"`, never touching `entity_type="services"` rows +- All four new actions guarded by `requireAdmin()` as the first statement, throwing `"Non autorizzato"` before any DB access + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Extend admin-queries.ts with LeadWithTags and LeadFieldOptions** - `5b583bc` (feat) +2. **Task 2: Add updateLeadField + tag CRUD server actions to leads/actions.ts** - `7d98b27` (feat) + +_Note: Task 2 had `tdd="true"` but the plan's `` block explicitly specifies manual-trace verification (no dedicated test file convention in this codebase, matching Phase 11's equivalent actions) — verified via line-by-line trace against all 11 documented behaviors, all pass._ + +## Files Created/Modified +- `src/lib/admin-queries.ts` - Added `desc` to drizzle-orm import, `LEAD_STAGES` import, `LEADS_TAG_ENTITY` const, `LeadWithTags`/`LeadFieldOptions` types, `getLeadsWithTags()`, `getLeadFieldOptions()` +- `src/app/admin/leads/actions.ts` - Added `tags` to schema import, `and` to drizzle-orm import, `LEAD_STAGES` import, `getServerSession`/`authOptions` imports, `requireAdmin()` helper, `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` + +## Decisions Made +- Followed the plan's interfaces exactly (1:1 replication of Phase 11's `getAllServices`/`getCatalogFieldOptions` and `catalog/actions.ts` tag-CRUD pattern) +- Pre-existing lint issues unrelated to this plan's changes were logged to `deferred-items.md` rather than fixed (Scope Boundary rule) — see Deviations below + +## Deviations from Plan + +### Deferred (out of scope, logged not fixed) + +**1. [Scope Boundary] Pre-existing `no-explicit-any` lint error in `updateLead`** +- **Found during:** Task 2 verification (`npx eslint src/app/admin/leads/actions.ts`) +- **Issue:** Line 54, `const updateData: Record = { updated_at: new Date() };` inside the pre-existing `updateLead()` function (untouched by this plan) causes `npx eslint src/app/admin/leads/actions.ts` to exit 1 +- **Resolution:** Confirmed via `git show` that this line existed before Task 2's edits (predates Phase 14). Logged to `.planning/phases/14-crm-attio-style-fix/deferred-items.md` per Scope Boundary rule — not fixed. The four new actions added in this plan introduce zero new lint errors/warnings (verified: only line 54 is flagged). +- **Files modified:** `.planning/phases/14-crm-attio-style-fix/deferred-items.md` (new) +- **Verification:** `npx eslint src/app/admin/leads/actions.ts --format stylish` shows only line 54:38 flagged, pre-dating this commit +- **Committed in:** `7d98b27` + +**2. [Scope Boundary] Pre-existing unused-import warnings in admin-queries.ts** +- **Found during:** Task 1 verification (`npx eslint src/lib/admin-queries.ts`) +- **Issue:** 4x `@typescript-eslint/no-unused-vars` warnings for `settings`, `ServiceCatalog`, `ProjectOffer`, `OfferPhaseService` — pre-existing imports unrelated to the new `getLeadsWithTags`/`getLeadFieldOptions` code, which was appended at end-of-file +- **Resolution:** Logged to `deferred-items.md`, not fixed (out of scope) +- **Files modified:** `.planning/phases/14-crm-attio-style-fix/deferred-items.md` (new) +- **Committed in:** `7d98b27` + +--- + +**Total deviations:** 2 deferred (both Scope Boundary — pre-existing issues unrelated to this plan's changes) +**Impact on plan:** None — `npx tsc --noEmit` exits 0 cleanly across the whole project; the eslint exit-1 on `actions.ts` is caused entirely by pre-existing code this plan does not touch. All plan-specified grep/type checks pass. + +## Issues Encountered +None. + +## User Setup Required + +None - no external service configuration required. No schema migration created or required (confirmed: `tags.entity_type` is unconstrained text, already supports "leads" in production). + +## Next Phase Readiness + +- `LeadWithTags`, `LeadFieldOptions`, `getLeadsWithTags()`, `getLeadFieldOptions()` are ready for Plan 14-02 (LeadTable rewrite, LeadsSearch, page wiring) +- `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` server actions are ready for Plan 14-02's inline-edit UI wiring +- Pre-existing actions (`createLead`, `updateLead`, `deleteLead`, `logActivity`, `assignQuoteToLead`) remain unchanged, as required by out-of-scope note in RESEARCH.md A5 / Open Question 4 +- Two pre-existing lint issues logged in `deferred-items.md` for future cleanup — do not block Plan 14-02 + +--- +*Phase: 14-crm-attio-style-fix* +*Completed: 2026-06-14* From f21f13909dcff312e0f7f1fc5a2c7e9a920c33ba Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 14 Jun 2026 12:44:19 +0200 Subject: [PATCH 4/4] docs(14-01): append self-check results to SUMMARY - Verified created/modified files exist and task commits are present in git history --- .../phases/14-crm-attio-style-fix/14-01-SUMMARY.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md b/.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md index 8373c65..3c71b1f 100644 --- a/.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md +++ b/.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md @@ -119,3 +119,13 @@ None - no external service configuration required. No schema migration created o --- *Phase: 14-crm-attio-style-fix* *Completed: 2026-06-14* + +## Self-Check: PASSED + +- FOUND: src/lib/admin-queries.ts +- FOUND: src/app/admin/leads/actions.ts +- FOUND: .planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md +- FOUND: .planning/phases/14-crm-attio-style-fix/deferred-items.md +- FOUND commit: 5b583bc +- FOUND commit: 7d98b27 +- FOUND commit: d1acfe9