Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
accessService,
|
||||
approvalService,
|
||||
companySkillService,
|
||||
budgetService,
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
issueService,
|
||||
@@ -64,6 +65,7 @@ export function agentRoutes(db: Db) {
|
||||
const svc = agentService(db);
|
||||
const access = accessService(db);
|
||||
const approvalsSvc = approvalService(db);
|
||||
const budgets = budgetService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const secretsSvc = secretService(db);
|
||||
@@ -1094,6 +1096,19 @@ export function agentRoutes(db: Db) {
|
||||
details: { name: agent.name, role: agent.role },
|
||||
});
|
||||
|
||||
if (agent.budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
companyId,
|
||||
{
|
||||
scopeType: "agent",
|
||||
scopeId: agent.id,
|
||||
amount: agent.budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
actor.actorType === "user" ? actor.actorId : null,
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json(agent);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,104 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import createDOMPurify from "dompurify";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { createAssetImageMetadataSchema } from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { assetService, logActivity } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
const SVG_CONTENT_TYPE = "image/svg+xml";
|
||||
const ALLOWED_COMPANY_LOGO_CONTENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
SVG_CONTENT_TYPE,
|
||||
]);
|
||||
|
||||
function sanitizeSvgBuffer(input: Buffer): Buffer | null {
|
||||
const raw = input.toString("utf8").trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const baseDom = new JSDOM("");
|
||||
const domPurify = createDOMPurify(
|
||||
baseDom.window as unknown as Parameters<typeof createDOMPurify>[0],
|
||||
);
|
||||
domPurify.addHook("uponSanitizeAttribute", (_node, data) => {
|
||||
const attrName = data.attrName.toLowerCase();
|
||||
const attrValue = (data.attrValue ?? "").trim();
|
||||
|
||||
if (attrName.startsWith("on")) {
|
||||
data.keepAttr = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) {
|
||||
data.keepAttr = false;
|
||||
}
|
||||
});
|
||||
|
||||
let parsedDom: JSDOM | null = null;
|
||||
try {
|
||||
const sanitized = domPurify.sanitize(raw, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true, html: false },
|
||||
FORBID_TAGS: ["script", "foreignObject"],
|
||||
FORBID_CONTENTS: ["script", "foreignObject"],
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
});
|
||||
|
||||
parsedDom = new JSDOM(sanitized, { contentType: SVG_CONTENT_TYPE });
|
||||
const document = parsedDom.window.document;
|
||||
const root = document.documentElement;
|
||||
if (!root || root.tagName.toLowerCase() !== "svg") return null;
|
||||
|
||||
for (const el of Array.from(root.querySelectorAll("script, foreignObject"))) {
|
||||
el.remove();
|
||||
}
|
||||
for (const el of Array.from(root.querySelectorAll("*"))) {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const attrName = attr.name.toLowerCase();
|
||||
const attrValue = attr.value.trim();
|
||||
if (attrName.startsWith("on")) {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = root.outerHTML.trim();
|
||||
if (!output || !/^<svg[\s>]/i.test(output)) return null;
|
||||
return Buffer.from(output, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
parsedDom?.window.close();
|
||||
baseDom.window.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function assetRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
const svc = assetService(db);
|
||||
const upload = multer({
|
||||
const assetUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
const companyLogoUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
async function runSingleFileUpload(
|
||||
upload: ReturnType<typeof multer>,
|
||||
req: Request,
|
||||
res: Response,
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single("file")(req, res, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
@@ -29,7 +112,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(req, res);
|
||||
await runSingleFileUpload(assetUpload, req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
@@ -48,16 +131,6 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||
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 });
|
||||
@@ -65,13 +138,32 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
|
||||
const namespaceSuffix = parsedMeta.data.namespace ?? "general";
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (contentType !== SVG_CONTENT_TYPE && !isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
let fileBody = file.buffer;
|
||||
if (contentType === SVG_CONTENT_TYPE) {
|
||||
const sanitized = sanitizeSvgBuffer(file.buffer);
|
||||
if (!sanitized || sanitized.length <= 0) {
|
||||
res.status(422).json({ error: "SVG could not be sanitized" });
|
||||
return;
|
||||
}
|
||||
fileBody = sanitized;
|
||||
}
|
||||
if (fileBody.length <= 0) {
|
||||
res.status(422).json({ error: "Image is empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const stored = await storage.putFile({
|
||||
companyId,
|
||||
namespace: `assets/${namespaceSuffix}`,
|
||||
originalFilename: file.originalname || null,
|
||||
contentType,
|
||||
body: file.buffer,
|
||||
body: fileBody,
|
||||
});
|
||||
|
||||
const asset = await svc.create(companyId, {
|
||||
@@ -118,6 +210,105 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/logo", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(companyLogoUpload, req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Image 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_COMPANY_LOGO_CONTENT_TYPES.has(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
|
||||
let fileBody = file.buffer;
|
||||
if (contentType === SVG_CONTENT_TYPE) {
|
||||
const sanitized = sanitizeSvgBuffer(file.buffer);
|
||||
if (!sanitized || sanitized.length <= 0) {
|
||||
res.status(422).json({ error: "SVG could not be sanitized" });
|
||||
return;
|
||||
}
|
||||
fileBody = sanitized;
|
||||
}
|
||||
|
||||
if (fileBody.length <= 0) {
|
||||
res.status(422).json({ error: "Image is empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const stored = await storage.putFile({
|
||||
companyId,
|
||||
namespace: "assets/companies",
|
||||
originalFilename: file.originalname || null,
|
||||
contentType,
|
||||
body: fileBody,
|
||||
});
|
||||
|
||||
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,
|
||||
namespace: "assets/companies",
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -128,9 +319,14 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
assertCompanyAccess(req, asset.companyId);
|
||||
|
||||
const object = await storage.getObject(asset.companyId, asset.objectKey);
|
||||
res.setHeader("Content-Type", asset.contentType || object.contentType || "application/octet-stream");
|
||||
const responseContentType = asset.contentType || object.contentType || "application/octet-stream";
|
||||
res.setHeader("Content-Type", responseContentType);
|
||||
res.setHeader("Content-Length", String(asset.byteSize || object.contentLength || 0));
|
||||
res.setHeader("Cache-Control", "private, max-age=60");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (responseContentType === SVG_CONTENT_TYPE) {
|
||||
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
||||
}
|
||||
const filename = asset.originalFilename ?? "asset";
|
||||
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
|
||||
|
||||
@@ -142,4 +338,3 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { accessService, companyPortabilityService, companyService, logActivity } from "../services/index.js";
|
||||
import {
|
||||
accessService,
|
||||
budgetService,
|
||||
companyPortabilityService,
|
||||
companyService,
|
||||
logActivity,
|
||||
} from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function companyRoutes(db: Db) {
|
||||
@@ -17,6 +23,7 @@ export function companyRoutes(db: Db) {
|
||||
const svc = companyService(db);
|
||||
const portability = companyPortabilityService(db);
|
||||
const access = accessService(db);
|
||||
const budgets = budgetService(db);
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
assertBoard(req);
|
||||
@@ -122,6 +129,18 @@ export function companyRoutes(db: Db) {
|
||||
entityId: company.id,
|
||||
details: { name: company.name },
|
||||
});
|
||||
if (company.budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
company.id,
|
||||
{
|
||||
scopeType: "company",
|
||||
scopeId: company.id,
|
||||
amount: company.budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
req.actor.userId ?? "board",
|
||||
);
|
||||
}
|
||||
res.status(201).json(company);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { createCostEventSchema, updateBudgetSchema } from "@paperclipai/shared";
|
||||
import {
|
||||
createCostEventSchema,
|
||||
createFinanceEventSchema,
|
||||
resolveBudgetIncidentSchema,
|
||||
updateBudgetSchema,
|
||||
upsertBudgetPolicySchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { costService, companyService, agentService, logActivity } from "../services/index.js";
|
||||
import {
|
||||
budgetService,
|
||||
costService,
|
||||
financeService,
|
||||
companyService,
|
||||
agentService,
|
||||
heartbeatService,
|
||||
logActivity,
|
||||
} from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
|
||||
export function costRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const costs = costService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const budgetHooks = {
|
||||
cancelWorkForScope: heartbeat.cancelBudgetScopeWork,
|
||||
};
|
||||
const costs = costService(db, budgetHooks);
|
||||
const finance = financeService(db);
|
||||
const budgets = budgetService(db, budgetHooks);
|
||||
const companies = companyService(db);
|
||||
const agents = agentService(db);
|
||||
|
||||
@@ -40,12 +62,56 @@ export function costRoutes(db: Db) {
|
||||
res.status(201).json(event);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/finance-events", validate(createFinanceEventSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
assertBoard(req);
|
||||
|
||||
const event = await finance.createEvent(companyId, {
|
||||
...req.body,
|
||||
occurredAt: new Date(req.body.occurredAt),
|
||||
});
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "finance_event.reported",
|
||||
entityType: "finance_event",
|
||||
entityId: event.id,
|
||||
details: {
|
||||
amountCents: event.amountCents,
|
||||
biller: event.biller,
|
||||
eventKind: event.eventKind,
|
||||
direction: event.direction,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(event);
|
||||
});
|
||||
|
||||
function parseDateRange(query: Record<string, unknown>) {
|
||||
const from = query.from ? new Date(query.from as string) : undefined;
|
||||
const to = query.to ? new Date(query.to as string) : undefined;
|
||||
const fromRaw = query.from as string | undefined;
|
||||
const toRaw = query.to as string | undefined;
|
||||
const from = fromRaw ? new Date(fromRaw) : undefined;
|
||||
const to = toRaw ? new Date(toRaw) : undefined;
|
||||
if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date");
|
||||
if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date");
|
||||
return (from || to) ? { from, to } : undefined;
|
||||
}
|
||||
|
||||
function parseLimit(query: Record<string, unknown>) {
|
||||
const raw = query.limit as string | undefined;
|
||||
if (!raw) return 100;
|
||||
const limit = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
|
||||
throw badRequest("invalid 'limit' value");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/costs/summary", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -62,6 +128,117 @@ export function costRoutes(db: Db) {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await costs.byAgentModel(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await costs.byProvider(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await costs.byBiller(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/finance-summary", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const summary = await finance.summary(companyId, range);
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await finance.byBiller(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await finance.byKind(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/finance-events", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const limit = parseLimit(req.query);
|
||||
const rows = await finance.list(companyId, range, limit);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/window-spend", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const rows = await costs.windowSpend(companyId);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/quota-windows", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
assertBoard(req);
|
||||
// validate companyId resolves to a real company so the "__none__" sentinel
|
||||
// and any forged ids are rejected before we touch provider credentials
|
||||
const company = await companies.getById(companyId);
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
return;
|
||||
}
|
||||
const results = await fetchAllQuotaWindows();
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/budgets/overview", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const overview = await budgets.overview(companyId);
|
||||
res.json(overview);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/budgets/policies",
|
||||
validate(upsertBudgetPolicySchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const summary = await budgets.upsertPolicy(companyId, req.body, req.actor.userId ?? "board");
|
||||
res.json(summary);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/budget-incidents/:incidentId/resolve",
|
||||
validate(resolveBudgetIncidentSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
const incidentId = req.params.incidentId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const incident = await budgets.resolveIncident(companyId, incidentId, req.body, req.actor.userId ?? "board");
|
||||
res.json(incident);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -73,6 +250,7 @@ export function costRoutes(db: Db) {
|
||||
router.patch("/companies/:companyId/budgets", validate(updateBudgetSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const company = await companies.update(companyId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
@@ -89,6 +267,17 @@ export function costRoutes(db: Db) {
|
||||
details: { budgetMonthlyCents: req.body.budgetMonthlyCents },
|
||||
});
|
||||
|
||||
await budgets.upsertPolicy(
|
||||
companyId,
|
||||
{
|
||||
scopeType: "company",
|
||||
scopeId: companyId,
|
||||
amount: req.body.budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
req.actor.userId ?? "board",
|
||||
);
|
||||
|
||||
res.json(company);
|
||||
});
|
||||
|
||||
@@ -100,6 +289,8 @@ export function costRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
if (req.actor.type === "agent") {
|
||||
if (req.actor.agentId !== agentId) {
|
||||
res.status(403).json({ error: "Agent can only change its own budget" });
|
||||
@@ -125,6 +316,17 @@ export function costRoutes(db: Db) {
|
||||
details: { budgetMonthlyCents: updated.budgetMonthlyCents },
|
||||
});
|
||||
|
||||
await budgets.upsertPolicy(
|
||||
updated.companyId,
|
||||
{
|
||||
scopeType: "agent",
|
||||
scopeId: updated.id,
|
||||
amount: updated.budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
||||
);
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
|
||||
@@ -921,6 +921,19 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
|
||||
if (issue.projectId) {
|
||||
const project = await projectsSvc.getById(issue.projectId);
|
||||
if (project?.pausedAt) {
|
||||
res.status(409).json({
|
||||
error:
|
||||
project.pauseReason === "budget"
|
||||
? "Project is paused because its budget hard-stop was reached"
|
||||
: "Project is paused",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
|
||||
res.status(403).json({ error: "Agent can only checkout as itself" });
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user