feat(board): daily board linked to shots

A /board page that replaces the shared doc used as the day-to-day tracker.
One card per active/on-hold project with a status line, dated milestones
and to-do lines typed or pasted in the doc's own format:

  DH2_VFX_027_090 finalise (CHRIS)
  DH2_VFX_005_010 gfx update @michelle wait: client GFX ADH_GFX_005_020

Shot codes link to the shot (live status + latest version), (NAME)/@name
assigns, wait: marks a blocker, headings group lines. Codes that match no
shot are flagged, with a relink when there is an obvious near miss.

Board lines are their own model, not Tasks: tasks drive shot status and
notifications, and a note like "finalise" must not touch either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-07 17:30:40 +02:00
parent 27d9618995
commit 34af26dfbf
8 changed files with 1331 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { findShot, parseBoardText } from "@/lib/board-parse";
// The board is studio-internal. Anyone on staff can add and tick off lines;
// it is a shared to-do list, not an approval surface.
async function requireStaff() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (session.user.role === "CLIENT") throw new Error("Insufficient permissions");
return session.user;
}
async function boardContext(projectId: string) {
const [shots, users] = await Promise.all([
db.shot.findMany({
where: { projectId },
select: { id: true, shotCode: true, clientCode: true },
}),
db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true },
}),
]);
return { shots, users };
}
const addSchema = z.object({
projectId: z.string().cuid(),
input: z.string().min(1).max(20000),
group: z.string().max(100).optional(),
});
/** Adds one line, or a pasted block of lines, to a project's board. */
export async function addBoardItems(data: z.infer<typeof addSchema>) {
const user = await requireStaff();
const parsed = addSchema.parse(data);
const items = parseBoardText(parsed.input, {
...(await boardContext(parsed.projectId)),
group: parsed.group,
});
if (items.length === 0) return { success: true, added: 0, unmatched: [] as string[] };
const last = await db.boardItem.findFirst({
where: { projectId: parsed.projectId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const base = (last?.sortOrder ?? -1) + 1;
await db.boardItem.createMany({
data: items.map((item, i) => ({
projectId: parsed.projectId,
shotId: item.shotId,
code: item.code,
text: item.text,
group: item.group,
assigneeId: item.assigneeId,
waitingOn: item.waitingOn,
sortOrder: base + i,
createdById: user.id,
})),
});
revalidatePath("/board");
return {
success: true,
added: items.length,
unmatched: items.filter((i) => i.code && !i.shotId).map((i) => i.code as string),
};
}
const updateSchema = z.object({
text: z.string().max(5000).optional(),
code: z.string().max(100).nullable().optional(),
group: z.string().max(100).optional(),
assigneeId: z.string().cuid().nullable().optional(),
waitingOn: z.string().max(500).nullable().optional(),
});
export async function updateBoardItem(id: string, data: z.infer<typeof updateSchema>) {
await requireStaff();
const parsed = updateSchema.parse(data);
const item = await db.boardItem.findUnique({ where: { id }, select: { projectId: true } });
if (!item) throw new Error("Board item not found");
// Changing the code re-links the line; a code that matches nothing is kept
// as a flag, exactly as when it was first typed.
let link: { code: string | null; shotId: string | null } | undefined;
if (parsed.code !== undefined) {
const code = parsed.code?.trim() || null;
const shot = code ? findShot(code, (await boardContext(item.projectId)).shots) : null;
link = { code: shot?.shotCode ?? code?.toUpperCase() ?? null, shotId: shot?.id ?? null };
}
await db.boardItem.update({
where: { id },
data: {
...(parsed.text !== undefined && { text: parsed.text.trim() }),
...(parsed.group !== undefined && { group: parsed.group.trim() }),
...(parsed.assigneeId !== undefined && { assigneeId: parsed.assigneeId }),
...(parsed.waitingOn !== undefined && { waitingOn: parsed.waitingOn?.trim() || null }),
...link,
},
});
revalidatePath("/board");
return { success: true };
}
export async function setBoardItemDone(id: string, done: boolean) {
const user = await requireStaff();
await db.boardItem.update({
where: { id },
data: done ? { doneAt: new Date(), doneById: user.id } : { doneAt: null, doneById: null },
});
revalidatePath("/board");
return { success: true };
}
export async function deleteBoardItem(id: string) {
await requireStaff();
await db.boardItem.delete({ where: { id } });
revalidatePath("/board");
return { success: true };
}
const statusSchema = z.object({
projectId: z.string().cuid(),
status: z.string().max(2000),
});
export async function setProjectBoardStatus(data: z.infer<typeof statusSchema>) {
await requireStaff();
const parsed = statusSchema.parse(data);
const status = parsed.status.trim() || null;
await db.project.update({
where: { id: parsed.projectId },
data: { boardStatus: status, boardStatusAt: status ? new Date() : null },
});
revalidatePath("/board");
return { success: true };
}
const milestoneSchema = z.object({
projectId: z.string().cuid(),
label: z.string().min(1).max(200),
date: z.string().min(1),
});
export async function addMilestone(data: z.infer<typeof milestoneSchema>) {
await requireStaff();
const parsed = milestoneSchema.parse(data);
const date = new Date(parsed.date);
if (isNaN(date.getTime())) throw new Error("Invalid date");
await db.projectMilestone.create({
data: { projectId: parsed.projectId, label: parsed.label.trim(), date },
});
revalidatePath("/board");
return { success: true };
}
export async function setMilestoneDone(id: string, done: boolean) {
await requireStaff();
await db.projectMilestone.update({
where: { id },
data: { doneAt: done ? new Date() : null },
});
revalidatePath("/board");
return { success: true };
}
export async function deleteMilestone(id: string) {
await requireStaff();
await db.projectMilestone.delete({ where: { id } });
revalidatePath("/board");
return { success: true };
}