93206f73fa
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent has a heartbeat scheduler that wakes it on timers and on
events; every wake spawns an adapter (Claude / Codex / …) run that bills
the operator's subscription
> - When an operator archives a company, the agents inside it remain in
invokable states — the heartbeat scheduler never consults company status
— so timers keep firing and event-driven wakes (comments, mentions,
blockers-resolved, etc.) keep cascading
> - On real deployments this silently drains the operator's
subscription: idle archived companies wake their CEOs hourly, plus any
cross-company event cascade
> - This pull request enforces "archived ⇒ never spawns a run" as a
structural invariant by guarding the wake path AND cascading agent state
on archive/reactivate
> - The benefit is that archived companies stop billing the operator,
and the UI/queue stays consistent with the invariant
## What Changed
- `server/src/services/heartbeat.ts`:
- `enqueueWakeup()` loads the company and short-circuits when status is
not `active`. Background sources (timer, automation, events) write a
`company.inactive` skipped wake and return `null`; explicit user invokes
throw a `conflict` so the UI surfaces the real reason.
- `tickTimers()` joins agents to active companies so the scheduler does
not iterate archived-company agents at all (no skip-row noise).
- `server/src/services/companies.ts`:
- `archive(id, actor?)` pauses runnable agents with `pauseReason =
"company_archived"` inside the transaction (preserving
`pending_approval`, `terminated`, and agents paused for unrelated
reasons), then cancels `queued`/`running` heartbeat runs after the
transaction commits.
- `update(id, data, actor?)` reverses the cascade only for agents whose
`pauseReason === "company_archived"` on the `archived → active`
transition; manually-paused agents stay paused.
- Both methods emit activity-log entries (`company.archived` with
`agentsPaused` + `runsCancelled`, `company.reactivated` with
`agentsRestored`) so the audit trail fires regardless of caller.
- `packages/shared/src/constants.ts` + `server/src/services/budgets.ts`:
add `company_archived` to the legal `PauseReason` union so the
restorable marker is a first-class value.
-
`packages/db/src/migrations/0094_backfill_archived_company_agent_pauses.sql`:
backfill so existing archived-company agents become `paused /
company_archived` (excludes `pending_approval`).
- `ui/src/lib/activity-format.ts`: add the `company.reactivated` label.
## Verification
- `npx vitest run src/__tests__/companies-service.test.ts` — archive
cascade, reactivate cascade, and activity-log entries (with counts) all
pass.
- `npx vitest run
src/__tests__/heartbeat-archived-company-guard.test.ts` — timer +
on-demand + event-wake paths all blocked for archived companies;
`company.inactive` skipped-wake row written; user-initiated wakes throw
`conflict`.
- `pnpm typecheck` — clean.
- Manual repro from the bug description: archive a company, wait an
interval / post a comment on one of its issues, observe zero new
heartbeat runs.
## Risks
- Migration `0094` is a single bulk UPDATE on `agents` joined to
archived `companies`. On large deployments it briefly holds row locks on
archived-company agent rows; should be quick because the predicate is
narrow (`status NOT IN (paused, terminated, pending_approval)` and
`companies.status = 'archived'`).
- New `pauseReason` value (`company_archived`) is opaque to older
clients that only know the previous union. Acceptable because the union
is read as plain text and the contract is sync'd in the same change.
- Behavior change for users: invoking an agent in an archived company
now fails with a conflict instead of silently spawning a run. Intended.
## Model Used
- Claude (Anthropic) — model `claude-opus-4-7` ("Opus 4.7"), Claude Code
CLI, with tool use (Read/Edit/Bash/Grep). No extended thinking mode.
## 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 checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [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 — N/A, no UI changes beyond an activity-log label string
- [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
## Related Work
Fixes #1348 (`[Bug] Archived companies still running heartbeats and
consuming tokens`).
Prior attempts and parallel work in this area:
- #1365 and #1429 by @hungdqdesign (March 2026) — both closed without
merging. Same three-layer shape (`tickTimers` / `enqueueWakeup` /
`resumeQueuedRuns` + archive-route cancellation) targeting #1348. Credit
for first publicly proposing the wake-path-guard approach.
- #5865 by @stubbi (May 2026, open) — adds the same `companies.status !=
'archived'` joins to `tickTimers`, `enqueueWakeup`, `resumeQueuedRuns`,
**and** routines `tickScheduledTriggers`, bundled with plugin-table
tenant isolation (`plugin_entities` / `plugin_job_runs` / `plugin_logs`
/ `plugin_webhook_deliveries` get a `companyId` FK with `ON DELETE
CASCADE`). This PR is narrower — it does not touch routines or plugin
tables — but adds the **archive cascade** (pause agents with
`pauseReason = "company_archived"`), the **reactivate reverse**
(un-pause only that subset), the **`company_archived` pause-reason
marker**, and a **backfill migration** for pre-existing archived
companies, which #5865 does not include. Happy to coordinate sequencing
or rebase if #5865 lands first.
496 lines
17 KiB
TypeScript
496 lines
17 KiB
TypeScript
import { and, count, eq, gte, inArray, isNull, lt, notInArray, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
companies,
|
|
companyLogos,
|
|
assets,
|
|
agents,
|
|
agentApiKeys,
|
|
agentRuntimeState,
|
|
agentTaskSessions,
|
|
agentWakeupRequests,
|
|
issues,
|
|
issueComments,
|
|
projects,
|
|
goals,
|
|
heartbeatRuns,
|
|
heartbeatRunEvents,
|
|
costEvents,
|
|
financeEvents,
|
|
issueReadStates,
|
|
approvalComments,
|
|
approvals,
|
|
activityLog,
|
|
companySecrets,
|
|
joinRequests,
|
|
invites,
|
|
principalPermissionGrants,
|
|
companyMemberships,
|
|
companySkills,
|
|
documents,
|
|
} from "@paperclipai/db";
|
|
import { notFound, unprocessable } from "../errors.js";
|
|
import { environmentService } from "./environments.js";
|
|
import { heartbeatService } from "./heartbeat.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
|
|
export interface CompanyActivityActor {
|
|
actorType: "user" | "agent" | "system" | "plugin";
|
|
actorId: string;
|
|
agentId?: string | null;
|
|
runId?: string | null;
|
|
}
|
|
|
|
const SYSTEM_COMPANY_ACTOR: CompanyActivityActor = {
|
|
actorType: "system",
|
|
actorId: "system",
|
|
agentId: null,
|
|
runId: null,
|
|
};
|
|
|
|
export function companyService(db: Db) {
|
|
const ISSUE_PREFIX_FALLBACK = "CMP";
|
|
const environmentsSvc = environmentService(db);
|
|
const heartbeat = heartbeatService(db);
|
|
|
|
type CompanyTx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
|
|
|
async function applyArchiveCascadeInTx(tx: CompanyTx, id: string) {
|
|
const pausedAgentRows = await tx
|
|
.update(agents)
|
|
.set({
|
|
status: "paused",
|
|
pauseReason: "company_archived",
|
|
pausedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(
|
|
eq(agents.companyId, id),
|
|
notInArray(agents.status, ["paused", "terminated", "pending_approval"]),
|
|
))
|
|
.returning({ id: agents.id });
|
|
|
|
const activeRunIds = await tx
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(and(
|
|
eq(heartbeatRuns.companyId, id),
|
|
inArray(heartbeatRuns.status, ["queued", "running"]),
|
|
))
|
|
.then((rows) => rows.map((row) => row.id));
|
|
|
|
await tx
|
|
.update(agentWakeupRequests)
|
|
.set({
|
|
status: "cancelled",
|
|
error: "Cancelled because the company was archived",
|
|
finishedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(
|
|
eq(agentWakeupRequests.companyId, id),
|
|
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]),
|
|
isNull(agentWakeupRequests.runId),
|
|
));
|
|
|
|
return { agentsPaused: pausedAgentRows.length, activeRunIds };
|
|
}
|
|
|
|
async function finalizeArchive(
|
|
id: string,
|
|
actor: CompanyActivityActor,
|
|
cascade: { agentsPaused: number; activeRunIds: string[] },
|
|
) {
|
|
for (const runId of cascade.activeRunIds) {
|
|
await heartbeat.cancelRun(runId, "Cancelled because the company was archived");
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId: id,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId ?? null,
|
|
runId: actor.runId ?? null,
|
|
action: "company.archived",
|
|
entityType: "company",
|
|
entityId: id,
|
|
details: {
|
|
agentsPaused: cascade.agentsPaused,
|
|
runsCancelled: cascade.activeRunIds.length,
|
|
},
|
|
});
|
|
}
|
|
|
|
const companySelection = {
|
|
id: companies.id,
|
|
name: companies.name,
|
|
description: companies.description,
|
|
status: companies.status,
|
|
issuePrefix: companies.issuePrefix,
|
|
issueCounter: companies.issueCounter,
|
|
budgetMonthlyCents: companies.budgetMonthlyCents,
|
|
spentMonthlyCents: companies.spentMonthlyCents,
|
|
attachmentMaxBytes: companies.attachmentMaxBytes,
|
|
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
|
feedbackDataSharingEnabled: companies.feedbackDataSharingEnabled,
|
|
feedbackDataSharingConsentAt: companies.feedbackDataSharingConsentAt,
|
|
feedbackDataSharingConsentByUserId: companies.feedbackDataSharingConsentByUserId,
|
|
feedbackDataSharingTermsVersion: companies.feedbackDataSharingTermsVersion,
|
|
brandColor: companies.brandColor,
|
|
logoAssetId: companyLogos.assetId,
|
|
createdAt: companies.createdAt,
|
|
updatedAt: companies.updatedAt,
|
|
};
|
|
|
|
function enrichCompany<T extends { logoAssetId: string | null }>(company: T) {
|
|
return {
|
|
...company,
|
|
logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
|
|
};
|
|
}
|
|
|
|
function currentUtcMonthWindow(now = new Date()) {
|
|
const year = now.getUTCFullYear();
|
|
const month = now.getUTCMonth();
|
|
return {
|
|
start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
|
|
end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)),
|
|
};
|
|
}
|
|
|
|
async function getMonthlySpendByCompanyIds(
|
|
companyIds: string[],
|
|
database: Pick<Db, "select"> = db,
|
|
) {
|
|
if (companyIds.length === 0) return new Map<string, number>();
|
|
const { start, end } = currentUtcMonthWindow();
|
|
const rows = await database
|
|
.select({
|
|
companyId: costEvents.companyId,
|
|
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
|
})
|
|
.from(costEvents)
|
|
.where(
|
|
and(
|
|
inArray(costEvents.companyId, companyIds),
|
|
gte(costEvents.occurredAt, start),
|
|
lt(costEvents.occurredAt, end),
|
|
),
|
|
)
|
|
.groupBy(costEvents.companyId);
|
|
return new Map(rows.map((row) => [row.companyId, Number(row.spentMonthlyCents ?? 0)]));
|
|
}
|
|
|
|
async function hydrateCompanySpend<T extends { id: string; spentMonthlyCents: number }>(
|
|
rows: T[],
|
|
database: Pick<Db, "select"> = db,
|
|
) {
|
|
const spendByCompanyId = await getMonthlySpendByCompanyIds(rows.map((row) => row.id), database);
|
|
return rows.map((row) => ({
|
|
...row,
|
|
spentMonthlyCents: spendByCompanyId.get(row.id) ?? 0,
|
|
}));
|
|
}
|
|
|
|
function getCompanyQuery(database: Pick<Db, "select">) {
|
|
return database
|
|
.select(companySelection)
|
|
.from(companies)
|
|
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id));
|
|
}
|
|
|
|
function deriveIssuePrefixBase(name: string) {
|
|
const normalized = name.toUpperCase().replace(/[^A-Z]/g, "");
|
|
return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK;
|
|
}
|
|
|
|
function suffixForAttempt(attempt: number) {
|
|
if (attempt <= 1) return "";
|
|
return "A".repeat(attempt - 1);
|
|
}
|
|
|
|
function isIssuePrefixConflict(error: unknown) {
|
|
const seen = new Set<unknown>();
|
|
let current = error;
|
|
while (typeof current === "object" && current !== null && !seen.has(current)) {
|
|
seen.add(current);
|
|
const maybe = current as { code?: string; constraint?: string; constraint_name?: string; cause?: unknown };
|
|
const constraint = maybe.constraint ?? maybe.constraint_name;
|
|
if (maybe.code === "23505" && constraint === "companies_issue_prefix_idx") {
|
|
return true;
|
|
}
|
|
current = maybe.cause;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function createCompanyWithUniquePrefix(data: typeof companies.$inferInsert) {
|
|
const base = deriveIssuePrefixBase(data.name);
|
|
let suffix = 1;
|
|
while (suffix < 10000) {
|
|
const candidate = `${base}${suffixForAttempt(suffix)}`;
|
|
try {
|
|
const rows = await db
|
|
.insert(companies)
|
|
.values({ ...data, issuePrefix: candidate })
|
|
.returning();
|
|
return rows[0];
|
|
} catch (error) {
|
|
if (!isIssuePrefixConflict(error)) throw error;
|
|
}
|
|
suffix += 1;
|
|
}
|
|
throw new Error("Unable to allocate unique issue prefix");
|
|
}
|
|
|
|
return {
|
|
list: async () => {
|
|
const rows = await getCompanyQuery(db);
|
|
const hydrated = await hydrateCompanySpend(rows);
|
|
return hydrated.map((row) => enrichCompany(row));
|
|
},
|
|
|
|
getById: async (id: string) => {
|
|
const row = await getCompanyQuery(db)
|
|
.where(eq(companies.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) return null;
|
|
const [hydrated] = await hydrateCompanySpend([row], db);
|
|
return enrichCompany(hydrated);
|
|
},
|
|
|
|
create: async (data: typeof companies.$inferInsert) => {
|
|
const created = await createCompanyWithUniquePrefix(data);
|
|
await environmentsSvc.ensureLocalEnvironment(created.id);
|
|
const row = await getCompanyQuery(db)
|
|
.where(eq(companies.id, created.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) throw notFound("Company not found after creation");
|
|
const [hydrated] = await hydrateCompanySpend([row], db);
|
|
return enrichCompany(hydrated);
|
|
},
|
|
|
|
update: async (
|
|
id: string,
|
|
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
|
|
actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR,
|
|
) => {
|
|
const result = await db.transaction(async (tx) => {
|
|
const existing = await getCompanyQuery(tx)
|
|
.where(eq(companies.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
const { logoAssetId, ...companyPatch } = data;
|
|
const willReactivate = existing.status !== "active" && companyPatch.status === "active";
|
|
const willArchive = existing.status !== "archived" && companyPatch.status === "archived";
|
|
|
|
if (logoAssetId !== undefined && logoAssetId !== null) {
|
|
const nextLogoAsset = await tx
|
|
.select({ id: assets.id, companyId: assets.companyId })
|
|
.from(assets)
|
|
.where(eq(assets.id, logoAssetId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!nextLogoAsset) throw notFound("Logo asset not found");
|
|
if (nextLogoAsset.companyId !== existing.id) {
|
|
throw unprocessable("Logo asset must belong to the same company");
|
|
}
|
|
}
|
|
|
|
const updated = await tx
|
|
.update(companies)
|
|
.set({ ...companyPatch, updatedAt: new Date() })
|
|
.where(eq(companies.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!updated) return null;
|
|
|
|
let agentsRestored = 0;
|
|
if (willReactivate) {
|
|
const restoredRows = await tx
|
|
.update(agents)
|
|
.set({
|
|
status: "idle",
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(
|
|
eq(agents.companyId, id),
|
|
eq(agents.status, "paused"),
|
|
eq(agents.pauseReason, "company_archived"),
|
|
))
|
|
.returning({ id: agents.id });
|
|
agentsRestored = restoredRows.length;
|
|
}
|
|
|
|
const archiveCascade = willArchive ? await applyArchiveCascadeInTx(tx, id) : null;
|
|
|
|
if (logoAssetId === null) {
|
|
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
|
} else if (logoAssetId !== undefined) {
|
|
await tx
|
|
.insert(companyLogos)
|
|
.values({
|
|
companyId: id,
|
|
assetId: logoAssetId,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: companyLogos.companyId,
|
|
set: {
|
|
assetId: logoAssetId,
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (logoAssetId !== undefined && existing.logoAssetId && existing.logoAssetId !== logoAssetId) {
|
|
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
|
|
}
|
|
|
|
const [hydrated] = await hydrateCompanySpend([{
|
|
...updated,
|
|
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
|
|
}], tx);
|
|
|
|
const shouldLogReactivation = willReactivate &&
|
|
(existing.status === "archived" || agentsRestored > 0);
|
|
|
|
return {
|
|
company: enrichCompany(hydrated),
|
|
reactivated: shouldLogReactivation ? { agentsRestored } : null,
|
|
archiveCascade,
|
|
};
|
|
});
|
|
if (!result) return null;
|
|
if (result.reactivated) {
|
|
await logActivity(db, {
|
|
companyId: id,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId ?? null,
|
|
runId: actor.runId ?? null,
|
|
action: "company.reactivated",
|
|
entityType: "company",
|
|
entityId: id,
|
|
details: { agentsRestored: result.reactivated.agentsRestored },
|
|
});
|
|
}
|
|
if (result.archiveCascade) {
|
|
await finalizeArchive(id, actor, result.archiveCascade);
|
|
}
|
|
return result.company;
|
|
},
|
|
|
|
archive: async (id: string, actor: CompanyActivityActor = SYSTEM_COMPANY_ACTOR) => {
|
|
const result = await db.transaction(async (tx) => {
|
|
const existing = await tx
|
|
.select({ status: companies.status })
|
|
.from(companies)
|
|
.where(eq(companies.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
const wasAlreadyArchived = existing.status === "archived";
|
|
|
|
if (!wasAlreadyArchived) {
|
|
await tx
|
|
.update(companies)
|
|
.set({ status: "archived", updatedAt: new Date() })
|
|
.where(eq(companies.id, id));
|
|
}
|
|
|
|
const cascade = wasAlreadyArchived ? null : await applyArchiveCascadeInTx(tx, id);
|
|
|
|
const row = await getCompanyQuery(tx)
|
|
.where(eq(companies.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) return null;
|
|
const [hydrated] = await hydrateCompanySpend([row], tx);
|
|
return {
|
|
company: enrichCompany(hydrated),
|
|
cascade,
|
|
};
|
|
});
|
|
if (!result) return null;
|
|
|
|
if (result.cascade) {
|
|
await finalizeArchive(id, actor, result.cascade);
|
|
}
|
|
|
|
return result.company;
|
|
},
|
|
|
|
remove: (id: string) =>
|
|
db.transaction(async (tx) => {
|
|
// Delete from child tables in dependency order
|
|
const companyRunIds = await tx
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.companyId, id));
|
|
|
|
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
|
|
if (companyRunIds.length > 0) {
|
|
await tx
|
|
.delete(heartbeatRunEvents)
|
|
.where(inArray(heartbeatRunEvents.runId, companyRunIds.map((run) => run.id)));
|
|
}
|
|
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
|
|
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
|
|
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
|
|
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
|
|
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
|
|
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(financeEvents).where(eq(financeEvents.companyId, id));
|
|
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
|
|
await tx.delete(approvals).where(eq(approvals.companyId, id));
|
|
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
|
|
await tx.delete(joinRequests).where(eq(joinRequests.companyId, id));
|
|
await tx.delete(invites).where(eq(invites.companyId, id));
|
|
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
|
|
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
|
|
await tx.delete(companySkills).where(eq(companySkills.companyId, id));
|
|
await tx.delete(issueReadStates).where(eq(issueReadStates.companyId, id));
|
|
await tx.delete(documents).where(eq(documents.companyId, id));
|
|
await tx.delete(issues).where(eq(issues.companyId, id));
|
|
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
|
await tx.delete(assets).where(eq(assets.companyId, id));
|
|
await tx.delete(goals).where(eq(goals.companyId, id));
|
|
await tx.delete(projects).where(eq(projects.companyId, id));
|
|
await tx.delete(agents).where(eq(agents.companyId, id));
|
|
const rows = await tx
|
|
.delete(companies)
|
|
.where(eq(companies.id, id))
|
|
.returning();
|
|
return rows[0] ?? null;
|
|
}),
|
|
|
|
stats: () =>
|
|
Promise.all([
|
|
db
|
|
.select({ companyId: agents.companyId, count: count() })
|
|
.from(agents)
|
|
.groupBy(agents.companyId),
|
|
db
|
|
.select({ companyId: issues.companyId, count: count() })
|
|
.from(issues)
|
|
.groupBy(issues.companyId),
|
|
]).then(([agentRows, issueRows]) => {
|
|
const result: Record<string, { agentCount: number; issueCount: number }> = {};
|
|
for (const row of agentRows) {
|
|
result[row.companyId] = { agentCount: row.count, issueCount: 0 };
|
|
}
|
|
for (const row of issueRows) {
|
|
if (result[row.companyId]) {
|
|
result[row.companyId].issueCount = row.count;
|
|
} else {
|
|
result[row.companyId] = { agentCount: 0, issueCount: row.count };
|
|
}
|
|
}
|
|
return result;
|
|
}),
|
|
};
|
|
}
|