Add server routes for companies, approvals, costs, and dashboard
New routes: companies, approvals, costs, dashboard, authz. New services: companies, approvals, costs, dashboard, heartbeat, activity-log. Add auth middleware and structured error handling. Expand existing agent and issue routes with richer CRUD operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
26
server/src/services/activity-log.ts
Normal file
26
server/src/services/activity-log.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { activityLog } from "@paperclip/db";
|
||||
|
||||
export interface LogActivityInput {
|
||||
companyId: string;
|
||||
actorType: "agent" | "user" | "system";
|
||||
actorId: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
agentId?: string | null;
|
||||
details?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function logActivity(db: Db, input: LogActivityInput) {
|
||||
await db.insert(activityLog).values({
|
||||
companyId: input.companyId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
agentId: input.agentId ?? null,
|
||||
details: input.details ?? null,
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { activityLog } from "@paperclip/db";
|
||||
|
||||
export interface ActivityFilters {
|
||||
companyId: string;
|
||||
agentId?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
@@ -10,26 +11,20 @@ export interface ActivityFilters {
|
||||
|
||||
export function activityService(db: Db) {
|
||||
return {
|
||||
list: (filters?: ActivityFilters) => {
|
||||
const conditions = [];
|
||||
list: (filters: ActivityFilters) => {
|
||||
const conditions = [eq(activityLog.companyId, filters.companyId)];
|
||||
|
||||
if (filters?.agentId) {
|
||||
if (filters.agentId) {
|
||||
conditions.push(eq(activityLog.agentId, filters.agentId));
|
||||
}
|
||||
if (filters?.entityType) {
|
||||
if (filters.entityType) {
|
||||
conditions.push(eq(activityLog.entityType, filters.entityType));
|
||||
}
|
||||
if (filters?.entityId) {
|
||||
if (filters.entityId) {
|
||||
conditions.push(eq(activityLog.entityId, filters.entityId));
|
||||
}
|
||||
|
||||
const query = db.select().from(activityLog);
|
||||
|
||||
if (conditions.length > 0) {
|
||||
return query.where(and(...conditions)).orderBy(desc(activityLog.createdAt));
|
||||
}
|
||||
|
||||
return query.orderBy(desc(activityLog.createdAt));
|
||||
return db.select().from(activityLog).where(and(...conditions)).orderBy(desc(activityLog.createdAt));
|
||||
},
|
||||
|
||||
create: (data: typeof activityLog.$inferInsert) =>
|
||||
|
||||
@@ -1,38 +1,183 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents } from "@paperclip/db";
|
||||
import { agents, agentApiKeys, heartbeatRuns } from "@paperclip/db";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function createToken() {
|
||||
return `pcp_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function agentService(db: Db) {
|
||||
async function getById(id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function ensureManager(companyId: string, managerId: string) {
|
||||
const manager = await getById(managerId);
|
||||
if (!manager) throw notFound("Manager not found");
|
||||
if (manager.companyId !== companyId) {
|
||||
throw unprocessable("Manager must belong to same company");
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function assertNoCycle(agentId: string, reportsTo: string | null | undefined) {
|
||||
if (!reportsTo) return;
|
||||
if (reportsTo === agentId) throw unprocessable("Agent cannot report to itself");
|
||||
|
||||
let cursor: string | null = reportsTo;
|
||||
while (cursor) {
|
||||
if (cursor === agentId) throw unprocessable("Reporting relationship would create cycle");
|
||||
const next = await getById(cursor);
|
||||
cursor = next?.reportsTo ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list: () => db.select().from(agents),
|
||||
list: (companyId: string) =>
|
||||
db.select().from(agents).where(eq(agents.companyId, companyId)),
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
getById,
|
||||
|
||||
create: (data: typeof agents.$inferInsert) =>
|
||||
db
|
||||
create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">) => {
|
||||
if (data.reportsTo) {
|
||||
await ensureManager(companyId, data.reportsTo);
|
||||
}
|
||||
|
||||
const created = await db
|
||||
.insert(agents)
|
||||
.values(data)
|
||||
.values({ ...data, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]),
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
update: (id: string, data: Partial<typeof agents.$inferInsert>) =>
|
||||
db
|
||||
return 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),
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db
|
||||
.delete(agents)
|
||||
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
|
||||
.update(agents)
|
||||
.set({ status: "paused", updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
resume: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "terminated") throw conflict("Cannot resume terminated agent");
|
||||
|
||||
return db
|
||||
.update(agents)
|
||||
.set({ status: "idle", updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
terminate: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ status: "terminated", updatedAt: new Date() })
|
||||
.where(eq(agents.id, id));
|
||||
|
||||
await db
|
||||
.update(agentApiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(agentApiKeys.agentId, id));
|
||||
|
||||
return getById(id);
|
||||
},
|
||||
|
||||
createApiKey: async (id: string, name: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) throw notFound("Agent not found");
|
||||
|
||||
const token = createToken();
|
||||
const keyHash = hashToken(token);
|
||||
const created = await db
|
||||
.insert(agentApiKeys)
|
||||
.values({
|
||||
agentId: id,
|
||||
companyId: existing.companyId,
|
||||
name,
|
||||
keyHash,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
token,
|
||||
createdAt: created.createdAt,
|
||||
};
|
||||
},
|
||||
|
||||
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 key = row.reportsTo ?? null;
|
||||
const group = byManager.get(key) ?? [];
|
||||
group.push(row);
|
||||
byManager.set(key, group);
|
||||
}
|
||||
|
||||
const build = (managerId: string | null): Array<Record<string, unknown>> => {
|
||||
const members = byManager.get(managerId) ?? [];
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
reports: build(member.id),
|
||||
}));
|
||||
};
|
||||
|
||||
return build(null);
|
||||
},
|
||||
|
||||
runningForAgent: (agentId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))),
|
||||
};
|
||||
}
|
||||
|
||||
113
server/src/services/approvals.ts
Normal file
113
server/src/services/approvals.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { approvals } from "@paperclip/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { agentService } from "./agents.js";
|
||||
|
||||
export function approvalService(db: Db) {
|
||||
const agentsSvc = agentService(db);
|
||||
|
||||
return {
|
||||
list: (companyId: string, status?: string) => {
|
||||
const conditions = [eq(approvals.companyId, companyId)];
|
||||
if (status) conditions.push(eq(approvals.status, status));
|
||||
return db.select().from(approvals).where(and(...conditions));
|
||||
},
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
create: (companyId: string, data: Omit<typeof approvals.$inferInsert, "companyId">) =>
|
||||
db
|
||||
.insert(approvals)
|
||||
.values({ ...data, companyId })
|
||||
.returning()
|
||||
.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 now = new Date();
|
||||
const updated = await db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: "approved",
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(approvals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
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>)
|
||||
: {},
|
||||
contextMode: String(payload.contextMode ?? "thin"),
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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 now = new Date();
|
||||
return db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: "rejected",
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(approvals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
};
|
||||
}
|
||||
39
server/src/services/companies.ts
Normal file
39
server/src/services/companies.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { companies } from "@paperclip/db";
|
||||
|
||||
export function companyService(db: Db) {
|
||||
return {
|
||||
list: () => db.select().from(companies),
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
create: (data: typeof companies.$inferInsert) =>
|
||||
db
|
||||
.insert(companies)
|
||||
.values(data)
|
||||
.returning()
|
||||
.then((rows) => rows[0]),
|
||||
|
||||
update: (id: string, data: Partial<typeof companies.$inferInsert>) =>
|
||||
db
|
||||
.update(companies)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
archive: (id: string) =>
|
||||
db
|
||||
.update(companies)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
};
|
||||
}
|
||||
112
server/src/services/costs.ts
Normal file
112
server/src/services/costs.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { and, desc, eq, isNotNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents, companies, costEvents } from "@paperclip/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export function costService(db: Db) {
|
||||
return {
|
||||
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
|
||||
const agent = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, data.agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
if (agent.companyId !== companyId) {
|
||||
throw unprocessable("Agent does not belong to company");
|
||||
}
|
||||
|
||||
const event = await db
|
||||
.insert(costEvents)
|
||||
.values({ ...data, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${event.costCents}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, event.agentId));
|
||||
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
spentMonthlyCents: sql`${companies.spentMonthlyCents} + ${event.costCents}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companies.id, companyId));
|
||||
|
||||
const updatedAgent = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, event.agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (
|
||||
updatedAgent &&
|
||||
updatedAgent.budgetMonthlyCents > 0 &&
|
||||
updatedAgent.spentMonthlyCents >= updatedAgent.budgetMonthlyCents &&
|
||||
updatedAgent.status !== "paused" &&
|
||||
updatedAgent.status !== "terminated"
|
||||
) {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ status: "paused", updatedAt: new Date() })
|
||||
.where(eq(agents.id, updatedAgent.id));
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
summary: async (companyId: string) => {
|
||||
const company = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!company) throw notFound("Company not found");
|
||||
|
||||
const utilization =
|
||||
company.budgetMonthlyCents > 0
|
||||
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
companyId,
|
||||
monthSpendCents: company.spentMonthlyCents,
|
||||
monthBudgetCents: company.budgetMonthlyCents,
|
||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||
};
|
||||
},
|
||||
|
||||
byAgent: async (companyId: string) =>
|
||||
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)`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(eq(costEvents.companyId, companyId))
|
||||
.groupBy(costEvents.agentId)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
|
||||
|
||||
byProject: async (companyId: string) =>
|
||||
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)`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(eq(costEvents.companyId, companyId), isNotNull(costEvents.projectId)))
|
||||
.groupBy(costEvents.projectId)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
|
||||
};
|
||||
}
|
||||
96
server/src/services/dashboard.ts
Normal file
96
server/src/services/dashboard.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents, approvals, companies, issues } from "@paperclip/db";
|
||||
import { notFound } from "../errors.js";
|
||||
|
||||
export function dashboardService(db: Db) {
|
||||
return {
|
||||
summary: async (companyId: string) => {
|
||||
const company = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!company) throw notFound("Company not found");
|
||||
|
||||
const agentRows = await db
|
||||
.select({ status: agents.status, count: sql<number>`count(*)` })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId))
|
||||
.groupBy(agents.status);
|
||||
|
||||
const taskRows = await db
|
||||
.select({ status: issues.status, count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId))
|
||||
.groupBy(issues.status);
|
||||
|
||||
const pendingApprovals = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(approvals)
|
||||
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
|
||||
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const staleTasks = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.status, "in_progress"),
|
||||
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
|
||||
const agentCounts: Record<string, number> = {
|
||||
active: 0,
|
||||
running: 0,
|
||||
paused: 0,
|
||||
error: 0,
|
||||
};
|
||||
for (const row of agentRows) {
|
||||
agentCounts[row.status] = Number(row.count);
|
||||
}
|
||||
|
||||
const taskCounts: Record<string, number> = {
|
||||
open: 0,
|
||||
inProgress: 0,
|
||||
blocked: 0,
|
||||
done: 0,
|
||||
};
|
||||
for (const row of taskRows) {
|
||||
const count = Number(row.count);
|
||||
if (row.status === "in_progress") taskCounts.inProgress += count;
|
||||
if (row.status === "blocked") taskCounts.blocked += count;
|
||||
if (row.status === "done") taskCounts.done += count;
|
||||
if (row.status !== "done" && row.status !== "cancelled") taskCounts.open += count;
|
||||
}
|
||||
|
||||
const utilization =
|
||||
company.budgetMonthlyCents > 0
|
||||
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
companyId,
|
||||
agents: {
|
||||
active: agentCounts.active,
|
||||
running: agentCounts.running,
|
||||
paused: agentCounts.paused,
|
||||
error: agentCounts.error,
|
||||
},
|
||||
tasks: taskCounts,
|
||||
costs: {
|
||||
monthSpendCents: company.spentMonthlyCents,
|
||||
monthBudgetCents: company.budgetMonthlyCents,
|
||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||
},
|
||||
pendingApprovals,
|
||||
staleTasks,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { goals } from "@paperclip/db";
|
||||
|
||||
export function goalService(db: Db) {
|
||||
return {
|
||||
list: () => db.select().from(goals),
|
||||
list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)),
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
@@ -13,10 +13,10 @@ export function goalService(db: Db) {
|
||||
.where(eq(goals.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
create: (data: typeof goals.$inferInsert) =>
|
||||
create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
|
||||
db
|
||||
.insert(goals)
|
||||
.values(data)
|
||||
.values({ ...data, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]),
|
||||
|
||||
|
||||
354
server/src/services/heartbeat.ts
Normal file
354
server/src/services/heartbeat.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents, heartbeatRuns } from "@paperclip/db";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
interface RunningProcess {
|
||||
child: ChildProcess;
|
||||
graceSec: number;
|
||||
}
|
||||
|
||||
const runningProcesses = new Map<string, RunningProcess>();
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
export function heartbeatService(db: Db) {
|
||||
async function getAgent(agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function setRunStatus(
|
||||
runId: string,
|
||||
status: string,
|
||||
patch?: Partial<typeof heartbeatRuns.$inferInsert>,
|
||||
) {
|
||||
return db
|
||||
.update(heartbeatRuns)
|
||||
.set({ status, ...patch, updatedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function finalizeAgentStatus(agentId: string, ok: boolean) {
|
||||
const existing = await getAgent(agentId);
|
||||
if (!existing) return;
|
||||
|
||||
if (existing.status === "paused" || existing.status === "terminated") {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
status: ok ? "idle" : "error",
|
||||
lastHeartbeatAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, agentId));
|
||||
}
|
||||
|
||||
async function executeHttpRun(runId: string, agentId: string, config: Record<string, unknown>, context: Record<string, unknown>) {
|
||||
const url = asString(config.url, "");
|
||||
if (!url) throw new Error("HTTP adapter missing url");
|
||||
|
||||
const method = asString(config.method, "POST");
|
||||
const timeoutMs = asNumber(config.timeoutMs, 15000);
|
||||
const headers = parseObject(config.headers) as Record<string, string>;
|
||||
const payloadTemplate = parseObject(config.payloadTemplate);
|
||||
const body = { ...payloadTemplate, agentId, runId, context };
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP invoke failed with status ${res.status}`);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeProcessRun(
|
||||
runId: string,
|
||||
_agentId: string,
|
||||
config: Record<string, unknown>,
|
||||
) {
|
||||
const command = asString(config.command, "");
|
||||
if (!command) throw new Error("Process adapter missing command");
|
||||
|
||||
const args = asStringArray(config.args);
|
||||
const cwd = typeof config.cwd === "string" ? config.cwd : process.cwd();
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 900);
|
||||
const graceSec = asNumber(config.graceSec, 15);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
|
||||
runningProcesses.set(runId, { child, graceSec });
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
child.kill("SIGTERM");
|
||||
await setRunStatus(runId, "timed_out", {
|
||||
error: `Timed out after ${timeoutSec}s`,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
runningProcesses.delete(runId);
|
||||
resolve();
|
||||
}, timeoutSec * 1000);
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
logger.info({ runId, output: String(chunk) }, "agent process stdout");
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
logger.warn({ runId, output: String(chunk) }, "agent process stderr");
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
|
||||
if (signal) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`Process exited with code ${code ?? -1}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function executeRun(runId: string) {
|
||||
const run = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = await getAgent(run.agentId);
|
||||
if (!agent) {
|
||||
await setRunStatus(runId, "failed", {
|
||||
error: "Agent not found",
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await setRunStatus(run.id, "running", { startedAt: new Date() });
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ status: "running", updatedAt: new Date() })
|
||||
.where(eq(agents.id, agent.id));
|
||||
|
||||
try {
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const context = (run.contextSnapshot ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (agent.adapterType === "http") {
|
||||
await executeHttpRun(run.id, agent.id, config, context);
|
||||
} else {
|
||||
await executeProcessRun(run.id, agent.id, config);
|
||||
}
|
||||
|
||||
const latestRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, run.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (latestRun?.status === "timed_out" || latestRun?.status === "cancelled") {
|
||||
await finalizeAgentStatus(agent.id, false);
|
||||
return;
|
||||
}
|
||||
|
||||
await setRunStatus(run.id, "succeeded", { finishedAt: new Date(), error: null });
|
||||
await finalizeAgentStatus(agent.id, true);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown adapter failure";
|
||||
await setRunStatus(run.id, "failed", {
|
||||
error: message,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
await finalizeAgentStatus(agent.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list: (companyId: string, agentId?: string) => {
|
||||
if (!agentId) {
|
||||
return db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)));
|
||||
},
|
||||
|
||||
invoke: async (
|
||||
agentId: string,
|
||||
invocationSource: "scheduler" | "manual" | "callback" = "manual",
|
||||
contextSnapshot: Record<string, unknown> = {},
|
||||
) => {
|
||||
const agent = await getAgent(agentId);
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
|
||||
if (agent.status === "paused" || agent.status === "terminated") {
|
||||
throw conflict("Agent is not invokable in its current state", { status: agent.status });
|
||||
}
|
||||
|
||||
const activeRun = await db
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.agentId, agentId),
|
||||
inArray(heartbeatRuns.status, ["queued", "running"]),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (activeRun) {
|
||||
throw conflict("Agent already has an active heartbeat run", { runId: activeRun.id });
|
||||
}
|
||||
|
||||
const run = await db
|
||||
.insert(heartbeatRuns)
|
||||
.values({
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
invocationSource,
|
||||
status: "queued",
|
||||
contextSnapshot,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
void executeRun(run.id).catch((err) => {
|
||||
logger.error({ err, runId: run.id }, "heartbeat execution failed");
|
||||
});
|
||||
|
||||
return run;
|
||||
},
|
||||
|
||||
cancelRun: async (runId: string) => {
|
||||
const run = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!run) throw notFound("Heartbeat run not found");
|
||||
if (run.status !== "running" && run.status !== "queued") return run;
|
||||
|
||||
const running = runningProcesses.get(run.id);
|
||||
if (running) {
|
||||
running.child.kill("SIGTERM");
|
||||
const graceMs = Math.max(1, running.graceSec) * 1000;
|
||||
setTimeout(() => {
|
||||
if (!running.child.killed) {
|
||||
running.child.kill("SIGKILL");
|
||||
}
|
||||
}, graceMs);
|
||||
}
|
||||
|
||||
const cancelled = await setRunStatus(run.id, "cancelled", {
|
||||
finishedAt: new Date(),
|
||||
error: "Cancelled by control plane",
|
||||
});
|
||||
|
||||
runningProcesses.delete(run.id);
|
||||
return cancelled;
|
||||
},
|
||||
|
||||
cancelActiveForAgent: async (agentId: string) => {
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.agentId, agentId),
|
||||
inArray(heartbeatRuns.status, ["queued", "running"]),
|
||||
),
|
||||
);
|
||||
|
||||
for (const run of runs) {
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: new Date(),
|
||||
error: "Cancelled due to agent pause",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
|
||||
const running = runningProcesses.get(run.id);
|
||||
if (running) {
|
||||
running.child.kill("SIGTERM");
|
||||
runningProcesses.delete(run.id);
|
||||
}
|
||||
}
|
||||
|
||||
return runs.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
export { companyService } from "./companies.js";
|
||||
export { agentService } from "./agents.js";
|
||||
export { projectService } from "./projects.js";
|
||||
export { issueService } from "./issues.js";
|
||||
export { issueService, type IssueFilters } from "./issues.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 { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
|
||||
@@ -1,10 +1,62 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { issues } from "@paperclip/db";
|
||||
import { 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: [],
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStatusSideEffects(
|
||||
status: string | undefined,
|
||||
patch: Partial<typeof issues.$inferInsert>,
|
||||
): Partial<typeof issues.$inferInsert> {
|
||||
if (!status) return patch;
|
||||
|
||||
if (status === "in_progress" && !patch.startedAt) {
|
||||
patch.startedAt = new Date();
|
||||
}
|
||||
if (status === "done") {
|
||||
patch.completedAt = new Date();
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
patch.cancelledAt = new Date();
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
export interface IssueFilters {
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function issueService(db: Db) {
|
||||
return {
|
||||
list: () => db.select().from(issues),
|
||||
list: async (companyId: string, filters?: IssueFilters) => {
|
||||
const conditions = [eq(issues.companyId, companyId)];
|
||||
if (filters?.status) conditions.push(eq(issues.status, filters.status));
|
||||
if (filters?.assigneeAgentId) {
|
||||
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
|
||||
}
|
||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
||||
|
||||
return db.select().from(issues).where(and(...conditions)).orderBy(desc(issues.updatedAt));
|
||||
},
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
@@ -13,20 +65,55 @@ export function issueService(db: Db) {
|
||||
.where(eq(issues.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
create: (data: typeof issues.$inferInsert) =>
|
||||
db
|
||||
.insert(issues)
|
||||
.values(data)
|
||||
.returning()
|
||||
.then((rows) => rows[0]),
|
||||
create: (companyId: string, data: Omit<typeof issues.$inferInsert, "companyId">) => {
|
||||
const values = { ...data, companyId } as typeof issues.$inferInsert;
|
||||
if (values.status === "in_progress" && !values.startedAt) {
|
||||
values.startedAt = new Date();
|
||||
}
|
||||
if (values.status === "done") {
|
||||
values.completedAt = new Date();
|
||||
}
|
||||
if (values.status === "cancelled") {
|
||||
values.cancelledAt = new Date();
|
||||
}
|
||||
|
||||
update: (id: string, data: Partial<typeof issues.$inferInsert>) =>
|
||||
db
|
||||
return db
|
||||
.insert(issues)
|
||||
.values(values)
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<typeof issues.$inferInsert>) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
if (data.status) {
|
||||
assertTransition(existing.status, data.status);
|
||||
}
|
||||
|
||||
const patch: Partial<typeof issues.$inferInsert> = {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (patch.status === "in_progress" && !patch.assigneeAgentId && !existing.assigneeAgentId) {
|
||||
throw unprocessable("in_progress issues require an assignee");
|
||||
}
|
||||
|
||||
applyStatusSideEffects(data.status, patch);
|
||||
|
||||
return db
|
||||
.update(issues)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.set(patch)
|
||||
.where(eq(issues.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db
|
||||
@@ -34,5 +121,116 @@ export function issueService(db: Db) {
|
||||
.where(eq(issues.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
checkout: async (id: string, agentId: string, expectedStatuses: string[]) => {
|
||||
const now = new Date();
|
||||
const updated = await db
|
||||
.update(issues)
|
||||
.set({
|
||||
assigneeAgentId: agentId,
|
||||
status: "in_progress",
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(issues.id, id),
|
||||
inArray(issues.status, expectedStatuses),
|
||||
or(isNull(issues.assigneeAgentId), eq(issues.assigneeAgentId, agentId)),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (updated) return updated;
|
||||
|
||||
const current = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!current) throw notFound("Issue not found");
|
||||
|
||||
throw conflict("Issue checkout conflict", {
|
||||
issueId: current.id,
|
||||
status: current.status,
|
||||
assigneeAgentId: current.assigneeAgentId,
|
||||
});
|
||||
},
|
||||
|
||||
release: async (id: string, actorAgentId?: string) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existing) return null;
|
||||
if (actorAgentId && existing.assigneeAgentId && existing.assigneeAgentId !== actorAgentId) {
|
||||
throw conflict("Only assignee can release issue");
|
||||
}
|
||||
|
||||
return db
|
||||
.update(issues)
|
||||
.set({
|
||||
status: "todo",
|
||||
assigneeAgentId: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
listComments: (issueId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.issueId, issueId))
|
||||
.orderBy(desc(issueComments.createdAt)),
|
||||
|
||||
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
|
||||
const issue = await db
|
||||
.select({ companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
return db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
issueId,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
body,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
staleCount: async (companyId: string, minutes = 60) => {
|
||||
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
|
||||
const result = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.status, "in_progress"),
|
||||
sql`${issues.startedAt} < ${cutoff.toISOString()}`,
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { projects } from "@paperclip/db";
|
||||
|
||||
export function projectService(db: Db) {
|
||||
return {
|
||||
list: () => db.select().from(projects),
|
||||
list: (companyId: string) => db.select().from(projects).where(eq(projects.companyId, companyId)),
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
@@ -13,10 +13,10 @@ export function projectService(db: Db) {
|
||||
.where(eq(projects.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
create: (data: typeof projects.$inferInsert) =>
|
||||
create: (companyId: string, data: Omit<typeof projects.$inferInsert, "companyId">) =>
|
||||
db
|
||||
.insert(projects)
|
||||
.values(data)
|
||||
.values({ ...data, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user