"use server"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/db"; import { requireUserId } from "@/lib/auth-helpers"; import { boardPath, categoryAccessFilter } from "@/lib/access"; import { TodoDetailsSchema, TodoTitleSchema } from "@/lib/validation/todo"; import type { TodoDTO } from "@/types/board"; export async function createTodo( groupId: string, title: string, details?: string ): Promise { const userId = await requireUserId(); const parsedTitle = TodoTitleSchema.parse(title); const parsedDetails = details ? TodoDetailsSchema.parse(details) : undefined; const group = await prisma.group.findFirst({ where: { id: groupId, category: categoryAccessFilter(userId) }, select: { id: true, category: { select: { projectId: true } } }, }); if (!group) throw new Error("Group not found"); const count = await prisma.todo.count({ where: { groupId } }); const todo = await prisma.todo.create({ data: { title: parsedTitle, details: parsedDetails ?? null, order: count, groupId }, }); revalidatePath(boardPath(group.category.projectId)); return { id: todo.id, title: todo.title, details: todo.details, completed: todo.completed, order: todo.order, groupId: todo.groupId, createdAt: todo.createdAt.toISOString(), updatedAt: todo.updatedAt.toISOString(), completedAt: todo.completedAt?.toISOString() ?? null, }; } /** * Bulk-inserts several to-dos in one transaction, e.g. from the "Add * using AI" interview. Unlike calling `createTodo` in a loop, `order` is * computed once up front and assigned sequentially within the same * transaction, so concurrent items in this batch never race each other's * `prisma.todo.count` read. */ export async function createTodos( groupId: string, todos: { title: string; details?: string }[] ): Promise { const userId = await requireUserId(); const parsed = todos.map((t) => ({ title: TodoTitleSchema.parse(t.title), details: t.details ? TodoDetailsSchema.parse(t.details) : undefined, })); const group = await prisma.group.findFirst({ where: { id: groupId, category: categoryAccessFilter(userId) }, select: { id: true, category: { select: { projectId: true } } }, }); if (!group) throw new Error("Group not found"); const count = await prisma.todo.count({ where: { groupId } }); const created = await prisma.$transaction( parsed.map((t, index) => prisma.todo.create({ data: { title: t.title, details: t.details ?? null, order: count + index, groupId }, }) ) ); revalidatePath(boardPath(group.category.projectId)); return created.map((todo) => ({ id: todo.id, title: todo.title, details: todo.details, completed: todo.completed, order: todo.order, groupId: todo.groupId, createdAt: todo.createdAt.toISOString(), updatedAt: todo.updatedAt.toISOString(), completedAt: todo.completedAt?.toISOString() ?? null, })); } export async function updateTodo( todoId: string, data: { title?: string; details?: string | null } ): Promise { const userId = await requireUserId(); const update: { title?: string; details?: string | null } = {}; if (data.title !== undefined) update.title = TodoTitleSchema.parse(data.title); if (data.details !== undefined) { update.details = data.details ? TodoDetailsSchema.parse(data.details) : null; } const todo = await prisma.todo.findFirst({ where: { id: todoId, group: { category: categoryAccessFilter(userId) } }, select: { group: { select: { category: { select: { projectId: true } } } } }, }); if (!todo) throw new Error("To-do not found"); await prisma.todo.update({ where: { id: todoId }, data: update }); revalidatePath(boardPath(todo.group.category.projectId)); } export async function toggleTodo(todoId: string, completed: boolean): Promise { const userId = await requireUserId(); const todo = await prisma.todo.findFirst({ where: { id: todoId, group: { category: categoryAccessFilter(userId) } }, select: { group: { select: { category: { select: { projectId: true } } } } }, }); if (!todo) throw new Error("To-do not found"); await prisma.todo.update({ where: { id: todoId }, data: { completed, completedAt: completed ? new Date() : null }, }); revalidatePath(boardPath(todo.group.category.projectId)); } export async function deleteTodo(todoId: string): Promise { const userId = await requireUserId(); const todo = await prisma.todo.findFirst({ where: { id: todoId, group: { category: categoryAccessFilter(userId) } }, select: { group: { select: { category: { select: { projectId: true } } } } }, }); if (!todo) throw new Error("To-do not found"); await prisma.todo.delete({ where: { id: todoId } }); revalidatePath(boardPath(todo.group.category.projectId)); } /** Persists a new to-do order within a single group. */ export async function reorderTodos(groupId: string, orderedTodoIds: string[]): Promise { const userId = await requireUserId(); const group = await prisma.group.findFirst({ where: { id: groupId, category: categoryAccessFilter(userId) }, include: { todos: { select: { id: true } }, category: { select: { projectId: true } }, }, }); if (!group) throw new Error("Group not found"); const ownedIds = new Set(group.todos.map((t) => t.id)); if ( orderedTodoIds.length !== ownedIds.size || !orderedTodoIds.every((id) => ownedIds.has(id)) ) { throw new Error("To-do list does not match this group's to-dos"); } await prisma.$transaction( orderedTodoIds.map((id, index) => prisma.todo.update({ where: { id }, data: { order: index } }) ) ); revalidatePath(boardPath(group.category.projectId)); }