b96b6a2083
schema.ts: clientTranscripts table (id, lead_id/client_id nullable FKs, title, content NOT NULL, call_date, created_at) + leadsRelations/ clientsRelations updated + clientTranscriptsRelations + TS types. lead-service.ts: getTranscripts(leadId) — call_date DESC ordering. actions.ts: addTranscript + deleteTranscript with requireAdmin guard, Zod validation, and revalidatePath. TypeScript: no errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
311 lines
9.3 KiB
TypeScript
311 lines
9.3 KiB
TypeScript
"use server";
|
|
|
|
import { z } from "zod";
|
|
import { db } from "@/db";
|
|
import { leads, quotes, tags, clientTranscripts } from "@/db/schema";
|
|
import { nanoid } from "nanoid";
|
|
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<typeof createLeadSchema>) {
|
|
const parsed = createLeadSchema.safeParse(data);
|
|
|
|
if (!parsed.success) {
|
|
return { success: false, error: parsed.error.issues[0].message };
|
|
}
|
|
|
|
try {
|
|
const [lead] = await db
|
|
.insert(leads)
|
|
.values({
|
|
name: parsed.data.name,
|
|
email: parsed.data.email || null,
|
|
phone: parsed.data.phone || null,
|
|
company: parsed.data.company || null,
|
|
status: parsed.data.status || "contacted",
|
|
notes: parsed.data.notes || null,
|
|
})
|
|
.returning();
|
|
|
|
revalidatePath("/admin/leads");
|
|
return { success: true, lead };
|
|
} catch (error) {
|
|
console.error("createLead error:", error);
|
|
return { success: false, error: "Errore nella creazione del lead" };
|
|
}
|
|
}
|
|
|
|
export async function updateLead(id: string, data: z.infer<typeof updateLeadSchema>) {
|
|
const parsed = updateLeadSchema.safeParse(data);
|
|
|
|
if (!parsed.success) {
|
|
return { success: false, error: parsed.error.issues[0].message };
|
|
}
|
|
|
|
try {
|
|
const updateData: Record<string, any> = { updated_at: new Date() };
|
|
|
|
if (parsed.data.name !== undefined) updateData.name = parsed.data.name;
|
|
if (parsed.data.email !== undefined) updateData.email = parsed.data.email || null;
|
|
if (parsed.data.phone !== undefined) updateData.phone = parsed.data.phone || null;
|
|
if (parsed.data.company !== undefined) updateData.company = parsed.data.company || null;
|
|
if (parsed.data.status !== undefined) updateData.status = parsed.data.status;
|
|
if (parsed.data.notes !== undefined) updateData.notes = parsed.data.notes || null;
|
|
|
|
const [updated] = await db
|
|
.update(leads)
|
|
.set(updateData)
|
|
.where(eq(leads.id, id))
|
|
.returning();
|
|
|
|
revalidatePath("/admin/leads");
|
|
revalidatePath(`/admin/leads/${id}`);
|
|
return { success: true, lead: updated };
|
|
} catch (error) {
|
|
console.error("updateLead error:", error);
|
|
return { success: false, error: "Errore nell'aggiornamento" };
|
|
}
|
|
}
|
|
|
|
export async function deleteLead(id: string) {
|
|
try {
|
|
await db.delete(leads).where(eq(leads.id, id));
|
|
revalidatePath("/admin/leads");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("deleteLead error:", error);
|
|
return { success: false, error: "Errore nell'eliminazione" };
|
|
}
|
|
}
|
|
|
|
// Activity logging
|
|
export async function logActivity(data: z.infer<typeof createActivitySchema>) {
|
|
const parsed = createActivitySchema.safeParse(data);
|
|
|
|
if (!parsed.success) {
|
|
return { success: false, error: parsed.error.issues[0].message };
|
|
}
|
|
|
|
try {
|
|
const activity = await createActivity({
|
|
lead_id: parsed.data.lead_id,
|
|
type: parsed.data.type,
|
|
activity_date: new Date(parsed.data.activity_date),
|
|
duration_minutes: parsed.data.duration_minutes,
|
|
notes: parsed.data.notes,
|
|
});
|
|
|
|
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
|
|
return { success: true, activity };
|
|
} catch (error) {
|
|
console.error("logActivity error:", error);
|
|
return { success: false, error: "Errore nella registrazione" };
|
|
}
|
|
}
|
|
|
|
// Quote assignment
|
|
const assignQuoteSchema = z.object({
|
|
lead_id: z.string().min(1),
|
|
quote_token: z.string().optional(),
|
|
});
|
|
|
|
export async function assignQuoteToLead(data: z.infer<typeof assignQuoteSchema>) {
|
|
const parsed = assignQuoteSchema.safeParse(data);
|
|
|
|
if (!parsed.success) {
|
|
return { success: false, error: parsed.error.issues[0].message };
|
|
}
|
|
|
|
try {
|
|
// Link quote to lead and update lead status
|
|
const token = parsed.data.quote_token;
|
|
const leadId = parsed.data.lead_id;
|
|
|
|
if (!token) {
|
|
return { success: false, error: "Token preventivo richiesto" };
|
|
}
|
|
|
|
// Find quote by token
|
|
const [quote] = await db
|
|
.select()
|
|
.from(quotes)
|
|
.where(eq(quotes.token, token))
|
|
.limit(1);
|
|
|
|
if (!quote) {
|
|
return { success: false, error: "Preventivo non trovato" };
|
|
}
|
|
|
|
// Update quote to link to lead (if not already linked)
|
|
await db
|
|
.update(quotes)
|
|
.set({ lead_id: leadId })
|
|
.where(eq(quotes.id, quote.id));
|
|
|
|
// Update lead status to "proposal_sent"
|
|
await updateLeadStage(leadId, "proposal_sent");
|
|
|
|
revalidatePath(`/admin/leads/${leadId}`);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("assignQuoteToLead error:", error);
|
|
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");
|
|
}
|
|
|
|
// ── Transcript actions (Phase 20 — KB-02) ────────────────────────────────────
|
|
|
|
const addTranscriptSchema = z.object({
|
|
lead_id: z.string().min(1),
|
|
title: z.string().optional(),
|
|
content: z.string().min(1, "Il testo del transcript è obbligatorio"),
|
|
call_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida (YYYY-MM-DD)"),
|
|
});
|
|
|
|
export async function addTranscript(data: z.infer<typeof addTranscriptSchema>) {
|
|
await requireAdmin();
|
|
|
|
const parsed = addTranscriptSchema.safeParse(data);
|
|
if (!parsed.success) {
|
|
return { success: false, error: parsed.error.issues[0].message };
|
|
}
|
|
|
|
try {
|
|
const [transcript] = await db
|
|
.insert(clientTranscripts)
|
|
.values({
|
|
id: nanoid(),
|
|
lead_id: parsed.data.lead_id,
|
|
title: parsed.data.title || null,
|
|
content: parsed.data.content,
|
|
call_date: parsed.data.call_date,
|
|
})
|
|
.returning();
|
|
|
|
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
|
|
return { success: true, transcript };
|
|
} catch (error) {
|
|
console.error("addTranscript error:", error);
|
|
return { success: false, error: "Errore nel salvataggio del transcript" };
|
|
}
|
|
}
|
|
|
|
export async function deleteTranscript(transcriptId: string, leadId: string) {
|
|
await requireAdmin();
|
|
|
|
if (!transcriptId) throw new Error("ID transcript richiesto");
|
|
|
|
try {
|
|
await db
|
|
.delete(clientTranscripts)
|
|
.where(eq(clientTranscripts.id, transcriptId));
|
|
|
|
revalidatePath(`/admin/leads/${leadId}`);
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("deleteTranscript error:", error);
|
|
return { success: false, error: "Errore nell'eliminazione del transcript" };
|
|
}
|
|
}
|