"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 { GroupColorSchema, GroupTitleSchema } from "@/lib/validation/group"; import type { GroupDTO } from "@/types/board"; export async function createGroup( categoryId: string, title: string, color: string ): Promise { const userId = await requireUserId(); const parsedTitle = GroupTitleSchema.parse(title); const parsedColor = GroupColorSchema.parse(color); const category = await prisma.category.findFirst({ where: { id: categoryId, ...categoryAccessFilter(userId) }, select: { id: true, projectId: true }, }); if (!category) throw new Error("Category not found"); const count = await prisma.group.count({ where: { categoryId } }); const group = await prisma.group.create({ data: { title: parsedTitle, color: parsedColor, order: count, categoryId }, }); revalidatePath(boardPath(category.projectId)); return { id: group.id, title: group.title, color: group.color, order: group.order, noteContent: group.noteContent, categoryId: group.categoryId, todos: [], }; } export async function updateGroup( groupId: string, data: { title?: string; color?: string } ): Promise { const userId = await requireUserId(); const update: { title?: string; color?: string } = {}; if (data.title !== undefined) update.title = GroupTitleSchema.parse(data.title); if (data.color !== undefined) update.color = GroupColorSchema.parse(data.color); const group = await prisma.group.findFirst({ where: { id: groupId, category: categoryAccessFilter(userId) }, select: { category: { select: { projectId: true } } }, }); if (!group) throw new Error("Group not found"); await prisma.group.update({ where: { id: groupId }, data: update }); revalidatePath(boardPath(group.category.projectId)); } export async function deleteGroup(groupId: string): Promise { const userId = await requireUserId(); const group = await prisma.group.findFirst({ where: { id: groupId, category: categoryAccessFilter(userId) }, select: { category: { select: { projectId: true } } }, }); if (!group) throw new Error("Group not found"); await prisma.group.delete({ where: { id: groupId } }); revalidatePath(boardPath(group.category.projectId)); } /** * Archives a group instead of deleting it -- the row (and its to-dos) is * kept, just excluded from the board query going forward. */ export async function archiveGroup(groupId: string): Promise { const userId = await requireUserId(); const group = await prisma.group.findFirst({ where: { id: groupId, category: categoryAccessFilter(userId) }, select: { category: { select: { projectId: true } } }, }); if (!group) throw new Error("Group not found"); await prisma.group.update({ where: { id: groupId }, data: { archivedAt: new Date() } }); revalidatePath(boardPath(group.category.projectId)); } /** Persists a new card order within a single lane. */ export async function reorderGroupsInCategory( categoryId: string, orderedGroupIds: string[] ): Promise { const userId = await requireUserId(); const category = await prisma.category.findFirst({ where: { id: categoryId, ...categoryAccessFilter(userId) }, include: { groups: { select: { id: true } } }, }); if (!category) throw new Error("Category not found"); const ownedIds = new Set(category.groups.map((g) => g.id)); if ( orderedGroupIds.length !== ownedIds.size || !orderedGroupIds.every((id) => ownedIds.has(id)) ) { throw new Error("Group list does not match this category's groups"); } await prisma.$transaction( orderedGroupIds.map((id, index) => prisma.group.update({ where: { id }, data: { order: index } }) ) ); revalidatePath(boardPath(category.projectId)); } /** * Moves a card to a different lane and persists the resulting order for * both the source and destination lanes. Both lanes must be in the same * board (Home, or the same Project) -- boards are isolated spaces, so a * group can't be dragged from one into another. */ export async function moveGroupToCategory( groupId: string, targetCategoryId: string, orderedGroupIdsInTargetLane: string[], orderedGroupIdsInSourceLane: string[] ): Promise { const userId = await requireUserId(); const [group, targetCategory] = await Promise.all([ prisma.group.findFirst({ where: { id: groupId, category: categoryAccessFilter(userId) }, select: { id: true, categoryId: true, category: { select: { projectId: true } } }, }), prisma.category.findFirst({ where: { id: targetCategoryId, ...categoryAccessFilter(userId) }, select: { id: true, projectId: true }, }), ]); if (!group) throw new Error("Group not found"); if (!targetCategory) throw new Error("Target category not found"); if (group.category.projectId !== targetCategory.projectId) { throw new Error("Can't move a group into a different board"); } const targetIndex = orderedGroupIdsInTargetLane.indexOf(groupId); if (targetIndex === -1) { throw new Error("Moved group must be present in the target lane's order list"); } await prisma.$transaction([ prisma.group.update({ where: { id: groupId }, data: { categoryId: targetCategoryId, order: targetIndex }, }), ...orderedGroupIdsInTargetLane .map((id, index) => ({ id, index })) .filter(({ id }) => id !== groupId) .map(({ id, index }) => prisma.group.update({ where: { id }, data: { order: index } })), ...orderedGroupIdsInSourceLane.map((id, index) => prisma.group.update({ where: { id }, data: { order: index } }) ), ]); revalidatePath(boardPath(targetCategory.projectId)); }