bopodev-db 0.1.31 → 0.1.33

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.
@@ -1,5 +1,5 @@
1
1
 
2
2
  
3
- > bopodev-db@0.1.31 build /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopohq/packages/db
3
+ > bopodev-db@0.1.33 build /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopohq/packages/db
4
4
  > tsc -p tsconfig.json --emitDeclarationOnly
5
5
 
@@ -1,4 +1,4 @@
1
1
 
2
- > bopodev-db@0.1.30 typecheck /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopohq/packages/db
2
+ > bopodev-db@0.1.32 typecheck /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopohq/packages/db
3
3
  > tsc -p tsconfig.json --noEmit
4
4
 
@@ -0,0 +1,56 @@
1
+ import type { BopoDb } from "../client";
2
+ export type AssistantMessageRole = "user" | "assistant" | "system";
3
+ export declare function getOrCreateAssistantThread(db: BopoDb, companyId: string): Promise<{
4
+ id: string;
5
+ companyId: string;
6
+ createdAt: Date;
7
+ updatedAt: Date;
8
+ }>;
9
+ /** New empty thread; previous threads and messages remain in the database. */
10
+ export declare function createAssistantThread(db: BopoDb, companyId: string): Promise<{
11
+ id: string;
12
+ companyId: string;
13
+ createdAt: Date;
14
+ updatedAt: Date;
15
+ }>;
16
+ export declare function getAssistantThreadById(db: BopoDb, companyId: string, threadId: string): Promise<{
17
+ id: string;
18
+ companyId: string;
19
+ createdAt: Date;
20
+ updatedAt: Date;
21
+ } | null>;
22
+ export declare function touchAssistantThread(db: BopoDb, threadId: string): Promise<void>;
23
+ export declare function insertAssistantMessage(db: BopoDb, input: {
24
+ threadId: string;
25
+ companyId: string;
26
+ role: AssistantMessageRole;
27
+ body: string;
28
+ metadataJson?: string | null;
29
+ }): Promise<{
30
+ id: string;
31
+ threadId: string;
32
+ companyId: string;
33
+ role: string;
34
+ body: string;
35
+ metadataJson: string | null;
36
+ createdAt: Date;
37
+ }>;
38
+ export declare function listAssistantMessages(db: BopoDb, threadId: string, limit?: number): Promise<{
39
+ id: string;
40
+ threadId: string;
41
+ companyId: string;
42
+ role: string;
43
+ body: string;
44
+ metadataJson: string | null;
45
+ createdAt: Date;
46
+ }[]>;
47
+ /** Threads with at least one message in `[startInclusive, endExclusive)` on `created_at`. */
48
+ export declare function listAssistantChatThreadStatsInCreatedAtRange(db: BopoDb, companyId: string, startInclusive: Date, endExclusive: Date): Promise<Array<{
49
+ threadId: string;
50
+ messageCount: number;
51
+ }>>;
52
+ /** Threads with at least one message in the UTC calendar month (for callers without local bounds). */
53
+ export declare function listAssistantChatThreadStatsInUtcMonth(db: BopoDb, companyId: string, year: number, month1Based: number): Promise<Array<{
54
+ threadId: string;
55
+ messageCount: number;
56
+ }>>;
@@ -14,3 +14,17 @@ export declare function assertTemplateBelongsToCompany(db: BopoDb, companyId: st
14
14
  export declare function compactUpdate<T extends Record<string, unknown>>(input: T): {
15
15
  [k: string]: unknown;
16
16
  };
17
+ type GoalLevel = "company" | "project" | "agent";
18
+ /**
19
+ * Validates level ↔ projectId and optional parent_goal_id tree rules:
20
+ * - company: projectId must be null; parent must be company-level (or absent).
21
+ * - project: projectId required; parent must be company-level or absent.
22
+ * - agent: parent absent, company-level, or project-level with same projectId as child (child projectId required in that case).
23
+ */
24
+ export declare function assertValidGoalHierarchy(db: BopoDb, companyId: string, input: {
25
+ id?: string;
26
+ level: GoalLevel;
27
+ projectId: string | null;
28
+ parentGoalId: string | null;
29
+ }): Promise<void>;
30
+ export {};
@@ -1,3 +1,4 @@
1
1
  export * from "./helpers";
2
2
  export * from "./companies";
3
+ export * from "./company-assistant-chat";
3
4
  export * from "./legacy";
@@ -566,9 +566,9 @@ export declare function createGoal(db: BopoDb, input: {
566
566
  title: string;
567
567
  description?: string;
568
568
  }): Promise<{
569
+ projectId: string | null;
570
+ parentGoalId: string | null;
569
571
  companyId: string;
570
- projectId?: string | null;
571
- parentGoalId?: string | null;
572
572
  ownerAgentId?: string | null;
573
573
  level: "company" | "project" | "agent";
574
574
  title: string;
@@ -880,6 +880,10 @@ export declare function appendCost(db: BopoDb, input: {
880
880
  projectId?: string | null;
881
881
  issueId?: string | null;
882
882
  agentId?: string | null;
883
+ /** Discriminator for reporting (e.g. `company_assistant`); null for heartbeat / legacy */
884
+ costCategory?: string | null;
885
+ assistantThreadId?: string | null;
886
+ assistantMessageId?: string | null;
883
887
  }): Promise<string>;
884
888
  export declare function listCostEntries(db: BopoDb, companyId: string, limit?: number): Promise<{
885
889
  id: string;
@@ -888,6 +892,9 @@ export declare function listCostEntries(db: BopoDb, companyId: string, limit?: n
888
892
  projectId: string | null;
889
893
  issueId: string | null;
890
894
  agentId: string | null;
895
+ costCategory: string | null;
896
+ assistantThreadId: string | null;
897
+ assistantMessageId: string | null;
891
898
  providerType: string;
892
899
  runtimeModelId: string | null;
893
900
  pricingProviderType: string | null;
@@ -899,6 +906,16 @@ export declare function listCostEntries(db: BopoDb, companyId: string, limit?: n
899
906
  usdCostStatus: string | null;
900
907
  createdAt: Date;
901
908
  }[]>;
909
+ export type CostLedgerAggregate = {
910
+ rowCount: number;
911
+ tokenInput: number;
912
+ tokenOutput: number;
913
+ /** Sum of `usd_cost` as a decimal string (full precision from DB). */
914
+ usdTotal: string;
915
+ };
916
+ /** Sum every ledger row for the company in `[startInclusive, endExclusive)` (typically UTC month). */
917
+ export declare function aggregateCompanyCostLedgerInRange(db: BopoDb, companyId: string, startInclusive: Date, endExclusive: Date): Promise<CostLedgerAggregate>;
918
+ export declare function aggregateCompanyCostLedgerAllTime(db: BopoDb, companyId: string): Promise<CostLedgerAggregate>;
902
919
  export declare function listHeartbeatRuns(db: BopoDb, companyId: string, limit?: number): Promise<{
903
920
  id: string;
904
921
  companyId: string;
package/dist/schema.d.ts CHANGED
@@ -3854,6 +3854,207 @@ export declare const attentionInboxStates: import("drizzle-orm/pg-core").PgTable
3854
3854
  };
3855
3855
  dialect: "pg";
3856
3856
  }>;
3857
+ export declare const companyAssistantThreads: import("drizzle-orm/pg-core").PgTableWithColumns<{
3858
+ name: "company_assistant_threads";
3859
+ schema: undefined;
3860
+ columns: {
3861
+ id: import("drizzle-orm/pg-core").PgColumn<{
3862
+ name: "id";
3863
+ tableName: "company_assistant_threads";
3864
+ dataType: "string";
3865
+ columnType: "PgText";
3866
+ data: string;
3867
+ driverParam: string;
3868
+ notNull: true;
3869
+ hasDefault: false;
3870
+ isPrimaryKey: true;
3871
+ isAutoincrement: false;
3872
+ hasRuntimeDefault: false;
3873
+ enumValues: [string, ...string[]];
3874
+ baseColumn: never;
3875
+ identity: undefined;
3876
+ generated: undefined;
3877
+ }, {}, {}>;
3878
+ companyId: import("drizzle-orm/pg-core").PgColumn<{
3879
+ name: "company_id";
3880
+ tableName: "company_assistant_threads";
3881
+ dataType: "string";
3882
+ columnType: "PgText";
3883
+ data: string;
3884
+ driverParam: string;
3885
+ notNull: true;
3886
+ hasDefault: false;
3887
+ isPrimaryKey: false;
3888
+ isAutoincrement: false;
3889
+ hasRuntimeDefault: false;
3890
+ enumValues: [string, ...string[]];
3891
+ baseColumn: never;
3892
+ identity: undefined;
3893
+ generated: undefined;
3894
+ }, {}, {}>;
3895
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
3896
+ name: "created_at";
3897
+ tableName: "company_assistant_threads";
3898
+ dataType: "date";
3899
+ columnType: "PgTimestamp";
3900
+ data: Date;
3901
+ driverParam: string;
3902
+ notNull: true;
3903
+ hasDefault: true;
3904
+ isPrimaryKey: false;
3905
+ isAutoincrement: false;
3906
+ hasRuntimeDefault: false;
3907
+ enumValues: undefined;
3908
+ baseColumn: never;
3909
+ identity: undefined;
3910
+ generated: undefined;
3911
+ }, {}, {}>;
3912
+ updatedAt: import("drizzle-orm/pg-core").PgColumn<{
3913
+ name: "updated_at";
3914
+ tableName: "company_assistant_threads";
3915
+ dataType: "date";
3916
+ columnType: "PgTimestamp";
3917
+ data: Date;
3918
+ driverParam: string;
3919
+ notNull: true;
3920
+ hasDefault: true;
3921
+ isPrimaryKey: false;
3922
+ isAutoincrement: false;
3923
+ hasRuntimeDefault: false;
3924
+ enumValues: undefined;
3925
+ baseColumn: never;
3926
+ identity: undefined;
3927
+ generated: undefined;
3928
+ }, {}, {}>;
3929
+ };
3930
+ dialect: "pg";
3931
+ }>;
3932
+ export declare const companyAssistantMessages: import("drizzle-orm/pg-core").PgTableWithColumns<{
3933
+ name: "company_assistant_messages";
3934
+ schema: undefined;
3935
+ columns: {
3936
+ id: import("drizzle-orm/pg-core").PgColumn<{
3937
+ name: "id";
3938
+ tableName: "company_assistant_messages";
3939
+ dataType: "string";
3940
+ columnType: "PgText";
3941
+ data: string;
3942
+ driverParam: string;
3943
+ notNull: true;
3944
+ hasDefault: false;
3945
+ isPrimaryKey: true;
3946
+ isAutoincrement: false;
3947
+ hasRuntimeDefault: false;
3948
+ enumValues: [string, ...string[]];
3949
+ baseColumn: never;
3950
+ identity: undefined;
3951
+ generated: undefined;
3952
+ }, {}, {}>;
3953
+ threadId: import("drizzle-orm/pg-core").PgColumn<{
3954
+ name: "thread_id";
3955
+ tableName: "company_assistant_messages";
3956
+ dataType: "string";
3957
+ columnType: "PgText";
3958
+ data: string;
3959
+ driverParam: string;
3960
+ notNull: true;
3961
+ hasDefault: false;
3962
+ isPrimaryKey: false;
3963
+ isAutoincrement: false;
3964
+ hasRuntimeDefault: false;
3965
+ enumValues: [string, ...string[]];
3966
+ baseColumn: never;
3967
+ identity: undefined;
3968
+ generated: undefined;
3969
+ }, {}, {}>;
3970
+ companyId: import("drizzle-orm/pg-core").PgColumn<{
3971
+ name: "company_id";
3972
+ tableName: "company_assistant_messages";
3973
+ dataType: "string";
3974
+ columnType: "PgText";
3975
+ data: string;
3976
+ driverParam: string;
3977
+ notNull: true;
3978
+ hasDefault: false;
3979
+ isPrimaryKey: false;
3980
+ isAutoincrement: false;
3981
+ hasRuntimeDefault: false;
3982
+ enumValues: [string, ...string[]];
3983
+ baseColumn: never;
3984
+ identity: undefined;
3985
+ generated: undefined;
3986
+ }, {}, {}>;
3987
+ role: import("drizzle-orm/pg-core").PgColumn<{
3988
+ name: "role";
3989
+ tableName: "company_assistant_messages";
3990
+ dataType: "string";
3991
+ columnType: "PgText";
3992
+ data: string;
3993
+ driverParam: string;
3994
+ notNull: true;
3995
+ hasDefault: false;
3996
+ isPrimaryKey: false;
3997
+ isAutoincrement: false;
3998
+ hasRuntimeDefault: false;
3999
+ enumValues: [string, ...string[]];
4000
+ baseColumn: never;
4001
+ identity: undefined;
4002
+ generated: undefined;
4003
+ }, {}, {}>;
4004
+ body: import("drizzle-orm/pg-core").PgColumn<{
4005
+ name: "body";
4006
+ tableName: "company_assistant_messages";
4007
+ dataType: "string";
4008
+ columnType: "PgText";
4009
+ data: string;
4010
+ driverParam: string;
4011
+ notNull: true;
4012
+ hasDefault: false;
4013
+ isPrimaryKey: false;
4014
+ isAutoincrement: false;
4015
+ hasRuntimeDefault: false;
4016
+ enumValues: [string, ...string[]];
4017
+ baseColumn: never;
4018
+ identity: undefined;
4019
+ generated: undefined;
4020
+ }, {}, {}>;
4021
+ metadataJson: import("drizzle-orm/pg-core").PgColumn<{
4022
+ name: "metadata_json";
4023
+ tableName: "company_assistant_messages";
4024
+ dataType: "string";
4025
+ columnType: "PgText";
4026
+ data: string;
4027
+ driverParam: string;
4028
+ notNull: false;
4029
+ hasDefault: false;
4030
+ isPrimaryKey: false;
4031
+ isAutoincrement: false;
4032
+ hasRuntimeDefault: false;
4033
+ enumValues: [string, ...string[]];
4034
+ baseColumn: never;
4035
+ identity: undefined;
4036
+ generated: undefined;
4037
+ }, {}, {}>;
4038
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
4039
+ name: "created_at";
4040
+ tableName: "company_assistant_messages";
4041
+ dataType: "date";
4042
+ columnType: "PgTimestamp";
4043
+ data: Date;
4044
+ driverParam: string;
4045
+ notNull: true;
4046
+ hasDefault: true;
4047
+ isPrimaryKey: false;
4048
+ isAutoincrement: false;
4049
+ hasRuntimeDefault: false;
4050
+ enumValues: undefined;
4051
+ baseColumn: never;
4052
+ identity: undefined;
4053
+ generated: undefined;
4054
+ }, {}, {}>;
4055
+ };
4056
+ dialect: "pg";
4057
+ }>;
3857
4058
  export declare const costLedger: import("drizzle-orm/pg-core").PgTableWithColumns<{
3858
4059
  name: "cost_ledger";
3859
4060
  schema: undefined;
@@ -3960,6 +4161,57 @@ export declare const costLedger: import("drizzle-orm/pg-core").PgTableWithColumn
3960
4161
  identity: undefined;
3961
4162
  generated: undefined;
3962
4163
  }, {}, {}>;
4164
+ costCategory: import("drizzle-orm/pg-core").PgColumn<{
4165
+ name: "cost_category";
4166
+ tableName: "cost_ledger";
4167
+ dataType: "string";
4168
+ columnType: "PgText";
4169
+ data: string;
4170
+ driverParam: string;
4171
+ notNull: false;
4172
+ hasDefault: false;
4173
+ isPrimaryKey: false;
4174
+ isAutoincrement: false;
4175
+ hasRuntimeDefault: false;
4176
+ enumValues: [string, ...string[]];
4177
+ baseColumn: never;
4178
+ identity: undefined;
4179
+ generated: undefined;
4180
+ }, {}, {}>;
4181
+ assistantThreadId: import("drizzle-orm/pg-core").PgColumn<{
4182
+ name: "assistant_thread_id";
4183
+ tableName: "cost_ledger";
4184
+ dataType: "string";
4185
+ columnType: "PgText";
4186
+ data: string;
4187
+ driverParam: string;
4188
+ notNull: false;
4189
+ hasDefault: false;
4190
+ isPrimaryKey: false;
4191
+ isAutoincrement: false;
4192
+ hasRuntimeDefault: false;
4193
+ enumValues: [string, ...string[]];
4194
+ baseColumn: never;
4195
+ identity: undefined;
4196
+ generated: undefined;
4197
+ }, {}, {}>;
4198
+ assistantMessageId: import("drizzle-orm/pg-core").PgColumn<{
4199
+ name: "assistant_message_id";
4200
+ tableName: "cost_ledger";
4201
+ dataType: "string";
4202
+ columnType: "PgText";
4203
+ data: string;
4204
+ driverParam: string;
4205
+ notNull: false;
4206
+ hasDefault: false;
4207
+ isPrimaryKey: false;
4208
+ isAutoincrement: false;
4209
+ hasRuntimeDefault: false;
4210
+ enumValues: [string, ...string[]];
4211
+ baseColumn: never;
4212
+ identity: undefined;
4213
+ generated: undefined;
4214
+ }, {}, {}>;
3963
4215
  providerType: import("drizzle-orm/pg-core").PgColumn<{
3964
4216
  name: "provider_type";
3965
4217
  tableName: "cost_ledger";
@@ -9114,6 +9366,57 @@ export declare const schema: {
9114
9366
  identity: undefined;
9115
9367
  generated: undefined;
9116
9368
  }, {}, {}>;
9369
+ costCategory: import("drizzle-orm/pg-core").PgColumn<{
9370
+ name: "cost_category";
9371
+ tableName: "cost_ledger";
9372
+ dataType: "string";
9373
+ columnType: "PgText";
9374
+ data: string;
9375
+ driverParam: string;
9376
+ notNull: false;
9377
+ hasDefault: false;
9378
+ isPrimaryKey: false;
9379
+ isAutoincrement: false;
9380
+ hasRuntimeDefault: false;
9381
+ enumValues: [string, ...string[]];
9382
+ baseColumn: never;
9383
+ identity: undefined;
9384
+ generated: undefined;
9385
+ }, {}, {}>;
9386
+ assistantThreadId: import("drizzle-orm/pg-core").PgColumn<{
9387
+ name: "assistant_thread_id";
9388
+ tableName: "cost_ledger";
9389
+ dataType: "string";
9390
+ columnType: "PgText";
9391
+ data: string;
9392
+ driverParam: string;
9393
+ notNull: false;
9394
+ hasDefault: false;
9395
+ isPrimaryKey: false;
9396
+ isAutoincrement: false;
9397
+ hasRuntimeDefault: false;
9398
+ enumValues: [string, ...string[]];
9399
+ baseColumn: never;
9400
+ identity: undefined;
9401
+ generated: undefined;
9402
+ }, {}, {}>;
9403
+ assistantMessageId: import("drizzle-orm/pg-core").PgColumn<{
9404
+ name: "assistant_message_id";
9405
+ tableName: "cost_ledger";
9406
+ dataType: "string";
9407
+ columnType: "PgText";
9408
+ data: string;
9409
+ driverParam: string;
9410
+ notNull: false;
9411
+ hasDefault: false;
9412
+ isPrimaryKey: false;
9413
+ isAutoincrement: false;
9414
+ hasRuntimeDefault: false;
9415
+ enumValues: [string, ...string[]];
9416
+ baseColumn: never;
9417
+ identity: undefined;
9418
+ generated: undefined;
9419
+ }, {}, {}>;
9117
9420
  providerType: import("drizzle-orm/pg-core").PgColumn<{
9118
9421
  name: "provider_type";
9119
9422
  tableName: "cost_ledger";
@@ -10659,5 +10962,206 @@ export declare const schema: {
10659
10962
  };
10660
10963
  dialect: "pg";
10661
10964
  }>;
10965
+ companyAssistantThreads: import("drizzle-orm/pg-core").PgTableWithColumns<{
10966
+ name: "company_assistant_threads";
10967
+ schema: undefined;
10968
+ columns: {
10969
+ id: import("drizzle-orm/pg-core").PgColumn<{
10970
+ name: "id";
10971
+ tableName: "company_assistant_threads";
10972
+ dataType: "string";
10973
+ columnType: "PgText";
10974
+ data: string;
10975
+ driverParam: string;
10976
+ notNull: true;
10977
+ hasDefault: false;
10978
+ isPrimaryKey: true;
10979
+ isAutoincrement: false;
10980
+ hasRuntimeDefault: false;
10981
+ enumValues: [string, ...string[]];
10982
+ baseColumn: never;
10983
+ identity: undefined;
10984
+ generated: undefined;
10985
+ }, {}, {}>;
10986
+ companyId: import("drizzle-orm/pg-core").PgColumn<{
10987
+ name: "company_id";
10988
+ tableName: "company_assistant_threads";
10989
+ dataType: "string";
10990
+ columnType: "PgText";
10991
+ data: string;
10992
+ driverParam: string;
10993
+ notNull: true;
10994
+ hasDefault: false;
10995
+ isPrimaryKey: false;
10996
+ isAutoincrement: false;
10997
+ hasRuntimeDefault: false;
10998
+ enumValues: [string, ...string[]];
10999
+ baseColumn: never;
11000
+ identity: undefined;
11001
+ generated: undefined;
11002
+ }, {}, {}>;
11003
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
11004
+ name: "created_at";
11005
+ tableName: "company_assistant_threads";
11006
+ dataType: "date";
11007
+ columnType: "PgTimestamp";
11008
+ data: Date;
11009
+ driverParam: string;
11010
+ notNull: true;
11011
+ hasDefault: true;
11012
+ isPrimaryKey: false;
11013
+ isAutoincrement: false;
11014
+ hasRuntimeDefault: false;
11015
+ enumValues: undefined;
11016
+ baseColumn: never;
11017
+ identity: undefined;
11018
+ generated: undefined;
11019
+ }, {}, {}>;
11020
+ updatedAt: import("drizzle-orm/pg-core").PgColumn<{
11021
+ name: "updated_at";
11022
+ tableName: "company_assistant_threads";
11023
+ dataType: "date";
11024
+ columnType: "PgTimestamp";
11025
+ data: Date;
11026
+ driverParam: string;
11027
+ notNull: true;
11028
+ hasDefault: true;
11029
+ isPrimaryKey: false;
11030
+ isAutoincrement: false;
11031
+ hasRuntimeDefault: false;
11032
+ enumValues: undefined;
11033
+ baseColumn: never;
11034
+ identity: undefined;
11035
+ generated: undefined;
11036
+ }, {}, {}>;
11037
+ };
11038
+ dialect: "pg";
11039
+ }>;
11040
+ companyAssistantMessages: import("drizzle-orm/pg-core").PgTableWithColumns<{
11041
+ name: "company_assistant_messages";
11042
+ schema: undefined;
11043
+ columns: {
11044
+ id: import("drizzle-orm/pg-core").PgColumn<{
11045
+ name: "id";
11046
+ tableName: "company_assistant_messages";
11047
+ dataType: "string";
11048
+ columnType: "PgText";
11049
+ data: string;
11050
+ driverParam: string;
11051
+ notNull: true;
11052
+ hasDefault: false;
11053
+ isPrimaryKey: true;
11054
+ isAutoincrement: false;
11055
+ hasRuntimeDefault: false;
11056
+ enumValues: [string, ...string[]];
11057
+ baseColumn: never;
11058
+ identity: undefined;
11059
+ generated: undefined;
11060
+ }, {}, {}>;
11061
+ threadId: import("drizzle-orm/pg-core").PgColumn<{
11062
+ name: "thread_id";
11063
+ tableName: "company_assistant_messages";
11064
+ dataType: "string";
11065
+ columnType: "PgText";
11066
+ data: string;
11067
+ driverParam: string;
11068
+ notNull: true;
11069
+ hasDefault: false;
11070
+ isPrimaryKey: false;
11071
+ isAutoincrement: false;
11072
+ hasRuntimeDefault: false;
11073
+ enumValues: [string, ...string[]];
11074
+ baseColumn: never;
11075
+ identity: undefined;
11076
+ generated: undefined;
11077
+ }, {}, {}>;
11078
+ companyId: import("drizzle-orm/pg-core").PgColumn<{
11079
+ name: "company_id";
11080
+ tableName: "company_assistant_messages";
11081
+ dataType: "string";
11082
+ columnType: "PgText";
11083
+ data: string;
11084
+ driverParam: string;
11085
+ notNull: true;
11086
+ hasDefault: false;
11087
+ isPrimaryKey: false;
11088
+ isAutoincrement: false;
11089
+ hasRuntimeDefault: false;
11090
+ enumValues: [string, ...string[]];
11091
+ baseColumn: never;
11092
+ identity: undefined;
11093
+ generated: undefined;
11094
+ }, {}, {}>;
11095
+ role: import("drizzle-orm/pg-core").PgColumn<{
11096
+ name: "role";
11097
+ tableName: "company_assistant_messages";
11098
+ dataType: "string";
11099
+ columnType: "PgText";
11100
+ data: string;
11101
+ driverParam: string;
11102
+ notNull: true;
11103
+ hasDefault: false;
11104
+ isPrimaryKey: false;
11105
+ isAutoincrement: false;
11106
+ hasRuntimeDefault: false;
11107
+ enumValues: [string, ...string[]];
11108
+ baseColumn: never;
11109
+ identity: undefined;
11110
+ generated: undefined;
11111
+ }, {}, {}>;
11112
+ body: import("drizzle-orm/pg-core").PgColumn<{
11113
+ name: "body";
11114
+ tableName: "company_assistant_messages";
11115
+ dataType: "string";
11116
+ columnType: "PgText";
11117
+ data: string;
11118
+ driverParam: string;
11119
+ notNull: true;
11120
+ hasDefault: false;
11121
+ isPrimaryKey: false;
11122
+ isAutoincrement: false;
11123
+ hasRuntimeDefault: false;
11124
+ enumValues: [string, ...string[]];
11125
+ baseColumn: never;
11126
+ identity: undefined;
11127
+ generated: undefined;
11128
+ }, {}, {}>;
11129
+ metadataJson: import("drizzle-orm/pg-core").PgColumn<{
11130
+ name: "metadata_json";
11131
+ tableName: "company_assistant_messages";
11132
+ dataType: "string";
11133
+ columnType: "PgText";
11134
+ data: string;
11135
+ driverParam: string;
11136
+ notNull: false;
11137
+ hasDefault: false;
11138
+ isPrimaryKey: false;
11139
+ isAutoincrement: false;
11140
+ hasRuntimeDefault: false;
11141
+ enumValues: [string, ...string[]];
11142
+ baseColumn: never;
11143
+ identity: undefined;
11144
+ generated: undefined;
11145
+ }, {}, {}>;
11146
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
11147
+ name: "created_at";
11148
+ tableName: "company_assistant_messages";
11149
+ dataType: "date";
11150
+ columnType: "PgTimestamp";
11151
+ data: Date;
11152
+ driverParam: string;
11153
+ notNull: true;
11154
+ hasDefault: true;
11155
+ isPrimaryKey: false;
11156
+ isAutoincrement: false;
11157
+ hasRuntimeDefault: false;
11158
+ enumValues: undefined;
11159
+ baseColumn: never;
11160
+ identity: undefined;
11161
+ generated: undefined;
11162
+ }, {}, {}>;
11163
+ };
11164
+ dialect: "pg";
11165
+ }>;
10662
11166
  };
