Persist non-issue inbox dismissals

This commit is contained in:
dotta 2026-04-07 18:26:34 -05:00
parent 1de5fb9316
commit 5640d29ab0
23 changed files with 13623 additions and 54 deletions

View file

@ -0,0 +1,69 @@
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { validate } from "../middleware/validate.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { inboxDismissalService, logActivity } from "../services/index.js";
const inboxDismissalSchema = z.object({
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
});
export function inboxDismissalRoutes(db: Db) {
const router = Router();
const svc = inboxDismissalService(db);
router.get("/companies/:companyId/inbox-dismissals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const dismissals = await svc.list(companyId, req.actor.userId);
res.json(dismissals);
});
router.post(
"/companies/:companyId/inbox-dismissals",
validate(inboxDismissalSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date());
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "inbox.dismissed",
entityType: "company",
entityId: companyId,
details: {
userId: req.actor.userId,
itemKey: dismissal.itemKey,
dismissedAt: dismissal.dismissedAt,
},
});
res.status(201).json(dismissal);
},
);
return router;
}

View file

@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";

View file

@ -1,12 +1,20 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { joinRequests } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { inboxDismissals, joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.js";
function buildDismissedAtByKey(
dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>,
): Map<string, number> {
return new Map(
dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]),
);
}
export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
@ -26,23 +34,36 @@ export function sidebarBadgeRoutes(db: Db) {
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
}
const joinRequestCount = canApproveJoins
const visibleJoinRequests = canApproveJoins
? await db
.select({ count: sql<number>`count(*)` })
.select({
id: joinRequests.id,
updatedAt: joinRequests.updatedAt,
createdAt: joinRequests.createdAt,
})
.from(joinRequests)
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
.then((rows) => Number(rows[0]?.count ?? 0))
: 0;
: [];
const dismissedAtByKey =
req.actor.type === "board" && req.actor.userId
? await db
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
.from(inboxDismissals)
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId)))
.then(buildDismissedAtByKey)
: new Map<string, number>();
const badges = await svc.get(companyId, {
joinRequests: joinRequestCount,
dismissals: dismissedAtByKey,
joinRequests: visibleJoinRequests,
});
const summary = await dashboard.summary(companyId);
const hasFailedRuns = badges.failedRuns > 0;
const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
badges.inbox = badges.failedRuns + alertsCount + badges.joinRequests + badges.approvals;
res.json(badges);
});