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"; 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(attachment: T) { return { ...attachment, contentPath: `/api/attachments/${attachment.id}/content`, }; } async function runSingleFileUpload(req: Request, res: Response) { await new Promise((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); if (req.actor.type === "board") return true; if (!req.actor.agentId) { res.status(403).json({ error: "Agent authentication required" }); return false; } const actorAgent = await agentsSvc.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== companyId) { res.status(403).json({ error: "Forbidden" }); return false; } if (actorAgent.role === "ceo" || Boolean(actorAgent.permissions?.canCreateAgents)) return true; res.status(403).json({ error: "Missing permission to link approvals" }); return false; } router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const result = await svc.list(companyId, { status: req.query.status as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, projectId: req.query.projectId as string | undefined, }); res.json(result); }); router.get("/issues/:id", async (req, res) => { const id = req.params.id as string; const isIdentifier = /^[A-Z]+-\d+$/i.test(id); const issue = isIdentifier ? await svc.getByIdentifier(id) : await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); 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) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const approvals = await issueApprovalsSvc.listApprovalsForIssue(id); res.json(approvals); }); router.post("/issues/:id/approvals", validate(linkIssueApprovalSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; const actor = getActorInfo(req); await issueApprovalsSvc.link(id, req.body.approvalId, { agentId: actor.agentId, userId: actor.actorType === "user" ? actor.actorId : null, }); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.approval_linked", entityType: "issue", entityId: issue.id, details: { approvalId: req.body.approvalId }, }); const approvals = await issueApprovalsSvc.listApprovalsForIssue(id); res.status(201).json(approvals); }); router.delete("/issues/:id/approvals/:approvalId", async (req, res) => { const id = req.params.id as string; const approvalId = req.params.approvalId as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return; await issueApprovalsSvc.unlink(id, approvalId); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.approval_unlinked", entityType: "issue", entityId: issue.id, details: { approvalId }, }); res.json({ ok: true }); }); router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const actor = getActorInfo(req); const issue = await svc.create(companyId, { ...req.body, 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.created", entityType: "issue", entityId: issue.id, details: { title: issue.title }, }); if (issue.assigneeAgentId) { void heartbeat .wakeup(issue.assigneeAgentId, { source: "assignment", triggerDetail: "system", reason: "issue_assigned", payload: { issueId: issue.id, mutation: "create" }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, source: "issue.create" }, }) .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue create")); } res.status(201).json(issue); }); router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, existing.companyId); const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } const issue = await svc.update(id, updateFields); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } // Build activity details with previous values for changed fields const previous: Record = {}; for (const key of Object.keys(updateFields)) { if (key in existing && (existing as Record)[key] !== (updateFields as Record)[key]) { previous[key] = (existing as Record)[key]; } } const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.updated", entityType: "issue", entityId: issue.id, details: { ...updateFields, _previous: Object.keys(previous).length > 0 ? previous : undefined }, }); let comment = null; if (commentBody) { comment = await svc.addComment(id, commentBody, { agentId: actor.agentId ?? undefined, userId: actor.actorType === "user" ? actor.actorId : undefined, }); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.comment_added", entityType: "issue", entityId: issue.id, details: { commentId: comment.id }, }); // @-mention wakeups svc.findMentionedAgents(issue.companyId, commentBody).then((ids) => { for (const mentionedId of ids) { heartbeat.wakeup(mentionedId, { source: "automation", triggerDetail: "system", reason: `Mentioned in comment on issue ${id}`, payload: { issueId: id, commentId: comment!.id }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: id, commentId: comment!.id, source: "comment.mention" }, }).catch((err) => logger.warn({ err, agentId: mentionedId }, "failed to wake mentioned agent")); } }).catch((err) => logger.warn({ err, issueId: id }, "failed to resolve @-mentions")); } const assigneeChanged = req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId; if (assigneeChanged && issue.assigneeAgentId) { void heartbeat .wakeup(issue.assigneeAgentId, { source: "assignment", triggerDetail: "system", reason: "issue_assigned", payload: { issueId: issue.id, mutation: "update" }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, source: "issue.update" }, }) .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue update")); } res.json({ ...issue, comment }); }); router.delete("/issues/:id", async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, existing.companyId); const issue = await svc.remove(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.deleted", entityType: "issue", entityId: issue.id, }); res.json(issue); }); router.post("/issues/:id/checkout", validate(checkoutIssueSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) { res.status(403).json({ error: "Agent can only checkout as itself" }); return; } const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses); const actor = getActorInfo(req); await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.checked_out", entityType: "issue", entityId: issue.id, details: { agentId: req.body.agentId }, }); void heartbeat .wakeup(req.body.agentId, { source: "assignment", triggerDetail: "system", reason: "issue_checked_out", payload: { issueId: issue.id, mutation: "checkout" }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, source: "issue.checkout" }, }) .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout")); res.json(updated); }); router.post("/issues/:id/release", async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, existing.companyId); const released = await svc.release(id, req.actor.type === "agent" ? req.actor.agentId : undefined); if (!released) { res.status(404).json({ error: "Issue not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: released.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.released", entityType: "issue", entityId: released.id, }); res.json(released); }); router.get("/issues/:id/comments", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const comments = await svc.listComments(id); res.json(comments); }); router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); const actor = getActorInfo(req); const reopenRequested = req.body.reopen === true; const isClosed = issue.status === "done" || issue.status === "cancelled"; let reopened = false; let reopenFromStatus: string | null = null; let currentIssue = issue; if (reopenRequested && isClosed) { const reopenedIssue = await svc.update(id, { status: "todo" }); if (!reopenedIssue) { res.status(404).json({ error: "Issue not found" }); return; } reopened = true; reopenFromStatus = issue.status; currentIssue = reopenedIssue; await logActivity(db, { companyId: currentIssue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.updated", entityType: "issue", entityId: currentIssue.id, details: { status: "todo", reopened: true, reopenedFrom: reopenFromStatus, source: "comment", }, }); } const comment = await svc.addComment(id, req.body.body, { agentId: actor.agentId ?? undefined, userId: actor.actorType === "user" ? actor.actorId : undefined, }); await logActivity(db, { companyId: currentIssue.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "issue.comment_added", entityType: "issue", entityId: currentIssue.id, details: { commentId: comment.id }, }); // @-mention wakeups svc.findMentionedAgents(issue.companyId, req.body.body).then((ids) => { for (const mentionedId of ids) { heartbeat.wakeup(mentionedId, { source: "automation", triggerDetail: "system", reason: `Mentioned in comment on issue ${id}`, payload: { issueId: id, commentId: comment.id }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: id, commentId: comment.id, source: "comment.mention" }, }).catch((err) => logger.warn({ err, agentId: mentionedId }, "failed to wake mentioned agent")); } }).catch((err) => logger.warn({ err, issueId: id }, "failed to resolve @-mentions")); if (reopened && currentIssue.assigneeAgentId) { void heartbeat .wakeup(currentIssue.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_reopened_via_comment", payload: { issueId: currentIssue.id, commentId: comment.id, reopenedFrom: reopenFromStatus, mutation: "comment", }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: currentIssue.id, taskId: currentIssue.id, commentId: comment.id, source: "issue.comment.reopen", wakeReason: "issue_reopened_via_comment", reopenedFrom: reopenFromStatus, }, }) .catch((err) => logger.warn({ err, issueId: currentIssue.id }, "failed to wake assignee on issue reopen comment")); } else if (currentIssue.assigneeAgentId) { void heartbeat .wakeup(currentIssue.assigneeAgentId, { source: "automation", triggerDetail: "system", reason: "issue_commented", payload: { issueId: currentIssue.id, commentId: comment.id, mutation: "comment", }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, contextSnapshot: { issueId: currentIssue.id, taskId: currentIssue.id, commentId: comment.id, source: "issue.comment", wakeReason: "issue_commented", }, }) .catch((err) => logger.warn({ err, issueId: currentIssue.id }, "failed to wake assignee on issue comment")); } 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; }