10663
11167
  export declare const touchUpdatedAtSql: import("drizzle-orm").SQL<unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-db",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,20 @@
1
+ CREATE TABLE "company_assistant_threads" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "company_id" text NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
4
+ "created_at" timestamp DEFAULT now() NOT NULL,
5
+ "updated_at" timestamp DEFAULT now() NOT NULL
6
+ );
7
+
8
+ CREATE INDEX "company_assistant_threads_company_idx" ON "company_assistant_threads" ("company_id");
9
+
10
+ CREATE TABLE "company_assistant_messages" (
11
+ "id" text PRIMARY KEY NOT NULL,
12
+ "thread_id" text NOT NULL REFERENCES "company_assistant_threads"("id") ON DELETE CASCADE,
13
+ "company_id" text NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE,
14
+ "role" text NOT NULL,
15
+ "body" text NOT NULL,
16
+ "metadata_json" text,
17
+ "created_at" timestamp DEFAULT now() NOT NULL
18
+ );
19
+
20
+ CREATE INDEX "company_assistant_messages_thread_created_idx" ON "company_assistant_messages" ("thread_id","created_at");
@@ -0,0 +1,7 @@
1
+ ALTER TABLE "cost_ledger" ADD COLUMN "cost_category" text;
2
+ --> statement-breakpoint
3
+ ALTER TABLE "cost_ledger" ADD COLUMN "assistant_thread_id" text REFERENCES "company_assistant_threads"("id") ON DELETE SET NULL;
4
+ --> statement-breakpoint
5
+ ALTER TABLE "cost_ledger" ADD COLUMN "assistant_message_id" text REFERENCES "company_assistant_messages"("id") ON DELETE SET NULL;
6
+ --> statement-breakpoint
7
+ CREATE INDEX "idx_cost_ledger_company_category_created" ON "cost_ledger" ("company_id", "cost_category", "created_at");
@@ -0,0 +1 @@
1
+ ALTER TABLE "goals" ADD CONSTRAINT "goals_parent_goal_id_goals_id_fk" FOREIGN KEY ("parent_goal_id") REFERENCES "goals"("id") ON DELETE SET NULL;
@@ -43,6 +43,27 @@
43
43
  "when": 1743000000000,
