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.
- package/LICENSE +21 -0
- package/package.json +36 -0
- package/src/app.ts +76 -0
- package/src/context.ts +8 -0
- package/src/http.ts +9 -0
- package/src/middleware/company-scope.ts +15 -0
- package/src/middleware/request-actor.ts +81 -0
- package/src/realtime/governance.ts +66 -0
- package/src/realtime/hub.ts +142 -0
- package/src/realtime/office-space.ts +448 -0
- package/src/routes/agents.ts +305 -0
- package/src/routes/companies.ts +58 -0
- package/src/routes/goals.ts +134 -0
- package/src/routes/governance.ts +208 -0
- package/src/routes/heartbeats.ts +61 -0
- package/src/routes/issues.ts +319 -0
- package/src/routes/observability.ts +47 -0
- package/src/routes/projects.ts +152 -0
- package/src/scripts/db-init.ts +13 -0
- package/src/server.ts +51 -0
- package/src/services/budget-service.ts +31 -0
- package/src/services/governance-service.ts +229 -0
- package/src/services/heartbeat-service.ts +706 -0
- package/src/worker/scheduler.ts +23 -0
- package/tsconfig.json +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BopoHQ contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bopodev-api",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"src",
|
|
8
|
+
"tsconfig.json"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"cors": "^2.8.5",
|
|
12
|
+
"cron-parser": "^5.3.1",
|
|
13
|
+
"drizzle-orm": "^0.44.5",
|
|
14
|
+
"express": "^5.1.0",
|
|
15
|
+
"nanoid": "^5.1.5",
|
|
16
|
+
"ws": "^8.19.0",
|
|
17
|
+
"zod": "^4.1.5",
|
|
18
|
+
"bopodev-agent-sdk": "0.1.1",
|
|
19
|
+
"bopodev-contracts": "0.1.1",
|
|
20
|
+
"bopodev-db": "0.1.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/cors": "^2.8.19",
|
|
24
|
+
"@types/express": "^5.0.3",
|
|
25
|
+
"@types/ws": "^8.18.1",
|
|
26
|
+
"tsx": "^4.20.5"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "tsx watch src/server.ts",
|
|
30
|
+
"start": "tsx src/server.ts",
|
|
31
|
+
"db:init": "tsx src/scripts/db-init.ts",
|
|
32
|
+
"build": "tsc -p tsconfig.json",
|
|
33
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import cors from "cors";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import type { NextFunction, Request, Response } from "express";
|
|
4
|
+
import { sql } from "drizzle-orm";
|
|
5
|
+
import { RepositoryValidationError } from "bopodev-db";
|
|
6
|
+
import { nanoid } from "nanoid";
|
|
7
|
+
import type { AppContext } from "./context";
|
|
8
|
+
import { createAgentsRouter } from "./routes/agents";
|
|
9
|
+
import { createCompaniesRouter } from "./routes/companies";
|
|
10
|
+
import { createGoalsRouter } from "./routes/goals";
|
|
11
|
+
import { createGovernanceRouter } from "./routes/governance";
|
|
12
|
+
import { createHeartbeatRouter } from "./routes/heartbeats";
|
|
13
|
+
import { createIssuesRouter } from "./routes/issues";
|
|
14
|
+
import { createObservabilityRouter } from "./routes/observability";
|
|
15
|
+
import { createProjectsRouter } from "./routes/projects";
|
|
16
|
+
import { sendError } from "./http";
|
|
17
|
+
import { attachRequestActor } from "./middleware/request-actor";
|
|
18
|
+
|
|
19
|
+
export function createApp(ctx: AppContext) {
|
|
20
|
+
const app = express();
|
|
21
|
+
app.use(cors());
|
|
22
|
+
app.use(express.json());
|
|
23
|
+
app.use(attachRequestActor);
|
|
24
|
+
|
|
25
|
+
app.use((req, res, next) => {
|
|
26
|
+
const requestId = req.header("x-request-id")?.trim() || nanoid(14);
|
|
27
|
+
req.requestId = requestId;
|
|
28
|
+
res.setHeader("x-request-id", requestId);
|
|
29
|
+
next();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
app.get("/health", async (_req, res) => {
|
|
33
|
+
let dbReady = false;
|
|
34
|
+
let dbError: string | undefined;
|
|
35
|
+
try {
|
|
36
|
+
await ctx.db.execute(sql`SELECT 1`);
|
|
37
|
+
dbReady = true;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
dbError = String(error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let runtime = {};
|
|
43
|
+
try {
|
|
44
|
+
runtime = (await ctx.getRuntimeHealth?.()) ?? {};
|
|
45
|
+
} catch (error) {
|
|
46
|
+
runtime = { error: String(error) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ok = dbReady;
|
|
50
|
+
res.status(ok ? 200 : 503).json({
|
|
51
|
+
ok,
|
|
52
|
+
db: dbReady ? { ready: true } : { ready: false, error: dbError },
|
|
53
|
+
runtime
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
app.use("/companies", createCompaniesRouter(ctx));
|
|
58
|
+
app.use("/projects", createProjectsRouter(ctx));
|
|
59
|
+
app.use("/issues", createIssuesRouter(ctx));
|
|
60
|
+
app.use("/goals", createGoalsRouter(ctx));
|
|
61
|
+
app.use("/agents", createAgentsRouter(ctx));
|
|
62
|
+
app.use("/governance", createGovernanceRouter(ctx));
|
|
63
|
+
app.use("/heartbeats", createHeartbeatRouter(ctx));
|
|
64
|
+
app.use("/observability", createObservabilityRouter(ctx));
|
|
65
|
+
|
|
66
|
+
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
67
|
+
if (error instanceof RepositoryValidationError) {
|
|
68
|
+
return sendError(res, error.message, 422);
|
|
69
|
+
}
|
|
70
|
+
// eslint-disable-next-line no-console
|
|
71
|
+
console.error(error);
|
|
72
|
+
return sendError(res, "Internal server error", 500);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return app;
|
|
76
|
+
}
|
package/src/context.ts
ADDED
package/src/http.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Response } from "express";
|
|
2
|
+
|
|
3
|
+
export function sendOk<T>(res: Response, data: T) {
|
|
4
|
+
return res.status(200).json({ ok: true, data });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function sendError(res: Response, message: string, code = 400) {
|
|
8
|
+
return res.status(code).json({ ok: false, error: message });
|
|
9
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from "express";
|
|
2
|
+
import { sendError } from "../http";
|
|
3
|
+
import { canAccessCompany } from "./request-actor";
|
|
4
|
+
|
|
5
|
+
export function requireCompanyScope(req: Request, res: Response, next: NextFunction) {
|
|
6
|
+
const companyId = req.header("x-company-id") ?? req.query.companyId?.toString();
|
|
7
|
+
if (!companyId) {
|
|
8
|
+
return sendError(res, "Missing company scope. Provide x-company-id header.", 422);
|
|
9
|
+
}
|
|
10
|
+
if (!canAccessCompany(req, companyId)) {
|
|
11
|
+
return sendError(res, "Actor does not have access to this company.", 403);
|
|
12
|
+
}
|
|
13
|
+
req.companyId = companyId;
|
|
14
|
+
next();
|
|
15
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from "express";
|
|
2
|
+
import { sendError } from "../http";
|
|
3
|
+
|
|
4
|
+
export type RequestActor = {
|
|
5
|
+
type: "board" | "member" | "agent";
|
|
6
|
+
id: string;
|
|
7
|
+
companyIds: string[] | null;
|
|
8
|
+
permissions: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
declare global {
|
|
12
|
+
namespace Express {
|
|
13
|
+
interface Request {
|
|
14
|
+
actor?: RequestActor;
|
|
15
|
+
companyId?: string;
|
|
16
|
+
requestId?: string;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function attachRequestActor(req: Request, _res: Response, next: NextFunction) {
|
|
22
|
+
const actorTypeHeader = req.header("x-actor-type")?.trim().toLowerCase();
|
|
23
|
+
const actorType = actorTypeHeader === "agent" || actorTypeHeader === "member" ? actorTypeHeader : "board";
|
|
24
|
+
const actorId = req.header("x-actor-id")?.trim() || "local-board";
|
|
25
|
+
const companyIdsHeader = req.header("x-actor-companies")?.trim();
|
|
26
|
+
const permissionsHeader = req.header("x-actor-permissions")?.trim();
|
|
27
|
+
|
|
28
|
+
const companyIds = parseCommaList(companyIdsHeader);
|
|
29
|
+
const permissions = parseCommaList(permissionsHeader) ?? [];
|
|
30
|
+
|
|
31
|
+
req.actor = {
|
|
32
|
+
type: actorType,
|
|
33
|
+
id: actorId,
|
|
34
|
+
companyIds: actorType === "board" ? null : companyIds ?? [],
|
|
35
|
+
permissions
|
|
36
|
+
};
|
|
37
|
+
next();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function requirePermission(permission: string) {
|
|
41
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
42
|
+
if (!hasPermission(req, permission)) {
|
|
43
|
+
return sendError(res, `Missing permission: ${permission}`, 403);
|
|
44
|
+
}
|
|
45
|
+
next();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function requireBoardRole(req: Request, res: Response, next: NextFunction) {
|
|
50
|
+
if ((req.actor?.type ?? "board") !== "board") {
|
|
51
|
+
return sendError(res, "Board role required.", 403);
|
|
52
|
+
}
|
|
53
|
+
next();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function canAccessCompany(req: Request, companyId: string) {
|
|
57
|
+
const actor = req.actor;
|
|
58
|
+
if (!actor || actor.type === "board") {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return actor.companyIds?.includes(companyId) ?? false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasPermission(req: Request, permission: string) {
|
|
65
|
+
const actor = req.actor;
|
|
66
|
+
if (!actor || actor.type === "board") {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return actor.permissions.includes(permission);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseCommaList(value: string | undefined) {
|
|
73
|
+
if (!value) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const normalized = value
|
|
77
|
+
.split(",")
|
|
78
|
+
.map((entry) => entry.trim())
|
|
79
|
+
.filter((entry) => entry.length > 0);
|
|
80
|
+
return normalized.length > 0 ? normalized : null;
|
|
81
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ApprovalRequest, RealtimeEventEnvelope, RealtimeMessage } from "bopodev-contracts";
|
|
2
|
+
import { listApprovalRequests, type BopoDb } from "bopodev-db";
|
|
3
|
+
|
|
4
|
+
export async function loadGovernanceRealtimeSnapshot(db: BopoDb, companyId: string): Promise<Extract<RealtimeMessage, { kind: "event" }>> {
|
|
5
|
+
const approvals = await listApprovalRequests(db, companyId);
|
|
6
|
+
|
|
7
|
+
return createRealtimeEvent(companyId, {
|
|
8
|
+
channel: "governance",
|
|
9
|
+
event: {
|
|
10
|
+
type: "approvals.snapshot",
|
|
11
|
+
approvals: approvals.filter((approval) => approval.status === "pending").map(serializeStoredApproval)
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createGovernanceRealtimeEvent(
|
|
17
|
+
companyId: string,
|
|
18
|
+
event: Extract<RealtimeEventEnvelope, { channel: "governance" }>["event"]
|
|
19
|
+
): Extract<RealtimeMessage, { kind: "event" }> {
|
|
20
|
+
return createRealtimeEvent(companyId, {
|
|
21
|
+
channel: "governance",
|
|
22
|
+
event
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function serializeStoredApproval(approval: {
|
|
27
|
+
id: string;
|
|
28
|
+
companyId: string;
|
|
29
|
+
requestedByAgentId: string | null;
|
|
30
|
+
action: string;
|
|
31
|
+
payloadJson: string;
|
|
32
|
+
status: string;
|
|
33
|
+
createdAt: Date;
|
|
34
|
+
resolvedAt: Date | null;
|
|
35
|
+
}): ApprovalRequest {
|
|
36
|
+
return {
|
|
37
|
+
id: approval.id,
|
|
38
|
+
companyId: approval.companyId,
|
|
39
|
+
requestedByAgentId: approval.requestedByAgentId,
|
|
40
|
+
action: approval.action as ApprovalRequest["action"],
|
|
41
|
+
payload: parsePayload(approval.payloadJson),
|
|
42
|
+
status: approval.status as ApprovalRequest["status"],
|
|
43
|
+
createdAt: approval.createdAt.toISOString(),
|
|
44
|
+
resolvedAt: approval.resolvedAt?.toISOString() ?? null
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createRealtimeEvent(
|
|
49
|
+
companyId: string,
|
|
50
|
+
envelope: RealtimeEventEnvelope
|
|
51
|
+
): Extract<RealtimeMessage, { kind: "event" }> {
|
|
52
|
+
return {
|
|
53
|
+
kind: "event",
|
|
54
|
+
companyId,
|
|
55
|
+
...envelope
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parsePayload(payloadJson: string): Record<string, unknown> {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(payloadJson) as unknown;
|
|
62
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import {
|
|
3
|
+
RealtimeChannelSchema,
|
|
4
|
+
type RealtimeChannel,
|
|
5
|
+
type RealtimeMessage
|
|
6
|
+
} from "bopodev-contracts";
|
|
7
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
8
|
+
|
|
9
|
+
type RealtimeEventMessage = Extract<RealtimeMessage, { kind: "event" }>;
|
|
10
|
+
type RealtimeBootstrapLoader = (companyId: string) => Promise<RealtimeEventMessage>;
|
|
11
|
+
|
|
12
|
+
export interface RealtimeHub {
|
|
13
|
+
publish(message: RealtimeEventMessage): void;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function attachRealtimeHub(
|
|
18
|
+
server: Server,
|
|
19
|
+
options: {
|
|
20
|
+
bootstrapLoaders?: Partial<Record<RealtimeChannel, RealtimeBootstrapLoader>>;
|
|
21
|
+
} = {}
|
|
22
|
+
): RealtimeHub {
|
|
23
|
+
const socketsByCompanyAndChannel = new Map<string, Set<WebSocket>>();
|
|
24
|
+
const wss = new WebSocketServer({ server, path: "/realtime" });
|
|
25
|
+
|
|
26
|
+
wss.on("connection", async (socket, request) => {
|
|
27
|
+
const subscription = getSubscription(request.url);
|
|
28
|
+
if (!subscription) {
|
|
29
|
+
socket.close(1008, "Invalid realtime subscription");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const channel of subscription.channels) {
|
|
34
|
+
addSocket(subscription.companyId, channel, socket);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
socket.on("close", () => {
|
|
38
|
+
for (const channel of subscription.channels) {
|
|
39
|
+
removeSocket(subscription.companyId, channel, socket);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
send(socket, {
|
|
44
|
+
kind: "subscribed",
|
|
45
|
+
companyId: subscription.companyId,
|
|
46
|
+
channels: subscription.channels
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
for (const channel of subscription.channels) {
|
|
51
|
+
const loader = options.bootstrapLoaders?.[channel];
|
|
52
|
+
if (!loader) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
send(socket, await loader(subscription.companyId));
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
socket.close(1011, "Failed to load realtime bootstrap");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
publish(message) {
|
|
64
|
+
const key = buildSubscriptionKey(message.companyId, message.channel);
|
|
65
|
+
const sockets = socketsByCompanyAndChannel.get(key);
|
|
66
|
+
if (!sockets || sockets.size === 0) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const payload = JSON.stringify(message);
|
|
71
|
+
for (const socket of sockets) {
|
|
72
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
73
|
+
socket.send(payload);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
close() {
|
|
78
|
+
return new Promise<void>((resolve, reject) => {
|
|
79
|
+
wss.close((error) => {
|
|
80
|
+
if (error) {
|
|
81
|
+
reject(error);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
resolve();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function addSocket(companyId: string, channel: RealtimeChannel, socket: WebSocket) {
|
|
91
|
+
const key = buildSubscriptionKey(companyId, channel);
|
|
92
|
+
const sockets = socketsByCompanyAndChannel.get(key) ?? new Set<WebSocket>();
|
|
93
|
+
sockets.add(socket);
|
|
94
|
+
socketsByCompanyAndChannel.set(key, sockets);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function removeSocket(companyId: string, channel: RealtimeChannel, socket: WebSocket) {
|
|
98
|
+
const key = buildSubscriptionKey(companyId, channel);
|
|
99
|
+
const sockets = socketsByCompanyAndChannel.get(key);
|
|
100
|
+
if (!sockets) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
sockets.delete(socket);
|
|
104
|
+
if (sockets.size === 0) {
|
|
105
|
+
socketsByCompanyAndChannel.delete(key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getSubscription(requestUrl: string | undefined) {
|
|
111
|
+
if (!requestUrl) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const url = new URL(requestUrl, "http://localhost");
|
|
116
|
+
const companyId = url.searchParams.get("companyId")?.trim();
|
|
117
|
+
const requestedChannels = url.searchParams.get("channels")?.trim();
|
|
118
|
+
const channelValues = requestedChannels ? requestedChannels.split(",").map((channel) => channel.trim()).filter(Boolean) : ["governance"];
|
|
119
|
+
const channels = channelValues.flatMap((channel) => {
|
|
120
|
+
const parsed = RealtimeChannelSchema.safeParse(channel);
|
|
121
|
+
return parsed.success ? [parsed.data] : [];
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!companyId || channels.length === 0) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
companyId,
|
|
130
|
+
channels: Array.from(new Set(channels))
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildSubscriptionKey(companyId: string, channel: RealtimeChannel) {
|
|
135
|
+
return `${companyId}:${channel}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function send(socket: WebSocket, message: RealtimeMessage) {
|
|
139
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
140
|
+
socket.send(JSON.stringify(message));
|
|
141
|
+
}
|
|
142
|
+
}
|