Implement agent hiring, approval workflows, config revisions, LLM reflection, and sidebar badges
Agent management: hire endpoint with permission gates and pending_approval status, config revision tracking with rollback, agent duplicate route, permission CRUD. Block pending_approval agents from auth, heartbeat, and assignments. Approvals: revision request/resubmit flow, approval comments CRUD, issue-approval linking, auto-wake agents on approval decisions with context snapshot. Costs: per-agent breakdown, period filtering (month/week/day/all), cost by agent list endpoint. Adapters: agentConfigurationDoc on all adapters, /llms/agent-configuration.txt reflection routes. Inject PAPERCLIP_APPROVAL_ID, PAPERCLIP_APPROVAL_STATUS, PAPERCLIP_LINKED_ISSUE_IDS into adapter environments. Sidebar badges endpoint for pending approval/inbox counts. Dashboard and company settings extensions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export interface NormalizedAgentPermissions {
|
||||
export type NormalizedAgentPermissions = Record<string, unknown> & {
|
||||
canCreateAgents: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions {
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents, agentApiKeys, heartbeatRuns } from "@paperclip/db";
|
||||
import {
|
||||
agents,
|
||||
agentConfigRevisions,
|
||||
agentApiKeys,
|
||||
agentRuntimeState,
|
||||
agentWakeupRequests,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
} from "@paperclip/db";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
@@ -12,13 +21,118 @@ function createToken() {
|
||||
return `pcp_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
const CONFIG_REVISION_FIELDS = [
|
||||
"name",
|
||||
"role",
|
||||
"title",
|
||||
"reportsTo",
|
||||
"capabilities",
|
||||
"adapterType",
|
||||
"adapterConfig",
|
||||
"runtimeConfig",
|
||||
"budgetMonthlyCents",
|
||||
"metadata",
|
||||
] as const;
|
||||
|
||||
type ConfigRevisionField = (typeof CONFIG_REVISION_FIELDS)[number];
|
||||
type AgentConfigSnapshot = Pick<typeof agents.$inferSelect, ConfigRevisionField>;
|
||||
|
||||
interface RevisionMetadata {
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
source?: string;
|
||||
rolledBackFromRevisionId?: string | null;
|
||||
}
|
||||
|
||||
interface UpdateAgentOptions {
|
||||
recordRevision?: RevisionMetadata;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function jsonEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function buildConfigSnapshot(
|
||||
row: Pick<typeof agents.$inferSelect, ConfigRevisionField>,
|
||||
): AgentConfigSnapshot {
|
||||
return {
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
title: row.title,
|
||||
reportsTo: row.reportsTo,
|
||||
capabilities: row.capabilities,
|
||||
adapterType: row.adapterType,
|
||||
adapterConfig: row.adapterConfig ?? {},
|
||||
runtimeConfig: row.runtimeConfig ?? {},
|
||||
budgetMonthlyCents: row.budgetMonthlyCents,
|
||||
metadata: row.metadata ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasConfigPatchFields(data: Partial<typeof agents.$inferInsert>) {
|
||||
return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field));
|
||||
}
|
||||
|
||||
function diffConfigSnapshot(
|
||||
before: AgentConfigSnapshot,
|
||||
after: AgentConfigSnapshot,
|
||||
): string[] {
|
||||
return CONFIG_REVISION_FIELDS.filter((field) => !jsonEqual(before[field], after[field]));
|
||||
}
|
||||
|
||||
function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$inferInsert> {
|
||||
if (!isPlainRecord(snapshot)) throw unprocessable("Invalid revision snapshot");
|
||||
|
||||
if (typeof snapshot.name !== "string" || snapshot.name.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: name");
|
||||
}
|
||||
if (typeof snapshot.role !== "string" || snapshot.role.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: role");
|
||||
}
|
||||
if (typeof snapshot.adapterType !== "string" || snapshot.adapterType.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: adapterType");
|
||||
}
|
||||
if (typeof snapshot.budgetMonthlyCents !== "number" || !Number.isFinite(snapshot.budgetMonthlyCents)) {
|
||||
throw unprocessable("Invalid revision snapshot: budgetMonthlyCents");
|
||||
}
|
||||
|
||||
return {
|
||||
name: snapshot.name,
|
||||
role: snapshot.role,
|
||||
title: typeof snapshot.title === "string" || snapshot.title === null ? snapshot.title : null,
|
||||
reportsTo:
|
||||
typeof snapshot.reportsTo === "string" || snapshot.reportsTo === null ? snapshot.reportsTo : null,
|
||||
capabilities:
|
||||
typeof snapshot.capabilities === "string" || snapshot.capabilities === null
|
||||
? snapshot.capabilities
|
||||
: null,
|
||||
adapterType: snapshot.adapterType,
|
||||
adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {},
|
||||
runtimeConfig: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {},
|
||||
budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)),
|
||||
metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function agentService(db: Db) {
|
||||
function normalizeAgentRow(row: typeof agents.$inferSelect) {
|
||||
return {
|
||||
...row,
|
||||
permissions: normalizeAgentPermissions(row.permissions, row.role),
|
||||
};
|
||||
}
|
||||
|
||||
async function getById(id: string) {
|
||||
return db
|
||||
const row = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? normalizeAgentRow(row) : null;
|
||||
}
|
||||
|
||||
async function ensureManager(companyId: string, managerId: string) {
|
||||
@@ -42,9 +156,76 @@ export function agentService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAgent(
|
||||
id: string,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
options?: UpdateAgentOptions,
|
||||
) {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
if (existing.status === "terminated" && data.status && data.status !== "terminated") {
|
||||
throw conflict("Terminated agents cannot be resumed");
|
||||
}
|
||||
if (
|
||||
existing.status === "pending_approval" &&
|
||||
data.status &&
|
||||
data.status !== "pending_approval" &&
|
||||
data.status !== "terminated"
|
||||
) {
|
||||
throw conflict("Pending approval agents cannot be activated directly");
|
||||
}
|
||||
|
||||
if (data.reportsTo !== undefined) {
|
||||
if (data.reportsTo) {
|
||||
await ensureManager(existing.companyId, data.reportsTo);
|
||||
}
|
||||
await assertNoCycle(id, data.reportsTo);
|
||||
}
|
||||
|
||||
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
|
||||
if (data.permissions !== undefined) {
|
||||
const role = (data.role ?? existing.role) as string;
|
||||
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role);
|
||||
}
|
||||
|
||||
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
|
||||
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ ...normalizedPatch, updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const normalizedUpdated = updated ? normalizeAgentRow(updated) : null;
|
||||
|
||||
if (normalizedUpdated && shouldRecordRevision && beforeConfig) {
|
||||
const afterConfig = buildConfigSnapshot(normalizedUpdated);
|
||||
const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig);
|
||||
if (changedKeys.length > 0) {
|
||||
await db.insert(agentConfigRevisions).values({
|
||||
companyId: normalizedUpdated.companyId,
|
||||
agentId: normalizedUpdated.id,
|
||||
createdByAgentId: options?.recordRevision?.createdByAgentId ?? null,
|
||||
createdByUserId: options?.recordRevision?.createdByUserId ?? null,
|
||||
source: options?.recordRevision?.source ?? "patch",
|
||||
rolledBackFromRevisionId: options?.recordRevision?.rolledBackFromRevisionId ?? null,
|
||||
changedKeys,
|
||||
beforeConfig: beforeConfig as unknown as Record<string, unknown>,
|
||||
afterConfig: afterConfig as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedUpdated;
|
||||
}
|
||||
|
||||
return {
|
||||
list: (companyId: string) =>
|
||||
db.select().from(agents).where(eq(agents.companyId, companyId)),
|
||||
list: async (companyId: string) => {
|
||||
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
return rows.map(normalizeAgentRow);
|
||||
},
|
||||
|
||||
getById,
|
||||
|
||||
@@ -53,62 +234,48 @@ export function agentService(db: Db) {
|
||||
await ensureManager(companyId, data.reportsTo);
|
||||
}
|
||||
|
||||
const role = data.role ?? "general";
|
||||
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
|
||||
const created = await db
|
||||
.insert(agents)
|
||||
.values({ ...data, companyId })
|
||||
.values({ ...data, companyId, role, permissions: normalizedPermissions })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return created;
|
||||
return normalizeAgentRow(created);
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<typeof agents.$inferInsert>) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
if (existing.status === "terminated" && data.status && data.status !== "terminated") {
|
||||
throw conflict("Terminated agents cannot be resumed");
|
||||
}
|
||||
|
||||
if (data.reportsTo !== undefined) {
|
||||
if (data.reportsTo) {
|
||||
await ensureManager(existing.companyId, data.reportsTo);
|
||||
}
|
||||
await assertNoCycle(id, data.reportsTo);
|
||||
}
|
||||
|
||||
return db
|
||||
.update(agents)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
update: updateAgent,
|
||||
|
||||
pause: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "terminated") throw conflict("Cannot pause terminated agent");
|
||||
|
||||
return db
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ status: "paused", updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return updated ? normalizeAgentRow(updated) : null;
|
||||
},
|
||||
|
||||
resume: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "terminated") throw conflict("Cannot resume terminated agent");
|
||||
if (existing.status === "pending_approval") {
|
||||
throw conflict("Pending approval agents cannot be resumed");
|
||||
}
|
||||
|
||||
return db
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ status: "idle", updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return updated ? normalizeAgentRow(updated) : null;
|
||||
},
|
||||
|
||||
terminate: async (id: string) => {
|
||||
@@ -128,9 +295,104 @@ export function agentService(db: Db) {
|
||||
return getById(id);
|
||||
},
|
||||
|
||||
remove: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.agentId, id));
|
||||
const deleted = await tx
|
||||
.delete(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return deleted ? normalizeAgentRow(deleted) : null;
|
||||
});
|
||||
},
|
||||
|
||||
activatePendingApproval: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status !== "pending_approval") return existing;
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ status: "idle", updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return updated ? normalizeAgentRow(updated) : null;
|
||||
},
|
||||
|
||||
updatePermissions: async (id: string, permissions: { canCreateAgents: boolean }) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({
|
||||
permissions: normalizeAgentPermissions(permissions, existing.role),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return updated ? normalizeAgentRow(updated) : null;
|
||||
},
|
||||
|
||||
listConfigRevisions: async (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(agentConfigRevisions)
|
||||
.where(eq(agentConfigRevisions.agentId, id))
|
||||
.orderBy(desc(agentConfigRevisions.createdAt)),
|
||||
|
||||
getConfigRevision: async (id: string, revisionId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(agentConfigRevisions)
|
||||
.where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
rollbackConfigRevision: async (
|
||||
id: string,
|
||||
revisionId: string,
|
||||
actor: { agentId?: string | null; userId?: string | null },
|
||||
) => {
|
||||
const revision = await db
|
||||
.select()
|
||||
.from(agentConfigRevisions)
|
||||
.where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!revision) return null;
|
||||
|
||||
const patch = configPatchFromSnapshot(revision.afterConfig);
|
||||
return updateAgent(id, patch, {
|
||||
recordRevision: {
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
source: "rollback",
|
||||
rolledBackFromRevisionId: revision.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
createApiKey: async (id: string, name: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) throw notFound("Agent not found");
|
||||
if (existing.status === "pending_approval") {
|
||||
throw conflict("Cannot create keys for pending approval agents");
|
||||
}
|
||||
if (existing.status === "terminated") {
|
||||
throw conflict("Cannot create keys for terminated agents");
|
||||
}
|
||||
|
||||
const token = createToken();
|
||||
const keyHash = hashToken(token);
|
||||
@@ -175,8 +437,9 @@ export function agentService(db: Db) {
|
||||
|
||||
orgForCompany: async (companyId: string) => {
|
||||
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
const byManager = new Map<string | null, typeof rows>();
|
||||
for (const row of rows) {
|
||||
const normalizedRows = rows.map(normalizeAgentRow);
|
||||
const byManager = new Map<string | null, typeof normalizedRows>();
|
||||
for (const row of normalizedRows) {
|
||||
const key = row.reportsTo ?? null;
|
||||
const group = byManager.get(key) ?? [];
|
||||
group.push(row);
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { approvals } from "@paperclip/db";
|
||||
import { approvalComments, approvals } from "@paperclip/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { agentService } from "./agents.js";
|
||||
|
||||
export function approvalService(db: Db) {
|
||||
const agentsSvc = agentService(db);
|
||||
const canResolveStatuses = new Set(["pending", "revision_requested"]);
|
||||
|
||||
async function getExistingApproval(id: string) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) throw notFound("Approval not found");
|
||||
return existing;
|
||||
}
|
||||
|
||||
return {
|
||||
list: (companyId: string, status?: string) => {
|
||||
@@ -29,15 +40,9 @@ export function approvalService(db: Db) {
|
||||
.then((rows) => rows[0]),
|
||||
|
||||
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existing) throw notFound("Approval not found");
|
||||
if (existing.status !== "pending") {
|
||||
throw unprocessable("Only pending approvals can be approved");
|
||||
const existing = await getExistingApproval(id);
|
||||
if (!canResolveStatuses.has(existing.status)) {
|
||||
throw unprocessable("Only pending or revision requested approvals can be approved");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -56,46 +61,46 @@ export function approvalService(db: Db) {
|
||||
|
||||
if (updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
await agentsSvc.create(updated.companyId, {
|
||||
name: String(payload.name ?? "New Agent"),
|
||||
role: String(payload.role ?? "general"),
|
||||
title: typeof payload.title === "string" ? payload.title : null,
|
||||
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
|
||||
capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,
|
||||
adapterType: String(payload.adapterType ?? "process"),
|
||||
adapterConfig:
|
||||
typeof payload.adapterConfig === "object" && payload.adapterConfig !== null
|
||||
? (payload.adapterConfig as Record<string, unknown>)
|
||||
: {},
|
||||
budgetMonthlyCents:
|
||||
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,
|
||||
metadata:
|
||||
typeof payload.metadata === "object" && payload.metadata !== null
|
||||
? (payload.metadata as Record<string, unknown>)
|
||||
: null,
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
await agentsSvc.activatePendingApproval(payloadAgentId);
|
||||
} else {
|
||||
await agentsSvc.create(updated.companyId, {
|
||||
name: String(payload.name ?? "New Agent"),
|
||||
role: String(payload.role ?? "general"),
|
||||
title: typeof payload.title === "string" ? payload.title : null,
|
||||
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
|
||||
capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,
|
||||
adapterType: String(payload.adapterType ?? "process"),
|
||||
adapterConfig:
|
||||
typeof payload.adapterConfig === "object" && payload.adapterConfig !== null
|
||||
? (payload.adapterConfig as Record<string, unknown>)
|
||||
: {},
|
||||
budgetMonthlyCents:
|
||||
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,
|
||||
metadata:
|
||||
typeof payload.metadata === "object" && payload.metadata !== null
|
||||
? (payload.metadata as Record<string, unknown>)
|
||||
: null,
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
permissions: undefined,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existing) throw notFound("Approval not found");
|
||||
if (existing.status !== "pending") {
|
||||
throw unprocessable("Only pending approvals can be rejected");
|
||||
const existing = await getExistingApproval(id);
|
||||
if (!canResolveStatuses.has(existing.status)) {
|
||||
throw unprocessable("Only pending or revision requested approvals can be rejected");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return db
|
||||
const updated = await db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: "rejected",
|
||||
@@ -107,6 +112,92 @@ export function approvalService(db: Db) {
|
||||
.where(eq(approvals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
await agentsSvc.terminate(payloadAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const existing = await getExistingApproval(id);
|
||||
if (existing.status !== "pending") {
|
||||
throw unprocessable("Only pending approvals can request revision");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: "revision_requested",
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(approvals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
resubmit: async (id: string, payload?: Record<string, unknown>) => {
|
||||
const existing = await getExistingApproval(id);
|
||||
if (existing.status !== "revision_requested") {
|
||||
throw unprocessable("Only revision requested approvals can be resubmitted");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: "pending",
|
||||
payload: payload ?? existing.payload,
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(approvals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
listComments: async (approvalId: string) => {
|
||||
const existing = await getExistingApproval(approvalId);
|
||||
return db
|
||||
.select()
|
||||
.from(approvalComments)
|
||||
.where(
|
||||
and(
|
||||
eq(approvalComments.approvalId, approvalId),
|
||||
eq(approvalComments.companyId, existing.companyId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(approvalComments.createdAt));
|
||||
},
|
||||
|
||||
addComment: async (
|
||||
approvalId: string,
|
||||
body: string,
|
||||
actor: { agentId?: string; userId?: string },
|
||||
) => {
|
||||
const existing = await getExistingApproval(approvalId);
|
||||
return db
|
||||
.insert(approvalComments)
|
||||
.values({
|
||||
companyId: existing.companyId,
|
||||
approvalId,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
body,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
heartbeatRuns,
|
||||
heartbeatRunEvents,
|
||||
costEvents,
|
||||
approvalComments,
|
||||
approvals,
|
||||
activityLog,
|
||||
} from "@paperclip/db";
|
||||
@@ -61,6 +62,7 @@ export function companyService(db: Db) {
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
|
||||
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
|
||||
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
|
||||
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
|
||||
await tx.delete(approvals).where(eq(approvals.companyId, id));
|
||||
await tx.delete(issues).where(eq(issues.companyId, id));
|
||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { and, desc, eq, isNotNull, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents, companies, costEvents } from "@paperclip/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export interface CostDateRange {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
export function costService(db: Db) {
|
||||
return {
|
||||
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
|
||||
@@ -61,7 +66,7 @@ export function costService(db: Db) {
|
||||
return event;
|
||||
},
|
||||
|
||||
summary: async (companyId: string) => {
|
||||
summary: async (companyId: string, range?: CostDateRange) => {
|
||||
const company = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
@@ -70,43 +75,71 @@ export function costService(db: Db) {
|
||||
|
||||
if (!company) throw notFound("Company not found");
|
||||
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
const [{ total }] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions));
|
||||
|
||||
const spendCents = Number(total);
|
||||
const utilization =
|
||||
company.budgetMonthlyCents > 0
|
||||
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
|
||||
? (spendCents / company.budgetMonthlyCents) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
companyId,
|
||||
monthSpendCents: company.spentMonthlyCents,
|
||||
monthBudgetCents: company.budgetMonthlyCents,
|
||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||
spendCents,
|
||||
budgetCents: company.budgetMonthlyCents,
|
||||
utilizationPercent: Number(utilization.toFixed(2)),
|
||||
};
|
||||
},
|
||||
|
||||
byAgent: async (companyId: string) =>
|
||||
db
|
||||
byAgent: async (companyId: string, range?: CostDateRange) => {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
return db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)`,
|
||||
agentName: agents.name,
|
||||
agentStatus: agents.status,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(eq(costEvents.companyId, companyId))
|
||||
.groupBy(costEvents.agentId)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
|
||||
.leftJoin(agents, eq(costEvents.agentId, agents.id))
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.agentId, agents.name, agents.status)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
},
|
||||
|
||||
byProject: async (companyId: string) =>
|
||||
db
|
||||
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||
const conditions: ReturnType<typeof eq>[] = [
|
||||
eq(costEvents.companyId, companyId),
|
||||
isNotNull(costEvents.projectId),
|
||||
];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
return db
|
||||
.select({
|
||||
projectId: costEvents.projectId,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)`,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(eq(costEvents.companyId, companyId), isNotNull(costEvents.projectId)))
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.projectId)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, gte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents, approvals, companies, issues } from "@paperclip/db";
|
||||
import { agents, approvals, companies, costEvents, issues } from "@paperclip/db";
|
||||
import { notFound } from "../errors.js";
|
||||
|
||||
export function dashboardService(db: Db) {
|
||||
@@ -69,9 +69,24 @@ export function dashboardService(db: Db) {
|
||||
if (row.status !== "done" && row.status !== "cancelled") taskCounts.open += count;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const [{ monthSpend }] = await db
|
||||
.select({
|
||||
monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
gte(costEvents.occurredAt, monthStart),
|
||||
),
|
||||
);
|
||||
|
||||
const monthSpendCents = Number(monthSpend);
|
||||
const utilization =
|
||||
company.budgetMonthlyCents > 0
|
||||
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
|
||||
? (monthSpendCents / company.budgetMonthlyCents) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
@@ -84,7 +99,7 @@ export function dashboardService(db: Db) {
|
||||
},
|
||||
tasks: taskCounts,
|
||||
costs: {
|
||||
monthSpendCents: company.spentMonthlyCents,
|
||||
monthSpendCents,
|
||||
monthBudgetCents: company.budgetMonthlyCents,
|
||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||
},
|
||||
|
||||
@@ -621,7 +621,11 @@ export function heartbeatService(db: Db) {
|
||||
const agent = await getAgent(agentId);
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
|
||||
if (agent.status === "paused" || agent.status === "terminated") {
|
||||
if (
|
||||
agent.status === "paused" ||
|
||||
agent.status === "terminated" ||
|
||||
agent.status === "pending_approval"
|
||||
) {
|
||||
throw conflict("Agent is not invokable in its current state", { status: agent.status });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ export { companyService } from "./companies.js";
|
||||
export { agentService } from "./agents.js";
|
||||
export { projectService } from "./projects.js";
|
||||
export { issueService, type IssueFilters } from "./issues.js";
|
||||
export { issueApprovalService } from "./issue-approvals.js";
|
||||
export { goalService } from "./goals.js";
|
||||
export { activityService, type ActivityFilters } from "./activity.js";
|
||||
export { approvalService } from "./approvals.js";
|
||||
export { costService } from "./costs.js";
|
||||
export { heartbeatService } from "./heartbeat.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
|
||||
169
server/src/services/issue-approvals.ts
Normal file
169
server/src/services/issue-approvals.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { approvals, issueApprovals, issues } from "@paperclip/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
interface LinkActor {
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
export function issueApprovalService(db: Db) {
|
||||
async function getIssue(issueId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getApproval(approvalId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, approvalId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertIssueAndApprovalSameCompany(issueId: string, approvalId: string) {
|
||||
const issue = await getIssue(issueId);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
const approval = await getApproval(approvalId);
|
||||
if (!approval) throw notFound("Approval not found");
|
||||
|
||||
if (issue.companyId !== approval.companyId) {
|
||||
throw unprocessable("Issue and approval must belong to the same company");
|
||||
}
|
||||
|
||||
return { issue, approval };
|
||||
}
|
||||
|
||||
return {
|
||||
listApprovalsForIssue: async (issueId: string) => {
|
||||
const issue = await getIssue(issueId);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: approvals.id,
|
||||
companyId: approvals.companyId,
|
||||
type: approvals.type,
|
||||
requestedByAgentId: approvals.requestedByAgentId,
|
||||
requestedByUserId: approvals.requestedByUserId,
|
||||
status: approvals.status,
|
||||
payload: approvals.payload,
|
||||
decisionNote: approvals.decisionNote,
|
||||
decidedByUserId: approvals.decidedByUserId,
|
||||
decidedAt: approvals.decidedAt,
|
||||
createdAt: approvals.createdAt,
|
||||
updatedAt: approvals.updatedAt,
|
||||
})
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(eq(issueApprovals.issueId, issueId))
|
||||
.orderBy(desc(issueApprovals.createdAt));
|
||||
},
|
||||
|
||||
listIssuesForApproval: async (approvalId: string) => {
|
||||
const approval = await getApproval(approvalId);
|
||||
if (!approval) throw notFound("Approval not found");
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
projectId: issues.projectId,
|
||||
goalId: issues.goalId,
|
||||
parentId: issues.parentId,
|
||||
title: issues.title,
|
||||
description: issues.description,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
createdByAgentId: issues.createdByAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
issueNumber: issues.issueNumber,
|
||||
identifier: issues.identifier,
|
||||
requestDepth: issues.requestDepth,
|
||||
billingCode: issues.billingCode,
|
||||
startedAt: issues.startedAt,
|
||||
completedAt: issues.completedAt,
|
||||
cancelledAt: issues.cancelledAt,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issueApprovals)
|
||||
.innerJoin(issues, eq(issueApprovals.issueId, issues.id))
|
||||
.where(eq(issueApprovals.approvalId, approvalId))
|
||||
.orderBy(desc(issueApprovals.createdAt));
|
||||
},
|
||||
|
||||
link: async (issueId: string, approvalId: string, actor?: LinkActor) => {
|
||||
const { issue } = await assertIssueAndApprovalSameCompany(issueId, approvalId);
|
||||
|
||||
await db
|
||||
.insert(issueApprovals)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
issueId,
|
||||
approvalId,
|
||||
linkedByAgentId: actor?.agentId ?? null,
|
||||
linkedByUserId: actor?.userId ?? null,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(issueApprovals)
|
||||
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
unlink: async (issueId: string, approvalId: string) => {
|
||||
await assertIssueAndApprovalSameCompany(issueId, approvalId);
|
||||
await db
|
||||
.delete(issueApprovals)
|
||||
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId)));
|
||||
},
|
||||
|
||||
linkManyForApproval: async (approvalId: string, issueIds: string[], actor?: LinkActor) => {
|
||||
if (issueIds.length === 0) return;
|
||||
|
||||
const approval = await getApproval(approvalId);
|
||||
if (!approval) throw notFound("Approval not found");
|
||||
|
||||
const uniqueIssueIds = Array.from(new Set(issueIds));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(inArray(issues.id, uniqueIssueIds));
|
||||
|
||||
if (rows.length !== uniqueIssueIds.length) {
|
||||
throw notFound("One or more issues not found");
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.companyId !== approval.companyId) {
|
||||
throw unprocessable("Issue and approval must belong to the same company");
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(issueApprovals)
|
||||
.values(
|
||||
uniqueIssueIds.map((issueId) => ({
|
||||
companyId: approval.companyId,
|
||||
issueId,
|
||||
approvalId,
|
||||
linkedByAgentId: actor?.agentId ?? null,
|
||||
linkedByUserId: actor?.userId ?? null,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,21 +3,12 @@ import type { Db } from "@paperclip/db";
|
||||
import { agents, companies, issues, issueComments } from "@paperclip/db";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
const ISSUE_TRANSITIONS: Record<string, string[]> = {
|
||||
backlog: ["todo", "cancelled"],
|
||||
todo: ["in_progress", "blocked", "cancelled"],
|
||||
in_progress: ["in_review", "blocked", "done", "cancelled"],
|
||||
in_review: ["in_progress", "done", "cancelled"],
|
||||
blocked: ["todo", "in_progress", "cancelled"],
|
||||
done: ["todo"],
|
||||
cancelled: ["todo"],
|
||||
};
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
|
||||
function assertTransition(from: string, to: string) {
|
||||
if (from === to) return;
|
||||
const allowed = ISSUE_TRANSITIONS[from] ?? [];
|
||||
if (!allowed.includes(to)) {
|
||||
throw conflict(`Invalid issue status transition: ${from} -> ${to}`);
|
||||
if (!ALL_ISSUE_STATUSES.includes(to)) {
|
||||
throw conflict(`Unknown issue status: ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +37,29 @@ export interface IssueFilters {
|
||||
}
|
||||
|
||||
export function issueService(db: Db) {
|
||||
async function assertAssignableAgent(companyId: string, agentId: string) {
|
||||
const assignee = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!assignee) throw notFound("Assignee agent not found");
|
||||
if (assignee.companyId !== companyId) {
|
||||
throw unprocessable("Assignee must belong to same company");
|
||||
}
|
||||
if (assignee.status === "pending_approval") {
|
||||
throw conflict("Cannot assign work to pending approval agents");
|
||||
}
|
||||
if (assignee.status === "terminated") {
|
||||
throw conflict("Cannot assign work to terminated agents");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list: async (companyId: string, filters?: IssueFilters) => {
|
||||
const conditions = [eq(issues.companyId, companyId)];
|
||||
@@ -77,6 +91,9 @@ export function issueService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
create: async (companyId: string, data: Omit<typeof issues.$inferInsert, "companyId">) => {
|
||||
if (data.assigneeAgentId) {
|
||||
await assertAssignableAgent(companyId, data.assigneeAgentId);
|
||||
}
|
||||
return db.transaction(async (tx) => {
|
||||
const [company] = await tx
|
||||
.update(companies)
|
||||
@@ -123,6 +140,9 @@ export function issueService(db: Db) {
|
||||
if (patch.status === "in_progress" && !patch.assigneeAgentId && !existing.assigneeAgentId) {
|
||||
throw unprocessable("in_progress issues require an assignee");
|
||||
}
|
||||
if (data.assigneeAgentId) {
|
||||
await assertAssignableAgent(existing.companyId, data.assigneeAgentId);
|
||||
}
|
||||
|
||||
applyStatusSideEffects(data.status, patch);
|
||||
if (data.status && data.status !== "done") {
|
||||
@@ -148,6 +168,14 @@ export function issueService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
checkout: async (id: string, agentId: string, expectedStatuses: string[]) => {
|
||||
const issueCompany = await db
|
||||
.select({ companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issueCompany) throw notFound("Issue not found");
|
||||
await assertAssignableAgent(issueCompany.companyId, agentId);
|
||||
|
||||
const now = new Date();
|
||||
const updated = await db
|
||||
.update(issues)
|
||||
|
||||
29
server/src/services/sidebar-badges.ts
Normal file
29
server/src/services/sidebar-badges.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { approvals } from "@paperclip/db";
|
||||
import type { SidebarBadges } from "@paperclip/shared";
|
||||
|
||||
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
||||
|
||||
export function sidebarBadgeService(db: Db) {
|
||||
return {
|
||||
get: async (companyId: string): Promise<SidebarBadges> => {
|
||||
const actionableApprovals = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(approvals)
|
||||
.where(
|
||||
and(
|
||||
eq(approvals.companyId, companyId),
|
||||
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
|
||||
return {
|
||||
// Inbox currently mirrors actionable approvals; expand as inbox categories grow.
|
||||
inbox: actionableApprovals,
|
||||
approvals: actionableApprovals,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user