forked from farhoodlabs/paperclip
5f45712846
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The board depends on issue, inbox, cost, and company-skill surfaces to stay accurate and fast while agents are actively working > - The PAP-1497 follow-up branch exposed a few rough edges in those surfaces: stale active-run state on completed issues, missing creator filters, oversized issue payload scans, and placeholder issue-route parsing > - Those gaps make the control plane harder to trust because operators can see misleading run state, miss the right subset of work, or pay extra query/render cost on large issue records > - This pull request tightens those follow-ups across server and UI code, and adds regression coverage for the affected paths > - The benefit is a more reliable issue workflow, safer high-volume cost aggregation, and clearer board/operator navigation ## What Changed - Added the `v2026.415.0` release changelog entry. - Fixed stale issue-run presentation after completion and reused the shared issue-path parser so literal route placeholders no longer become issue links. - Added creator filters to the Issues page and Inbox, including persisted filter-state normalization and regression coverage. - Bounded issue detail/list project-mention scans and trimmed large issue-list payload fields to keep issue reads lighter. - Hardened company-skill list projection and cost/finance aggregation so large markdown blobs and large summed values do not leak into list responses or overflow 32-bit casts. - Added targeted server/UI regression tests for company skills, costs/finance, issue mention scanning, creator filters, inbox normalization, and issue reference parsing. ## Verification - `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/costs-service.test.ts server/src/__tests__/issues-goal-context-routes.test.ts server/src/__tests__/issues-service.test.ts ui/src/lib/inbox.test.ts ui/src/lib/issue-filters.test.ts ui/src/lib/issue-reference.test.ts` - `gh pr checks 3779` Current pass set on the PR head: `policy`, `verify`, `e2e`, `security/snyk (cryppadotta)`, `Greptile Review` ## Risks - Creator filter options are derived from the currently loaded issue/agent data, so very sparse result sets may not surface every historical creator until they appear in the active dataset. - Cost/finance aggregate casts now use `double precision`; that removes the current overflow risk, but future schema changes should keep large-value aggregation behavior under review. - Issue detail mention scanning now skips comment-body scans on the detail route, so any consumer that relied on comment-only project mentions there would need to fetch them separately. ## Model Used - OpenAI Codex, GPT-5-based coding agent with terminal tool use and local code execution in the Paperclip workspace. Exact internal model ID/context-window exposure is not surfaced in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agents, costEvents, financeEvents, goals, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
|
import { notFound, unprocessable } from "../errors.js";
|
|
|
|
export interface FinanceDateRange {
|
|
from?: Date;
|
|
to?: Date;
|
|
}
|
|
|
|
async function assertBelongsToCompany(
|
|
db: Db,
|
|
table: any,
|
|
id: string,
|
|
companyId: string,
|
|
label: string,
|
|
) {
|
|
const row = await db
|
|
.select()
|
|
.from(table)
|
|
.where(eq(table.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!row) throw notFound(`${label} not found`);
|
|
if ((row as unknown as { companyId: string }).companyId !== companyId) {
|
|
throw unprocessable(`${label} does not belong to company`);
|
|
}
|
|
}
|
|
|
|
function rangeConditions(companyId: string, range?: FinanceDateRange) {
|
|
const conditions: ReturnType<typeof eq>[] = [eq(financeEvents.companyId, companyId)];
|
|
if (range?.from) conditions.push(gte(financeEvents.occurredAt, range.from));
|
|
if (range?.to) conditions.push(lte(financeEvents.occurredAt, range.to));
|
|
return conditions;
|
|
}
|
|
|
|
export function financeService(db: Db) {
|
|
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
|
|
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
|
|
const estimatedDebitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`;
|
|
|
|
return {
|
|
createEvent: async (companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId">) => {
|
|
if (data.agentId) await assertBelongsToCompany(db, agents, data.agentId, companyId, "Agent");
|
|
if (data.issueId) await assertBelongsToCompany(db, issues, data.issueId, companyId, "Issue");
|
|
if (data.projectId) await assertBelongsToCompany(db, projects, data.projectId, companyId, "Project");
|
|
if (data.goalId) await assertBelongsToCompany(db, goals, data.goalId, companyId, "Goal");
|
|
if (data.heartbeatRunId) await assertBelongsToCompany(db, heartbeatRuns, data.heartbeatRunId, companyId, "Heartbeat run");
|
|
if (data.costEventId) await assertBelongsToCompany(db, costEvents, data.costEventId, companyId, "Cost event");
|
|
|
|
const event = await db
|
|
.insert(financeEvents)
|
|
.values({
|
|
...data,
|
|
companyId,
|
|
currency: data.currency ?? "USD",
|
|
direction: data.direction ?? "debit",
|
|
estimated: data.estimated ?? false,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
return event;
|
|
},
|
|
|
|
summary: async (companyId: string, range?: FinanceDateRange) => {
|
|
const conditions = rangeConditions(companyId, range);
|
|
const [row] = await db
|
|
.select({
|
|
debitCents: debitExpr,
|
|
creditCents: creditExpr,
|
|
estimatedDebitCents: estimatedDebitExpr,
|
|
eventCount: sql<number>`count(*)::int`,
|
|
})
|
|
.from(financeEvents)
|
|
.where(and(...conditions));
|
|
|
|
return {
|
|
companyId,
|
|
debitCents: Number(row?.debitCents ?? 0),
|
|
creditCents: Number(row?.creditCents ?? 0),
|
|
netCents: Number(row?.debitCents ?? 0) - Number(row?.creditCents ?? 0),
|
|
estimatedDebitCents: Number(row?.estimatedDebitCents ?? 0),
|
|
eventCount: Number(row?.eventCount ?? 0),
|
|
};
|
|
},
|
|
|
|
byBiller: async (companyId: string, range?: FinanceDateRange) => {
|
|
const conditions = rangeConditions(companyId, range);
|
|
return db
|
|
.select({
|
|
biller: financeEvents.biller,
|
|
debitCents: debitExpr,
|
|
creditCents: creditExpr,
|
|
estimatedDebitCents: estimatedDebitExpr,
|
|
eventCount: sql<number>`count(*)::int`,
|
|
kindCount: sql<number>`count(distinct ${financeEvents.eventKind})::int`,
|
|
netCents: sql<number>`(${debitExpr} - ${creditExpr})::double precision`,
|
|
})
|
|
.from(financeEvents)
|
|
.where(and(...conditions))
|
|
.groupBy(financeEvents.biller)
|
|
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller);
|
|
},
|
|
|
|
byKind: async (companyId: string, range?: FinanceDateRange) => {
|
|
const conditions = rangeConditions(companyId, range);
|
|
return db
|
|
.select({
|
|
eventKind: financeEvents.eventKind,
|
|
debitCents: debitExpr,
|
|
creditCents: creditExpr,
|
|
estimatedDebitCents: estimatedDebitExpr,
|
|
eventCount: sql<number>`count(*)::int`,
|
|
billerCount: sql<number>`count(distinct ${financeEvents.biller})::int`,
|
|
netCents: sql<number>`(${debitExpr} - ${creditExpr})::double precision`,
|
|
})
|
|
.from(financeEvents)
|
|
.where(and(...conditions))
|
|
.groupBy(financeEvents.eventKind)
|
|
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind);
|
|
},
|
|
|
|
list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => {
|
|
const conditions = rangeConditions(companyId, range);
|
|
return db
|
|
.select()
|
|
.from(financeEvents)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(financeEvents.occurredAt), desc(financeEvents.createdAt))
|
|
.limit(limit);
|
|
},
|
|
};
|
|
}
|