"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { db } from "@/db"; async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } import { phases, tasks, deliverables, documents, payments, clients, projects, comments, } from "@/db/schema"; import { eq, asc, inArray } from "drizzle-orm"; import { z } from "zod"; // ── ENTITY RESOLUTION ──────────────────────────────────────────────────────── // Both clientId and projectId are passed as "clientId" by tab components. // This helper resolves the correct projectId and revalidation path for either. async function resolveEntity(id: string): Promise<{ projectId: string | null; path: string }> { const asProject = await db .select({ id: projects.id }) .from(projects) .where(eq(projects.id, id)) .limit(1); if (asProject[0]) { return { projectId: id, path: `/admin/projects/${id}` }; } const asClient = await db .select({ id: projects.id }) .from(projects) .where(eq(projects.client_id, id)) .orderBy(asc(projects.created_at)) .limit(1); return { projectId: asClient[0]?.id ?? null, path: `/admin/clients/${id}` }; } // ── CLIENT CRUD ─────────────────────────────────────────────────────────────── const emptyToNull = (v: unknown) => { const s = typeof v === "string" ? v.trim() : ""; return s === "" ? null : s; }; const clientSchema = z.object({ name: z.string().min(1, "Nome richiesto"), brand_name: z.string().min(1, "Brand name richiesto"), brief: z.string(), email: z.string().optional().transform(emptyToNull), phone: z.string().optional().transform(emptyToNull), slug: z .string() .regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)") .optional() .or(z.literal("")) .transform((v) => (v === "" || v === undefined ? null : v)), }); export async function updateClient(clientId: string, formData: FormData) { await requireAdmin(); const parsed = clientSchema.safeParse({ name: formData.get("name"), brand_name: formData.get("brand_name"), brief: formData.get("brief") ?? "", email: formData.get("email") ?? "", phone: formData.get("phone") ?? "", slug: formData.get("slug") ?? "", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); try { await db.update(clients).set(parsed.data).where(eq(clients.id, clientId)); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); if (msg.includes("unique") || msg.includes("duplicate")) { redirect(`/admin/clients/${clientId}/edit?error=slug_taken`); } throw e; } revalidatePath(`/admin/clients/${clientId}`); revalidatePath("/admin"); redirect(`/admin/clients/${clientId}`); } export async function deleteClient(clientId: string) { await requireAdmin(); await db.delete(clients).where(eq(clients.id, clientId)); revalidatePath("/admin"); redirect("/admin"); } export async function setClientArchived(clientId: string, archived: boolean) { await requireAdmin(); await db.update(clients).set({ archived }).where(eq(clients.id, clientId)); revalidatePath(`/admin/clients/${clientId}`); revalidatePath("/admin"); } // ── PHASES ──────────────────────────────────────────────────────────────────── export async function addPhase(id: string, formData: FormData) { await requireAdmin(); const title = (formData.get("title") as string)?.trim(); if (!title) throw new Error("Titolo fase richiesto"); const { projectId, path } = await resolveEntity(id); if (!projectId) throw new Error("Nessun progetto trovato"); const existingPhases = await db .select({ sort_order: phases.sort_order }) .from(phases) .where(eq(phases.project_id, projectId)); const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1); await db.insert(phases).values({ project_id: projectId, title, sort_order: maxOrder + 1, status: "upcoming", }); revalidatePath(path); } export async function updatePhaseStatus(phaseId: string, id: string, status: string) { await requireAdmin(); const allowed = ["upcoming", "active", "done"]; if (!allowed.includes(status)) throw new Error("Stato non valido"); await db.update(phases).set({ status }).where(eq(phases.id, phaseId)); const { path } = await resolveEntity(id); revalidatePath(path); } export async function deletePhase(phaseId: string, id: string) { await requireAdmin(); // FK cascade on tasks and deliverables handles child rows await db.delete(phases).where(eq(phases.id, phaseId)); const { path } = await resolveEntity(id); revalidatePath(path); } // ── TASKS ───────────────────────────────────────────────────────────────────── export async function addTask(phaseId: string, id: string, formData: FormData) { await requireAdmin(); const title = (formData.get("title") as string)?.trim(); if (!title) throw new Error("Titolo task richiesto"); const existingTasks = await db .select({ sort_order: tasks.sort_order }) .from(tasks) .where(eq(tasks.phase_id, phaseId)); const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.sort_order), -1); await db.insert(tasks).values({ phase_id: phaseId, title, description: (formData.get("description") as string)?.trim() || null, sort_order: maxOrder + 1, status: "todo", }); // Cascade: a new todo task keeps phase active/upcoming — recompute to be safe await recomputePhaseStatus(phaseId); const { path } = await resolveEntity(id); revalidatePath(path); } // ── PHASE STATUS CASCADE ────────────────────────────────────────────────────── // Recomputes phase status from its tasks: // all done → done // any in_progress or done (but not all done) → active // all todo (or no tasks) → upcoming export async function recomputePhaseStatus(phaseId: string): Promise { const phaseTasks = await db .select({ status: tasks.status }) .from(tasks) .where(eq(tasks.phase_id, phaseId)); let newStatus: "upcoming" | "active" | "done" = "upcoming"; if (phaseTasks.length > 0) { const allDone = phaseTasks.every((t) => t.status === "done"); const anyActive = phaseTasks.some( (t) => t.status === "in_progress" || t.status === "done" ); if (allDone) newStatus = "done"; else if (anyActive) newStatus = "active"; } await db.update(phases).set({ status: newStatus }).where(eq(phases.id, phaseId)); } export async function updateTaskStatus(taskId: string, id: string, status: string) { await requireAdmin(); const allowed = ["todo", "in_progress", "done"]; if (!allowed.includes(status)) throw new Error("Stato non valido"); await db.update(tasks).set({ status }).where(eq(tasks.id, taskId)); // Cascade: recompute parent phase status from all its tasks const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1); if (taskRow[0]) await recomputePhaseStatus(taskRow[0].phase_id); const { path } = await resolveEntity(id); revalidatePath(path); } export async function deleteTask(taskId: string, id: string) { await requireAdmin(); // Fetch phase_id before deletion so we can recompute phase status after const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1); const phaseId = taskRow[0]?.phase_id; await db.delete(tasks).where(eq(tasks.id, taskId)); if (phaseId) await recomputePhaseStatus(phaseId); const { path } = await resolveEntity(id); revalidatePath(path); } // ── DELIVERABLES ────────────────────────────────────────────────────────────── export async function addDeliverable(taskId: string, id: string, formData: FormData) { await requireAdmin(); const title = (formData.get("title") as string)?.trim(); const url = (formData.get("url") as string)?.trim() || null; if (!title) throw new Error("Titolo deliverable richiesto"); await db.insert(deliverables).values({ task_id: taskId, title, url, status: "pending" }); const { path } = await resolveEntity(id); revalidatePath(path); } // ── DOCUMENTS ───────────────────────────────────────────────────────────────── const docSchema = z.object({ label: z.string().min(1, "Etichetta richiesta"), url: z.string().url("URL non valido"), }); export async function addDocument(id: string, formData: FormData) { await requireAdmin(); const parsed = docSchema.safeParse({ label: formData.get("label"), url: formData.get("url"), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); const { projectId, path } = await resolveEntity(id); if (!projectId) throw new Error("Nessun progetto trovato"); await db.insert(documents).values({ project_id: projectId, ...parsed.data }); revalidatePath(path); } export async function updateDocument(documentId: string, id: string, formData: FormData) { await requireAdmin(); const parsed = docSchema.safeParse({ label: formData.get("label"), url: formData.get("url"), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.update(documents).set(parsed.data).where(eq(documents.id, documentId)); const { path } = await resolveEntity(id); revalidatePath(path); } export async function deleteDocument(documentId: string, id: string) { await requireAdmin(); await db.delete(documents).where(eq(documents.id, documentId)); const { path } = await resolveEntity(id); revalidatePath(path); } // ── PAYMENTS ────────────────────────────────────────────────────────────────── export async function updatePaymentStatus(paymentId: string, id: string, status: string) { await requireAdmin(); const allowed = ["da_saldare", "inviata", "saldato"]; if (!allowed.includes(status)) throw new Error("Stato pagamento non valido"); const paid_at = status === "saldato" ? new Date() : null; await db.update(payments).set({ status, paid_at }).where(eq(payments.id, paymentId)); const { path } = await resolveEntity(id); revalidatePath(path); } // Imposta il mese in cui un pagamento è stato incassato (formato "YYYY-MM"). // Mappa al primo giorno del mese (mezzogiorno UTC per evitare drift di fuso) e // porta lo stato a "saldato" così l'incasso viene attribuito a quel mese nelle analytics. export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: string) { await requireAdmin(); const m = /^(\d{4})-(\d{2})$/.exec(monthStr); if (!m) throw new Error("Mese non valido"); const year = parseInt(m[1], 10); const month = parseInt(m[2], 10); if (month < 1 || month > 12) throw new Error("Mese non valido"); const paid_at = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0)); await db .update(payments) .set({ paid_at, status: "saldato" }) .where(eq(payments.id, paymentId)); const { path } = await resolveEntity(id); revalidatePath(path); } // Rescales payment amounts when the total changes. // If the payment has a `percent` field, use it (new plan rows). // Legacy rows (percent null) fall back to equal split across all rows. async function rescalePayments(projectId: string, newTotal: number): Promise { const projectPayments = await db .select({ id: payments.id, percent: payments.percent }) .from(payments) .where(eq(payments.project_id, projectId)); if (projectPayments.length === 0) return; const hasPercent = projectPayments.some((p) => p.percent !== null); if (hasPercent) { // New plan: rescale each row by its stored percent for (const p of projectPayments) { const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0; const newAmount = ((newTotal * pct) / 100).toFixed(2); await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id)); } } else { // Legacy: equal split across all rows (backward compat) const share = (newTotal / projectPayments.length).toFixed(2); for (const p of projectPayments) { await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id)); } } } export async function updateAcceptedTotal(id: string, formData: FormData) { await requireAdmin(); const raw = (formData.get("accepted_total") as string)?.trim(); const val = parseFloat(raw); if (isNaN(val) || val < 0) throw new Error("Importo non valido"); const asProject = await db .select({ id: projects.id }) .from(projects) .where(eq(projects.id, id)) .limit(1); if (asProject[0]) { // Project context: update projects.accepted_total + rescale this project's payments await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id)); await rescalePayments(id, val); revalidatePath(`/admin/projects/${id}`); } else { // Client context: update clients.accepted_total + rescale all project payments await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id)); const projectRows = await db.select({ id: projects.id }) .from(projects).where(eq(projects.client_id, id)); for (const proj of projectRows) { await rescalePayments(proj.id, val); } revalidatePath(`/admin/clients/${id}`); } } // ── COMMENTS (admin reply) ──────────────────────────────────────────────────── export async function postAdminComment(id: string, formData: FormData) { await requireAdmin(); const entity = formData.get("entity") as string; const body = (formData.get("body") as string)?.trim(); if (!body || !entity) throw new Error("Dati mancanti"); const [entity_type, entity_id] = entity.split(":"); if (!entity_type || !entity_id) throw new Error("Formato entity non valido"); const allowedTypes = ["task", "deliverable", "phase", "general"]; if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido"); await db.insert(comments).values({ entity_type, entity_id, author: "admin", body }); const { path } = await resolveEntity(id); revalidatePath(path); }