bopodev-db 0.1.32 → 0.1.34
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-lint.log +1 -1
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/repositories/helpers.d.ts +14 -0
- package/dist/repositories/legacy.d.ts +2 -2
- package/package.json +1 -1
- package/src/migrations/0008_goals_parent_goal_fk.sql +1 -0
- package/src/migrations/meta/_journal.json +7 -0
- package/src/repositories/helpers.ts +110 -0
- package/src/repositories/legacy.ts +42 -15
- package/src/schema.ts +2 -1
package/.turbo/turbo-build.log
CHANGED
package/.turbo/turbo-lint.log
CHANGED
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -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)
|
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(),
|