feat(HAI-023): add file attachments support for tasks
- Add core types and store methods for attachment CRUD operations - Add dashboard API routes for upload, download, and delete of attachments - Add attachment UI in TaskDetailModal with upload and management controls - Add CLI 'attach' command for attaching files to tasks - Update documentation with attachment feature usage details
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { Task, TaskDetail, TaskCreateInput, Column, MergeResult, Settings } from "@hai/core";
|
||||
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, Column, MergeResult, Settings } from "@hai/core";
|
||||
|
||||
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
@@ -54,3 +54,19 @@ export function updateSettings(settings: Partial<Settings>): Promise<Settings> {
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadAttachment(id: string, file: File): Promise<TaskAttachment> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch(`/api/tasks/${id}/attachments`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || "Upload failed");
|
||||
return data as TaskAttachment;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string, filename: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/attachments/${filename}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, Column, MergeResult } from "@hai/core";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@hai/core";
|
||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
|
||||
import { uploadAttachment, deleteAttachment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
@@ -20,6 +21,12 @@ function formatTimestamp(iso: string): string {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: TaskDetail;
|
||||
onClose: () => void;
|
||||
@@ -37,6 +44,9 @@ export function TaskDetailModal({
|
||||
onMergeTask,
|
||||
addToast,
|
||||
}: TaskDetailModalProps) {
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -92,6 +102,32 @@ export function TaskDetailModal({
|
||||
});
|
||||
}, [task.id, onMergeTask, onClose, addToast]);
|
||||
|
||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const attachment = await uploadAttachment(task.id, file);
|
||||
setAttachments((prev) => [...prev, attachment]);
|
||||
addToast("Screenshot attached", "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}, [task.id, addToast]);
|
||||
|
||||
const handleDeleteAttachment = useCallback(async (filename: string) => {
|
||||
try {
|
||||
await deleteAttachment(task.id, filename);
|
||||
setAttachments((prev) => prev.filter((a) => a.filename !== filename));
|
||||
addToast("Attachment deleted", "info");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, addToast]);
|
||||
|
||||
const transitions = VALID_TRANSITIONS[task.column] || [];
|
||||
|
||||
return (
|
||||
@@ -125,6 +161,78 @@ export function TaskDetailModal({
|
||||
<div className="detail-prompt">(no prompt)</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<h4>Attachments</h4>
|
||||
{attachments.length > 0 ? (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px", marginBottom: "8px" }}>
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
key={a.filename}
|
||||
style={{
|
||||
position: "relative",
|
||||
border: "1px solid var(--border, #333)",
|
||||
borderRadius: "6px",
|
||||
padding: "4px",
|
||||
background: "var(--bg-secondary, #1a1a2e)",
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={`/api/tasks/${task.id}/attachments/${a.filename}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={`/api/tasks/${task.id}/attachments/${a.filename}`}
|
||||
alt={a.originalName}
|
||||
style={{ maxWidth: "150px", maxHeight: "100px", display: "block", borderRadius: "4px" }}
|
||||
/>
|
||||
</a>
|
||||
<div style={{ fontSize: "11px", marginTop: "4px", opacity: 0.7 }}>
|
||||
{a.originalName} ({formatBytes(a.size)})
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(a.filename)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
right: "2px",
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
lineHeight: "20px",
|
||||
textAlign: "center",
|
||||
padding: 0,
|
||||
}}
|
||||
title="Delete attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ opacity: 0.5, marginBottom: "8px" }}>(no attachments)</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleUpload}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? "Uploading…" : "Attach Screenshot"}
|
||||
</button>
|
||||
</div>
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="detail-deps">
|
||||
<h4>Dependencies</h4>
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hai/core": "workspace:*",
|
||||
"@types/multer": "^2.1.0",
|
||||
"express": "^5.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"multer": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import http from "node:http";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import type { TaskStore } from "@hai/core";
|
||||
import type { TaskStore, TaskAttachment } from "@hai/core";
|
||||
import type { TaskDetail } from "@hai/core";
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
@@ -54,6 +54,49 @@ async function GET(app: express.Express, path: string): Promise<{ status: number
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper: send a request with method/body and return { status, body } */
|
||||
async function REQUEST(
|
||||
app: express.Express,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Buffer | string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
const url = new URL(`http://127.0.0.1:${addr.port}${path}`);
|
||||
const req = http.request(
|
||||
{ hostname: url.hostname, port: url.port, path: url.pathname, method, headers },
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
server.close();
|
||||
try {
|
||||
resolve({ status: res.statusCode!, body: JSON.parse(data) });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, body: data });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", (err) => { server.close(); reject(err); });
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a minimal multipart/form-data body */
|
||||
function buildMultipart(fieldName: string, filename: string, contentType: string, content: Buffer): { body: Buffer; boundary: string } {
|
||||
const boundary = "----TestBoundary" + Date.now();
|
||||
const header = `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`;
|
||||
const footer = `\r\n--${boundary}--\r\n`;
|
||||
const body = Buffer.concat([Buffer.from(header), content, Buffer.from(footer)]);
|
||||
return { body, boundary };
|
||||
}
|
||||
|
||||
describe("GET /tasks/:id", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
@@ -99,3 +142,97 @@ describe("GET /tasks/:id", () => {
|
||||
expect(res.body.error).toContain("Unexpected end of JSON input");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attachment routes", () => {
|
||||
const FAKE_ATTACHMENT: TaskAttachment = {
|
||||
filename: "1234-screenshot.png",
|
||||
originalName: "screenshot.png",
|
||||
mimeType: "image/png",
|
||||
size: 100,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
addAttachment: vi.fn().mockResolvedValue(FAKE_ATTACHMENT),
|
||||
getAttachment: vi.fn(),
|
||||
deleteAttachment: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, attachments: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("POST /tasks/:id/attachments — uploads a valid image", async () => {
|
||||
const content = Buffer.from("fake png content");
|
||||
const { body, boundary } = buildMultipart("file", "screenshot.png", "image/png", content);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.filename).toBe("1234-screenshot.png");
|
||||
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||
"HAI-001",
|
||||
"screenshot.png",
|
||||
expect.any(Buffer),
|
||||
"image/png",
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/attachments — returns 400 for invalid mime type", async () => {
|
||||
(store.addAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Invalid mime type 'text/plain'. Allowed: image/png, image/jpeg, image/gif, image/webp"),
|
||||
);
|
||||
|
||||
const content = Buffer.from("not an image");
|
||||
const { body, boundary } = buildMultipart("file", "file.txt", "text/plain", content);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid mime type");
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/attachments — returns 400 for oversized file", async () => {
|
||||
(store.addAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("File too large"),
|
||||
);
|
||||
|
||||
const content = Buffer.from("small but store rejects");
|
||||
const { body, boundary } = buildMultipart("file", "big.png", "image/png", content);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/HAI-001/attachments", body, {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("File too large");
|
||||
});
|
||||
|
||||
it("DELETE /tasks/:id/attachments/:filename — deletes attachment", async () => {
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/HAI-001/attachments/1234-screenshot.png");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("HAI-001", "1234-screenshot.png");
|
||||
});
|
||||
|
||||
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
|
||||
const err: NodeJS.ErrnoException = new Error("Attachment not found");
|
||||
err.code = "ENOENT";
|
||||
(store.deleteAttachment as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||||
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/HAI-001/attachments/nope.png");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import { createReadStream } from "node:fs";
|
||||
import type { TaskStore, Column, MergeResult } from "@hai/core";
|
||||
import { COLUMNS } from "@hai/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
});
|
||||
|
||||
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -101,6 +108,55 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// Upload attachment
|
||||
router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: "No file provided" });
|
||||
return;
|
||||
}
|
||||
const attachment = await store.addAttachment(
|
||||
req.params.id as string,
|
||||
req.file.originalname,
|
||||
req.file.buffer,
|
||||
req.file.mimetype,
|
||||
);
|
||||
res.status(201).json(attachment);
|
||||
} catch (err: any) {
|
||||
const status = err.message.includes("Invalid mime type") || err.message.includes("File too large") ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Download attachment
|
||||
router.get("/tasks/:id/attachments/:filename", async (req, res) => {
|
||||
try {
|
||||
const { path, mimeType } = await store.getAttachment(req.params.id, req.params.filename);
|
||||
res.setHeader("Content-Type", mimeType);
|
||||
createReadStream(path).pipe(res);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Delete attachment
|
||||
router.delete("/tasks/:id/attachments/:filename", async (req, res) => {
|
||||
try {
|
||||
const task = await store.deleteAttachment(req.params.id, req.params.filename);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user