"use server"; import { and, eq, max } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import type { ApiAuthContext } from "@/lib/api-auth"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { logActivityForScope } from "@/modules/_core/activity"; import { fireItemToggleHooks } from "@/modules/_core/registry"; import { listItems, lists } from "../schema"; import { canAccessList, getList, getListForScope } from "./queries"; import { notifyListChanged } from "./realtime"; import { itemInput, listInput, listUpdateInput, updateItemInput } from "./schemas"; function toScope(ctx: ApiAuthContext) { return { householdId: ctx.householdId, userId: ctx.userId }; } export async function createListForScope(scope: ApiAuthContext, input: z.input) { const parsed = listInput.parse(input); const [list] = await db .insert(lists) .values({ householdId: scope.householdId, type: parsed.type, name: parsed.name, }) .returning(); if (!list) throw new Error("List was not created"); await logActivityForScope(toScope(scope), { entityType: "lists.list", entityId: list.id, action: "create", payload: { name: list.name }, }); return list; } export async function createList(input: z.input) { const { household, user } = await getCurrentSession(); const list = await createListForScope( { householdId: household.id, userId: user.id, role: null }, input, ); revalidatePath("/lists"); return list; } export async function updateListForScope( scope: ApiAuthContext, input: { id: string } & z.input, ) { const parsed = z.object({ id: z.string().uuid() }).and(listUpdateInput).parse(input); await assertCanAccessList(parsed.id, scope.householdId); await db .update(lists) .set({ name: parsed.name, type: parsed.type, archived: parsed.archived, }) .where(eq(lists.id, parsed.id)); if (parsed.name || parsed.type) { await logActivityForScope(toScope(scope), { entityType: "lists.list", entityId: parsed.id, action: "update", payload: { ...(parsed.name ? { name: parsed.name } : {}), ...(parsed.type ? { type: parsed.type } : {}), }, }); } if (parsed.archived === true) { await logActivityForScope(toScope(scope), { entityType: "lists.list", entityId: parsed.id, action: "archive", }); } await notifyListChanged(parsed.id); } 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, user } = await getCurrentSession(); await updateListForScope( { householdId: household.id, userId: user.id, role: null }, { id: parsed.id, name: parsed.name }, ); revalidatePath("/lists"); revalidatePath(`/lists/${parsed.id}`); } export async function updateListProperties(input: { id: string; name?: string; type?: string }) { const parsed = z .object({ id: z.string().uuid(), name: listInput.shape.name.optional(), type: listInput.shape.type.optional(), }) .parse(input); const { household, user } = await getCurrentSession(); await updateListForScope({ householdId: household.id, userId: user.id, role: null }, parsed); revalidatePath("/lists"); revalidatePath(`/lists/${parsed.id}`); } export async function archiveList(input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); const { household, user } = await getCurrentSession(); await updateListForScope( { householdId: household.id, userId: user.id, role: null }, { id: parsed.id, archived: true }, ); revalidatePath("/lists"); revalidatePath(`/lists/${parsed.id}`); } export async function deleteListForScope(scope: ApiAuthContext, input: { id: string }) { await updateListForScope(scope, { id: input.id, archived: true }); } export async function addItemForScope(scope: ApiAuthContext, input: z.input) { const parsed = itemInput.parse(input); await assertCanAccessList(parsed.listId, scope.householdId); 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 logActivityForScope(toScope(scope), { entityType: "lists.item", entityId: item.id, action: "create", payload: { text: item.text }, }); await notifyListChanged(parsed.listId); return toItemDto(item); } export async function addItem(input: z.input) { const parsed = itemInput.parse(input); const { household, user } = await getCurrentSession(); await addItemForScope({ householdId: household.id, userId: user.id, role: null }, parsed); revalidatePath(`/lists/${parsed.listId}`); return getList(parsed.listId); } export async function toggleItemForScope( scope: ApiAuthContext, input: { id: string; done?: boolean }, ) { const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input); const existing = await getAuthorizedItem(parsed.id, scope.householdId); const done = parsed.done ?? !existing.done; await db .update(listItems) .set({ done, updatedAt: new Date() }) .where(eq(listItems.id, parsed.id)); await logActivityForScope(toScope(scope), { entityType: "lists.item", entityId: parsed.id, action: "toggle", payload: { done, text: existing.text }, }); if (done && existing.metadata && scope.userId) { await fireItemToggleHooks({ itemId: parsed.id, metadata: existing.metadata as Record, done, userId: scope.userId, }); } await notifyListChanged(existing.listId); return getListForScope(scope.householdId, existing.listId); } export async function toggleItem(input: { id: string; done?: boolean }) { const { household, user } = await getCurrentSession(); const list = await toggleItemForScope( { householdId: household.id, userId: user.id, role: null }, input, ); revalidatePath(`/lists/${list.id}`); return list; } export async function updateItemForScope( scope: ApiAuthContext, input: z.input, ) { const parsed = updateItemInput.parse(input); const existing = await getAuthorizedItem(parsed.id, scope.householdId); if (parsed.done !== undefined) { return toggleItemForScope(scope, { id: parsed.id, done: parsed.done }); } 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 logActivityForScope(toScope(scope), { entityType: "lists.item", entityId: parsed.id, action: "update", payload: { text: parsed.text ?? existing.text }, }); await notifyListChanged(existing.listId); return getListForScope(scope.householdId, existing.listId); } export async function updateItem(input: z.input) { const { household, user } = await getCurrentSession(); const list = await updateItemForScope( { householdId: household.id, userId: user.id, role: null }, input, ); revalidatePath(`/lists/${list.id}`); return list; } export async function deleteItemForScope(scope: ApiAuthContext, input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); const existing = await getAuthorizedItem(parsed.id, scope.householdId); await logActivityForScope(toScope(scope), { entityType: "lists.item", entityId: parsed.id, action: "delete", payload: { text: existing.text }, }); await db.delete(listItems).where(eq(listItems.id, parsed.id)); await notifyListChanged(existing.listId); return getListForScope(scope.householdId, existing.listId); } export async function deleteItem(input: { id: string }) { const { household, user } = await getCurrentSession(); const list = await deleteItemForScope( { householdId: household.id, userId: user.id, role: null }, input, ); revalidatePath(`/lists/${list.id}`); return list; } 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; } function toItemDto(item: typeof listItems.$inferSelect) { return { id: item.id, listId: item.listId, text: item.text, done: item.done, qty: item.qty, notes: item.notes, dueAt: item.dueAt?.toISOString() ?? null, assigneeId: item.assigneeId, position: item.position, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), }; }