Files
paperclip/server/src/services/companies.ts
2026-03-16 09:25:39 -05:00

256 lines
9.0 KiB
TypeScript

import { eq, count } 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,
approvalComments,
approvals,
activityLog,
companySecrets,
joinRequests,
invites,
principalPermissionGrants,
companyMemberships,
} from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
export function companyService(db: Db) {
const ISSUE_PREFIX_FALLBACK = "CMP";
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,
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
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 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 constraint = typeof error === "object" && error !== null && "constraint" in error
? (error as { constraint?: string }).constraint
: typeof error === "object" && error !== null && "constraint_name" in error
? (error as { constraint_name?: string }).constraint_name
: undefined;
return typeof error === "object"
&& error !== null
&& "code" in error
&& (error as { code?: string }).code === "23505"
&& constraint === "companies_issue_prefix_idx";
}
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: () =>
getCompanyQuery(db).then((rows) => rows.map((row) => enrichCompany(row))),
getById: (id: string) =>
getCompanyQuery(db)
.where(eq(companies.id, id))
.then((rows) => (rows[0] ? enrichCompany(rows[0]) : null)),
create: async (data: typeof companies.$inferInsert) => {
const created = await createCompanyWithUniquePrefix(data);
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");
return enrichCompany(row);
},
update: (
id: string,
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
) =>
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;
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;
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));
}
return enrichCompany({
...updated,
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
});
}),
archive: (id: string) =>
db.transaction(async (tx) => {
const updated = await tx
.update(companies)
.set({ status: "archived", updatedAt: new Date() })
.where(eq(companies.id, id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
const row = await getCompanyQuery(tx)
.where(eq(companies.id, id))
.then((rows) => rows[0] ?? null);
return row ? enrichCompany(row) : null;
}),
remove: (id: string) =>
db.transaction(async (tx) => {
// Delete from child tables in dependency order
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.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(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(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));
await tx.delete(activityLog).where(eq(activityLog.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;
}),
};
}