44
44
  "tag": "0005_work_loops",
45
45
  "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "7",
50
+ "when": 1743100000000,
51
+ "tag": "0006_company_assistant_chat",
52
+ "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "7",
57
+ "when": 1743200000000,
58
+ "tag": "0007_cost_ledger_company_assistant",
59
+ "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1743300000000,
65
+ "tag": "0008_goals_parent_goal_fk",
66
+ "breakpoints": true
46
67
  }
47
68
  ]
48
69
  }
@@ -0,0 +1,126 @@
1
+ import { and, asc, count, desc, eq, gte, lt } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import type { BopoDb } from "../client";
4
+ import { companyAssistantMessages, companyAssistantThreads } from "../schema";
5
+
6
+ export type AssistantMessageRole = "user" | "assistant" | "system";
7
+
8
+ export async function getOrCreateAssistantThread(db: BopoDb, companyId: string) {
9
+ const [existing] = await db
10
+ .select()
11
+ .from(companyAssistantThreads)
12
+ .where(eq(companyAssistantThreads.companyId, companyId))
13
+ .orderBy(desc(companyAssistantThreads.updatedAt))
14
+ .limit(1);
15
+ if (existing) {
16
+ return existing;
17
+ }
18
+ return createAssistantThreadRow(db, companyId);
19
+ }
20
+
21
+ async function createAssistantThreadRow(db: BopoDb, companyId: string) {
22
+ const id = nanoid(16);
23
+ const now = new Date();
24
+ await db.insert(companyAssistantThreads).values({
25
+ id,
26
+ companyId,
27
+ createdAt: now,
28
+ updatedAt: now
29
+ });
30
+ const [row] = await db.select().from(companyAssistantThreads).where(eq(companyAssistantThreads.id, id)).limit(1);
31
+ return row!;
32
+ }
33
+
34
+ /** New empty thread; previous threads and messages remain in the database. */
35
+ export async function createAssistantThread(db: BopoDb, companyId: string) {
36
+ return createAssistantThreadRow(db, companyId);
37
+ }
38
+
39
+ export async function getAssistantThreadById(db: BopoDb, companyId: string, threadId: string) {
40
+ const [row] = await db
41
+ .select()
42
+ .from(companyAssistantThreads)
43
+ .where(and(eq(companyAssistantThreads.id, threadId), eq(companyAssistantThreads.companyId, companyId)))
44
+ .limit(1);
45
+ return row ?? null;
46
+ }
47
+
48
+ export async function touchAssistantThread(db: BopoDb, threadId: string) {
49
+ await db
50
+ .update(companyAssistantThreads)
51
+ .set({ updatedAt: new Date() })
52
+ .where(eq(companyAssistantThreads.id, threadId));
53
+ }
54
+
55
+ export async function insertAssistantMessage(
56
+ db: BopoDb,
57
+ input: {
58
+ threadId: string;
59
+ companyId: string;
60
+ role: AssistantMessageRole;
61
+ body: string;
62
+ metadataJson?: string | null;
63
+ }
64
+ ) {
65
+ const id = nanoid(16);
66
+ await db.insert(companyAssistantMessages).values({
67
+ id,
68
+ threadId: input.threadId,
69
+ companyId: input.companyId,
70
+ role: input.role,
71
+ body: input.body,
72
+ metadataJson: input.metadataJson ?? null
73
+ });
74
+ await touchAssistantThread(db, input.threadId);
75
+ const [row] = await db.select().from(companyAssistantMessages).where(eq(companyAssistantMessages.id, id)).limit(1);
76
+ return row!;
77
+ }
78
+
79
+ export async function listAssistantMessages(db: BopoDb, threadId: string, limit = 100) {
80
+ const capped = Math.min(Math.max(1, limit), 200);
81
+ return db
82
+ .select()
83
+ .from(companyAssistantMessages)
84
+ .where(eq(companyAssistantMessages.threadId, threadId))
85
+ .orderBy(asc(companyAssistantMessages.createdAt))
86
+ .limit(capped);
87
+ }
88
+
89
+ /** Threads with at least one message in `[startInclusive, endExclusive)` on `created_at`. */
90
+ export async function listAssistantChatThreadStatsInCreatedAtRange(
91
+ db: BopoDb,
92
+ companyId: string,
93
+ startInclusive: Date,
94
+ endExclusive: Date
95
+ ): Promise<Array<{ threadId: string; messageCount: number }>> {
96
+ const rows = await db
97
+ .select({
98
+ threadId: companyAssistantMessages.threadId,
99
+ messageCount: count()
100
+ })
101
+ .from(companyAssistantMessages)
102
+ .where(
103
+ and(
104
+ eq(companyAssistantMessages.companyId, companyId),
105
+ gte(companyAssistantMessages.createdAt, startInclusive),
106
+ lt(companyAssistantMessages.createdAt, endExclusive)
107
+ )
108
+ )
109
+ .groupBy(companyAssistantMessages.threadId);
110
+ return rows.map((r) => ({
111
+ threadId: r.threadId,
112
+ messageCount: Number(r.messageCount) || 0
113
+ }));
114
+ }
115
+
116
+ /** Threads with at least one message in the UTC calendar month (for callers without local bounds). */
117
+ export async function listAssistantChatThreadStatsInUtcMonth(
118
+ db: BopoDb,
119
+ companyId: string,
120
+ year: number,
121
+ month1Based: number
122
+ ): Promise<Array<{ threadId: string; messageCount: number }>> {
123
+ const startUtc = new Date(Date.UTC(year, month1Based - 1, 1, 0, 0, 0, 0));
124
+ const endExclusiveUtc = new Date(Date.UTC(year, month1Based, 1, 0, 0, 0, 0));
125
+ return listAssistantChatThreadStatsInCreatedAtRange(db, companyId, startUtc, endExclusiveUtc);
126
+ }
@@ -102,3 +102,113 @@ export async function assertTemplateBelongsToCompany(db: BopoDb, companyId: stri
102
102
  export function compactUpdate<T extends Record<string, unknown>>(input: T) {
103
103
  return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
104
104
  }
105
+
106
+ const GOAL_PARENT_CHAIN_MAX_DEPTH = 32;
107
+
108
+ type GoalLevel = "company" | "project" | "agent";
109
+
110
+ /** Walk parent_goal_id upward; detects cycles and whether `selfId` appears (would make self an ancestor of the parent). */
111
+ async function walkGoalParentChain(
112
+ db: BopoDb,
113
+ companyId: string,
114
+ startParentId: string,
115
+ selfId: string | undefined
116
+ ) {
117
+ const visited = new Set<string>();
118
+ let current: string | null = startParentId;
119
+ let depth = 0;
120
+ while (current && depth < GOAL_PARENT_CHAIN_MAX_DEPTH) {
121
+ if (selfId && current === selfId) {
122
+ throw new RepositoryValidationError("Goal cannot be its own ancestor.");
123
+ }
124
+ if (visited.has(current)) {
125
+ throw new RepositoryValidationError("Parent goal chain contains a cycle.");
126
+ }
127
+ visited.add(current);
128
+ const [row] = await db
129
+ .select({ parentGoalId: goals.parentGoalId })
130
+ .from(goals)
131
+ .where(and(eq(goals.companyId, companyId), eq(goals.id, current)))
132
+ .limit(1);
133
+ current = row?.parentGoalId?.trim() ? row.parentGoalId : null;
134
+ depth += 1;
135
+ }
136
+ if (current && depth >= GOAL_PARENT_CHAIN_MAX_DEPTH) {
137
+ throw new RepositoryValidationError("Parent goal chain exceeds maximum depth.");
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Validates level ↔ projectId and optional parent_goal_id tree rules:
143
+ * - company: projectId must be null; parent must be company-level (or absent).
144
+ * - project: projectId required; parent must be company-level or absent.
145
+ * - agent: parent absent, company-level, or project-level with same projectId as child (child projectId required in that case).
146
+ */
147
+ export async function assertValidGoalHierarchy(
148
+ db: BopoDb,
149
+ companyId: string,
150
+ input: {
151
+ id?: string;
152
+ level: GoalLevel;
153
+ projectId: string | null;
154
+ parentGoalId: string | null;
155
+ }
156
+ ) {
157
+ const level = input.level;
158
+ const projectId = input.projectId?.trim() ? input.projectId.trim() : null;
159
+ const parentGoalId = input.parentGoalId?.trim() ? input.parentGoalId.trim() : null;
160
+
161
+ if (level === "company" && projectId) {
162
+ throw new RepositoryValidationError("Company goals cannot be scoped to a project.");
163
+ }
164
+ if (level === "project" && !projectId) {
165
+ throw new RepositoryValidationError("Project goals must have a project.");
166
+ }
167
+
168
+ if (!parentGoalId) {
169
+ return;
170
+ }
171
+
172
+ const [parent] = await db
173
+ .select({
174
+ id: goals.id,
175
+ level: goals.level,
176
+ projectId: goals.projectId
177
+ })
178
+ .from(goals)
179
+ .where(and(eq(goals.companyId, companyId), eq(goals.id, parentGoalId)))
180
+ .limit(1);
181
+
182
+ if (!parent) {
183
+ throw new RepositoryValidationError("Parent goal not found for company.");
184
+ }
185
+
186
+ const pLevel = parent.level as GoalLevel;
187
+ if (pLevel !== "company" && pLevel !== "project") {
188
+ throw new RepositoryValidationError("Parent goal must be company or project level.");
189
+ }
190
+
191
+ if (level === "company") {
192
+ if (pLevel !== "company") {
193
+ throw new RepositoryValidationError("Company goals may only have a company-level parent.");
194
+ }
195
+ } else if (level === "project") {
196
+ if (pLevel !== "company") {
197
+ throw new RepositoryValidationError("Project goals may only have a company-level parent.");
198
+ }
199
+ } else {
200
+ // agent
201
+ if (pLevel === "company") {
202
+ // ok
203
+ } else if (pLevel === "project") {
204
+ const parentPid = parent.projectId?.trim() ? parent.projectId : null;
205
+ if (!parentPid || parentPid !== projectId) {
206
+ throw new RepositoryValidationError(
207
+ "Agent goals with a project parent must use the same project as the parent goal."
208
+ );
209
+ }
210
+ }
211
+ }
212
+
213
+ await walkGoalParentChain(db, companyId, parentGoalId, input.id);
214
+ }
@@ -1,3 +1,4 @@
1
1
  export * from "./helpers";
2
2
  export * from "./companies";
3
+ export * from "./company-assistant-chat";
3
4
  export * from "./legacy";
@@ -1,4 +1,4 @@
1
- import { and, asc, desc, eq, gt, inArray, notInArray, sql } from "drizzle-orm";
1
+ import { and, asc, desc, eq, gt, gte, inArray, lt, notInArray, sql } from "drizzle-orm";
2
2
  import { nanoid } from "nanoid";
3
3
  import type { BopoDb } from "../client";
4
4
  import {
@@ -29,11 +29,11 @@ import {
29
29
  } from "../schema";
30
30
  import {
31
31
  assertAgentBelongsToCompany,
32
- assertGoalBelongsToCompany,
33
32
  assertIssueGoalsAssignable,
34
33
  assertIssueBelongsToCompany,
35
34
  assertProjectBelongsToCompany,
36
35
  assertTemplateBelongsToCompany,
36
+ assertValidGoalHierarchy,
37
37
  compactUpdate,
38
38
  RepositoryValidationError
39
39
  } from "./helpers";
@@ -946,11 +946,15 @@ export async function createGoal(
946
946
  description?: string;
947
947
  }
948
948
  ) {
949
- if (input.projectId) {
950
- await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
951
- }
952
- if (input.parentGoalId) {
953
- await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
949
+ const projectId = input.projectId?.trim() ? input.projectId.trim() : null;
950
+ const parentGoalId = input.parentGoalId?.trim() ? input.parentGoalId.trim() : null;
951
+ await assertValidGoalHierarchy(db, input.companyId, {
952
+ level: input.level,
953
+ projectId,
954
+ parentGoalId
955
+ });
956
+ if (projectId) {
957
+ await assertProjectBelongsToCompany(db, input.companyId, projectId);
954
958
  }
955
959
  if (input.ownerAgentId) {
956
960
  await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId);
@@ -959,14 +963,14 @@ export async function createGoal(
959
963
  await db.insert(goals).values({
960
964
  id,
961
965
  companyId: input.companyId,
962
- projectId: input.projectId ?? null,
963
- parentGoalId: input.parentGoalId ?? null,
966
+ projectId,
967
+ parentGoalId,
964
968
  ownerAgentId: input.ownerAgentId?.trim() ? input.ownerAgentId.trim() : null,
965
969
  level: input.level,
966
970
  title: input.title,
967
971
  description: input.description ?? null
968
972
  });
969
- return { id, ...input };
973
+ return { id, ...input, projectId, parentGoalId };
970
974
  }
971
975
 
972
976
  export async function listGoals(db: BopoDb, companyId: string) {
@@ -987,14 +991,37 @@ export async function updateGoal(
987
991
  status?: string;
988
992
  }
989
993
  ) {
990
- if (input.projectId) {
991
- await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
994
+ const [existing] = await db
995
+ .select({
996
+ level: goals.level,
997
+ projectId: goals.projectId,
998
+ parentGoalId: goals.parentGoalId
999
+ })
1000
+ .from(goals)
1001
+ .where(and(eq(goals.companyId, input.companyId), eq(goals.id, input.id)))
1002
+ .limit(1);
1003
+ if (!existing) {
1004
+ return null;
992
1005
  }
993
- if (input.parentGoalId) {
994
- await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
1006
+
1007
+ const effectiveLevel = (input.level ?? existing.level) as "company" | "project" | "agent";
1008
+ const effectiveProjectId = input.projectId !== undefined ? input.projectId : existing.projectId;
1009
+ const effectiveProjectIdNorm = effectiveProjectId?.trim() ? effectiveProjectId.trim() : null;
1010
+ const effectiveParent =
1011
+ input.parentGoalId !== undefined ? input.parentGoalId : existing.parentGoalId;
1012
+ const effectiveParentNorm = effectiveParent?.trim() ? effectiveParent.trim() : null;
1013
+
1014
+ await assertValidGoalHierarchy(db, input.companyId, {
1015
+ id: input.id,
1016
+ level: effectiveLevel,
1017
+ projectId: effectiveProjectIdNorm,
1018
+ parentGoalId: effectiveParentNorm
1019
+ });
1020
+ if (effectiveProjectIdNorm) {
1021
+ await assertProjectBelongsToCompany(db, input.companyId, effectiveProjectIdNorm);
995
1022
  }
996
- if (input.ownerAgentId) {
997
- await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId);
1023
+ if (input.ownerAgentId?.trim()) {
1024
+ await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId.trim());
998
1025
  }
999
1026
  const [goal] = await db
1000
1027
  .update(goals)
@@ -1518,6 +1545,10 @@ export async function appendCost(
1518
1545
  projectId?: string | null;
1519
1546
  issueId?: string | null;
1520
1547
  agentId?: string | null;
1548
+ /** Discriminator for reporting (e.g. `company_assistant`); null for heartbeat / legacy */
1549
+ costCategory?: string | null;
1550
+ assistantThreadId?: string | null;
1551
+ assistantMessageId?: string | null;
1521
1552
  }
1522
1553
  ) {
1523
1554
  const id = nanoid(14);
@@ -1525,6 +1556,9 @@ export async function appendCost(
1525
1556
  id,
1526
1557
  companyId: input.companyId,
1527
1558
  runId: input.runId ?? null,
1559
+ costCategory: input.costCategory ?? null,
1560
+ assistantThreadId: input.assistantThreadId ?? null,
1561
+ assistantMessageId: input.assistantMessageId ?? null,
1528
1562
  providerType: input.providerType,
1529
1563
  runtimeModelId: input.runtimeModelId ?? null,
1530
1564
  pricingProviderType: input.pricingProviderType ?? null,
@@ -1550,6 +1584,83 @@ export async function listCostEntries(db: BopoDb, companyId: string, limit = 200
1550
1584
  .limit(limit);
1551
1585
  }
1552
1586
 
1587
+ export type CostLedgerAggregate = {
1588
+ rowCount: number;
1589
+ tokenInput: number;
1590
+ tokenOutput: number;
1591
+ /** Sum of `usd_cost` as a decimal string (full precision from DB). */
1592
+ usdTotal: string;
1593
+ };
1594
+
1595
+ /** Sum every ledger row for the company in `[startInclusive, endExclusive)` (typically UTC month). */
1596
+ export async function aggregateCompanyCostLedgerInRange(
1597
+ db: BopoDb,
1598
+ companyId: string,
1599
+ startInclusive: Date,
1600
+ endExclusive: Date
1601
+ ): Promise<CostLedgerAggregate> {
1602
+ const [row] = await db
1603
+ .select({
1604
+ rowCount: sql<number>`count(*)::int`,
1605
+ tokenInput: sql<string>`coalesce(sum(${costLedger.tokenInput})::text, '0')`,
1606
+ tokenOutput: sql<string>`coalesce(sum(${costLedger.tokenOutput})::text, '0')`,
1607
+ usdTotal: sql<string>`coalesce(sum(${costLedger.usdCost})::text, '0')`
1608
+ })
1609
+ .from(costLedger)
1610
+ .where(
1611
+ and(
1612
+ eq(costLedger.companyId, companyId),
1613
+ gte(costLedger.createdAt, startInclusive),
1614
+ lt(costLedger.createdAt, endExclusive)
1615
+ )
1616
+ );
1617
+
1618
+ const parseBigIntish = (v: string) => {
1619
+ try {
1620
+ return Number(BigInt(v));
1621
+ } catch {
1622
+ return Number.parseInt(v, 10) || 0;
1623
+ }
1624
+ };
1625
+
1626
+ return {
1627
+ rowCount: Number(row?.rowCount ?? 0),
1628
+ tokenInput: parseBigIntish(String(row?.tokenInput ?? "0")),
1629
+ tokenOutput: parseBigIntish(String(row?.tokenOutput ?? "0")),
1630
+ usdTotal: String(row?.usdTotal ?? "0").trim() || "0"
1631
+ };
1632
+ }
1633
+
1634
+ export async function aggregateCompanyCostLedgerAllTime(
1635
+ db: BopoDb,
1636
+ companyId: string
1637
+ ): Promise<CostLedgerAggregate> {
1638
+ const [row] = await db
1639
+ .select({
1640
+ rowCount: sql<number>`count(*)::int`,
1641
+ tokenInput: sql<string>`coalesce(sum(${costLedger.tokenInput})::text, '0')`,
1642
+ tokenOutput: sql<string>`coalesce(sum(${costLedger.tokenOutput})::text, '0')`,
1643
+ usdTotal: sql<string>`coalesce(sum(${costLedger.usdCost})::text, '0')`
1644
+ })
1645
+ .from(costLedger)
1646
+ .where(eq(costLedger.companyId, companyId));
1647
+
1648
+ const parseBigIntish = (v: string) => {
1649
+ try {
1650
+ return Number(BigInt(v));
1651
+ } catch {
1652
+ return Number.parseInt(v, 10) || 0;
1653
+ }
1654
+ };
1655
+
1656
+ return {
1657
+ rowCount: Number(row?.rowCount ?? 0),
1658
+ tokenInput: parseBigIntish(String(row?.tokenInput ?? "0")),
1659
+ tokenOutput: parseBigIntish(String(row?.tokenOutput ?? "0")),
1660
+ usdTotal: String(row?.usdTotal ?? "0").trim() || "0"
1661
+ };
1662
+ }
1663
+
1553
1664
  export async function listHeartbeatRuns(db: BopoDb, companyId: string, limit = 100) {
1554
1665
  return db
1555
1666
  .select()
package/src/schema.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { sql } from "drizzle-orm";
2
+ import type { AnyPgColumn } from "drizzle-orm/pg-core";
2
3
  import { boolean, integer, numeric, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
3
4
 
4
5
  export const companies = pgTable("companies", {
@@ -52,7 +53,7 @@ export const goals = pgTable("goals", {
52
53
  .notNull()
53
54
  .references(() => companies.id, { onDelete: "cascade" }),
54
55
  projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
55
- parentGoalId: text("parent_goal_id"),
56
+ parentGoalId: text("parent_goal_id").references((): AnyPgColumn => goals.id, { onDelete: "set null" }),
56
57
  /** When set, this agent-level goal is included only for that agent's heartbeats; null = all agents. */
57
58
  ownerAgentId: text("owner_agent_id"),
58
59
  level: text("level").notNull(),
@@ -366,6 +367,29 @@ export const attentionInboxStates = pgTable(
366
367
  (table) => [primaryKey({ columns: [table.companyId, table.actorId, table.itemKey] })]
367
368
  );
368
369
 
370
+ export const companyAssistantThreads = pgTable("company_assistant_threads", {
371
+ id: text("id").primaryKey(),
372
+ companyId: text("company_id")
373
+ .notNull()
374
+ .references(() => companies.id, { onDelete: "cascade" }),
375
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
376
+ updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
377
+ });
378
+
379
+ export const companyAssistantMessages = pgTable("company_assistant_messages", {
380
+ id: text("id").primaryKey(),
381
+ threadId: text("thread_id")
382
+ .notNull()
383
+ .references(() => companyAssistantThreads.id, { onDelete: "cascade" }),
384
+ companyId: text("company_id")
385
+ .notNull()
386
+ .references(() => companies.id, { onDelete: "cascade" }),
387
+ role: text("role").notNull(),
388
+ body: text("body").notNull(),
389
+ metadataJson: text("metadata_json"),
390
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull()
391
+ });
392
+
369
393
  export const costLedger = pgTable("cost_ledger", {
370
394
  id: text("id").primaryKey(),
371
395
  companyId: text("company_id")
@@ -375,6 +399,10 @@ export const costLedger = pgTable("cost_ledger", {
375
399
  projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
376
400
  issueId: text("issue_id").references(() => issues.id, { onDelete: "set null" }),
377
401
  agentId: text("agent_id").references(() => agents.id, { onDelete: "set null" }),
402
+ /** e.g. `company_assistant` for owner-assistant chat; null for heartbeat and legacy rows */
403
+ costCategory: text("cost_category"),
404
+ assistantThreadId: text("assistant_thread_id").references(() => companyAssistantThreads.id, { onDelete: "set null" }),
405
+ assistantMessageId: text("assistant_message_id").references(() => companyAssistantMessages.id, { onDelete: "set null" }),
378
406
  providerType: text("provider_type").notNull(),
379
407
  runtimeModelId: text("runtime_model_id"),
380
408
  pricingProviderType: text("pricing_provider_type"),
@@ -536,7 +564,9 @@ export const schema = {
536
564
  templateVersions,
537
565
  templateInstalls,
538
566
  agentIssueLabels,
539
- projectWorkspaces
567
+ projectWorkspaces,
568
+ companyAssistantThreads,
569
+ companyAssistantMessages
540
570
  };
541
571
 
542
572
  export const touchUpdatedAtSql = sql`CURRENT_TIMESTAMP`;