feat: add storage system with local disk and S3 providers
Introduces a provider-agnostic storage subsystem for file attachments. Includes local disk and S3 backends, asset/attachment DB schemas, issue attachment CRUD routes with multer upload, CLI configure/doctor/env integration, and enriched issue ancestors with project/goal resolution. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,29 +1,65 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
checkoutIssueSchema,
|
||||
createIssueSchema,
|
||||
linkIssueApprovalSchema,
|
||||
updateIssueSchema,
|
||||
} from "@paperclip/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
agentService,
|
||||
goalService,
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
issueService,
|
||||
logActivity,
|
||||
projectService,
|
||||
} from "../services/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function issueRoutes(db: Db) {
|
||||
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
const svc = issueService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const agentsSvc = agentService(db);
|
||||
const projectsSvc = projectService(db);
|
||||
const goalsSvc = goalService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
function withContentPath<T extends { id: string }>(attachment: T) {
|
||||
return {
|
||||
...attachment,
|
||||
contentPath: `/api/attachments/${attachment.id}/content`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single("file")(req, res, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCanManageIssueApprovalLinks(req: Request, res: Response, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -62,8 +98,12 @@ export function issueRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const ancestors = await svc.getAncestors(issue.id);
|
||||
res.json({ ...issue, ancestors });
|
||||
const [ancestors, project, goal] = await Promise.all([
|
||||
svc.getAncestors(issue.id),
|
||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||
issue.goalId ? goalsSvc.getById(issue.goalId) : null,
|
||||
]);
|
||||
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null });
|
||||
});
|
||||
|
||||
router.get("/issues/:id/approvals", async (req, res) => {
|
||||
@@ -254,20 +294,17 @@ export function issueRoutes(db: Db) {
|
||||
|
||||
const assigneeChanged =
|
||||
req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId;
|
||||
const reopened =
|
||||
(existing.status === "done" || existing.status === "cancelled") &&
|
||||
issue.status !== "done" && issue.status !== "cancelled";
|
||||
|
||||
if ((assigneeChanged || reopened) && issue.assigneeAgentId) {
|
||||
if (assigneeChanged && issue.assigneeAgentId) {
|
||||
void heartbeat
|
||||
.wakeup(issue.assigneeAgentId, {
|
||||
source: reopened ? "automation" : "assignment",
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: reopened ? "issue_reopened" : "issue_assigned",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId: issue.id, mutation: "update" },
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: reopened ? "issue.reopen" : "issue.update" },
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.update" },
|
||||
})
|
||||
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue update"));
|
||||
}
|
||||
@@ -518,5 +555,169 @@ export function issueRoutes(db: Db) {
|
||||
res.status(201).json(comment);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/attachments", async (req, res) => {
|
||||
const issueId = req.params.id as string;
|
||||
const issue = await svc.getById(issueId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const attachments = await svc.listAttachments(issueId);
|
||||
res.json(attachments.map(withContentPath));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/issues/:issueId/attachments", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const issueId = req.params.issueId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const issue = await svc.getById(issueId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
if (issue.companyId !== companyId) {
|
||||
res.status(422).json({ error: "Issue does not belong to company" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
|
||||
if (!file) {
|
||||
res.status(400).json({ error: "Missing file field 'file'" });
|
||||
return;
|
||||
}
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
if (file.buffer.length <= 0) {
|
||||
res.status(422).json({ error: "Attachment is empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {});
|
||||
if (!parsedMeta.success) {
|
||||
res.status(400).json({ error: "Invalid attachment metadata", details: parsedMeta.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const stored = await storage.putFile({
|
||||
companyId,
|
||||
namespace: `issues/${issueId}`,
|
||||
originalFilename: file.originalname || null,
|
||||
contentType,
|
||||
body: file.buffer,
|
||||
});
|
||||
|
||||
const attachment = await svc.createAttachment({
|
||||
issueId,
|
||||
issueCommentId: parsedMeta.data.issueCommentId ?? null,
|
||||
provider: stored.provider,
|
||||
objectKey: stored.objectKey,
|
||||
contentType: stored.contentType,
|
||||
byteSize: stored.byteSize,
|
||||
sha256: stored.sha256,
|
||||
originalFilename: stored.originalFilename,
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.attachment_added",
|
||||
entityType: "issue",
|
||||
entityId: issueId,
|
||||
details: {
|
||||
attachmentId: attachment.id,
|
||||
originalFilename: attachment.originalFilename,
|
||||
contentType: attachment.contentType,
|
||||
byteSize: attachment.byteSize,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(withContentPath(attachment));
|
||||
});
|
||||
|
||||
router.get("/attachments/:attachmentId/content", async (req, res, next) => {
|
||||
const attachmentId = req.params.attachmentId as string;
|
||||
const attachment = await svc.getAttachmentById(attachmentId);
|
||||
if (!attachment) {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, attachment.companyId);
|
||||
|
||||
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
|
||||
res.setHeader("Content-Type", attachment.contentType || object.contentType || "application/octet-stream");
|
||||
res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
|
||||
res.setHeader("Cache-Control", "private, max-age=60");
|
||||
const filename = attachment.originalFilename ?? "attachment";
|
||||
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
|
||||
|
||||
object.stream.on("error", (err) => {
|
||||
next(err);
|
||||
});
|
||||
object.stream.pipe(res);
|
||||
});
|
||||
|
||||
router.delete("/attachments/:attachmentId", async (req, res) => {
|
||||
const attachmentId = req.params.attachmentId as string;
|
||||
const attachment = await svc.getAttachmentById(attachmentId);
|
||||
if (!attachment) {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, attachment.companyId);
|
||||
|
||||
try {
|
||||
await storage.deleteObject(attachment.companyId, attachment.objectKey);
|
||||
} catch (err) {
|
||||
logger.warn({ err, attachmentId }, "storage delete failed while removing attachment");
|
||||
}
|
||||
|
||||
const removed = await svc.removeAttachment(attachmentId);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: removed.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.attachment_removed",
|
||||
entityType: "issue",
|
||||
entityId: removed.issueId,
|
||||
details: {
|
||||
attachmentId: removed.id,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user