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(company: T) { return { ...company, logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null, }; } function getCompanyQuery(database: Pick) { 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 & { 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 = {}; 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; }), }; }