"use server"; import { and, asc, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { getWidget } from "@/modules/_core"; import { dashboards } from "@/modules/_core/schema"; import { type DashboardLayout } from "@/lib/dashboard"; import { computeDefaultLayout } from "@/lib/dashboard.server"; export type DashboardMeta = { id: string; name: string; slug: string; isDefault: boolean; position: number; }; export async function listDashboards(): Promise { const { user } = await getCurrentSession(); const rows = await db .select({ id: dashboards.id, name: dashboards.name, slug: dashboards.slug, isDefault: dashboards.isDefault, position: dashboards.position, }) .from(dashboards) .where(eq(dashboards.userId, user.id)) .orderBy(asc(dashboards.position), asc(dashboards.createdAt)); return rows; } export async function getDefaultDashboardSlug(): Promise { const { user } = await getCurrentSession(); const rows = await db .select({ slug: dashboards.slug }) .from(dashboards) .where(and(eq(dashboards.userId, user.id), eq(dashboards.isDefault, true))) .limit(1); if (rows[0]) return rows[0].slug; // Fallback: first dashboard by position const [first] = await db .select({ slug: dashboards.slug }) .from(dashboards) .where(eq(dashboards.userId, user.id)) .orderBy(asc(dashboards.position), asc(dashboards.createdAt)) .limit(1); if (first) return first.slug; // No dashboards yet — insert one directly without revalidatePath (we're in a // render path; the redirect that follows is a fresh request anyway). const slug = await uniqueSlug(user.id, "home"); await db .insert(dashboards) .values({ userId: user.id, name: "Home", slug, isDefault: false, position: 0 }); return slug; } export async function getDashboardBySlug(slug: string) { const { user } = await getCurrentSession(); const [row] = await db .select() .from(dashboards) .where(and(eq(dashboards.userId, user.id), eq(dashboards.slug, slug))) .limit(1); return row ?? null; } function toSlug(name: string): string { return ( name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") || "dashboard" ); } async function uniqueSlug(userId: string, base: string): Promise { let slug = base; let attempt = 1; for (;;) { const [existing] = await db .select({ id: dashboards.id }) .from(dashboards) .where(and(eq(dashboards.userId, userId), eq(dashboards.slug, slug))) .limit(1); if (!existing) return slug; attempt++; slug = `${base}-${attempt}`; } } export async function createDashboard(name: string): Promise { const parsed = z.string().trim().min(1).max(80).parse(name); const { user } = await getCurrentSession(); const slug = await uniqueSlug(user.id, toSlug(parsed)); const rows = await db .insert(dashboards) .values({ userId: user.id, name: parsed, slug, isDefault: false, position: 9999 }) .returning(); const row = rows[0]; if (!row) throw new Error("Insert failed"); revalidatePath("/"); return { id: row.id, name: row.name, slug: row.slug, isDefault: row.isDefault, position: row.position, }; } export async function renameDashboard(id: string, name: string): Promise { const parsedName = z.string().trim().min(1).max(80).parse(name); const { user } = await getCurrentSession(); await db .update(dashboards) .set({ name: parsedName, updatedAt: new Date() }) .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); revalidatePath("/"); } export async function deleteDashboard(id: string): Promise { const { user } = await getCurrentSession(); const all = await db .select({ id: dashboards.id, isDefault: dashboards.isDefault, slug: dashboards.slug }) .from(dashboards) .where(eq(dashboards.userId, user.id)); if (all.length <= 1) throw new Error("Cannot delete your only dashboard"); const target = all.find((d) => d.id === id); if (!target) throw new Error("Dashboard not found"); await db.delete(dashboards).where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); // If we deleted the default, promote another if (target.isDefault) { const next = all.find((d) => d.id !== id); if (next) { await db .update(dashboards) .set({ isDefault: true }) .where(and(eq(dashboards.id, next.id), eq(dashboards.userId, user.id))); } } revalidatePath("/"); redirect("/"); } export async function setDefaultDashboard(id: string): Promise { const { user } = await getCurrentSession(); await db.update(dashboards).set({ isDefault: false }).where(eq(dashboards.userId, user.id)); await db .update(dashboards) .set({ isDefault: true }) .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); revalidatePath("/"); } export async function reorderDashboards(orderedIds: string[]): Promise { const { user } = await getCurrentSession(); await Promise.all( orderedIds.map((id, i) => db .update(dashboards) .set({ position: i }) .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))), ), ); revalidatePath("/"); } export async function saveDashboardLayout(id: string, layout: DashboardLayout): Promise { const { user } = await getCurrentSession(); // Validate each widget's config against its registered schema for (const placement of layout.widgets) { const widget = getWidget(placement.widgetId); if (!widget) continue; widget.configSchema.parse(placement.config); } await db .update(dashboards) .set({ layout: layout as unknown as Record, updatedAt: new Date() }) .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); revalidatePath("/"); } export async function resetDashboardLayout(id: string): Promise { const { user } = await getCurrentSession(); const layout = computeDefaultLayout(); await db .update(dashboards) .set({ layout: layout as unknown as Record, updatedAt: new Date() }) .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); revalidatePath("/"); } export async function resolveWidgetConfigOptions(widgetId: string): Promise { const { user, household } = await getCurrentSession(); const widget = getWidget(widgetId); if (!widget?.resolveConfigOptions) return null; return widget.resolveConfigOptions({ userId: user.id, householdId: household.id }); }