feat(KB-034): add task archive/unarchive functionality

- Add 'archived' column to task store with archiveTask and unarchiveTask methods
- Add CLI commands: kb task archive <id> and kb task unarchive <id>
- Add pi extension tools for archive and unarchive operations
- Add dashboard API endpoints POST /api/tasks/:id/archive and /unarchive
- Add Archived column to board UI with archive/unarchive buttons
- Prevent drag-drop into archived column, add visual distinction
- Include duplicateTask from concurrent branch in merge resolution
This commit is contained in:
gsxdsm
2026-03-29 20:03:43 -07:00
parent d6a69d8c41
commit 52b8205220
22 changed files with 793 additions and 58 deletions

View File

@@ -36,7 +36,7 @@ function AppInner() {
return "board";
});
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask } = useTasks();
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
useEffect(() => {
fetchConfig()
@@ -161,6 +161,8 @@ function AppInner() {
onToggleAutoMerge={handleToggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={updateTask}
onArchiveTask={archiveTask}
onUnarchiveTask={unarchiveTask}
/>
) : (
<ListView

View File

@@ -35,9 +35,9 @@ describe("column fixed-width CSS", () => {
});
describe("desktop .board grid template", () => {
it("still uses repeat(5, minmax(260px, 1fr))", () => {
it("uses repeat(6, minmax(260px, 1fr)) for 6 columns", () => {
expect(css).toContain(
"grid-template-columns: repeat(5, minmax(260px, 1fr))",
"grid-template-columns: repeat(6, minmax(260px, 1fr))",
);
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
import { fetchTaskDetail, updateTask, archiveTask, unarchiveTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes } from "./api";
import type { Task, TaskDetail } from "@kb/core";
const FAKE_DETAIL: TaskDetail = {
@@ -637,4 +637,46 @@ describe("Git Management API", () => {
await expect(pushBranch()).rejects.toThrow("Push rejected");
});
});
describe("archiveTask", () => {
it("sends POST to archive endpoint", async () => {
const archivedTask: Task = { ...FAKE_DETAIL, column: "archived" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, archivedTask));
const response = await archiveTask("KB-001");
expect(response.column).toBe("archived");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/archive", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not in done" }, 400));
await expect(archiveTask("KB-001")).rejects.toThrow("Task not in done");
});
});
describe("unarchiveTask", () => {
it("sends POST to unarchive endpoint", async () => {
const unarchivedTask: Task = { ...FAKE_DETAIL, column: "done" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, unarchivedTask));
const response = await unarchiveTask("KB-001");
expect(response.column).toBe("done");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/unarchive", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Task not in archived" }, 400));
await expect(unarchiveTask("KB-001")).rejects.toThrow("Task not in archived");
});
});
});

View File

@@ -75,6 +75,14 @@ export function unpauseTask(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}/unpause`, { method: "POST" });
}
export function archiveTask(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}/archive`, { method: "POST" });
}
export function unarchiveTask(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}/unarchive`, { method: "POST" });
}
export function approvePlan(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}/approve-plan`, { method: "POST" });
}

View File

