@lightupai/polaris 0.0.2 → 0.0.3

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.
@@ -24,7 +24,7 @@ jobs:
24
24
  --health-retries 5
25
25
 
26
26
  env:
27
- DATABASE_URL: postgres://polaris:polaris@localhost:5432/polaris
27
+ DATABASE_URL: postgres://polaris:polaris@localhost:5432/polaris_test
28
28
 
29
29
  steps:
30
30
  - uses: actions/checkout@v4
@@ -35,4 +35,7 @@ jobs:
35
35
 
36
36
  - run: bun install
37
37
 
38
+ - name: Create test database
39
+ run: PGPASSWORD=polaris psql -h localhost -U polaris -d polaris -c "CREATE DATABASE polaris_test OWNER polaris;"
40
+
38
41
  - run: bun test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightupai/polaris",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "polaris": "bin/polaris"
package/src/cli/cli.ts CHANGED
@@ -63,7 +63,8 @@ async function login() {
63
63
  // 2. Wait for the token
64
64
  console.log("Waiting for authentication...");
65
65
  const token = await tokenPromise;
66
- callbackServer.stop(true);
66
+ // Keep server alive briefly so the browser can render the success page
67
+ setTimeout(() => callbackServer.stop(true), 3000);
67
68
 
68
69
  // 3. Validate the token and get user info
69
70
  const res = await fetch(`${SERVICE_URL}/auth/token?token=${token}`);
@@ -79,8 +80,20 @@ async function login() {
79
80
  participant_id: string;
80
81
  };
81
82
 
83
+ // Fetch org name
84
+ let orgName = userInfo.org_id;
85
+ try {
86
+ const orgRes = await fetch(`${SERVICE_URL}/auth/token?token=${token}`);
87
+ if (orgRes.ok) {
88
+ const orgData = (await orgRes.json()) as { org_id: string };
89
+ // The org name isn't in the token — use the email domain as display
90
+ orgName = userInfo.email.split("@")[1].split(".")[0];
91
+ orgName = orgName.charAt(0).toUpperCase() + orgName.slice(1);
92
+ }
93
+ } catch { /* use org_id as fallback */ }
94
+
82
95
  console.log(`\nAuthenticated as ${userInfo.name} (${userInfo.email})`);
83
- console.log(`Organization: ${userInfo.org_id}`);
96
+ console.log(`Organization: ${orgName}`);
84
97
  console.log(`Participant ID: ${userInfo.participant_id}\n`);
85
98
 
86
99
  // 4. Store credentials
@@ -202,9 +215,34 @@ Based on the arguments provided, do ONE of the following:
202
215
  await writeFile(settingsPath, JSON.stringify(mergedSettingsWithStatusLine, null, 2));
203
216
  console.log(`Status line config written to ${settingsPath}`);
204
217
 
218
+ // 9. Post system event (device connected)
219
+ const hostname = (await import("node:os")).hostname();
220
+ try {
221
+ await fetch(`${SERVICE_URL.replace(":3000", ":4321")}/projects/_system/sessions/_system/events`, {
222
+ method: "POST",
223
+ headers: {
224
+ "Content-Type": "application/json",
225
+ Authorization: `Bearer ${token}`,
226
+ },
227
+ body: JSON.stringify({
228
+ sender: userInfo.participant_id,
229
+ payload: {
230
+ hook_event_name: "Stop",
231
+ session_id: "_system",
232
+ stop_response: `Device connected: ${hostname} (${process.platform})`,
233
+ },
234
+ }),
235
+ });
236
+ // Notify web app dashboard to refresh
237
+ await fetch(`${SERVICE_URL}/api/notify-dashboard`, {
238
+ method: "POST",
239
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
240
+ });
241
+ } catch { /* non-fatal */ }
242
+
205
243
  console.log("\n✓ Polaris is set up on this machine!");
206
244
  console.log("\nNext steps:");
207
- console.log(" 1. Start the daemon: bun run src/daemon/daemon.ts");
245
+ console.log(" 1. Start the daemon: polaris daemon");
208
246
  console.log(" 2. Open your AI agent and run: /polaris join <project> <session>");
209
247
  }
210
248
 
