Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
import { Router, type Request, type Response } from "express";
|
|
|
|
|
import multer from "multer";
|
2026-03-03 08:45:26 -06:00
|
|
|
import type { Db } from "@paperclipai/db";
|
|
|
|
|
import { createAssetImageMetadataSchema } from "@paperclipai/shared";
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
import type { StorageService } from "../storage/types.js";
|
|
|
|
|
import { assetService, logActivity } from "../services/index.js";
|
|
|
|
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
2026-03-10 19:40:22 +05:30
|
|
|
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
|
|
|
|
|
export function assetRoutes(db: Db, storage: StorageService) {
|
|
|
|
|
const router = Router();
|
|
|
|
|
const svc = assetService(db);
|
|
|
|
|
const upload = multer({
|
|
|
|
|
storage: multer.memoryStorage(),
|
2026-03-10 19:40:22 +05:30
|
|
|
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router.post("/companies/:companyId/assets/images", async (req, res) => {
|
|
|
|
|
const companyId = req.params.companyId as string;
|
|
|
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await runSingleFileUpload(req, res);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof multer.MulterError) {
|
|
|
|
|
if (err.code === "LIMIT_FILE_SIZE") {
|
2026-03-10 20:01:08 +05:30
|
|
|
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
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();
|
2026-03-10 19:40:22 +05:30
|
|
|
if (!isAllowedContentType(contentType)) {
|
2026-03-10 19:54:42 +05:30
|
|
|
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (file.buffer.length <= 0) {
|
|
|
|
|
res.status(422).json({ error: "Image is empty" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsedMeta = createAssetImageMetadataSchema.safeParse(req.body ?? {});
|
|
|
|
|
if (!parsedMeta.success) {
|
|
|
|
|
res.status(400).json({ error: "Invalid image metadata", details: parsedMeta.error.issues });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
|
|
|
|
|
const actor = getActorInfo(req);
|
|
|
|
|
const stored = await storage.putFile({
|
|
|
|
|
companyId,
|
|
|
|
|
namespace: `assets/${namespaceSuffix}`,
|
|
|
|
|
originalFilename: file.originalname || null,
|
|
|
|
|
contentType,
|
|
|
|
|
body: file.buffer,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const asset = await svc.create(companyId, {
|
|
|
|
|
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: "asset.created",
|
|
|
|
|
entityType: "asset",
|
|
|
|
|
entityId: asset.id,
|
|
|
|
|
details: {
|
|
|
|
|
originalFilename: asset.originalFilename,
|
|
|
|
|
contentType: asset.contentType,
|
|
|
|
|
byteSize: asset.byteSize,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.status(201).json({
|
|
|
|
|
assetId: asset.id,
|
|
|
|
|
companyId: asset.companyId,
|
|
|
|
|
provider: asset.provider,
|
|
|
|
|
objectKey: asset.objectKey,
|
|
|
|
|
contentType: asset.contentType,
|
|
|
|
|
byteSize: asset.byteSize,
|
|
|
|
|
sha256: asset.sha256,
|
|
|
|
|
originalFilename: asset.originalFilename,
|
|
|
|
|
createdByAgentId: asset.createdByAgentId,
|
|
|
|
|
createdByUserId: asset.createdByUserId,
|
|
|
|
|
createdAt: asset.createdAt,
|
|
|
|
|
updatedAt: asset.updatedAt,
|
|
|
|
|
contentPath: `/api/assets/${asset.id}/content`,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
router.get("/assets/:assetId/content", async (req, res, next) => {
|
|
|
|
|
const assetId = req.params.assetId as string;
|
|
|
|
|
const asset = await svc.getById(assetId);
|
|
|
|
|
if (!asset) {
|
|
|
|
|
res.status(404).json({ error: "Asset not found" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
assertCompanyAccess(req, asset.companyId);
|
|
|
|
|
|
|
|
|
|
const object = await storage.getObject(asset.companyId, asset.objectKey);
|
|
|
|
|
res.setHeader("Content-Type", asset.contentType || object.contentType || "application/octet-stream");
|
|
|
|
|
res.setHeader("Content-Length", String(asset.byteSize || object.contentLength || 0));
|
|
|
|
|
res.setHeader("Cache-Control", "private, max-age=60");
|
|
|
|
|
const filename = asset.originalFilename ?? "asset";
|
|
|
|
|
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
|
|
|
|
|
|
|
|
|
|
object.stream.on("error", (err) => {
|
|
|
|
|
next(err);
|
|
|
|
|
});
|
|
|
|
|
object.stream.pipe(res);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return router;
|
|
|
|
|
}
|
|
|
|
|
|