Files
clienthub/src/app/admin/leads/actions.ts
T
simone b96b6a2083 feat(20-02): add clientTranscripts data layer
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>
2026-06-20 08:55:46 +02:00

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" };
}
}