package/src/service/db.ts CHANGED
@@ -8,6 +8,7 @@ export type Sql = postgres.Sql;
8
8
  export interface Org {
9
9
  id: string;
10
10
  name: string;
11
+ slug: string | null;
11
12
  domain: string | null;
12
13
  slack_team_id: string | null;
13
14
  slack_bot_token: string | null;
@@ -33,6 +34,7 @@ export async function createDb(connectionString?: string): Promise<Sql> {
33
34
  CREATE TABLE IF NOT EXISTS orgs (
34
35
  id TEXT PRIMARY KEY,
35
36
  name TEXT NOT NULL,
37
+ slug TEXT UNIQUE,
36
38
  domain TEXT,
37
39
  slack_team_id TEXT,
38
40
  slack_bot_token TEXT,
@@ -119,8 +121,18 @@ export async function getOrgByDomain(sql: Sql, domain: string): Promise<Org | nu
119
121
  return { ...row, created_at: row.created_at.toISOString() } as Org;
120
122
  }
121
123
 
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
+ export async function setOrgSlack(sql: Sql, orgId: string, teamId: string, botToken: string, systemChannelId?: string, slug?: string): Promise<void> {
125
+ if (slug) {
126
+ await sql`UPDATE orgs SET slack_team_id = ${teamId}, slack_bot_token = ${botToken}, slack_system_channel_id = ${systemChannelId ?? null}, slug = ${slug} WHERE id = ${orgId}`;
127
+ } else {
128
+ await sql`UPDATE orgs SET slack_team_id = ${teamId}, slack_bot_token = ${botToken}, slack_system_channel_id = ${systemChannelId ?? null} WHERE id = ${orgId}`;
129
+ }
130
+ }
131
+
132
+ export async function getOrgBySlug(sql: Sql, slug: string): Promise<Org | null> {
133
+ const [row] = await sql`SELECT * FROM orgs WHERE slug = ${slug}`;
134
+ if (!row) return null;
135
+ return { ...row, created_at: row.created_at.toISOString() } as Org;
124
136
  }
125
137
 
126
138
  // --- Users ---
@@ -1,8 +1,10 @@
1
1
  import { type Server, type ServerWebSocket } from "bun";
2
2
  import { z } from "zod";
3
+ import { postToSlackSystemChannel } from "../slack/system";
3
4
  import {
4
5
  createDb,
5
6
  createOrg,
7
+ getOrg,
6
8
  createProject,
7
9
  getProject,
8
10
  createSession,
@@ -285,7 +287,12 @@ export async function startServer(opts: {
285
287
 
286
288
  params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/events", "POST");
287
289
  if (params) {
288
- const session = await getSession(sql, orgId, params.proj, params.sess);
290
+ let session = await getSession(sql, orgId, params.proj, params.sess);
291
+ if (!session && params.proj === "_system") {
292
+ // Auto-create _system project and session
293
+ try { await createProject(sql, orgId, "_system"); } catch { /* exists */ }
294
+ try { session = await createSession(sql, orgId, "_system", "_system", null); } catch { session = await getSession(sql, orgId, "_system", "_system"); }
295
+ }
289
296
  if (!session) return error("Session not found", 404);
290
297
  const body = await jsonBody(req);
291
298
  const parsed = z
@@ -304,6 +311,15 @@ export async function startServer(opts: {
304
311
  await pushEvent(sql, orgId, event);
305
312
  broadcastEvent(event);
306
313
  broadcastSse(event);
314
+
315
+ // Forward _system events to Slack
316
+ if (params.proj === "_system") {
317
+ const text = (parsed.data.payload as { stop_response?: string; prompt?: string }).stop_response
318
+ ?? (parsed.data.payload as { prompt?: string }).prompt
319
+ ?? "System event";
320
+ await postToSlackSystemChannel(sql, orgId, `:computer: *${parsed.data.sender}*: ${text}`);
321
+ }
322
+
307
323
  return json(event, 201);
308
324
  }
309
325
 
@@ -1,4 +1,7 @@
1
- // --- Slack system channel: create, post events ---
1
+ // --- Slack system channel: create, post events, persist to DB ---
2
+
3
+ import { pushEvent, createProject, getProject, getOrg, type Sql } from "../service/db";
4
+ import type { PolarisEvent } from "../types";
2
5
 
3
6
  const SYSTEM_CHANNEL_NAME = "polaris-system";
4
7
 
@@ -78,30 +81,84 @@ export async function createSystemChannel(botToken: string, inviteEmail?: string
78
81
  throw new Error(`Failed to create or find #${SYSTEM_CHANNEL_NAME} channel: ${createRes.error}`);
79
82
  }
80
83
 
81
- // Post a system event to the #polaris channel.
82
- export async function postSystemEvent(
83
- botToken: string,
84
- channelId: string,
85
- text: string,
86
- context?: string
87
- ): Promise<void> {
88
- const blocks: Array<Record<string, unknown>> = [
89
- {
90
- type: "section",
91
- text: { type: "mrkdwn", text },
84
+ // Ensure the _system project exists for an org.
85
+ async function ensureSystemProject(sql: Sql, orgId: string): Promise<void> {
86
+ const existing = await getProject(sql, orgId, "_system");
87
+ if (!existing) {
88
+ try {
89
+ await createProject(sql, orgId, "_system");
90
+ } catch {
91
+ // Already exists (race condition)
92
+ }
93
+ }
94
+ }
95
+
96
+ // Post a system event — persists to DB and optionally posts to Slack.
97
+ export async function postSystemEvent(opts: {
98
+ sql: Sql;
99
+ orgId: string;
100
+ sender: string;
101
+ text: string;
102
+ context?: string;
103
+ botToken?: string;
104
+ channelId?: string;
105
+ }): Promise<void> {
106
+ const { sql, orgId, sender, text, context, botToken, channelId } = opts;
107
+
108
+ // Persist to DB under _system project and _system session
109
+ await ensureSystemProject(sql, orgId);
110
+ const event: PolarisEvent = {
111
+ id: crypto.randomUUID(),
112
+ project: "_system",
113
+ session: "_system",
114
+ timestamp: new Date().toISOString(),
115
+ source: "inject",
116
+ sender: sender as PolarisEvent["sender"],
117
+ payload: {
118
+ type: "inject" as const,
119
+ content: context ? `${text}\n${context}` : text,
120
+ sender: sender as PolarisEvent["sender"],
121
+ target: "_system",
92
122
  },
93
- ];
123
+ };
124
+ await pushEvent(sql, orgId, event);
94
125
 
95
- if (context) {
96
- blocks.push({
97
- type: "context",
98
- elements: [{ type: "mrkdwn", text: context }],
126
+ // Post to Slack if connected
127
+ if (botToken && channelId) {
128
+ const blocks: Array<Record<string, unknown>> = [
129
+ {
130
+ type: "section",
131
+ text: { type: "mrkdwn", text },
132
+ },
133
+ ];
134
+
135
+ if (context) {
136
+ blocks.push({
137
+ type: "context",
138
+ elements: [{ type: "mrkdwn", text: context }],
139
+ });
140
+ }
141
+
142
+ await slackApi(botToken, "chat.postMessage", {
143
+ channel: channelId,
144
+ text,
145
+ blocks,
99
146
  });
100
147
  }
148
+ }
101
149
 
102
- await slackApi(botToken, "chat.postMessage", {
103
- channel: channelId,
104
- text, // fallback for notifications
105
- blocks,
106
- });
150
+ // Post a _system event to Slack if the org has credentials.
151
+ // Called by the cloud service when it receives a _system project event.
152
+ export async function postToSlackSystemChannel(sql: Sql, orgId: string, text: string): Promise<void> {
153
+ try {
154
+ const org = await getOrg(sql, orgId);
155
+ if (!org?.slack_bot_token || !org?.slack_system_channel_id) return;
156
+
157
+ await slackApi(org.slack_bot_token, "chat.postMessage", {
158
+ channel: org.slack_system_channel_id,
159
+ text,
160
+ });
161
+ } catch {
162
+ // Non-fatal
163
+ }
107
164
  }
package/src/web/app.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  getUserByEmail,
10
10
  upsertUser,
11
11
  setOrgSlack,
12
+ getSessionEvents,
12
13
  type Sql,
13
14
  } from "../service/db";
14
15
  import { layout, nav } from "./layout";
@@ -155,15 +156,33 @@ export function createApp(sql: Sql) {
155
156
  const org = await getOrg(sql, payload.org_id);
156
157
  if (!org) return c.redirect("/login");
157
158
 
158
- // TODO: detect cliInstalled (check if user has ever hit /auth/token from CLI)
159
- // TODO: detect hasConnectedSession (check if user has any sessions as driver in DB)
159
+ // Detect setup status from system events
160
+ let cliInstalled = false;
161
+ const devices: Array<{ name: string; lastSeen: string; os: string; activeSession?: string }> = [];
162
+ try {
163
+ const systemEvents = await getSessionEvents(sql, payload.org_id, "_system", "_system");
164
+ for (const e of systemEvents) {
165
+ const text = (e.payload as { stop_response?: string }).stop_response ?? "";
166
+ const match = text.match(/^Device connected: (.+) \((.+)\)$/);
167
+ if (match) {
168
+ cliInstalled = true;
169
+ // Deduplicate by device name, keep latest
170
+ const existing = devices.findIndex((d) => d.name === match[1]);
171
+ const device = { name: match[1], os: match[2], lastSeen: e.timestamp };
172
+ if (existing >= 0) devices[existing] = device;
173
+ else devices.push(device);
174
+ }
175
+ }
176
+ } catch { /* _system project may not exist yet */ }
177
+
160
178
  const ctx = {
161
179
  token,
162
180
  userName: payload.name,
163
181
  orgName: org.name,
182
+ orgSlug: org.slug,
164
183
  email: payload.email,
165
184
  slackConnected: !!org.slack_team_id,
166
- cliInstalled: false,
185
+ cliInstalled,
167
186
  hasConnectedSession: false,
168
187
  };
169
188
 
@@ -171,9 +190,9 @@ export function createApp(sql: Sql) {
171
190
  const activeSessions: unknown[] = [];
172
191
 
173
192
  if (activeSessions.length > 0) {
174
- return layout(renderActiveView(ctx, [], []), "Polaris");
193
+ return layout(renderActiveView(ctx, [], [], devices), "Polaris");
175
194
  }
176
- return layout(renderSetupView(ctx), "Polaris");
195
+ return layout(renderSetupView(ctx, devices), "Polaris");
177
196
  });
178
197
 
179
198
  // --- Profile ---
@@ -192,6 +211,7 @@ export function createApp(sql: Sql) {
192
211
  token,
193
212
  userName: payload.name,
194
213
  orgName: org.name,
214
+ orgSlug: org.slug,
195
215
  email: payload.email,
196
216
  slackConnected: !!org.slack_team_id,
197
217
  cliInstalled: false,
@@ -205,9 +225,9 @@ export function createApp(sql: Sql) {
205
225
 
206
226
  app.get("/preview", (c) => {
207
227
  const mockToken = "preview-token";
208
- const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, email: mockUser.email };
228
+ const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, orgSlug: "lightup-data" as string | null, email: mockUser.email };
209
229
 
210
- const fresh = { ...base, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
230
+ const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
211
231
  const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false };
212
232
  const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false };
213
233
  const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true };
@@ -333,7 +353,7 @@ export function createApp(sql: Sql) {
333
353
  redirect_uri: process.env.SLACK_REDIRECT_URI ?? "http://localhost:3000/slack/callback",
334
354
  }),
335
355
  });
336
- const slackData = (await slackRes.json()) as { ok: boolean; team?: { id: string }; access_token?: string; error?: string };
356
+ const slackData = (await slackRes.json()) as { ok: boolean; team?: { id: string; name?: string }; access_token?: string; error?: string };
337
357
 
338
358
  if (!slackData.ok) {
339
359
  // Check if Slack is already connected (e.g., stale callback reload)
@@ -348,20 +368,99 @@ export function createApp(sql: Sql) {
348
368
  let systemChannelId: string | undefined;
349
369
  try {
350
370
  systemChannelId = await createSystemChannel(slackData.access_token!, payload.email);
351
- await postSystemEvent(
352
- slackData.access_token!,
353
- systemChannelId,
354
- `:star: *${payload.name}* connected this Slack workspace to Polaris`,
355
- `Organization: ${payload.org_id}`
356
- );
371
+ await postSystemEvent({
372
+ sql,
373
+ orgId: payload.org_id,
374
+ sender: payload.participant_id,
375
+ text: `:star: *${payload.name}* connected this Slack workspace to Polaris`,
376
+ context: `Organization: ${payload.org_id}`,
377
+ botToken: slackData.access_token!,
378
+ channelId: systemChannelId,
379
+ });
357
380
  } catch {
358
381
  // Non-fatal — Slack is connected even if channel creation fails
359
382
  }
360
383
 
361
- await setOrgSlack(sql, payload.org_id, slackData.team!.id, slackData.access_token!, systemChannelId);
384
+ // Set org slug from Slack workspace name if not already set
385
+ const currentOrg = await getOrg(sql, payload.org_id);
386
+ const slug = currentOrg?.slug ? undefined : (slackData.team?.name?.toLowerCase().replace(/\s+/g, "-") ?? undefined);
387
+ await setOrgSlack(sql, payload.org_id, slackData.team!.id, slackData.access_token!, systemChannelId, slug);
388
+ notifyDashboard(payload.org_id);
362
389
  return c.redirect(`/dashboard?token=${state}`);
363
390
  });
364
391
 
392
+ // --- Dashboard SSE (real-time status updates) ---
393
+
394
+ const dashboardListeners = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>(); // orgId → controllers
395
+
396
+ function notifyDashboard(orgId: string) {
397
+ const controllers = dashboardListeners.get(orgId);
398
+ if (!controllers) return;
399
+ const data = `data: refresh\n\n`;
400
+ const bytes = new TextEncoder().encode(data);
401
+ for (const ctrl of controllers) {
402
+ try { ctrl.enqueue(bytes); } catch { controllers.delete(ctrl); }
403
+ }
404
+ }
405
+
406
+ // Expose notifyDashboard so other parts of the app can trigger it
407
+ (app as unknown as { notifyDashboard: typeof notifyDashboard }).notifyDashboard = notifyDashboard;
408
+
409
+ app.get("/api/dashboard-events", async (c) => {
410
+ const token = c.req.query("token");
411
+ if (!token) return c.json({ error: "No token" }, 400);
412
+ const payload = await verifyToken(token);
413
+ if (!payload) return c.json({ error: "Invalid token" }, 401);
414
+
415
+ const orgId = payload.org_id;
416
+ let controller: ReadableStreamDefaultController<Uint8Array>;
417
+ const stream = new ReadableStream<Uint8Array>({
418
+ start(ctrl) {
419
+ controller = ctrl;
420
+ if (!dashboardListeners.has(orgId)) dashboardListeners.set(orgId, new Set());
421
+ dashboardListeners.get(orgId)!.add(controller);
422
+ },
423
+ cancel() {
424
+ dashboardListeners.get(orgId)?.delete(controller);
425
+ },
426
+ });
427
+
428
+ return new Response(stream, {
429
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
430
+ });
431
+ });
432
+
433
+ app.post("/api/notify-dashboard", async (c) => {
434
+ const auth = c.req.header("Authorization");
435
+ if (!auth?.startsWith("Bearer ")) return c.json({ error: "Unauthorized" }, 401);
436
+ const payload = await verifyToken(auth.slice(7));
437
+ if (!payload) return c.json({ error: "Invalid token" }, 401);
438
+ notifyDashboard(payload.org_id);
439
+ return c.json({ ok: true });
440
+ });
441
+
442
+ app.get("/api/dashboard-status", async (c) => {
443
+ const token = c.req.query("token");
444
+ if (!token) return c.json({ error: "No token" }, 400);
445
+ const payload = await verifyToken(token);
446
+ if (!payload) return c.json({ error: "Invalid token" }, 401);
447
+
448
+ const org = await getOrg(sql, payload.org_id);
449
+ const slackConnected = !!org?.slack_team_id;
450
+
451
+ let cliInstalled = false;
452
+ try {
453
+ const systemEvents = await getSessionEvents(sql, payload.org_id, "_system", "_system");
454
+ cliInstalled = systemEvents.some((e) =>
455
+ (e.payload as { stop_response?: string }).stop_response?.startsWith("Device connected:")
456
+ );
457
+ } catch { /* _system may not exist */ }
458
+
459
+ const hasConnectedSession = false; // TODO
460
+
461
+ return c.json({ slackConnected, cliInstalled, hasConnectedSession });
462
+ });
463
+
365
464
  // --- CLI auth flow ---
366
465
 
367
466
  // CLI calls this with a local callback port. We redirect to Google SSO
package/src/web/views.ts CHANGED
@@ -8,6 +8,7 @@ interface ViewContext {
8
8
  token: string;
9
9
  userName: string;
10
10
  orgName: string;
11
+ orgSlug: string | null;
11
12
  email: string;
12
13
  slackConnected: boolean;
13
14
  cliInstalled: boolean;
@@ -77,39 +78,56 @@ function renderFloorSection(ctx: ViewContext, compact = false, state: StepState
77
78
  }
78
79
 
79
80
  if (ctx.slackConnected) {
81
+ const slugRow = ctx.orgSlug
82
+ ? `<div class="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between">
83
+ <div>
84
+ <p class="text-xs text-gray-400">Org identifier</p>
85
+ <p class="text-sm font-mono text-gray-700">${ctx.orgSlug}</p>
86
+ </div>
87
+ <form action="/settings/slug" method="POST" class="flex items-center gap-2">
88
+ <input type="hidden" name="token" value="${ctx.token}">
89
+ <input type="text" name="slug" value="${ctx.orgSlug}" class="px-2 py-1 border border-gray-200 rounded text-xs font-mono w-40 focus:ring-polaris-500 focus:border-polaris-500 outline-none hidden" id="slug-edit-${ctx.token.slice(-6)}">
90
+ <button type="button" onclick="const i=this.previousElementSibling;i.classList.toggle('hidden');if(!i.classList.contains('hidden'))i.focus()" class="text-xs text-gray-400 hover:text-gray-600">Edit</button>
91
+ </form>
92
+ </div>`
93
+ : `<div class="mt-3 pt-3 border-t border-gray-100">
94
+ <p class="text-xs text-gray-400">Org identifier: <span class="text-gray-500">not set (will be set from first floor connection)</span></p>
95
+ </div>`;
96
+
80
97
  return `
81
98
  <div>
82
- ${sectionHeader("Floor")}
99
+ <div class="flex items-baseline gap-2 mb-3">
100
+ <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Floor</h2>
101
+ ${statusBadge("Connected", true)}
102
+ </div>
83
103
  <div class="bg-white border border-gray-200 rounded-lg p-5">
84
104
  <div class="flex items-center gap-3">
85
105
  <div class="w-10 h-10 rounded-lg bg-[#4A154B] flex items-center justify-center shrink-0">
86
106
  ${slackIcon.replace('class="w-4 h-4"', 'class="w-5 h-5 text-white"')}
87
107
  </div>
88
108
  <div>
89
- <div class="flex items-center gap-2">
90
- <p class="text-sm font-semibold text-gray-900">Slack</p>
91
- ${statusBadge("Connected", true)}
92
- </div>
109
+ <p class="text-sm font-semibold text-gray-900">Slack</p>
93
110
  <p class="text-sm text-gray-500 mt-0.5">Workspace linked. Channels are auto-created for your projects.</p>
94
111
  </div>
95
112
  </div>
113
+ ${slugRow}
96
114
  </div>
97
115
  </div>`;
98
116
  }
99
117
 
100
118
  return sectionWrap(state, `
101
119
  <div>
102
- ${sectionHeader("Floor")}
120
+ <div class="flex items-baseline gap-2 mb-3">
121
+ <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Floor</h2>
122
+ ${statusBadge("Not connected", false)}
123
+ </div>
103
124
  <div class="bg-white border ${state === "active" ? cardClass("active") : "border-amber-200"} rounded-lg p-5">
104
125
  <div class="flex items-center gap-3">
105
126
  <div class="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
106
127
  <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
107
128
  </div>
108
129
  <div>
109
- <div class="flex items-center gap-2">
110
- <p class="text-sm font-semibold text-gray-900">Slack</p>
111
- ${statusBadge("Not connected", false)}
112
- </div>
130
+ <p class="text-sm font-semibold text-gray-900">Slack</p>
113
131
  <p class="text-sm text-gray-500 mt-0.5">Connect your Slack workspace to enable the floor for your team.</p>
114
132
  </div>
115
133
  </div>
@@ -127,7 +145,17 @@ function renderDevicesSection(ctx: ViewContext, devices: DeviceFixture[], state:
127
145
  if (devices.length > 0) {
128
146
  return `
129
147
  <div>
130
- ${sectionHeader("Devices")}
148
+ <div class="flex items-baseline gap-2 mb-3">
149
+ <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Devices</h2>
150
+ ${statusBadge(`${devices.length} connected`, true)}
151
+ </div>
152
+ <details class="mb-3">
153
+ <summary class="text-xs text-polaris-700 hover:text-polaris-800 font-medium cursor-pointer select-none">+ Add another device</summary>
154
+ <div class="mt-2 bg-white border border-gray-200 rounded-lg p-4">
155
+ <p class="text-sm text-gray-500">Run on any new machine:</p>
156
+ ${copyBlock("npx @lightupai/polaris login")}
157
+ </div>
158
+ </details>
131
159
  <div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
132
160
  ${devices.map((d) => renderDeviceRow(d)).join("")}
133
161
  </div>
@@ -137,24 +165,21 @@ function renderDevicesSection(ctx: ViewContext, devices: DeviceFixture[], state:
137
165
  // Setup state — no devices yet
138
166
  return sectionWrap(state, `
139
167
  <div>
140
- ${sectionHeader("Devices")}
168
+ <div class="flex items-baseline gap-2 mb-3">
169
+ <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Devices</h2>
170
+ ${ctx.cliInstalled ? statusBadge("Installed", true) : statusBadge("Not installed", false)}
171
+ </div>
141
172
  <div class="bg-white border ${state === "active" ? cardClass("active") : "border-gray-200"} rounded-lg p-5">
142
- <div class="flex items-center gap-2">
143
- <p class="text-sm font-semibold text-gray-900">Install the CLI on your first device</p>
144
- ${ctx.cliInstalled ? statusBadge("Installed", true) : statusBadge("Not installed", false)}
145
- </div>
146
- <p class="text-sm text-gray-500 mt-1">${ctx.cliInstalled
147
- ? "Polaris is set up. Run the same command on other machines to add them."
148
- : "Run this in your terminal. Repeat on each machine you work from."}</p>
149
- ${ctx.cliInstalled
150
- ? ""
151
- : copyBlock("npx @lightup/polaris login")}
173
+ <p class="text-sm font-semibold text-gray-900">${ctx.cliInstalled ? "Add another device" : "Set up Polaris on your first device"}</p>
174
+ <p class="text-sm text-gray-500 mt-1">Run this in your terminal${ctx.cliInstalled ? " on any new machine" : ". Repeat on each machine you work from"}.</p>
175
+ ${copyBlock("npx @lightupai/polaris login")}
152
176
  </div>
153
177
  </div>`);
154
178
  }
155
179
 
156
180
  function renderDeviceRow(device: DeviceFixture): string {
157
- const isOnline = device.activeSession;
181
+ const recentThreshold = Date.now() - 60 * 60 * 1000; // 1 hour
182
+ const isOnline = device.activeSession || new Date(device.lastSeen).getTime() > recentThreshold;
158
183
  return `
159
184
  <div class="p-4 flex items-center justify-between">
160
185
  <div class="flex items-center gap-3">
@@ -172,10 +197,8 @@ function renderDeviceRow(device: DeviceFixture): string {
172
197
  </div>
173
198
  </div>
174
199
  <div class="text-right">
175
- ${isOnline
176
- ? `<p class="text-xs font-medium text-gray-700">${device.activeSession}</p>
177
- <p class="text-xs text-gray-400">Active now</p>`
178
- : `<p class="text-xs text-gray-400">Last seen ${new Date(device.lastSeen).toLocaleDateString()}</p>`}
200
+ ${device.activeSession ? `<p class="text-xs font-medium text-gray-700">${device.activeSession}</p>` : ""}
201
+ <p class="text-xs text-gray-400">${isOnline ? "Online" : "Offline"} · ${new Date(device.lastSeen).toLocaleString()}</p>
179
202
  </div>
180
203
  </div>`;
181
204
  }
@@ -187,6 +210,13 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
187
210
  return `
188
211
  <div>
189
212
  ${sectionHeader("Projects & Sessions")}
213
+ <details class="mb-3">
214
+ <summary class="text-xs text-polaris-700 hover:text-polaris-800 font-medium cursor-pointer select-none">+ Join another session</summary>
215
+ <div class="mt-2 bg-white border border-gray-200 rounded-lg p-4">
216
+ <p class="text-sm text-gray-500">Inside your AI agent, run:</p>
217
+ ${copyBlock("/polaris join &lt;project&gt; &lt;session&gt;")}
218
+ </div>
219
+ </details>
190
220
  <div class="space-y-3">
191
221
  ${sessions.map((s) => renderSessionCard(s, ctx.userName)).join("")}
192
222
  </div>
@@ -200,12 +230,12 @@ function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixtur
200
230
  // Setup state — no sessions yet
201
231
  return sectionWrap(state, `
202
232
  <div>
203
- ${sectionHeader("Projects & Sessions")}
233
+ <div class="flex items-baseline gap-2 mb-3">
234
+ <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Projects & Sessions</h2>
235
+ ${ctx.hasConnectedSession ? statusBadge("Connected", true) : statusBadge("Waiting", false)}
236
+ </div>
204
237
  <div class="bg-white border ${state === "active" ? cardClass("active") : "border-gray-200"} rounded-lg p-5">
205
- <div class="flex items-center gap-2">
206
- <p class="text-sm font-semibold text-gray-900">Connect your first session</p>
207
- ${ctx.hasConnectedSession ? statusBadge("Connected", true) : statusBadge("Waiting", false)}
208
- </div>
238
+ <p class="text-sm font-semibold text-gray-900">Connect your first session</p>
209
239
  <p class="text-sm text-gray-500 mt-1">${ctx.hasConnectedSession
210
240
  ? "You've connected a session. You're ready to collaborate."
211
241
  : "Inside your AI agent (Claude Code, Cursor, etc.), run:"}</p>
@@ -264,6 +294,20 @@ function renderProjectCard(project: ProjectFixture): string {
264
294
  </div>`;
265
295
  }
266
296
 
297
+ // --- Auto-refresh script ---
298
+
299
+ function autoRefreshScript(token: string): string {
300
+ return `
301
+ <script>
302
+ (function() {
303
+ const evtSource = new EventSource('/api/dashboard-events?token=${token}');
304
+ evtSource.onmessage = function(e) {
305
+ if (e.data === 'refresh') window.location.reload();
306
+ };
307
+ })();
308
+ </script>`;
309
+ }
310
+
267
311
  // --- Setup view (zero state) ---
268
312
  // Same three sections, but each shows its setup prompt instead of live data.
269
313
 
@@ -284,7 +328,8 @@ export function renderSetupView(ctx: ViewContext, devices: DeviceFixture[] = [])
284
328
  ${renderFloorSection(ctx, false, stepState("floor"))}
285
329
  ${renderDevicesSection(ctx, ctx.cliInstalled ? devices : [], stepState("devices"))}
286
330
  ${renderProjectsSessionsSection(ctx, [], [], stepState("sessions"))}
287
- </div>`;
331
+ </div>
332
+ ${autoRefreshScript(ctx.token)}`;
288
333
  }
289
334
 
290
335
  // --- Error view ---
@@ -313,7 +358,8 @@ export function renderActiveView(ctx: ViewContext, sessions: SessionFixture[], p
313
358
  ${renderFloorSection(ctx, true)}
314
359
  ${renderDevicesSection(ctx, devices)}
315
360
  ${renderProjectsSessionsSection(ctx, sessions, projects)}
316
- </div>`;
361
+ </div>
362
+ ${autoRefreshScript(ctx.token)}`;
317
363
  }
318
364
 
319
365
  // --- Profile view ---
@@ -3,7 +3,7 @@ import { startServer } from "../src/service/server";
3
3
  import { startDaemon } from "../src/daemon/daemon";
4
4
  import type { Sql } from "../src/service/db";
5
5
 
6
- const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
6
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
7
7
 
8
8
  let serviceUrl: string;
9
9
  let daemonUrl: string;
@@ -34,7 +34,7 @@ beforeEach(async () => {
34
34
  await sql`DROP TABLE IF EXISTS projects`;
35
35
  await sql`DROP TABLE IF EXISTS users`;
36
36
  await sql`DROP TABLE IF EXISTS orgs`;
37
- await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
37
+ await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
38
38
  await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
39
39
  await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
40
40
  await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
@@ -3,7 +3,7 @@ import { startDaemon } from "../src/daemon/daemon";
3
3
  import { startServer } from "../src/service/server";
4
4
  import type { Sql } from "../src/service/db";
5
5
 
6
- const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
6
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
7
7
 
8
8
  let daemonUrl: string;
9
9
  let serviceUrl: string;
@@ -36,7 +36,7 @@ beforeEach(async () => {
36
36
  await sql`DROP TABLE IF EXISTS projects`;
37
37
  await sql`DROP TABLE IF EXISTS users`;
38
38
  await sql`DROP TABLE IF EXISTS orgs`;
39
- await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
39
+ await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
40
40
  await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
41
41
  await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
42
42
  await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
package/tests/db.test.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  } from "../src/service/db";
24
24
  import type { PolarisEvent } from "../src/types";
25
25
 
26
- const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
26
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
27
27
 
28
28
  let sql: Sql;
29
29
 
package/tests/e2e.test.ts CHANGED
@@ -3,7 +3,7 @@ import { startServer } from "../src/service/server";
3
3
  import { startDaemon } from "../src/daemon/daemon";
4
4
  import type { Sql } from "../src/service/db";
5
5
 
6
- const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
6
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
7
7
 
8
8
  let serviceUrl: string;
9
9
  let daemonUrl: string;
@@ -34,7 +34,7 @@ beforeEach(async () => {
34
34
  await sql`DROP TABLE IF EXISTS projects`;
35
35
  await sql`DROP TABLE IF EXISTS users`;
36
36
  await sql`DROP TABLE IF EXISTS orgs`;
37
- await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
37
+ await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
38
38
  await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
39
39
  await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
40
40
  await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
@@ -3,7 +3,7 @@ import { startServer } from "../src/service/server";
3
3
  import type { Sql } from "../src/service/db";
4
4
  import type { Server } from "bun";
5
5
 
6
- const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
6
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
7
7
 
8
8
  let base: string;
9
9
  let sql: Sql;
@@ -26,7 +26,7 @@ beforeEach(async () => {
26
26
  await sql`DROP TABLE IF EXISTS projects`;
27
27
  await sql`DROP TABLE IF EXISTS users`;
28
28
  await sql`DROP TABLE IF EXISTS orgs`;
29
- await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
29
+ await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
30
30
  await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
31
31
  await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
32
32
  await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;
package/tests/web.test.ts CHANGED
@@ -13,12 +13,12 @@ import { createApp } from "../src/web/app";
13
13
  import { createDb, createOrg, createUser, type Sql } from "../src/service/db";
14
14
  import { createToken } from "../src/service/auth";
15
15
 
16
- const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris";
16
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
17
17
 
18
18
  // --- View context helpers ---
19
19
 
20
- const base = { token: "test-token", userName: "Manu Bansal", orgName: "Lightup", email: "manu@lightup.ai" };
21
- const fresh = { ...base, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
20
+ const base = { token: "test-token", userName: "Manu Bansal", orgName: "Lightup", orgSlug: "lightup-data" as string | null, email: "manu@lightup.ai" };
21
+ const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
22
22
  const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false };
23
23
  const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false };
24
24
  const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true };
@@ -35,7 +35,7 @@ describe("renderSetupView", () => {
35
35
  // Connect Slack button is present
36
36
  expect(html).toContain("Connect Slack");
37
37
  // Install CLI command is present
38
- expect(html).toContain("npx @lightup/polaris login");
38
+ expect(html).toContain("npx @lightupai/polaris login");
39
39
  // Connect session command is present
40
40
  expect(html).toContain("/polaris join my-project my-session");
41
41
  });
@@ -52,15 +52,14 @@ describe("renderSetupView", () => {
52
52
  const highlightIdx = html.indexOf("border-polaris-300");
53
53
  expect(highlightIdx).toBeGreaterThan(devicesIdx);
54
54
  // Install CLI command present
55
- expect(html).toContain("npx @lightup/polaris login");
55
+ expect(html).toContain("npx @lightupai/polaris login");
56
56
  });
57
57
 
58
58
  test("cli done: floor and devices done, sessions is highlighted", () => {
59
59
  const html = renderSetupView(cliDone, mockDevices);
60
- // Device list is shown (not install prompt)
60
+ // Device list is shown with "add another" tray
61
61
  expect(html).toContain("Manu's MacBook Pro");
62
- // No install CLI command
63
- expect(html).not.toContain("npx @lightup/polaris login");
62
+ expect(html).toContain("Add another device");
64
63
  // Session section has highlight
65
64
  const sessIdx = html.indexOf("Projects &amp; Sessions");
66
65
  const lastHighlight = html.lastIndexOf("border-polaris-300");
@@ -119,9 +118,9 @@ describe("renderActiveView", () => {
119
118
  const html = renderActiveView(allDone, mockActiveSessions, mockProjects, mockDevices);
120
119
  expect(html).toContain("Manu's MacBook Pro");
121
120
  expect(html).toContain("Manu's iMac");
122
- expect(html).toContain("Active now");
121
+ expect(html).toContain("Online");
123
122
  expect(html).toContain("polaris/auth");
124
- expect(html).toContain("Last seen");
123
+ expect(html).toContain("Offline");
125
124
  });
126
125
 
127
126
  test("hides devices section when no devices", () => {
@@ -269,7 +268,7 @@ describe("routes", () => {
269
268
  await sql`DROP TABLE IF EXISTS projects`;
270
269
  await sql`DROP TABLE IF EXISTS users`;
271
270
  await sql`DROP TABLE IF EXISTS orgs`;
272
- await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
271
+ await sql`CREATE TABLE IF NOT EXISTS orgs (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, domain TEXT, slack_team_id TEXT, slack_bot_token TEXT, slack_system_channel_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
273
272
  await sql`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), participant_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`;
274
273
  await sql`CREATE TABLE IF NOT EXISTS projects (name TEXT NOT NULL, org_id TEXT NOT NULL REFERENCES orgs(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, name))`;
275
274
  await sql`CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, project TEXT NOT NULL, org_id TEXT NOT NULL, driver TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (org_id, project, name), FOREIGN KEY (org_id, project) REFERENCES projects(org_id, name))`;