@@ -2,6 +2,7 @@ import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@k
import { COLUMNS } from "@kb/core";
import { Column } from "./Column";
import type { ToastType } from "../hooks/useToast";
import { useState } from "react";
interface BoardProps {
tasks: Task[];
@@ -20,9 +21,13 @@ interface BoardProps {
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
}
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: BoardProps) {
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
return (
<main className="board" id="board">
{COLUMNS.map((col) => (
@@ -48,8 +53,11 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
{...(col === "triage" ? { isCreating, onCancelCreate, onCreateTask, onNewTask } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: () => setArchivedCollapsed(!archivedCollapsed) } : {})}
/>
))}
</main>

View File

@@ -7,6 +7,7 @@ import { WorktreeGroup } from "./WorktreeGroup";
import { InlineCreateCard } from "./InlineCreateCard";
import { groupByWorktree } from "../utils/worktreeGrouping";
import type { ToastType } from "../hooks/useToast";
import { ChevronDown, ChevronUp } from "lucide-react";
interface ColumnProps {
column: ColumnType;
@@ -27,17 +28,27 @@ interface ColumnProps {
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask }: ColumnProps) {
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, collapsed, onToggleCollapse }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const countFlashing = useFlashOnIncrease(tasks.length);
// Archived column is collapsed by default - don't show drag state when collapsed
const isArchived = column === "archived";
const isCollapsed = isArchived && collapsed;
const handleDragOver = useCallback((e: React.DragEvent) => {
// Don't allow dropping into archived column via drag-drop
if (isArchived) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOver(true);
}, []);
}, [isArchived]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const el = e.currentTarget as HTMLElement;
@@ -61,7 +72,7 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
return (
<div
className={`column${dragOver ? " drag-over" : ""}`}
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
data-column={column}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -87,46 +98,68 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
+ New Task
</button>
)}
</div>
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
<div className="column-body">
{column === "triage" && isCreating && onCancelCreate && onCreateTask && (
<InlineCreateCard
tasks={allTasks}
onSubmit={onCreateTask}
onCancel={onCancelCreate}
addToast={addToast}
/>
)}
{column === "in-progress" ? (
(() => {
const groups = groupByWorktree(tasks, allTasks, maxConcurrent);
return groups.length === 0 ? (
<div className="empty-column">No tasks</div>
) : (
groups.map((group) => (
<WorktreeGroup
key={group.label}
label={group.label}
activeTasks={group.activeTasks}
queuedTasks={group.queuedTasks}
onOpenDetail={onOpenDetail}
addToast={addToast}
globalPaused={globalPaused}
tasks={allTasks}
onUpdateTask={onUpdateTask}
/>
))
);
})()
) : tasks.length === 0 ? (
<div className="empty-column">No tasks</div>
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={allTasks} onUpdateTask={onUpdateTask} />
))
{isArchived && onToggleCollapse && (
<button
className="btn btn-icon btn-sm"
onClick={onToggleCollapse}
title={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
aria-label={collapsed ? "Expand archived tasks" : "Collapse archived tasks"}
>
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
)}
</div>
{!isCollapsed && <p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>}
{!isCollapsed && (
<div className="column-body">
{column === "triage" && isCreating && onCancelCreate && onCreateTask && (
<InlineCreateCard
tasks={allTasks}
onSubmit={onCreateTask}
onCancel={onCancelCreate}
addToast={addToast}
/>
)}
{column === "in-progress" ? (
(() => {
const groups = groupByWorktree(tasks, allTasks, maxConcurrent);
return groups.length === 0 ? (
<div className="empty-column">No tasks</div>
) : (
groups.map((group) => (
<WorktreeGroup
key={group.label}
label={group.label}
activeTasks={group.activeTasks}
queuedTasks={group.queuedTasks}
onOpenDetail={onOpenDetail}
addToast={addToast}
globalPaused={globalPaused}
tasks={allTasks}
onUpdateTask={onUpdateTask}
/>
))
);
})()
) : tasks.length === 0 ? (
<div className="empty-column">No tasks</div>
) : (
tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onOpenDetail={onOpenDetail}
addToast={addToast}
globalPaused={globalPaused}
tasks={allTasks}
onUpdateTask={onUpdateTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
/>
))
)}
</div>
)}
</div>
);
}

View File

