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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/repositories/company-assistant-chat.d.ts +56 -0
- package/dist/repositories/helpers.d.ts +14 -0
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/legacy.d.ts +19 -2
- package/dist/schema.d.ts +504 -0
- package/package.json +1 -1
- package/src/migrations/0006_company_assistant_chat.sql +20 -0
- package/src/migrations/0007_cost_ledger_company_assistant.sql +7 -0
- package/src/migrations/0008_goals_parent_goal_fk.sql +1 -0
- package/src/migrations/meta/_journal.json +21 -0
- package/src/repositories/company-assistant-chat.ts +126 -0
- package/src/repositories/helpers.ts +110 -0
- package/src/repositories/index.ts +1 -0
- package/src/repositories/legacy.ts +127 -16
- package/src/schema.ts +32 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -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 {};
|
|
@@ -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
|
@@ -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,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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
|
963
|
-
parentGoalId
|
|
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
|
-
|
|
991
|
-
|
|
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
|
-
|
|
994
|
-
|
|
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`;
|