From 7d98b27e7560db696732cd170ed54d696b84f608 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 14 Jun 2026 12:41:36 +0200 Subject: [PATCH] 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"); +}