bopodev-db 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,769 @@
1
+ import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import type { BopoDb } from "./client";
4
+ import {
5
+ activityLogs,
6
+ agents,
7
+ approvalInboxStates,
8
+ approvalRequests,
9
+ auditEvents,
10
+ companies,
11
+ costLedger,
12
+ goals,
13
+ heartbeatRuns,
14
+ issueComments,
15
+ issues,
16
+ projects,
17
+ touchUpdatedAtSql
18
+ } from "./schema";
19
+
20
+ export class RepositoryValidationError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "RepositoryValidationError";
24
+ }
25
+ }
26
+
27
+ async function assertProjectBelongsToCompany(db: BopoDb, companyId: string, projectId: string) {
28
+ const [project] = await db
29
+ .select({ id: projects.id })
30
+ .from(projects)
31
+ .where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
32
+ .limit(1);
33
+ if (!project) {
34
+ throw new RepositoryValidationError("Project not found for company.");
35
+ }
36
+ }
37
+
38
+ async function assertIssueBelongsToCompany(db: BopoDb, companyId: string, issueId: string) {
39
+ const [issue] = await db
40
+ .select({ id: issues.id })
41
+ .from(issues)
42
+ .where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
43
+ .limit(1);
44
+ if (!issue) {
45
+ throw new RepositoryValidationError("Issue not found for company.");
46
+ }
47
+ }
48
+
49
+ async function assertGoalBelongsToCompany(db: BopoDb, companyId: string, goalId: string) {
50
+ const [goal] = await db
51
+ .select({ id: goals.id })
52
+ .from(goals)
53
+ .where(and(eq(goals.companyId, companyId), eq(goals.id, goalId)))
54
+ .limit(1);
55
+ if (!goal) {
56
+ throw new RepositoryValidationError("Parent goal not found for company.");
57
+ }
58
+ }
59
+
60
+ async function assertAgentBelongsToCompany(db: BopoDb, companyId: string, agentId: string) {
61
+ const [agent] = await db
62
+ .select({ id: agents.id })
63
+ .from(agents)
64
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
65
+ .limit(1);
66
+ if (!agent) {
67
+ throw new RepositoryValidationError("Agent not found for company.");
68
+ }
69
+ }
70
+
71
+ export async function createCompany(db: BopoDb, input: { name: string; mission?: string | null }) {
72
+ const id = nanoid(12);
73
+ await db.insert(companies).values({
74
+ id,
75
+ name: input.name,
76
+ mission: input.mission ?? null
77
+ });
78
+ return { id, ...input };
79
+ }
80
+
81
+ export async function listCompanies(db: BopoDb) {
82
+ return db.select().from(companies).orderBy(desc(companies.createdAt));
83
+ }
84
+
85
+ export async function updateCompany(
86
+ db: BopoDb,
87
+ input: { id: string; name?: string; mission?: string | null }
88
+ ) {
89
+ const [company] = await db
90
+ .update(companies)
91
+ .set(compactUpdate({ name: input.name, mission: input.mission }))
92
+ .where(eq(companies.id, input.id))
93
+ .returning();
94
+ return company ?? null;
95
+ }
96
+
97
+ export async function deleteCompany(db: BopoDb, id: string) {
98
+ const [deletedCompany] = await db.delete(companies).where(eq(companies.id, id)).returning({ id: companies.id });
99
+ return Boolean(deletedCompany);
100
+ }
101
+
102
+ export async function listProjects(db: BopoDb, companyId: string) {
103
+ return db.select().from(projects).where(eq(projects.companyId, companyId)).orderBy(desc(projects.createdAt));
104
+ }
105
+
106
+ export async function createProject(
107
+ db: BopoDb,
108
+ input: {
109
+ companyId: string;
110
+ name: string;
111
+ description?: string | null;
112
+ status?: "planned" | "active" | "paused" | "blocked" | "completed" | "archived";
113
+ plannedStartAt?: Date | null;
114
+ workspaceLocalPath?: string | null;
115
+ workspaceGithubRepo?: string | null;
116
+ }
117
+ ) {
118
+ const id = nanoid(12);
119
+ await db.insert(projects).values({
120
+ id,
121
+ companyId: input.companyId,
122
+ name: input.name,
123
+ description: input.description ?? null,
124
+ status: input.status ?? "planned",
125
+ plannedStartAt: input.plannedStartAt ?? null,
126
+ workspaceLocalPath: input.workspaceLocalPath ?? null,
127
+ workspaceGithubRepo: input.workspaceGithubRepo ?? null
128
+ });
129
+ return { id, ...input };
130
+ }
131
+
132
+ export async function updateProject(
133
+ db: BopoDb,
134
+ input: {
135
+ companyId: string;
136
+ id: string;
137
+ name?: string;
138
+ description?: string | null;
139
+ status?: "planned" | "active" | "paused" | "blocked" | "completed" | "archived";
140
+ plannedStartAt?: Date | null;
141
+ workspaceLocalPath?: string | null;
142
+ workspaceGithubRepo?: string | null;
143
+ }
144
+ ) {
145
+ const [project] = await db
146
+ .update(projects)
147
+ .set(
148
+ compactUpdate({
149
+ name: input.name,
150
+ description: input.description,
151
+ status: input.status,
152
+ plannedStartAt: input.plannedStartAt,
153
+ workspaceLocalPath: input.workspaceLocalPath,
154
+ workspaceGithubRepo: input.workspaceGithubRepo
155
+ })
156
+ )
157
+ .where(and(eq(projects.companyId, input.companyId), eq(projects.id, input.id)))
158
+ .returning();
159
+ return project ?? null;
160
+ }
161
+
162
+ export async function syncProjectGoals(
163
+ db: BopoDb,
164
+ input: { companyId: string; projectId: string; goalIds: string[] }
165
+ ) {
166
+ const dedupedGoalIds = Array.from(new Set(input.goalIds));
167
+ if (dedupedGoalIds.length > 0) {
168
+ const matchingGoals = await db
169
+ .select({ id: goals.id })
170
+ .from(goals)
171
+ .where(and(eq(goals.companyId, input.companyId), inArray(goals.id, dedupedGoalIds)));
172
+ if (matchingGoals.length !== dedupedGoalIds.length) {
173
+ throw new RepositoryValidationError("One or more goals do not belong to the company.");
174
+ }
175
+ }
176
+
177
+ const detachWhere =
178
+ dedupedGoalIds.length > 0
179
+ ? and(eq(goals.companyId, input.companyId), eq(goals.projectId, input.projectId), notInArray(goals.id, dedupedGoalIds))
180
+ : and(eq(goals.companyId, input.companyId), eq(goals.projectId, input.projectId));
181
+
182
+ await db
183
+ .update(goals)
184
+ .set({
185
+ projectId: null,
186
+ updatedAt: touchUpdatedAtSql
187
+ })
188
+ .where(detachWhere);
189
+
190
+ if (dedupedGoalIds.length > 0) {
191
+ await db
192
+ .update(goals)
193
+ .set({
194
+ projectId: input.projectId,
195
+ updatedAt: touchUpdatedAtSql
196
+ })
197
+ .where(and(eq(goals.companyId, input.companyId), inArray(goals.id, dedupedGoalIds)));
198
+ }
199
+ }
200
+
201
+ export async function deleteProject(db: BopoDb, companyId: string, id: string) {
202
+ const [deletedProject] = await db
203
+ .delete(projects)
204
+ .where(and(eq(projects.companyId, companyId), eq(projects.id, id)))
205
+ .returning({ id: projects.id });
206
+ return Boolean(deletedProject);
207
+ }
208
+
209
+ export async function listIssues(db: BopoDb, companyId: string, projectId?: string) {
210
+ const where = projectId
211
+ ? and(eq(issues.companyId, companyId), eq(issues.projectId, projectId))
212
+ : eq(issues.companyId, companyId);
213
+
214
+ return db.select().from(issues).where(where).orderBy(desc(issues.updatedAt));
215
+ }
216
+
217
+ export async function createIssue(
218
+ db: BopoDb,
219
+ input: {
220
+ companyId: string;
221
+ projectId: string;
222
+ parentIssueId?: string | null;
223
+ title: string;
224
+ body?: string;
225
+ status?: string;
226
+ priority?: string;
227
+ assigneeAgentId?: string | null;
228
+ labels?: string[];
229
+ tags?: string[];
230
+ }
231
+ ) {
232
+ await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
233
+ if (input.parentIssueId) {
234
+ await assertIssueBelongsToCompany(db, input.companyId, input.parentIssueId);
235
+ }
236
+ if (input.assigneeAgentId) {
237
+ await assertAgentBelongsToCompany(db, input.companyId, input.assigneeAgentId);
238
+ }
239
+ const id = nanoid(12);
240
+ await db.insert(issues).values({
241
+ id,
242
+ companyId: input.companyId,
243
+ projectId: input.projectId,
244
+ parentIssueId: input.parentIssueId ?? null,
245
+ title: input.title,
246
+ body: input.body,
247
+ status: input.status ?? "todo",
248
+ priority: input.priority ?? "none",
249
+ assigneeAgentId: input.assigneeAgentId ?? null,
250
+ labelsJson: JSON.stringify(input.labels ?? []),
251
+ tagsJson: JSON.stringify(input.tags ?? [])
252
+ });
253
+
254
+ return { id, ...input };
255
+ }
256
+
257
+ export async function updateIssue(
258
+ db: BopoDb,
259
+ input: {
260
+ companyId: string;
261
+ id: string;
262
+ projectId?: string;
263
+ title?: string;
264
+ body?: string | null;
265
+ status?: string;
266
+ priority?: string;
267
+ assigneeAgentId?: string | null;
268
+ labels?: string[];
269
+ tags?: string[];
270
+ }
271
+ ) {
272
+ if (input.projectId) {
273
+ await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
274
+ }
275
+ if (input.assigneeAgentId) {
276
+ await assertAgentBelongsToCompany(db, input.companyId, input.assigneeAgentId);
277
+ }
278
+ const [issue] = await db
279
+ .update(issues)
280
+ .set(
281
+ compactUpdate({
282
+ projectId: input.projectId,
283
+ title: input.title,
284
+ body: input.body,
285
+ status: input.status,
286
+ priority: input.priority,
287
+ assigneeAgentId: input.assigneeAgentId,
288
+ labelsJson: input.labels ? JSON.stringify(input.labels) : undefined,
289
+ tagsJson: input.tags ? JSON.stringify(input.tags) : undefined,
290
+ updatedAt: touchUpdatedAtSql
291
+ })
292
+ )
293
+ .where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.id)))
294
+ .returning();
295
+ return issue ?? null;
296
+ }
297
+
298
+ export async function deleteIssue(db: BopoDb, companyId: string, id: string) {
299
+ const [deletedIssue] = await db
300
+ .delete(issues)
301
+ .where(and(eq(issues.companyId, companyId), eq(issues.id, id)))
302
+ .returning({ id: issues.id });
303
+ return Boolean(deletedIssue);
304
+ }
305
+
306
+ export async function addIssueComment(
307
+ db: BopoDb,
308
+ input: {
309
+ companyId: string;
310
+ issueId: string;
311
+ authorType: "human" | "agent" | "system";
312
+ authorId?: string | null;
313
+ body: string;
314
+ }
315
+ ) {
316
+ await assertIssueBelongsToCompany(db, input.companyId, input.issueId);
317
+ const id = nanoid(12);
318
+ await db.insert(issueComments).values({
319
+ id,
320
+ companyId: input.companyId,
321
+ issueId: input.issueId,
322
+ authorType: input.authorType,
323
+ authorId: input.authorId ?? null,
324
+ body: input.body
325
+ });
326
+
327
+ return { id, ...input };
328
+ }
329
+
330
+ export async function listIssueComments(db: BopoDb, companyId: string, issueId: string) {
331
+ return db
332
+ .select()
333
+ .from(issueComments)
334
+ .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId)))
335
+ .orderBy(desc(issueComments.createdAt));
336
+ }
337
+
338
+ export async function updateIssueComment(
339
+ db: BopoDb,
340
+ input: {
341
+ companyId: string;
342
+ issueId: string;
343
+ id: string;
344
+ body: string;
345
+ }
346
+ ) {
347
+ const [comment] = await db
348
+ .update(issueComments)
349
+ .set({ body: input.body })
350
+ .where(
351
+ and(
352
+ eq(issueComments.companyId, input.companyId),
353
+ eq(issueComments.issueId, input.issueId),
354
+ eq(issueComments.id, input.id)
355
+ )
356
+ )
357
+ .returning();
358
+ return comment ?? null;
359
+ }
360
+
361
+ export async function deleteIssueComment(db: BopoDb, companyId: string, issueId: string, id: string) {
362
+ const [deletedComment] = await db
363
+ .delete(issueComments)
364
+ .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), eq(issueComments.id, id)))
365
+ .returning({ id: issueComments.id });
366
+ return Boolean(deletedComment);
367
+ }
368
+
369
+ export async function createGoal(
370
+ db: BopoDb,
371
+ input: {
372
+ companyId: string;
373
+ projectId?: string | null;
374
+ parentGoalId?: string | null;
375
+ level: "company" | "project" | "agent";
376
+ title: string;
377
+ description?: string;
378
+ }
379
+ ) {
380
+ if (input.projectId) {
381
+ await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
382
+ }
383
+ if (input.parentGoalId) {
384
+ await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
385
+ }
386
+ const id = nanoid(12);
387
+ await db.insert(goals).values({
388
+ id,
389
+ companyId: input.companyId,
390
+ projectId: input.projectId ?? null,
391
+ parentGoalId: input.parentGoalId ?? null,
392
+ level: input.level,
393
+ title: input.title,
394
+ description: input.description ?? null
395
+ });
396
+ return { id, ...input };
397
+ }
398
+
399
+ export async function listGoals(db: BopoDb, companyId: string) {
400
+ return db.select().from(goals).where(eq(goals.companyId, companyId)).orderBy(desc(goals.updatedAt));
401
+ }
402
+
403
+ export async function updateGoal(
404
+ db: BopoDb,
405
+ input: {
406
+ companyId: string;
407
+ id: string;
408
+ projectId?: string | null;
409
+ parentGoalId?: string | null;
410
+ level?: "company" | "project" | "agent";
411
+ title?: string;
412
+ description?: string | null;
413
+ status?: string;
414
+ }
415
+ ) {
416
+ if (input.projectId) {
417
+ await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
418
+ }
419
+ if (input.parentGoalId) {
420
+ await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
421
+ }
422
+ const [goal] = await db
423
+ .update(goals)
424
+ .set(
425
+ compactUpdate({
426
+ projectId: input.projectId,
427
+ parentGoalId: input.parentGoalId,
428
+ level: input.level,
429
+ title: input.title,
430
+ description: input.description,
431
+ status: input.status,
432
+ updatedAt: touchUpdatedAtSql
433
+ })
434
+ )
435
+ .where(and(eq(goals.companyId, input.companyId), eq(goals.id, input.id)))
436
+ .returning();
437
+ return goal ?? null;
438
+ }
439
+
440
+ export async function deleteGoal(db: BopoDb, companyId: string, id: string) {
441
+ const [deletedGoal] = await db
442
+ .delete(goals)
443
+ .where(and(eq(goals.companyId, companyId), eq(goals.id, id)))
444
+ .returning({ id: goals.id });
445
+ return Boolean(deletedGoal);
446
+ }
447
+
448
+ export async function createAgent(
449
+ db: BopoDb,
450
+ input: {
451
+ companyId: string;
452
+ managerAgentId?: string | null;
453
+ role: string;
454
+ name: string;
455
+ providerType: "claude_code" | "codex" | "http" | "shell";
456
+ heartbeatCron: string;
457
+ monthlyBudgetUsd: string;
458
+ canHireAgents?: boolean;
459
+ initialState?: Record<string, unknown>;
460
+ }
461
+ ) {
462
+ if (input.managerAgentId) {
463
+ await assertAgentBelongsToCompany(db, input.companyId, input.managerAgentId);
464
+ }
465
+ const id = nanoid(12);
466
+ await db.insert(agents).values({
467
+ id,
468
+ companyId: input.companyId,
469
+ managerAgentId: input.managerAgentId ?? null,
470
+ role: input.role,
471
+ name: input.name,
472
+ providerType: input.providerType,
473
+ heartbeatCron: input.heartbeatCron,
474
+ monthlyBudgetUsd: input.monthlyBudgetUsd,
475
+ canHireAgents: input.canHireAgents ?? false,
476
+ stateBlob: JSON.stringify(input.initialState ?? {})
477
+ });
478
+
479
+ return { id, ...input };
480
+ }
481
+
482
+ export async function listAgents(db: BopoDb, companyId: string) {
483
+ return db.select().from(agents).where(eq(agents.companyId, companyId)).orderBy(desc(agents.createdAt));
484
+ }
485
+
486
+ export async function updateAgent(
487
+ db: BopoDb,
488
+ input: {
489
+ companyId: string;
490
+ id: string;
491
+ managerAgentId?: string | null;
492
+ role?: string;
493
+ name?: string;
494
+ providerType?: "claude_code" | "codex" | "http" | "shell";
495
+ status?: string;
496
+ heartbeatCron?: string;
497
+ monthlyBudgetUsd?: string;
498
+ canHireAgents?: boolean;
499
+ stateBlob?: Record<string, unknown>;
500
+ }
501
+ ) {
502
+ if (input.managerAgentId) {
503
+ await assertAgentBelongsToCompany(db, input.companyId, input.managerAgentId);
504
+ }
505
+ const [agent] = await db
506
+ .update(agents)
507
+ .set(
508
+ compactUpdate({
509
+ managerAgentId: input.managerAgentId,
510
+ role: input.role,
511
+ name: input.name,
512
+ providerType: input.providerType,
513
+ status: input.status,
514
+ heartbeatCron: input.heartbeatCron,
515
+ monthlyBudgetUsd: input.monthlyBudgetUsd,
516
+ canHireAgents: input.canHireAgents,
517
+ stateBlob: input.stateBlob ? JSON.stringify(input.stateBlob) : undefined,
518
+ updatedAt: touchUpdatedAtSql
519
+ })
520
+ )
521
+ .where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.id)))
522
+ .returning();
523
+ return agent ?? null;
524
+ }
525
+
526
+ export async function deleteAgent(db: BopoDb, companyId: string, id: string) {
527
+ const [deletedAgent] = await db
528
+ .delete(agents)
529
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, id)))
530
+ .returning({ id: agents.id });
531
+ return Boolean(deletedAgent);
532
+ }
533
+
534
+ export async function appendAuditEvent(
535
+ db: BopoDb,
536
+ input: {
537
+ companyId: string;
538
+ actorType: "human" | "agent" | "system";
539
+ actorId?: string | null;
540
+ eventType: string;
541
+ entityType: string;
542
+ entityId: string;
543
+ correlationId?: string | null;
544
+ payload: Record<string, unknown>;
545
+ }
546
+ ) {
547
+ const id = nanoid(14);
548
+ await db.insert(auditEvents).values({
549
+ id,
550
+ companyId: input.companyId,
551
+ actorType: input.actorType,
552
+ actorId: input.actorId ?? null,
553
+ eventType: input.eventType,
554
+ entityType: input.entityType,
555
+ entityId: input.entityId,
556
+ correlationId: input.correlationId ?? null,
557
+ payloadJson: JSON.stringify(input.payload)
558
+ });
559
+ return id;
560
+ }
561
+
562
+ export async function listAuditEvents(db: BopoDb, companyId: string, limit = 100) {
563
+ return db
564
+ .select()
565
+ .from(auditEvents)
566
+ .where(eq(auditEvents.companyId, companyId))
567
+ .orderBy(desc(auditEvents.createdAt))
568
+ .limit(limit);
569
+ }
570
+
571
+ export async function createApprovalRequest(
572
+ db: BopoDb,
573
+ input: {
574
+ companyId: string;
575
+ requestedByAgentId?: string | null;
576
+ action: string;
577
+ payload: Record<string, unknown>;
578
+ }
579
+ ) {
580
+ const id = nanoid(12);
581
+ await db.insert(approvalRequests).values({
582
+ id,
583
+ companyId: input.companyId,
584
+ requestedByAgentId: input.requestedByAgentId ?? null,
585
+ action: input.action,
586
+ payloadJson: JSON.stringify(input.payload),
587
+ status: "pending"
588
+ });
589
+ return id;
590
+ }
591
+
592
+ export async function getApprovalRequest(db: BopoDb, companyId: string, approvalId: string) {
593
+ const [approval] = await db
594
+ .select()
595
+ .from(approvalRequests)
596
+ .where(and(eq(approvalRequests.companyId, companyId), eq(approvalRequests.id, approvalId)))
597
+ .limit(1);
598
+
599
+ return approval ?? null;
600
+ }
601
+
602
+ export async function listApprovalRequests(db: BopoDb, companyId: string) {
603
+ return db
604
+ .select()
605
+ .from(approvalRequests)
606
+ .where(eq(approvalRequests.companyId, companyId))
607
+ .orderBy(desc(approvalRequests.createdAt));
608
+ }
609
+
610
+ export async function listApprovalInboxStates(db: BopoDb, companyId: string, actorId: string) {
611
+ return db
612
+ .select()
613
+ .from(approvalInboxStates)
614
+ .where(and(eq(approvalInboxStates.companyId, companyId), eq(approvalInboxStates.actorId, actorId)))
615
+ .orderBy(desc(approvalInboxStates.updatedAt));
616
+ }
617
+
618
+ export async function markApprovalInboxSeen(
619
+ db: BopoDb,
620
+ input: {
621
+ companyId: string;
622
+ actorId: string;
623
+ approvalId: string;
624
+ seenAt?: Date;
625
+ }
626
+ ) {
627
+ const seenAt = input.seenAt ?? new Date();
628
+ await db
629
+ .insert(approvalInboxStates)
630
+ .values({
631
+ companyId: input.companyId,
632
+ actorId: input.actorId,
633
+ approvalId: input.approvalId,
634
+ seenAt
635
+ })
636
+ .onConflictDoUpdate({
637
+ target: [approvalInboxStates.companyId, approvalInboxStates.actorId, approvalInboxStates.approvalId],
638
+ set: {
639
+ seenAt,
640
+ updatedAt: sql`CURRENT_TIMESTAMP`
641
+ }
642
+ });
643
+ }
644
+
645
+ export async function markApprovalInboxDismissed(
646
+ db: BopoDb,
647
+ input: {
648
+ companyId: string;
649
+ actorId: string;
650
+ approvalId: string;
651
+ dismissedAt?: Date;
652
+ }
653
+ ) {
654
+ const dismissedAt = input.dismissedAt ?? new Date();
655
+ await db
656
+ .insert(approvalInboxStates)
657
+ .values({
658
+ companyId: input.companyId,
659
+ actorId: input.actorId,
660
+ approvalId: input.approvalId,
661
+ dismissedAt
662
+ })
663
+ .onConflictDoUpdate({
664
+ target: [approvalInboxStates.companyId, approvalInboxStates.actorId, approvalInboxStates.approvalId],
665
+ set: {
666
+ dismissedAt,
667
+ updatedAt: sql`CURRENT_TIMESTAMP`
668
+ }
669
+ });
670
+ }
671
+
672
+ export async function clearApprovalInboxDismissed(
673
+ db: BopoDb,
674
+ input: {
675
+ companyId: string;
676
+ actorId: string;
677
+ approvalId: string;
678
+ }
679
+ ) {
680
+ await db
681
+ .insert(approvalInboxStates)
682
+ .values({
683
+ companyId: input.companyId,
684
+ actorId: input.actorId,
685
+ approvalId: input.approvalId,
686
+ dismissedAt: null
687
+ })
688
+ .onConflictDoUpdate({
689
+ target: [approvalInboxStates.companyId, approvalInboxStates.actorId, approvalInboxStates.approvalId],
690
+ set: {
691
+ dismissedAt: null,
692
+ updatedAt: sql`CURRENT_TIMESTAMP`
693
+ }
694
+ });
695
+ }
696
+
697
+ export async function appendCost(
698
+ db: BopoDb,
699
+ input: {
700
+ companyId: string;
701
+ providerType: string;
702
+ tokenInput: number;
703
+ tokenOutput: number;
704
+ usdCost: string;
705
+ projectId?: string | null;
706
+ issueId?: string | null;
707
+ agentId?: string | null;
708
+ }
709
+ ) {
710
+ const id = nanoid(14);
711
+ await db.insert(costLedger).values({
712
+ id,
713
+ companyId: input.companyId,
714
+ providerType: input.providerType,
715
+ tokenInput: input.tokenInput,
716
+ tokenOutput: input.tokenOutput,
717
+ usdCost: input.usdCost,
718
+ projectId: input.projectId ?? null,
719
+ issueId: input.issueId ?? null,
720
+ agentId: input.agentId ?? null
721
+ });
722
+ return id;
723
+ }
724
+
725
+ export async function listCostEntries(db: BopoDb, companyId: string, limit = 200) {
726
+ return db
727
+ .select()
728
+ .from(costLedger)
729
+ .where(eq(costLedger.companyId, companyId))
730
+ .orderBy(desc(costLedger.createdAt))
731
+ .limit(limit);
732
+ }
733
+
734
+ export async function listHeartbeatRuns(db: BopoDb, companyId: string, limit = 100) {
735
+ return db
736
+ .select()
737
+ .from(heartbeatRuns)
738
+ .where(eq(heartbeatRuns.companyId, companyId))
739
+ .orderBy(desc(heartbeatRuns.startedAt))
740
+ .limit(limit);
741
+ }
742
+
743
+ export async function appendActivity(
744
+ db: BopoDb,
745
+ input: {
746
+ companyId: string;
747
+ issueId?: string | null;
748
+ actorType: "human" | "agent" | "system";
749
+ actorId?: string | null;
750
+ eventType: string;
751
+ payload: Record<string, unknown>;
752
+ }
753
+ ) {
754
+ const id = nanoid(12);
755
+ await db.insert(activityLogs).values({
756
+ id,
757
+ companyId: input.companyId,
758
+ issueId: input.issueId ?? null,
759
+ actorType: input.actorType,
760
+ actorId: input.actorId ?? null,
761
+ eventType: input.eventType,
762
+ payloadJson: JSON.stringify(input.payload)
763
+ });
764
+ return id;
765
+ }
766
+
767
+ function compactUpdate<T extends Record<string, unknown>>(input: T) {
768
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
769
+ }