feat: server-side issue search, dashboard charts, and inbox badges
Add ILIKE-based issue search across title, identifier, description, and comments with relevance ranking. Add assigneeUserId filter and allow agents to return issues to creator. Show assigned issue count in sidebar badges. Add minCount param to live-runs endpoint. Add activity charts (run activity, priority, status, success rate) to dashboard. Improve active agents panel with recent run cards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { Router, type Request } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclip/db";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import {
|
||||
createAgentKeySchema,
|
||||
createAgentHireSchema,
|
||||
@@ -987,20 +987,25 @@ export function agentRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const minCountParam = req.query.minCount as string | undefined;
|
||||
const minCount = minCountParam ? Math.max(0, Math.min(20, parseInt(minCountParam, 10) || 0)) : 0;
|
||||
|
||||
const columns = {
|
||||
id: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agentsTable.name,
|
||||
adapterType: agentsTable.adapterType,
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
|
||||
};
|
||||
|
||||
const liveRuns = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agentsTable.name,
|
||||
adapterType: agentsTable.adapterType,
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
|
||||
})
|
||||
.select(columns)
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
|
||||
.where(
|
||||
@@ -1011,6 +1016,26 @@ export function agentRoutes(db: Db) {
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt));
|
||||
|
||||
if (minCount > 0 && liveRuns.length < minCount) {
|
||||
const activeIds = liveRuns.map((r) => r.id);
|
||||
const recentRuns = await db
|
||||
.select(columns)
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
not(inArray(heartbeatRuns.status, ["queued", "running"])),
|
||||
...(activeIds.length > 0 ? [not(inArray(heartbeatRuns.id, activeIds))] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt))
|
||||
.limit(minCount - liveRuns.length);
|
||||
|
||||
res.json([...liveRuns, ...recentRuns]);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(liveRuns);
|
||||
});
|
||||
|
||||
|
||||
@@ -186,11 +186,24 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
router.get("/companies/:companyId/issues", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined;
|
||||
const assigneeUserId =
|
||||
assigneeUserFilterRaw === "me" && req.actor.type === "board"
|
||||
? req.actor.userId
|
||||
: assigneeUserFilterRaw;
|
||||
|
||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
status: req.query.status as string | undefined,
|
||||
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
||||
assigneeUserId,
|
||||
projectId: req.query.projectId as string | undefined,
|
||||
labelId: req.query.labelId as string | undefined,
|
||||
q: req.query.q as string | undefined,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
@@ -390,8 +403,20 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const assigneeWillChange =
|
||||
(req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
|
||||
(req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
|
||||
|
||||
const isAgentReturningIssueToCreator =
|
||||
req.actor.type === "agent" &&
|
||||
!!req.actor.agentId &&
|
||||
existing.assigneeAgentId === req.actor.agentId &&
|
||||
req.body.assigneeAgentId === null &&
|
||||
typeof req.body.assigneeUserId === "string" &&
|
||||
!!existing.createdByUserId &&
|
||||
req.body.assigneeUserId === existing.createdByUserId;
|
||||
|
||||
if (assigneeWillChange) {
|
||||
await assertCanAssignTasks(req, existing.companyId);
|
||||
if (!isAgentReturningIssueToCreator) {
|
||||
await assertCanAssignTasks(req, existing.companyId);
|
||||
}
|
||||
}
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { joinRequests } from "@paperclip/db";
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { issues, joinRequests } from "@paperclip/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
const INBOX_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const;
|
||||
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarBadgeService(db);
|
||||
@@ -32,7 +34,26 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||
: 0;
|
||||
|
||||
const badges = await svc.get(companyId, { joinRequests: joinRequestCount });
|
||||
const assignedIssueCount =
|
||||
req.actor.type === "board" && req.actor.userId
|
||||
? await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.assigneeUserId, req.actor.userId),
|
||||
inArray(issues.status, [...INBOX_ISSUE_STATUSES]),
|
||||
isNull(issues.hiddenAt),
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||
: 0;
|
||||
|
||||
const badges = await svc.get(companyId, {
|
||||
joinRequests: joinRequestCount,
|
||||
assignedIssues: assignedIssueCount,
|
||||
});
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user