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
This commit is contained in:
@@ -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<string, any> = { 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<string, any>` with a properly typed partial update object
|
||||||
|
(e.g. `Partial<typeof leads.$inferInsert>` 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.
|
||||||
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { leads, quotes } from "@/db/schema";
|
import { leads, quotes, tags } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { createLeadSchema, updateLeadSchema, createActivitySchema } from "@/lib/lead-validators";
|
import {
|
||||||
|
createLeadSchema,
|
||||||
|
updateLeadSchema,
|
||||||
|
createActivitySchema,
|
||||||
|
LEAD_STAGES,
|
||||||
|
} from "@/lib/lead-validators";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createActivity, updateLeadStage } from "@/lib/lead-service";
|
import { createActivity, updateLeadStage } from "@/lib/lead-service";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
export async function createLead(data: z.infer<typeof createLeadSchema>) {
|
export async function createLead(data: z.infer<typeof createLeadSchema>) {
|
||||||
const parsed = createLeadSchema.safeParse(data);
|
const parsed = createLeadSchema.safeParse(data);
|
||||||
@@ -159,3 +166,95 @@ export async function assignQuoteToLead(data: z.infer<typeof assignQuoteSchema>)
|
|||||||
return { success: false, error: "Errore nell'assegnazione" };
|
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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user