"use server"; import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/db"; import { requireUserId } from "@/lib/auth-helpers"; import { boardPath, categoryAccessFilter, projectAccessFilter } from "@/lib/access"; import { CategoryNameSchema } from "@/lib/validation/category"; import type { CategoryDTO } from "@/types/board"; /** * Creates a Category in the caller's Home board, or inside a Project the * caller owns when `projectId` is given -- verified against * projectAccessFilter() rather than trusted from the argument. */ export async function createCategory(name: string, projectId?: string): Promise { const userId = await requireUserId(); const parsedName = CategoryNameSchema.parse(name); if (projectId) { const project = await prisma.project.findFirst({ where: { id: projectId, ...projectAccessFilter(userId) }, select: { id: true }, }); if (!project) throw new Error("Project not found"); } const count = await prisma.category.count({ where: projectId ? { projectId } : { userId, projectId: null }, }); const category = await prisma.category.create({ data: projectId ? { name: parsedName, order: count, projectId } : { name: parsedName, order: count, userId }, }); revalidatePath(boardPath(projectId)); return { id: category.id, name: category.name, order: category.order, groups: [] }; } export async function renameCategory(categoryId: string, name: string): Promise { const userId = await requireUserId(); const parsedName = CategoryNameSchema.parse(name); const category = await prisma.category.findFirst({ where: { id: categoryId, ...categoryAccessFilter(userId) }, select: { id: true, projectId: true }, }); if (!category) throw new Error("Category not found"); await prisma.category.update({ where: { id: categoryId }, data: { name: parsedName } }); revalidatePath(boardPath(category.projectId)); } export type DeleteCategoryResult = { error?: string }; /** * Deleting a non-empty category would cascade-delete every group (and * their to-dos) inside it -- so this is an expected, user-facing * validation rather than an exceptional failure, and is modeled as a * return value rather than a thrown error (Next.js redacts thrown Server * Action error messages in production, so a specific message needs to * come back this way to actually reach the client). */ export async function deleteCategory(categoryId: string): Promise { const userId = await requireUserId(); const category = await prisma.category.findFirst({ where: { id: categoryId, ...categoryAccessFilter(userId) }, select: { projectId: true, _count: { select: { groups: true } } }, }); if (!category) return { error: "Category not found." }; if (category._count.groups > 0) { return { error: "This category still has groups in it. Delete or move them out first." }; } await prisma.category.delete({ where: { id: categoryId } }); revalidatePath(boardPath(category.projectId)); return {}; } /** * Persists a new lane order for every category in one board -- the * caller's Home board, or a Project they own when `projectId` is given. * `orderedIds` must contain every category id in that scope. */ export async function reorderCategories(orderedIds: string[], projectId?: string): Promise { const userId = await requireUserId(); const owned = await prisma.category.findMany({ where: projectId ? { projectId, project: projectAccessFilter(userId) } : { userId, projectId: null }, select: { id: true }, }); const ownedIds = new Set(owned.map((c) => c.id)); if (orderedIds.length !== ownedIds.size || !orderedIds.every((id) => ownedIds.has(id))) { throw new Error("Category list does not match this board's categories"); } await prisma.$transaction( orderedIds.map((id, index) => prisma.category.update({ where: { id }, data: { order: index } }) ) ); revalidatePath(boardPath(projectId)); }