bopodev-api 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { and, eq } from "drizzle-orm";
4
+ import { agents } from "bopodev-db";
5
+ import type { AppContext } from "../context";
6
+ import { sendError, sendOk } from "../http";
7
+ import { requireCompanyScope } from "../middleware/company-scope";
8
+ import { requirePermission } from "../middleware/request-actor";
9
+ import { runHeartbeatForAgent, runHeartbeatSweep } from "../services/heartbeat-service";
10
+
11
+ const runAgentSchema = z.object({
12
+ agentId: z.string().min(1)
13
+ });
14
+
15
+ export function createHeartbeatRouter(ctx: AppContext) {
16
+ const router = Router();
17
+ router.use(requireCompanyScope);
18
+
19
+ router.post("/run-agent", async (req, res) => {
20
+ requirePermission("heartbeats:run")(req, res, () => {});
21
+ if (res.headersSent) {
22
+ return;
23
+ }
24
+ const parsed = runAgentSchema.safeParse(req.body);
25
+ if (!parsed.success) {
26
+ return sendError(res, parsed.error.message, 422);
27
+ }
28
+ const [agent] = await ctx.db
29
+ .select({ id: agents.id, status: agents.status })
30
+ .from(agents)
31
+ .where(and(eq(agents.companyId, req.companyId!), eq(agents.id, parsed.data.agentId)))
32
+ .limit(1);
33
+ if (!agent) {
34
+ return sendError(res, "Agent not found.", 404);
35
+ }
36
+ if (agent.status === "paused" || agent.status === "terminated") {
37
+ return sendError(res, `Agent is not invokable in status '${agent.status}'.`, 409);
38
+ }
39
+
40
+ const runId = await runHeartbeatForAgent(ctx.db, req.companyId!, parsed.data.agentId, {
41
+ requestId: req.requestId,
42
+ trigger: "manual",
43
+ realtimeHub: ctx.realtimeHub
44
+ });
45
+ return sendOk(res, { runId, requestId: req.requestId });
46
+ });
47
+
48
+ router.post("/sweep", async (req, res) => {
49
+ requirePermission("heartbeats:sweep")(req, res, () => {});
50
+ if (res.headersSent) {
51
+ return;
52
+ }
53
+ const runIds = await runHeartbeatSweep(ctx.db, req.companyId!, {
54
+ requestId: req.requestId,
55
+ realtimeHub: ctx.realtimeHub
56
+ });
57
+ return sendOk(res, { runIds, requestId: req.requestId });
58
+ });
59
+
60
+ return router;
61
+ }
@@ -0,0 +1,319 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import {
4
+ addIssueComment,
5
+ appendActivity,
6
+ appendAuditEvent,
7
+ createIssue,
8
+ deleteIssueComment,
9
+ deleteIssue,
10
+ listIssueComments,
11
+ listIssues,
12
+ updateIssueComment,
13
+ updateIssue
14
+ } from "bopodev-db";
15
+ import type { AppContext } from "../context";
16
+ import { sendError, sendOk } from "../http";
17
+ import { requireCompanyScope } from "../middleware/company-scope";
18
+ import { requirePermission } from "../middleware/request-actor";
19
+
20
+ const createIssueSchema = z.object({
21
+ projectId: z.string().min(1),
22
+ parentIssueId: z.string().optional(),
23
+ title: z.string().min(1),
24
+ body: z.string().optional(),
25
+ status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).default("todo"),
26
+ priority: z.enum(["none", "low", "medium", "high", "urgent"]).default("none"),
27
+ assigneeAgentId: z.string().nullable().optional(),
28
+ labels: z.array(z.string()).default([]),
29
+ tags: z.array(z.string()).default([])
30
+ });
31
+
32
+ const createIssueCommentSchema = z.object({
33
+ body: z.string().min(1),
34
+ authorType: z.enum(["human", "agent", "system"]).default("human"),
35
+ authorId: z.string().optional()
36
+ });
37
+
38
+ const createIssueCommentLegacySchema = z.object({
39
+ issueId: z.string().min(1),
40
+ body: z.string().min(1),
41
+ authorType: z.enum(["human", "agent", "system"]).default("human"),
42
+ authorId: z.string().optional()
43
+ });
44
+
45
+ const updateIssueCommentSchema = z.object({
46
+ body: z.string().min(1)
47
+ });
48
+
49
+ function parseStringArray(value: unknown) {
50
+ if (Array.isArray(value)) {
51
+ return value.map((entry) => String(entry));
52
+ }
53
+ if (typeof value !== "string") {
54
+ return [];
55
+ }
56
+ try {
57
+ const parsed = JSON.parse(value);
58
+ return Array.isArray(parsed) ? parsed.map((entry) => String(entry)) : [];
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ function toIssueResponse(issue: Record<string, unknown>) {
65
+ const labels = parseStringArray(issue.labelsJson);
66
+ const tags = parseStringArray(issue.tagsJson);
67
+ const { labelsJson: _labelsJson, tagsJson: _tagsJson, ...rest } = issue;
68
+ return {
69
+ ...rest,
70
+ labels,
71
+ tags
72
+ };
73
+ }
74
+
75
+ const updateIssueSchema = z
76
+ .object({
77
+ projectId: z.string().min(1).optional(),
78
+ title: z.string().min(1).optional(),
79
+ body: z.string().nullable().optional(),
80
+ status: z.enum(["todo", "in_progress", "blocked", "in_review", "done", "canceled"]).optional(),
81
+ priority: z.enum(["none", "low", "medium", "high", "urgent"]).optional(),
82
+ assigneeAgentId: z.string().nullable().optional(),
83
+ labels: z.array(z.string()).optional(),
84
+ tags: z.array(z.string()).optional()
85
+ })
86
+ .refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
87
+
88
+ export function createIssuesRouter(ctx: AppContext) {
89
+ const router = Router();
90
+ router.use(requireCompanyScope);
91
+
92
+ router.get("/", async (req, res) => {
93
+ const projectId = req.query.projectId?.toString();
94
+ const rows = await listIssues(ctx.db, req.companyId!, projectId);
95
+ return sendOk(
96
+ res,
97
+ rows.map((row) => toIssueResponse(row as unknown as Record<string, unknown>))
98
+ );
99
+ });
100
+
101
+ router.post("/", async (req, res) => {
102
+ requirePermission("issues:write")(req, res, () => {});
103
+ if (res.headersSent) {
104
+ return;
105
+ }
106
+ const parsed = createIssueSchema.safeParse(req.body);
107
+ if (!parsed.success) {
108
+ return sendError(res, parsed.error.message, 422);
109
+ }
110
+ const issue = await createIssue(ctx.db, { companyId: req.companyId!, ...parsed.data });
111
+ await appendActivity(ctx.db, {
112
+ companyId: req.companyId!,
113
+ issueId: issue.id,
114
+ actorType: "human",
115
+ eventType: "issue.created",
116
+ payload: { issue }
117
+ });
118
+ await appendAuditEvent(ctx.db, {
119
+ companyId: req.companyId!,
120
+ actorType: "human",
121
+ eventType: "issue.created",
122
+ entityType: "issue",
123
+ entityId: issue.id,
124
+ payload: issue
125
+ });
126
+ return sendOk(res, toIssueResponse(issue as unknown as Record<string, unknown>));
127
+ });
128
+
129
+ router.get("/:issueId/comments", async (req, res) => {
130
+ const comments = await listIssueComments(ctx.db, req.companyId!, req.params.issueId);
131
+ return sendOk(res, comments);
132
+ });
133
+
134
+ router.post("/:issueId/comments", async (req, res) => {
135
+ requirePermission("issues:write")(req, res, () => {});
136
+ if (res.headersSent) {
137
+ return;
138
+ }
139
+ const parsed = createIssueCommentSchema.safeParse(req.body);
140
+ if (!parsed.success) {
141
+ return sendError(res, parsed.error.message, 422);
142
+ }
143
+ const comment = await addIssueComment(ctx.db, {
144
+ companyId: req.companyId!,
145
+ issueId: req.params.issueId,
146
+ ...parsed.data
147
+ });
148
+ await appendActivity(ctx.db, {
149
+ companyId: req.companyId!,
150
+ issueId: comment.issueId,
151
+ actorType: comment.authorType,
152
+ actorId: comment.authorId,
153
+ eventType: "issue.comment_added",
154
+ payload: { commentId: comment.id }
155
+ });
156
+ await appendAuditEvent(ctx.db, {
157
+ companyId: req.companyId!,
158
+ actorType: comment.authorType,
159
+ actorId: comment.authorId,
160
+ eventType: "issue.comment_added",
161
+ entityType: "issue_comment",
162
+ entityId: comment.id,
163
+ payload: comment
164
+ });
165
+ return sendOk(res, comment);
166
+ });
167
+
168
+ // Backward-compatible endpoint used by older clients.
169
+ router.post("/comment", async (req, res) => {
170
+ requirePermission("issues:write")(req, res, () => {});
171
+ if (res.headersSent) {
172
+ return;
173
+ }
174
+ const parsed = createIssueCommentLegacySchema.safeParse(req.body);
175
+ if (!parsed.success) {
176
+ return sendError(res, parsed.error.message, 422);
177
+ }
178
+ const comment = await addIssueComment(ctx.db, { companyId: req.companyId!, ...parsed.data });
179
+ await appendActivity(ctx.db, {
180
+ companyId: req.companyId!,
181
+ issueId: comment.issueId,
182
+ actorType: comment.authorType,
183
+ actorId: comment.authorId,
184
+ eventType: "issue.comment_added",
185
+ payload: { commentId: comment.id }
186
+ });
187
+ await appendAuditEvent(ctx.db, {
188
+ companyId: req.companyId!,
189
+ actorType: comment.authorType,
190
+ actorId: comment.authorId,
191
+ eventType: "issue.comment_added",
192
+ entityType: "issue_comment",
193
+ entityId: comment.id,
194
+ payload: comment
195
+ });
196
+ return sendOk(res, comment);
197
+ });
198
+
199
+ router.put("/:issueId/comments/:commentId", async (req, res) => {
200
+ requirePermission("issues:write")(req, res, () => {});
201
+ if (res.headersSent) {
202
+ return;
203
+ }
204
+ const parsed = updateIssueCommentSchema.safeParse(req.body);
205
+ if (!parsed.success) {
206
+ return sendError(res, parsed.error.message, 422);
207
+ }
208
+
209
+ const comment = await updateIssueComment(ctx.db, {
210
+ companyId: req.companyId!,
211
+ issueId: req.params.issueId,
212
+ id: req.params.commentId,
213
+ body: parsed.data.body
214
+ });
215
+ if (!comment) {
216
+ return sendError(res, "Comment not found.", 404);
217
+ }
218
+
219
+ await appendActivity(ctx.db, {
220
+ companyId: req.companyId!,
221
+ issueId: req.params.issueId,
222
+ actorType: "human",
223
+ eventType: "issue.comment_updated",
224
+ payload: { commentId: comment.id }
225
+ });
226
+ await appendAuditEvent(ctx.db, {
227
+ companyId: req.companyId!,
228
+ actorType: "human",
229
+ eventType: "issue.comment_updated",
230
+ entityType: "issue_comment",
231
+ entityId: comment.id,
232
+ payload: comment
233
+ });
234
+ return sendOk(res, comment);
235
+ });
236
+
237
+ router.delete("/:issueId/comments/:commentId", async (req, res) => {
238
+ requirePermission("issues:write")(req, res, () => {});
239
+ if (res.headersSent) {
240
+ return;
241
+ }
242
+ const deleted = await deleteIssueComment(ctx.db, req.companyId!, req.params.issueId, req.params.commentId);
243
+ if (!deleted) {
244
+ return sendError(res, "Comment not found.", 404);
245
+ }
246
+
247
+ await appendActivity(ctx.db, {
248
+ companyId: req.companyId!,
249
+ issueId: req.params.issueId,
250
+ actorType: "human",
251
+ eventType: "issue.comment_deleted",
252
+ payload: { commentId: req.params.commentId }
253
+ });
254
+ await appendAuditEvent(ctx.db, {
255
+ companyId: req.companyId!,
256
+ actorType: "human",
257
+ eventType: "issue.comment_deleted",
258
+ entityType: "issue_comment",
259
+ entityId: req.params.commentId,
260
+ payload: { id: req.params.commentId, issueId: req.params.issueId }
261
+ });
262
+ return sendOk(res, { deleted: true });
263
+ });
264
+
265
+ router.put("/:issueId", async (req, res) => {
266
+ requirePermission("issues:write")(req, res, () => {});
267
+ if (res.headersSent) {
268
+ return;
269
+ }
270
+ const parsed = updateIssueSchema.safeParse(req.body);
271
+ if (!parsed.success) {
272
+ return sendError(res, parsed.error.message, 422);
273
+ }
274
+
275
+ const issue = await updateIssue(ctx.db, { companyId: req.companyId!, id: req.params.issueId, ...parsed.data });
276
+ if (!issue) {
277
+ return sendError(res, "Issue not found.", 404);
278
+ }
279
+
280
+ await appendActivity(ctx.db, {
281
+ companyId: req.companyId!,
282
+ issueId: issue.id,
283
+ actorType: "human",
284
+ eventType: "issue.updated",
285
+ payload: { issue }
286
+ });
287
+ await appendAuditEvent(ctx.db, {
288
+ companyId: req.companyId!,
289
+ actorType: "human",
290
+ eventType: "issue.updated",
291
+ entityType: "issue",
292
+ entityId: issue.id,
293
+ payload: issue
294
+ });
295
+ return sendOk(res, toIssueResponse(issue as unknown as Record<string, unknown>));
296
+ });
297
+
298
+ router.delete("/:issueId", async (req, res) => {
299
+ requirePermission("issues:write")(req, res, () => {});
300
+ if (res.headersSent) {
301
+ return;
302
+ }
303
+ const deleted = await deleteIssue(ctx.db, req.companyId!, req.params.issueId);
304
+ if (!deleted) {
305
+ return sendError(res, "Issue not found.", 404);
306
+ }
307
+ await appendAuditEvent(ctx.db, {
308
+ companyId: req.companyId!,
309
+ actorType: "human",
310
+ eventType: "issue.deleted",
311
+ entityType: "issue",
312
+ entityId: req.params.issueId,
313
+ payload: { id: req.params.issueId }
314
+ });
315
+ return sendOk(res, { deleted: true });
316
+ });
317
+
318
+ return router;
319
+ }
@@ -0,0 +1,47 @@
1
+ import { Router } from "express";
2
+ import { listAuditEvents, listCostEntries, listHeartbeatRuns } from "bopodev-db";
3
+ import type { AppContext } from "../context";
4
+ import { sendOk } from "../http";
5
+ import { requireCompanyScope } from "../middleware/company-scope";
6
+
7
+ export function createObservabilityRouter(ctx: AppContext) {
8
+ const router = Router();
9
+ router.use(requireCompanyScope);
10
+
11
+ router.get("/logs", async (req, res) => {
12
+ const rows = await listAuditEvents(ctx.db, req.companyId!);
13
+ return sendOk(
14
+ res,
15
+ rows.map((row) => ({
16
+ ...row,
17
+ payload: parsePayload(row.payloadJson)
18
+ }))
19
+ );
20
+ });
21
+
22
+ router.get("/costs", async (req, res) => {
23
+ const rows = await listCostEntries(ctx.db, req.companyId!);
24
+ return sendOk(
25
+ res,
26
+ rows.map((row) => ({
27
+ ...row,
28
+ usdCost: typeof row.usdCost === "number" ? row.usdCost : Number(row.usdCost ?? 0)
29
+ }))
30
+ );
31
+ });
32
+
33
+ router.get("/heartbeats", async (req, res) => {
34
+ return sendOk(res, await listHeartbeatRuns(ctx.db, req.companyId!));
35
+ });
36
+
37
+ return router;
38
+ }
39
+
40
+ function parsePayload(payloadJson: string) {
41
+ try {
42
+ const parsed = JSON.parse(payloadJson) as unknown;
43
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
44
+ } catch {
45
+ return {};
46
+ }
47
+ }
@@ -0,0 +1,152 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { appendAuditEvent, createProject, deleteProject, listProjects, syncProjectGoals, updateProject } from "bopodev-db";
4
+ import type { AppContext } from "../context";
5
+ import { sendError, sendOk } from "../http";
6
+ import { requireCompanyScope } from "../middleware/company-scope";
7
+ import { requirePermission } from "../middleware/request-actor";
8
+
9
+ const projectStatusSchema = z.enum(["planned", "active", "paused", "blocked", "completed", "archived"]);
10
+
11
+ const createProjectSchema = z.object({
12
+ name: z.string().min(1),
13
+ description: z.string().optional(),
14
+ status: projectStatusSchema.default("planned"),
15
+ plannedStartAt: z.string().optional(),
16
+ workspaceLocalPath: z.string().optional(),
17
+ workspaceGithubRepo: z.string().url().optional(),
18
+ goalIds: z.array(z.string().min(1)).default([])
19
+ });
20
+
21
+ const updateProjectSchema = z
22
+ .object({
23
+ name: z.string().min(1).optional(),
24
+ description: z.string().nullable().optional(),
25
+ status: projectStatusSchema.optional(),
26
+ plannedStartAt: z.string().nullable().optional(),
27
+ workspaceLocalPath: z.string().nullable().optional(),
28
+ workspaceGithubRepo: z.string().url().nullable().optional(),
29
+ goalIds: z.array(z.string().min(1)).optional()
30
+ })
31
+ .refine((payload) => Object.keys(payload).length > 0, "At least one field must be provided.");
32
+
33
+ function parsePlannedStartAt(value?: string | null) {
34
+ if (!value) {
35
+ return null;
36
+ }
37
+ const parsed = new Date(value);
38
+ if (Number.isNaN(parsed.getTime())) {
39
+ throw new Error("Invalid plannedStartAt value.");
40
+ }
41
+ return parsed;
42
+ }
43
+
44
+ export function createProjectsRouter(ctx: AppContext) {
45
+ const router = Router();
46
+ router.use(requireCompanyScope);
47
+
48
+ router.get("/", async (req, res) => {
49
+ const projects = await listProjects(ctx.db, req.companyId!);
50
+ return sendOk(res, projects);
51
+ });
52
+
53
+ router.post("/", async (req, res) => {
54
+ requirePermission("projects:write")(req, res, () => {});
55
+ if (res.headersSent) {
56
+ return;
57
+ }
58
+ const parsed = createProjectSchema.safeParse(req.body);
59
+ if (!parsed.success) {
60
+ return sendError(res, parsed.error.message, 422);
61
+ }
62
+ const project = await createProject(ctx.db, {
63
+ companyId: req.companyId!,
64
+ name: parsed.data.name,
65
+ description: parsed.data.description,
66
+ status: parsed.data.status,
67
+ plannedStartAt: parsePlannedStartAt(parsed.data.plannedStartAt),
68
+ workspaceLocalPath: parsed.data.workspaceLocalPath,
69
+ workspaceGithubRepo: parsed.data.workspaceGithubRepo
70
+ });
71
+ await syncProjectGoals(ctx.db, {
72
+ companyId: req.companyId!,
73
+ projectId: project.id,
74
+ goalIds: parsed.data.goalIds
75
+ });
76
+ await appendAuditEvent(ctx.db, {
77
+ companyId: req.companyId!,
78
+ actorType: "human",
79
+ eventType: "project.created",
80
+ entityType: "project",
81
+ entityId: project.id,
82
+ payload: project
83
+ });
84
+ return sendOk(res, project);
85
+ });
86
+
87
+ router.put("/:projectId", async (req, res) => {
88
+ requirePermission("projects:write")(req, res, () => {});
89
+ if (res.headersSent) {
90
+ return;
91
+ }
92
+ const parsed = updateProjectSchema.safeParse(req.body);
93
+ if (!parsed.success) {
94
+ return sendError(res, parsed.error.message, 422);
95
+ }
96
+
97
+ const project = await updateProject(ctx.db, {
98
+ companyId: req.companyId!,
99
+ id: req.params.projectId,
100
+ name: parsed.data.name,
101
+ description: parsed.data.description,
102
+ status: parsed.data.status,
103
+ plannedStartAt:
104
+ parsed.data.plannedStartAt === undefined ? undefined : parsePlannedStartAt(parsed.data.plannedStartAt),
105
+ workspaceLocalPath: parsed.data.workspaceLocalPath,
106
+ workspaceGithubRepo: parsed.data.workspaceGithubRepo
107
+ });
108
+ if (!project) {
109
+ return sendError(res, "Project not found.", 404);
110
+ }
111
+ if (parsed.data.goalIds) {
112
+ await syncProjectGoals(ctx.db, {
113
+ companyId: req.companyId!,
114
+ projectId: project.id,
115
+ goalIds: parsed.data.goalIds
116
+ });
117
+ }
118
+
119
+ await appendAuditEvent(ctx.db, {
120
+ companyId: req.companyId!,
121
+ actorType: "human",
122
+ eventType: "project.updated",
123
+ entityType: "project",
124
+ entityId: project.id,
125
+ payload: project
126
+ });
127
+ return sendOk(res, project);
128
+ });
129
+
130
+ router.delete("/:projectId", async (req, res) => {
131
+ requirePermission("projects:write")(req, res, () => {});
132
+ if (res.headersSent) {
133
+ return;
134
+ }
135
+ const deleted = await deleteProject(ctx.db, req.companyId!, req.params.projectId);
136
+ if (!deleted) {
137
+ return sendError(res, "Project not found.", 404);
138
+ }
139
+
140
+ await appendAuditEvent(ctx.db, {
141
+ companyId: req.companyId!,
142
+ actorType: "human",
143
+ eventType: "project.deleted",
144
+ entityType: "project",
145
+ entityId: req.params.projectId,
146
+ payload: { id: req.params.projectId }
147
+ });
148
+ return sendOk(res, { deleted: true });
149
+ });
150
+
151
+ return router;
152
+ }
@@ -0,0 +1,13 @@
1
+ import { bootstrapDatabase } from "bopodev-db";
2
+
3
+ async function main() {
4
+ const { client } = await bootstrapDatabase(process.env.BOPO_DB_PATH);
5
+ const maybeClose = (client as { close?: () => Promise<void> }).close;
6
+ if (maybeClose) {
7
+ await maybeClose.call(client);
8
+ }
9
+ // eslint-disable-next-line no-console
10
+ console.log("Database initialized.");
11
+ }
12
+
13
+ void main();
package/src/server.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { createServer } from "node:http";
2
+ import { bootstrapDatabase } from "bopodev-db";
3
+ import { checkRuntimeCommandHealth } from "bopodev-agent-sdk";
4
+ import { createApp } from "./app";
5
+ import { loadGovernanceRealtimeSnapshot } from "./realtime/governance";
6
+ import { loadOfficeSpaceRealtimeSnapshot } from "./realtime/office-space";
7
+ import { attachRealtimeHub } from "./realtime/hub";
8
+ import { createHeartbeatScheduler } from "./worker/scheduler";
9
+
10
+ async function main() {
11
+ const dbPath = process.env.BOPO_DB_PATH;
12
+ const port = Number(process.env.PORT ?? 4020);
13
+ const { db } = await bootstrapDatabase(dbPath);
14
+ const codexCommand = process.env.BOPO_CODEX_COMMAND ?? "codex";
15
+ const getRuntimeHealth = async () => {
16
+ const codex = await checkRuntimeCommandHealth(codexCommand, {
17
+ timeoutMs: 5_000
18
+ });
19
+ return {
20
+ codex
21
+ };
22
+ };
23
+ const startupCodexHealth = await checkRuntimeCommandHealth(codexCommand, {
24
+ timeoutMs: 5_000
25
+ });
26
+ if (!startupCodexHealth.available) {
27
+ // eslint-disable-next-line no-console
28
+ console.warn("[startup] Codex command preflight failed.", startupCodexHealth);
29
+ }
30
+
31
+ const server = createServer();
32
+ const realtimeHub = attachRealtimeHub(server, {
33
+ bootstrapLoaders: {
34
+ governance: (companyId) => loadGovernanceRealtimeSnapshot(db, companyId),
35
+ "office-space": (companyId) => loadOfficeSpaceRealtimeSnapshot(db, companyId)
36
+ }
37
+ });
38
+ const app = createApp({ db, getRuntimeHealth, realtimeHub });
39
+ server.on("request", app);
40
+ server.listen(port, () => {
41
+ // eslint-disable-next-line no-console
42
+ console.log(`BopoHQ API running on http://localhost:${port}`);
43
+ });
44
+
45
+ const defaultCompanyId = process.env.BOPO_DEFAULT_COMPANY_ID;
46
+ if (defaultCompanyId) {
47
+ createHeartbeatScheduler(db, defaultCompanyId, realtimeHub);
48
+ }
49
+ }
50
+
51
+ void main();
@@ -0,0 +1,31 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import type { BopoDb } from "bopodev-db";
3
+ import { agents } from "bopodev-db";
4
+
5
+ export interface BudgetCheckResult {
6
+ allowed: boolean;
7
+ hardStopped: boolean;
8
+ utilizationPct: number;
9
+ }
10
+
11
+ export async function checkAgentBudget(db: BopoDb, companyId: string, agentId: string): Promise<BudgetCheckResult> {
12
+ const [agent] = await db
13
+ .select()
14
+ .from(agents)
15
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
16
+ .limit(1);
17
+
18
+ if (!agent) {
19
+ return { allowed: false, hardStopped: true, utilizationPct: 100 };
20
+ }
21
+
22
+ const monthlyBudget = Number(agent.monthlyBudgetUsd);
23
+ const usedBudget = Number(agent.usedBudgetUsd);
24
+ const utilizationPct = monthlyBudget <= 0 ? 0 : (usedBudget / monthlyBudget) * 100;
25
+
26
+ return {
27
+ allowed: utilizationPct < 100,
28
+ hardStopped: utilizationPct >= 100,
29
+ utilizationPct
30
+ };
31
+ }