176 lines
5.7 KiB
TypeScript
176 lines
5.7 KiB
TypeScript
"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<TodoDTO> {
|
|
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<TodoDTO[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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));
|
|
}
|