"use server"; import { and, eq, max } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { logActivity } from "@/modules/_core/activity"; import { fireItemToggleHooks } from "@/modules/_core/registry"; import { listItems, lists } from "../schema"; import { getOrCreateDefaultList } from "./defaults"; import { canAccessList, getList } from "./queries"; import { notifyListChanged } from "./realtime"; const listInput = z.object({ type: z.string().trim().min(1).max(80), name: z.string().trim().min(1).max(120), }); const itemInput = z.object({ listId: z.string().uuid(), text: z.string().trim().min(1).max(300), qty: z.string().trim().max(80).nullable().optional(), notes: z.string().trim().max(2000).nullable().optional(), dueAt: z.coerce.date().nullable().optional(), assigneeId: z.string().uuid().nullable().optional(), metadata: z.record(z.string(), z.unknown()).nullable().optional(), }); const updateItemInput = z.object({ id: z.string().uuid(), text: z.string().trim().min(1).max(300).optional(), qty: z.string().trim().max(80).nullable().optional(), notes: z.string().trim().max(2000).nullable().optional(), dueAt: z.coerce.date().nullable().optional(), assigneeId: z.string().uuid().nullable().optional(), }); export async function createList(input: z.input) { const parsed = listInput.parse(input); const { household } = await getCurrentSession(); const [list] = await db .insert(lists) .values({ householdId: household.id, type: parsed.type, name: parsed.name, }) .returning(); if (!list) throw new Error("List was not created"); await logActivity({ entityType: "lists.list", entityId: list.id, action: "create", payload: { name: list.name }, }); revalidatePath("/lists"); return list; } export async function renameList(input: { id: string; name: string }) { const parsed = z.object({ id: z.string().uuid(), name: listInput.shape.name }).parse(input); const { household } = await getCurrentSession(); await assertCanAccessList(parsed.id, household.id); await db.update(lists).set({ name: parsed.name }).where(eq(lists.id, parsed.id)); await logActivity({ entityType: "lists.list", entityId: parsed.id, action: "update", payload: { name: parsed.name }, }); revalidatePath("/lists"); revalidatePath(`/lists/${parsed.id}`); await notifyListChanged(parsed.id); } export async function archiveList(input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); const { household } = await getCurrentSession(); await assertCanAccessList(parsed.id, household.id); await db.update(lists).set({ archived: true }).where(eq(lists.id, parsed.id)); await logActivity({ entityType: "lists.list", entityId: parsed.id, action: "archive" }); revalidatePath("/lists"); revalidatePath(`/lists/${parsed.id}`); await notifyListChanged(parsed.id); } export async function addItem(input: z.input) { const parsed = itemInput.parse(input); const { household } = await getCurrentSession(); await assertCanAccessList(parsed.listId, household.id); const [positionRow] = await db .select({ maxPosition: max(listItems.position) }) .from(listItems) .where(eq(listItems.listId, parsed.listId)); const [item] = await db .insert(listItems) .values({ listId: parsed.listId, text: parsed.text, qty: parsed.qty || null, notes: parsed.notes || null, dueAt: parsed.dueAt ?? null, assigneeId: parsed.assigneeId ?? null, metadata: parsed.metadata ?? null, position: (positionRow?.maxPosition ?? -1) + 1, }) .returning(); if (!item) throw new Error("List item was not created"); await logActivity({ entityType: "lists.item", entityId: item.id, action: "create", payload: { text: item.text }, }); revalidatePath(`/lists/${parsed.listId}`); await notifyListChanged(parsed.listId); return getList(parsed.listId); } export async function addItemToDefaultList(input: { type: string; text: string }) { const parsed = z .object({ type: listInput.shape.type, text: itemInput.shape.text, }) .parse(input); const { household } = await getCurrentSession(); const list = await getOrCreateDefaultList({ householdId: household.id, type: parsed.type }); return addItem({ listId: list.id, text: parsed.text }); } export async function toggleItem(input: { id: string; done?: boolean }) { const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input); const { household, user } = await getCurrentSession(); const existing = await getAuthorizedItem(parsed.id, household.id); const done = parsed.done ?? !existing.done; await db .update(listItems) .set({ done, updatedAt: new Date() }) .where(eq(listItems.id, parsed.id)); await logActivity({ entityType: "lists.item", entityId: parsed.id, action: "toggle", payload: { done, text: existing.text }, }); if (done && existing.metadata) { await fireItemToggleHooks({ itemId: parsed.id, metadata: existing.metadata as Record, done, userId: user.id, }); } revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); return getList(existing.listId); } export async function updateItem(input: z.input) { const parsed = updateItemInput.parse(input); const { household } = await getCurrentSession(); const existing = await getAuthorizedItem(parsed.id, household.id); await db .update(listItems) .set({ text: parsed.text, qty: parsed.qty === undefined ? undefined : parsed.qty || null, notes: parsed.notes === undefined ? undefined : parsed.notes || null, dueAt: parsed.dueAt === undefined ? undefined : parsed.dueAt, assigneeId: parsed.assigneeId === undefined ? undefined : parsed.assigneeId, updatedAt: new Date(), }) .where(eq(listItems.id, parsed.id)); await logActivity({ entityType: "lists.item", entityId: parsed.id, action: "update", payload: { text: parsed.text ?? existing.text }, }); revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); return getList(existing.listId); } export async function deleteItem(input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); const { household } = await getCurrentSession(); const existing = await getAuthorizedItem(parsed.id, household.id); await logActivity({ entityType: "lists.item", entityId: parsed.id, action: "delete", payload: { text: existing.text }, }); await db.delete(listItems).where(eq(listItems.id, parsed.id)); revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); return getList(existing.listId); } export async function reorderItems(input: { listId: string; itemIds: string[] }) { const parsed = z .object({ listId: z.string().uuid(), itemIds: z.array(z.string().uuid()) }) .parse(input); const { household } = await getCurrentSession(); await assertCanAccessList(parsed.listId, household.id); await db.transaction(async (tx) => { for (const [position, id] of parsed.itemIds.entries()) { await tx .update(listItems) .set({ position, updatedAt: new Date() }) .where(and(eq(listItems.id, id), eq(listItems.listId, parsed.listId))); } }); revalidatePath(`/lists/${parsed.listId}`); await notifyListChanged(parsed.listId); return getList(parsed.listId); } async function assertCanAccessList(listId: string, householdId: string) { if (!(await canAccessList(listId, householdId))) throw new Error("Forbidden"); } async function getAuthorizedItem(itemId: string, householdId: string) { const [item] = await db .select({ id: listItems.id, listId: listItems.listId, done: listItems.done, text: listItems.text, metadata: listItems.metadata, }) .from(listItems) .innerJoin(lists, eq(listItems.listId, lists.id)) .where(and(eq(listItems.id, itemId), eq(lists.householdId, householdId))) .limit(1); if (!item) throw new Error("Item not found"); return item; }