@lightupai/polaris 0.0.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/.github/workflows/ci.yml +38 -0
- package/.mcp.json +12 -0
- package/LICENSE +201 -0
- package/Makefile +38 -0
- package/PLAN.md +438 -0
- package/docker-compose.yml +14 -0
- package/docs/README.md +377 -0
- package/hooks/capture.sh +19 -0
- package/hooks/statusline.sh +30 -0
- package/package.json +22 -0
- package/scripts/setup-google-oauth.sh +111 -0
- package/scripts/setup-slack-app.sh +115 -0
- package/skills/polaris/SKILL.md +29 -0
- package/src/cli/cli.ts +294 -0
- package/src/client/client.ts +245 -0
- package/src/daemon/daemon.ts +275 -0
- package/src/service/auth.ts +45 -0
- package/src/service/db.ts +275 -0
- package/src/service/server.ts +406 -0
- package/src/slack/system.ts +107 -0
- package/src/types.ts +108 -0
- package/src/web/app.ts +397 -0
- package/src/web/fixtures.ts +121 -0
- package/src/web/layout.ts +68 -0
- package/src/web/pages.ts +156 -0
- package/src/web/serve.ts +13 -0
- package/src/web/views.ts +356 -0
- package/tests/auth.test.ts +37 -0
- package/tests/client.test.ts +187 -0
- package/tests/daemon.test.ts +220 -0
- package/tests/db.test.ts +282 -0
- package/tests/e2e.test.ts +415 -0
- package/tests/service.test.ts +365 -0
- package/tests/types.test.ts +240 -0
- package/tests/web.test.ts +420 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
+
|
|
3
|
+
// --- Session registry ---
|
|
4
|
+
|
|
5
|
+
interface SessionMapping {
|
|
6
|
+
ccSessionId: string;
|
|
7
|
+
project: string;
|
|
8
|
+
session: string;
|
|
9
|
+
user: string;
|
|
10
|
+
ws: WebSocket | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const sessions = new Map<string, SessionMapping>(); // keyed by ccSessionId
|
|
14
|
+
|
|
15
|
+
// IPC callbacks for MCP servers to receive advisor messages
|
|
16
|
+
const mcpCallbacks = new Map<string, (event: unknown) => void>(); // keyed by ccSessionId
|
|
17
|
+
|
|
18
|
+
function getServiceUrl(): string {
|
|
19
|
+
return process.env.POLARIS_SERVICE_URL ?? "http://localhost:4321";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Cloud WebSocket management ---
|
|
23
|
+
|
|
24
|
+
function connectCloudWs(mapping: SessionMapping) {
|
|
25
|
+
const serviceUrl = getServiceUrl();
|
|
26
|
+
const wsUrl = serviceUrl.replace(/^http/, "ws");
|
|
27
|
+
const ws = new WebSocket(`${wsUrl}/projects/${mapping.project}/sessions/${mapping.session}/ws`);
|
|
28
|
+
|
|
29
|
+
ws.onmessage = (event) => {
|
|
30
|
+
try {
|
|
31
|
+
const data = JSON.parse(event.data as string);
|
|
32
|
+
// Forward inject events to the registered MCP callback
|
|
33
|
+
if (data.source === "inject") {
|
|
34
|
+
const callback = mcpCallbacks.get(mapping.ccSessionId);
|
|
35
|
+
if (callback) callback(data);
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Ignore malformed messages
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
ws.onclose = () => {
|
|
43
|
+
// Reconnect if still registered
|
|
44
|
+
if (sessions.has(mapping.ccSessionId) && sessions.get(mapping.ccSessionId)!.project === mapping.project) {
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
if (sessions.has(mapping.ccSessionId)) {
|
|
47
|
+
connectCloudWs(sessions.get(mapping.ccSessionId)!);
|
|
48
|
+
}
|
|
49
|
+
}, 3000);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
ws.onerror = () => {
|
|
54
|
+
// Will trigger onclose
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
mapping.ws = ws;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function disconnectCloudWs(ccSessionId: string) {
|
|
61
|
+
const mapping = sessions.get(ccSessionId);
|
|
62
|
+
if (mapping?.ws) {
|
|
63
|
+
mapping.ws.close();
|
|
64
|
+
mapping.ws = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- HTTP Server ---
|
|
69
|
+
|
|
70
|
+
function json(data: unknown, status = 200): Response {
|
|
71
|
+
return new Response(JSON.stringify(data), {
|
|
72
|
+
status,
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function error(message: string, status: number): Response {
|
|
78
|
+
return json({ error: message }, status);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function startDaemon(port = Number(process.env.POLARIS_DAEMON_PORT ?? 4321)): {
|
|
82
|
+
server: Server;
|
|
83
|
+
sessions: Map<string, SessionMapping>;
|
|
84
|
+
mcpCallbacks: Map<string, (event: unknown) => void>;
|
|
85
|
+
stop: () => void;
|
|
86
|
+
} {
|
|
87
|
+
const server = Bun.serve({
|
|
88
|
+
port,
|
|
89
|
+
hostname: "127.0.0.1",
|
|
90
|
+
|
|
91
|
+
async fetch(req) {
|
|
92
|
+
const url = new URL(req.url);
|
|
93
|
+
const { pathname } = url;
|
|
94
|
+
const method = req.method;
|
|
95
|
+
|
|
96
|
+
// POST /register — MCP server registers its CC session
|
|
97
|
+
if (method === "POST" && pathname === "/register") {
|
|
98
|
+
try {
|
|
99
|
+
const body = (await req.json()) as { ccSessionId: string };
|
|
100
|
+
if (!body.ccSessionId) return error("ccSessionId required", 400);
|
|
101
|
+
// Register without a session mapping yet — that comes from /connect
|
|
102
|
+
if (!sessions.has(body.ccSessionId)) {
|
|
103
|
+
sessions.set(body.ccSessionId, {
|
|
104
|
+
ccSessionId: body.ccSessionId,
|
|
105
|
+
project: "",
|
|
106
|
+
session: "",
|
|
107
|
+
user: "",
|
|
108
|
+
ws: null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return json({ status: "registered", ccSessionId: body.ccSessionId });
|
|
112
|
+
} catch {
|
|
113
|
+
return error("Invalid JSON", 400);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// POST /connect — bind a CC session to a polaris project/session
|
|
118
|
+
if (method === "POST" && pathname === "/connect") {
|
|
119
|
+
try {
|
|
120
|
+
const body = (await req.json()) as {
|
|
121
|
+
ccSessionId: string;
|
|
122
|
+
project: string;
|
|
123
|
+
session: string;
|
|
124
|
+
user: string;
|
|
125
|
+
};
|
|
126
|
+
if (!body.ccSessionId || !body.project || !body.session || !body.user) {
|
|
127
|
+
return error("ccSessionId, project, session, and user are required", 400);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Disconnect existing cloud WS if switching sessions
|
|
131
|
+
disconnectCloudWs(body.ccSessionId);
|
|
132
|
+
|
|
133
|
+
const mapping: SessionMapping = {
|
|
134
|
+
ccSessionId: body.ccSessionId,
|
|
135
|
+
project: body.project,
|
|
136
|
+
session: body.session,
|
|
137
|
+
user: body.user,
|
|
138
|
+
ws: null,
|
|
139
|
+
};
|
|
140
|
+
sessions.set(body.ccSessionId, mapping);
|
|
141
|
+
|
|
142
|
+
// Ensure the project exists on the cloud service (create if not)
|
|
143
|
+
const serviceUrl = getServiceUrl();
|
|
144
|
+
await fetch(`${serviceUrl}/projects`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify({ name: body.project }),
|
|
148
|
+
}); // Ignore 409 (already exists)
|
|
149
|
+
|
|
150
|
+
// Ensure the session exists (create if not, claim driver)
|
|
151
|
+
const sessionRes = await fetch(`${serviceUrl}/projects/${body.project}/sessions`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify({ name: body.session, driver: body.user }),
|
|
155
|
+
});
|
|
156
|
+
if (!sessionRes.ok && sessionRes.status !== 409) {
|
|
157
|
+
const err = await sessionRes.text();
|
|
158
|
+
return error(`Failed to create session: ${err}`, 500);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// If session already existed, try to claim driver
|
|
162
|
+
if (sessionRes.status === 409) {
|
|
163
|
+
await fetch(`${serviceUrl}/projects/${body.project}/sessions/${body.session}/driver`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ driver: body.user }),
|
|
167
|
+
}); // Ignore errors (might already be driver)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Connect to cloud WebSocket
|
|
171
|
+
connectCloudWs(mapping);
|
|
172
|
+
|
|
173
|
+
return json({
|
|
174
|
+
status: "connected",
|
|
175
|
+
project: body.project,
|
|
176
|
+
session: body.session,
|
|
177
|
+
user: body.user,
|
|
178
|
+
});
|
|
179
|
+
} catch {
|
|
180
|
+
return error("Invalid JSON", 400);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// POST /disconnect — unbind a CC session
|
|
185
|
+
if (method === "POST" && pathname === "/disconnect") {
|
|
186
|
+
try {
|
|
187
|
+
const body = (await req.json()) as { ccSessionId: string };
|
|
188
|
+
if (!body.ccSessionId) return error("ccSessionId required", 400);
|
|
189
|
+
disconnectCloudWs(body.ccSessionId);
|
|
190
|
+
const mapping = sessions.get(body.ccSessionId);
|
|
191
|
+
if (mapping) {
|
|
192
|
+
mapping.project = "";
|
|
193
|
+
mapping.session = "";
|
|
194
|
+
mapping.user = "";
|
|
195
|
+
}
|
|
196
|
+
return json({ status: "disconnected" });
|
|
197
|
+
} catch {
|
|
198
|
+
return error("Invalid JSON", 400);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// POST /events — hook events arrive here, routed by session_id in the payload
|
|
203
|
+
if (method === "POST" && pathname === "/events") {
|
|
204
|
+
try {
|
|
205
|
+
const body = (await req.json()) as { session_id?: string; [key: string]: unknown };
|
|
206
|
+
const ccSessionId = body.session_id;
|
|
207
|
+
if (!ccSessionId) return error("session_id required in hook payload", 400);
|
|
208
|
+
|
|
209
|
+
const mapping = sessions.get(ccSessionId);
|
|
210
|
+
if (!mapping || !mapping.project) {
|
|
211
|
+
// Session not connected to polaris — silently discard
|
|
212
|
+
return json({ status: "not_connected" });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Relay to cloud service
|
|
216
|
+
const serviceUrl = getServiceUrl();
|
|
217
|
+
const res = await fetch(
|
|
218
|
+
`${serviceUrl}/projects/${mapping.project}/sessions/${mapping.session}/events`,
|
|
219
|
+
{
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: { "Content-Type": "application/json" },
|
|
222
|
+
body: JSON.stringify({ sender: mapping.user, payload: body }),
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (!res.ok) {
|
|
227
|
+
const err = await res.text();
|
|
228
|
+
return new Response(err, { status: res.status });
|
|
229
|
+
}
|
|
230
|
+
return json({ status: "relayed" });
|
|
231
|
+
} catch {
|
|
232
|
+
return error("Invalid JSON", 400);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// GET /status/:ccSessionId — status line queries this
|
|
237
|
+
if (method === "GET" && pathname.startsWith("/status/")) {
|
|
238
|
+
const ccSessionId = pathname.slice("/status/".length);
|
|
239
|
+
const mapping = sessions.get(ccSessionId);
|
|
240
|
+
if (!mapping || !mapping.project) {
|
|
241
|
+
return json({ connected: false });
|
|
242
|
+
}
|
|
243
|
+
return json({
|
|
244
|
+
connected: true,
|
|
245
|
+
project: mapping.project,
|
|
246
|
+
session: mapping.session,
|
|
247
|
+
user: mapping.user,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// GET /status — daemon health + all active sessions
|
|
252
|
+
if (method === "GET" && pathname === "/status") {
|
|
253
|
+
const active = Array.from(sessions.values())
|
|
254
|
+
.filter((m) => m.project)
|
|
255
|
+
.map((m) => ({
|
|
256
|
+
ccSessionId: m.ccSessionId,
|
|
257
|
+
project: m.project,
|
|
258
|
+
session: m.session,
|
|
259
|
+
user: m.user,
|
|
260
|
+
}));
|
|
261
|
+
return json({ ok: true, version: "0.0.1", sessions: active });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return error("Not found", 404);
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return { server, sessions, mcpCallbacks, stop: () => server.stop(true) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// --- Run if executed directly ---
|
|
272
|
+
if (import.meta.main) {
|
|
273
|
+
const { server } = startDaemon();
|
|
274
|
+
console.error(`Polaris daemon listening on http://127.0.0.1:${server.port}`);
|
|
275
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as jose from "jose";
|
|
2
|
+
|
|
3
|
+
const JWT_SECRET = new TextEncoder().encode(process.env.POLARIS_JWT_SECRET ?? "polaris-dev-secret-change-in-prod");
|
|
4
|
+
const JWT_ISSUER = "polaris";
|
|
5
|
+
const JWT_EXPIRY = "30d";
|
|
6
|
+
|
|
7
|
+
export interface TokenPayload {
|
|
8
|
+
sub: string; // user ID
|
|
9
|
+
email: string;
|
|
10
|
+
name: string;
|
|
11
|
+
org_id: string;
|
|
12
|
+
participant_id: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function createToken(payload: TokenPayload): Promise<string> {
|
|
16
|
+
return new jose.SignJWT({
|
|
17
|
+
email: payload.email,
|
|
18
|
+
name: payload.name,
|
|
19
|
+
org_id: payload.org_id,
|
|
20
|
+
participant_id: payload.participant_id,
|
|
21
|
+
})
|
|
22
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
23
|
+
.setIssuedAt()
|
|
24
|
+
.setIssuer(JWT_ISSUER)
|
|
25
|
+
.setSubject(payload.sub)
|
|
26
|
+
.setExpirationTime(JWT_EXPIRY)
|
|
27
|
+
.sign(JWT_SECRET);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function verifyToken(token: string): Promise<TokenPayload | null> {
|
|
31
|
+
try {
|
|
32
|
+
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
|
|
33
|
+
issuer: JWT_ISSUER,
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
sub: payload.sub!,
|
|
37
|
+
email: payload.email as string,
|
|
38
|
+
name: payload.name as string,
|
|
39
|
+
org_id: payload.org_id as string,
|
|
40
|
+
participant_id: payload.participant_id as string,
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
import type { PolarisEvent, Project, Session, ParticipantId } from "../types";
|
|
3
|
+
|
|
4
|
+
export type Sql = postgres.Sql;
|
|
5
|
+
|
|
6
|
+
// --- Types ---
|
|
7
|
+
|
|
8
|
+
export interface Org {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
domain: string | null;
|
|
12
|
+
slack_team_id: string | null;
|
|
13
|
+
slack_bot_token: string | null;
|
|
14
|
+
slack_system_channel_id: string | null;
|
|
15
|
+
created_at: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface User {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
name: string;
|
|
22
|
+
org_id: string;
|
|
23
|
+
participant_id: string;
|
|
24
|
+
created_at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Schema ---
|
|
28
|
+
|
|
29
|
+
export async function createDb(connectionString?: string): Promise<Sql> {
|
|
30
|
+
const sql = postgres(connectionString ?? process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris");
|
|
31
|
+
|
|
32
|
+
await sql`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS orgs (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
name TEXT NOT NULL,
|
|
36
|
+
domain TEXT,
|
|
37
|
+
slack_team_id TEXT,
|
|
38
|
+
slack_bot_token TEXT,
|
|
39
|
+
slack_system_channel_id TEXT,
|
|
40
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
41
|
+
)
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
await sql`
|
|
45
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
email TEXT NOT NULL UNIQUE,
|
|
48
|
+
name TEXT NOT NULL,
|
|
49
|
+
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
50
|
+
participant_id TEXT NOT NULL,
|
|
51
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
52
|
+
)
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
await sql`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
57
|
+
name TEXT NOT NULL,
|
|
58
|
+
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
59
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
60
|
+
PRIMARY KEY (org_id, name)
|
|
61
|
+
)
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
await sql`
|
|
65
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
66
|
+
name TEXT NOT NULL,
|
|
67
|
+
project TEXT NOT NULL,
|
|
68
|
+
org_id TEXT NOT NULL,
|
|
69
|
+
driver TEXT,
|
|
70
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
71
|
+
PRIMARY KEY (org_id, project, name),
|
|
72
|
+
FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name)
|
|
73
|
+
)
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
await sql`
|
|
77
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
78
|
+
id UUID PRIMARY KEY,
|
|
79
|
+
org_id TEXT NOT NULL,
|
|
80
|
+
project TEXT NOT NULL,
|
|
81
|
+
session TEXT NOT NULL,
|
|
82
|
+
timestamp TIMESTAMPTZ NOT NULL,
|
|
83
|
+
source TEXT NOT NULL,
|
|
84
|
+
sender TEXT NOT NULL,
|
|
85
|
+
payload JSONB NOT NULL
|
|
86
|
+
)
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
await sql`
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_events_project ON events(org_id, project, timestamp)
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
await sql`
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_events_session ON events(org_id, project, session, timestamp)
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
return sql;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Orgs ---
|
|
101
|
+
|
|
102
|
+
export async function createOrg(sql: Sql, id: string, name: string, domain?: string): Promise<Org> {
|
|
103
|
+
const [row] = await sql`
|
|
104
|
+
INSERT INTO orgs (id, name, domain) VALUES (${id}, ${name}, ${domain ?? null})
|
|
105
|
+
RETURNING *
|
|
106
|
+
`;
|
|
107
|
+
return { ...row, created_at: row.created_at.toISOString() } as Org;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function getOrg(sql: Sql, id: string): Promise<Org | null> {
|
|
111
|
+
const [row] = await sql`SELECT * FROM orgs WHERE id = ${id}`;
|
|
112
|
+
if (!row) return null;
|
|
113
|
+
return { ...row, created_at: row.created_at.toISOString() } as Org;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function getOrgByDomain(sql: Sql, domain: string): Promise<Org | null> {
|
|
117
|
+
const [row] = await sql`SELECT * FROM orgs WHERE domain = ${domain}`;
|
|
118
|
+
if (!row) return null;
|
|
119
|
+
return { ...row, created_at: row.created_at.toISOString() } as Org;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function setOrgSlack(sql: Sql, orgId: string, teamId: string, botToken: string, systemChannelId?: string): Promise<void> {
|
|
123
|
+
await sql`UPDATE orgs SET slack_team_id = ${teamId}, slack_bot_token = ${botToken}, slack_system_channel_id = ${systemChannelId ?? null} WHERE id = ${orgId}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Users ---
|
|
127
|
+
|
|
128
|
+
export async function createUser(sql: Sql, id: string, email: string, name: string, orgId: string, participantId: string): Promise<User> {
|
|
129
|
+
const [row] = await sql`
|
|
130
|
+
INSERT INTO users (id, email, name, org_id, participant_id) VALUES (${id}, ${email}, ${name}, ${orgId}, ${participantId})
|
|
131
|
+
RETURNING *
|
|
132
|
+
`;
|
|
133
|
+
return { ...row, created_at: row.created_at.toISOString() } as User;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function getUser(sql: Sql, id: string): Promise<User | null> {
|
|
137
|
+
const [row] = await sql`SELECT * FROM users WHERE id = ${id}`;
|
|
138
|
+
if (!row) return null;
|
|
139
|
+
return { ...row, created_at: row.created_at.toISOString() } as User;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function getUserByEmail(sql: Sql, email: string): Promise<User | null> {
|
|
143
|
+
const [row] = await sql`SELECT * FROM users WHERE email = ${email}`;
|
|
144
|
+
if (!row) return null;
|
|
145
|
+
return { ...row, created_at: row.created_at.toISOString() } as User;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function upsertUser(sql: Sql, id: string, email: string, name: string, orgId: string, participantId: string): Promise<User> {
|
|
149
|
+
const [row] = await sql`
|
|
150
|
+
INSERT INTO users (id, email, name, org_id, participant_id) VALUES (${id}, ${email}, ${name}, ${orgId}, ${participantId})
|
|
151
|
+
ON CONFLICT (email) DO UPDATE SET name = ${name}, org_id = ${orgId}, participant_id = ${participantId}
|
|
152
|
+
RETURNING *
|
|
153
|
+
`;
|
|
154
|
+
return { ...row, created_at: row.created_at.toISOString() } as User;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Projects (org-scoped) ---
|
|
158
|
+
|
|
159
|
+
export async function createProject(sql: Sql, orgId: string, name: string): Promise<Project> {
|
|
160
|
+
const [row] = await sql`
|
|
161
|
+
INSERT INTO projects (name, org_id) VALUES (${name}, ${orgId}) RETURNING name, created_at
|
|
162
|
+
`;
|
|
163
|
+
return { name: row.name, created_at: row.created_at.toISOString() };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function getProject(sql: Sql, orgId: string, name: string): Promise<Project | null> {
|
|
167
|
+
const [row] = await sql`
|
|
168
|
+
SELECT name, created_at FROM projects WHERE org_id = ${orgId} AND name = ${name}
|
|
169
|
+
`;
|
|
170
|
+
if (!row) return null;
|
|
171
|
+
return { name: row.name, created_at: row.created_at.toISOString() };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- Sessions (org-scoped) ---
|
|
175
|
+
|
|
176
|
+
export async function createSession(
|
|
177
|
+
sql: Sql,
|
|
178
|
+
orgId: string,
|
|
179
|
+
project: string,
|
|
180
|
+
name: string,
|
|
181
|
+
driver: ParticipantId | null
|
|
182
|
+
): Promise<Session> {
|
|
183
|
+
const [row] = await sql`
|
|
184
|
+
INSERT INTO sessions (name, project, org_id, driver)
|
|
185
|
+
VALUES (${name}, ${project}, ${orgId}, ${driver})
|
|
186
|
+
RETURNING name, project, driver, created_at
|
|
187
|
+
`;
|
|
188
|
+
return {
|
|
189
|
+
name: row.name,
|
|
190
|
+
project: row.project,
|
|
191
|
+
driver: row.driver,
|
|
192
|
+
created_at: row.created_at.toISOString(),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function getSession(sql: Sql, orgId: string, project: string, name: string): Promise<Session | null> {
|
|
197
|
+
const [row] = await sql`
|
|
198
|
+
SELECT name, project, driver, created_at FROM sessions
|
|
199
|
+
WHERE org_id = ${orgId} AND project = ${project} AND name = ${name}
|
|
200
|
+
`;
|
|
201
|
+
if (!row) return null;
|
|
202
|
+
return {
|
|
203
|
+
name: row.name,
|
|
204
|
+
project: row.project,
|
|
205
|
+
driver: row.driver,
|
|
206
|
+
created_at: row.created_at.toISOString(),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function setDriver(sql: Sql, orgId: string, project: string, session: string, driver: ParticipantId): Promise<void> {
|
|
211
|
+
await sql`
|
|
212
|
+
UPDATE sessions SET driver = ${driver}
|
|
213
|
+
WHERE org_id = ${orgId} AND project = ${project} AND name = ${session}
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function clearDriver(sql: Sql, orgId: string, project: string, session: string): Promise<void> {
|
|
218
|
+
await sql`
|
|
219
|
+
UPDATE sessions SET driver = NULL
|
|
220
|
+
WHERE org_id = ${orgId} AND project = ${project} AND name = ${session}
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- Events (org-scoped) ---
|
|
225
|
+
|
|
226
|
+
export async function pushEvent(sql: Sql, orgId: string, event: PolarisEvent): Promise<void> {
|
|
227
|
+
await sql`
|
|
228
|
+
INSERT INTO events (id, org_id, project, session, timestamp, source, sender, payload)
|
|
229
|
+
VALUES (${event.id}, ${orgId}, ${event.project}, ${event.session}, ${event.timestamp}, ${event.source}, ${event.sender}, ${sql.json(event.payload)})
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function rowToEvent(row: {
|
|
234
|
+
id: string;
|
|
235
|
+
project: string;
|
|
236
|
+
session: string;
|
|
237
|
+
timestamp: Date;
|
|
238
|
+
source: string;
|
|
239
|
+
sender: string;
|
|
240
|
+
payload: unknown;
|
|
241
|
+
}): PolarisEvent {
|
|
242
|
+
return {
|
|
243
|
+
id: row.id,
|
|
244
|
+
project: row.project,
|
|
245
|
+
session: row.session,
|
|
246
|
+
timestamp: row.timestamp.toISOString(),
|
|
247
|
+
source: row.source as PolarisEvent["source"],
|
|
248
|
+
sender: row.sender as ParticipantId,
|
|
249
|
+
payload: row.payload as PolarisEvent["payload"],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function getProjectEvents(sql: Sql, orgId: string, project: string): Promise<PolarisEvent[]> {
|
|
254
|
+
const rows = await sql`
|
|
255
|
+
SELECT id, project, session, timestamp, source, sender, payload
|
|
256
|
+
FROM events WHERE org_id = ${orgId} AND project = ${project} ORDER BY timestamp ASC
|
|
257
|
+
`;
|
|
258
|
+
return rows.map(rowToEvent);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function getSessionEvents(sql: Sql, orgId: string, project: string, session: string): Promise<PolarisEvent[]> {
|
|
262
|
+
const rows = await sql`
|
|
263
|
+
SELECT id, project, session, timestamp, source, sender, payload
|
|
264
|
+
FROM events WHERE org_id = ${orgId} AND project = ${project} AND session = ${session} ORDER BY timestamp ASC
|
|
265
|
+
`;
|
|
266
|
+
return rows.map(rowToEvent);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function getEventsSince(sql: Sql, orgId: string, project: string, since: string): Promise<PolarisEvent[]> {
|
|
270
|
+
const rows = await sql`
|
|
271
|
+
SELECT id, project, session, timestamp, source, sender, payload
|
|
272
|
+
FROM events WHERE org_id = ${orgId} AND project = ${project} AND timestamp > ${since} ORDER BY timestamp ASC
|
|
273
|
+
`;
|
|
274
|
+
return rows.map(rowToEvent);
|
|
275
|
+
}
|