@@ -12,6 +12,7 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
"in-progress": "var(--in-progress)",
"in-review": "var(--in-review)",
done: "var(--done)",
archived: "var(--text-secondary)",
};
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
@@ -247,7 +248,8 @@ export function ListView({
todo: [],
"in-progress": [],
"in-review": [],
done: []
done: [],
archived: []
};
sorted.forEach(task => groups[task.column].push(task));
return groups;
@@ -318,6 +320,12 @@ export function ListView({
const taskId = e.dataTransfer.getData("text/plain");
if (!taskId) return;
// Prevent dropping into archived column
if (column === "archived") {
addToast("Tasks can only be archived via the archive button", "error");
return;
}
try {
await onMoveTask(taskId, column);
} catch (err: any) {

View File

@@ -10,6 +10,7 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
"in-progress": "rgba(188,140,255,0.15)",
"in-review": "rgba(63,185,80,0.15)",
done: "rgba(139,148,158,0.15)",
archived: "rgba(120,120,120,0.1)",
};
const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
@@ -18,6 +19,7 @@ const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
"in-progress": "var(--in-progress)",
"in-review": "var(--in-review)",
done: "var(--done)",
archived: "var(--text-secondary)",
};
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
@@ -35,6 +37,8 @@ interface TaskCardProps {
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
}
export function TaskCard({
@@ -45,6 +49,8 @@ export function TaskCard({
globalPaused,
tasks = [],
onUpdateTask,
onArchiveTask,
onUnarchiveTask,
}: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
@@ -139,8 +145,9 @@ export function TaskCard({
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isArchived = task.column === "archived";
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDraggable = !queued && !isPaused && !isEditing; // Disable drag during edit
const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived
// Check if this card can be edited inline
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
@@ -369,6 +376,42 @@ export function TaskCard({
<Pencil size={12} />
</button>
)}
{/* Archive button for done column tasks */}
{task.column === "done" && onArchiveTask && (
<button
className="card-archive-btn"
onClick={(e) => {
e.stopPropagation();
onArchiveTask(task.id).then(() => {
addToast(`Archived ${task.id}`, "success");
}).catch((err: any) => {
addToast(`Failed to archive ${task.id}: ${err.message}`, "error");
});
}}
title="Archive task"
aria-label="Archive task"
>
Archive
</button>
)}
{/* Unarchive button for archived column tasks */}
{task.column === "archived" && onUnarchiveTask && (
<button
className="card-unarchive-btn"
onClick={(e) => {
e.stopPropagation();
onUnarchiveTask(task.id).then(() => {
addToast(`Unarchived ${task.id}`, "success");
}).catch((err: any) => {
addToast(`Failed to unarchive ${task.id}: ${err.message}`, "error");
});
}}
title="Unarchive task"
aria-label="Unarchive task"
>
Unarchive
</button>
)}
</div>
<div className="card-title">
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}

View File

@@ -46,14 +46,14 @@ describe("Board", () => {
expect(main.id).toBe("board");
});
it("renders all 5 columns", () => {
it("renders all 6 columns", () => {
renderBoard();
for (const col of COLUMNS) {
expect(screen.getByTestId(`column-${col}`)).toBeDefined();
}
});
it("renders all 5 columns as direct children of .board (CSS selector target)", () => {
it("renders all 6 columns as direct children of .board (CSS selector target)", () => {
renderBoard();
const board = screen.getByRole("main");
// The mock Column renders <div data-testid="column-{col}" />, which are direct children

View File

@@ -598,7 +598,7 @@ describe("ListView", () => {
// Find section headers by their structure
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(5); // One for each column
expect(sectionHeaders.length).toBe(6); // One for each column
// Check that triage section shows count of 2
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
@@ -1152,7 +1152,7 @@ describe("ListView Hide Done Tasks", () => {
// All section headers should be visible initially
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeadersBefore.length).toBe(5); // All 5 columns
expect(sectionHeadersBefore.length).toBe(6); // All 6 columns
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });

View File

@@ -156,5 +156,21 @@ export function useTasks() {
}
}, []);
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask };
const archiveTask = useCallback(async (id: string): Promise<Task> => {
const task = await api.archiveTask(id);
setTasks((prev) =>
prev.map((t) => (t.id === id ? task : t))
);
return task;
}, []);
const unarchiveTask = useCallback(async (id: string): Promise<Task> => {
const task = await api.unarchiveTask(id);
setTasks((prev) =>
prev.map((t) => (t.id === id ? task : t))
);
return task;
}, []);
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask };
}

View File

@@ -242,7 +242,7 @@ body {
/* === Board === */
.board {
display: grid;
grid-template-columns: repeat(5, minmax(260px, 1fr));
grid-template-columns: repeat(6, minmax(260px, 1fr));
gap: 12px;
padding: 16px 24px;
height: calc(100vh - 57px);
@@ -310,6 +310,9 @@ body {
.dot-done {
background: var(--done);
}
.dot-archived {
background: var(--text-muted);
}
.column-header h2 {
font-size: 14px;
@@ -1640,6 +1643,44 @@ body {
outline-offset: 1px;
}
/* Archive/Unarchive buttons */
.card-archive-btn,
.card-unarchive-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
margin-left: 8px;
font-size: 11px;
font-weight: 500;
background: var(--card);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
}
.card:hover .card-archive-btn,
.card:hover .card-unarchive-btn {
opacity: 1;
}
.card-archive-btn:hover,
.card-unarchive-btn:hover {
background: var(--card-hover);
color: var(--text);
border-color: var(--border-hover);
}
.card-archive-btn:focus,
.card-unarchive-btn:focus {
opacity: 1;
outline: 1px solid var(--todo);
outline-offset: 1px;
}
/* Loading state during save */
.card-edit-loading {
display: flex;

View File

@@ -15,6 +15,8 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
@@ -261,6 +263,110 @@ describe("POST /tasks/:id/duplicate", () => {
});
});
describe("POST /tasks/:id/archive", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
archiveTask: vi.fn(),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("archives a done task and returns the updated task", async () => {
const archivedTask = { ...FAKE_TASK_DETAIL, column: "archived" };
(store.archiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(archivedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.column).toBe("archived");
expect(store.archiveTask).toHaveBeenCalledWith("KB-001");
});
it("returns 400 when task is not in done column", async () => {
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot archive KB-001: task is in 'triage', must be in 'done'"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("must be in 'done'");
});
it("returns 500 on unexpected errors", async () => {
(store.archiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/archive", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Database error");
});
});
describe("POST /tasks/:id/unarchive", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
unarchiveTask: vi.fn(),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("unarchives an archived task and returns the updated task", async () => {
const unarchivedTask = { ...FAKE_TASK_DETAIL, column: "done" };
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockResolvedValue(unarchivedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body.column).toBe("done");
expect(store.unarchiveTask).toHaveBeenCalledWith("KB-001");
});
it("returns 400 when task is not in archived column", async () => {
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Cannot unarchive KB-001: task is in 'done', must be in 'archived'"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("must be in 'archived'");
});
it("returns 500 on unexpected errors", async () => {
(store.unarchiveTask as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Database error"));
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unarchive", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Database error");
});
});
describe("PATCH /tasks/:id", () => {
let store: TaskStore;

View File

@@ -685,6 +685,28 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// Archive task (done → archived)
router.post("/tasks/:id/archive", async (req, res) => {
try {
const task = await store.archiveTask(req.params.id);
res.json(task);
} catch (err: any) {
const status = err.message?.includes("must be in") ? 400 : 500;
res.status(status).json({ error: err.message });
}
});
// Unarchive task (archived → done)
router.post("/tasks/:id/unarchive", async (req, res) => {
try {
const task = await store.unarchiveTask(req.params.id);
res.json(task);
} catch (err: any) {
const status = err.message?.includes("must be in") ? 400 : 500;
res.status(status).json({ error: err.message });
}
});
// Upload attachment
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
try {