@lightupai/polaris 0.0.42 → 0.0.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightupai/polaris",
3
- "version": "0.0.42",
3
+ "version": "0.0.44",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "polaris": "bin/polaris",
package/src/service/db.ts CHANGED
@@ -194,6 +194,17 @@ export async function listUsers(sql: Sql, orgId: string): Promise<User[]> {
194
194
  return rows.map((r) => ({ ...r, created_at: r.created_at.toISOString() }) as User);
195
195
  }
196
196
 
197
+ export async function getRecentSignups(sql: Sql, since: Date, limit = 10): Promise<Array<User & { org_name: string }>> {
198
+ const rows = await sql`
199
+ SELECT u.*, o.name as org_name
200
+ FROM users u JOIN orgs o ON u.org_id = o.id
201
+ WHERE u.created_at >= ${since.toISOString()}
202
+ ORDER BY u.created_at DESC
203
+ LIMIT ${limit}
204
+ `;
205
+ return rows.map((r) => ({ ...r, created_at: r.created_at.toISOString(), org_name: r.org_name }) as User & { org_name: string });
206
+ }
207
+
197
208
  // --- Projects (org-scoped) ---
198
209
 
199
210
  export async function createProject(sql: Sql, orgId: string, name: string): Promise<Project> {
package/src/web/app.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  listSessions,
15
15
  getSessionPromptCounts,
16
16
  getProjectEvents,
17
+ getRecentSignups,
17
18
  type Sql,
18
19
  } from "../service/db";
19
20
  import { layout, nav } from "./layout";
@@ -52,6 +53,33 @@ function notifySignup(opts: { name: string; email: string; domain: string; orgNa
52
53
  }).catch(() => {});
53
54
  }
54
55
 
56
+ function startSignupRollup(sql: Sql): void {
57
+ const HOUR = 60 * 60 * 1000;
58
+
59
+ async function postRollup(): Promise<void> {
60
+ const botToken = process.env.SIGNUP_SLACK_BOT_TOKEN;
61
+ if (!botToken) return;
62
+
63
+ const since = new Date(Date.now() - HOUR);
64
+ const signups = await getRecentSignups(sql, since, 10);
65
+ if (signups.length === 0) return;
66
+
67
+ const lines = signups.map((s) => `• *${s.name}* (${s.email}) — ${s.org_name}`);
68
+ const text = `:chart_with_upwards_trend: *${signups.length} signup${signups.length === 1 ? "" : "s"} in the last hour*\n${lines.join("\n")}`;
69
+
70
+ fetch("https://slack.com/api/chat.postMessage", {
71
+ method: "POST",
72
+ headers: {
73
+ Authorization: `Bearer ${botToken}`,
74
+ "Content-Type": "application/json",
75
+ },
76
+ body: JSON.stringify({ channel: SIGNUP_CHANNEL, text }),
77
+ }).catch(() => {});
78
+ }
79
+
80
+ setInterval(postRollup, HOUR);
81
+ }
82
+
55
83
  // --- Google OAuth ---
56
84
 
57
85
  function getGoogle(): Google {
@@ -88,6 +116,9 @@ setInterval(() => {
88
116
  export function createApp(sql: Sql) {
89
117
  const app = new Hono();
90
118
 
119
+ // Start hourly signup rollup
120
+ startSignupRollup(sql);
121
+
91
122
  // --- Landing page ---
92
123
 
93
124
  app.get("/", (c) => {
@@ -0,0 +1,286 @@
1
+ import { describe, expect, test, beforeAll, afterAll, beforeEach } from "bun:test";
2
+ import { createDb, createOrg, createUser, getRecentSignups, type Sql } from "../src/service/db";
3
+ import { resetTestData } from "./helpers";
4
+
5
+ const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris_test";
6
+
7
+ let sql: Sql;
8
+
9
+ // Capture outgoing Slack API calls
10
+ const slackCalls: Array<{ url: string; body: Record<string, unknown> }> = [];
11
+ const originalFetch = globalThis.fetch;
12
+
13
+ beforeAll(async () => {
14
+ sql = await createDb(DATABASE_URL);
15
+
16
+ // Intercept fetch to capture Slack API calls
17
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
18
+ const url = typeof input === "string" ? input : input.toString();
19
+
20
+ if (url.startsWith("https://slack.com/api/")) {
21
+ const body = JSON.parse(init?.body as string);
22
+ slackCalls.push({ url, body });
23
+ return new Response(JSON.stringify({ ok: true }), {
24
+ headers: { "Content-Type": "application/json" },
25
+ });
26
+ }
27
+
28
+ return originalFetch(input, init);
29
+ };
30
+ });
31
+
32
+ afterAll(async () => {
33
+ globalThis.fetch = originalFetch;
34
+ await sql.end();
35
+ });
36
+
37
+ beforeEach(async () => {
38
+ await resetTestData(sql);
39
+ slackCalls.length = 0;
40
+ });
41
+
42
+ // --- getRecentSignups ---
43
+
44
+ describe("getRecentSignups", () => {
45
+ test("returns users created within the time window", async () => {
46
+ await createOrg(sql, "org1", "Acme", "acme.com");
47
+ await createUser(sql, "u1", "alice@acme.com", "Alice", "org1", "user:alice");
48
+ await createUser(sql, "u2", "bob@acme.com", "Bob", "org1", "user:bob");
49
+
50
+ const since = new Date(Date.now() - 60 * 60 * 1000);
51
+ const signups = await getRecentSignups(sql, since, 10);
52
+
53
+ expect(signups).toHaveLength(2);
54
+ expect(signups[0].org_name).toBe("Acme");
55
+ expect(signups.map((s) => s.name).sort()).toEqual(["Alice", "Bob"]);
56
+ });
57
+
58
+ test("excludes users created before the time window", async () => {
59
+ await createOrg(sql, "org1", "Acme", "acme.com");
60
+ await createUser(sql, "u1", "old@acme.com", "Old User", "org1", "user:old");
61
+ await sql`UPDATE users SET created_at = now() - interval '2 hours' WHERE id = 'u1'`;
62
+
63
+ const since = new Date(Date.now() - 60 * 60 * 1000);
64
+ const signups = await getRecentSignups(sql, since, 10);
65
+
66
+ expect(signups).toHaveLength(0);
67
+ });
68
+
69
+ test("respects limit parameter", async () => {
70
+ await createOrg(sql, "org1", "Acme", "acme.com");
71
+ for (let i = 0; i < 5; i++) {
72
+ await createUser(sql, `u${i}`, `user${i}@acme.com`, `User ${i}`, "org1", `user:user${i}`);
73
+ }
74
+
75
+ const since = new Date(Date.now() - 60 * 60 * 1000);
76
+ const signups = await getRecentSignups(sql, since, 3);
77
+
78
+ expect(signups).toHaveLength(3);
79
+ });
80
+
81
+ test("returns most recent first", async () => {
82
+ await createOrg(sql, "org1", "Acme", "acme.com");
83
+ await createUser(sql, "u1", "first@acme.com", "First", "org1", "user:first");
84
+ await sql`UPDATE users SET created_at = now() - interval '10 seconds' WHERE id = 'u1'`;
85
+ await createUser(sql, "u2", "second@acme.com", "Second", "org1", "user:second");
86
+
87
+ const since = new Date(Date.now() - 60 * 60 * 1000);
88
+ const signups = await getRecentSignups(sql, since, 10);
89
+
90
+ expect(signups[0].name).toBe("Second");
91
+ expect(signups[1].name).toBe("First");
92
+ });
93
+
94
+ test("joins org name correctly across multiple orgs", async () => {
95
+ await createOrg(sql, "org1", "Acme", "acme.com");
96
+ await createOrg(sql, "org2", "Startup", "startup.io");
97
+ await createUser(sql, "u1", "alice@acme.com", "Alice", "org1", "user:alice");
98
+ await createUser(sql, "u2", "bob@startup.io", "Bob", "org2", "user:bob");
99
+
100
+ const since = new Date(Date.now() - 60 * 60 * 1000);
101
+ const signups = await getRecentSignups(sql, since, 10);
102
+
103
+ const alice = signups.find((s) => s.name === "Alice");
104
+ const bob = signups.find((s) => s.name === "Bob");
105
+ expect(alice?.org_name).toBe("Acme");
106
+ expect(bob?.org_name).toBe("Startup");
107
+ });
108
+ });
109
+
110
+ // --- notifySignup (imported indirectly via the app module) ---
111
+ // We test the notification by importing and calling it directly.
112
+
113
+ // Since notifySignup is a private function in app.ts, we test the
114
+ // observable behavior: when the web app processes a signup, the right
115
+ // Slack calls are made. We simulate this by importing the notification
116
+ // logic as a standalone function.
117
+
118
+ // Extract the notification logic for direct testing:
119
+ function notifySignup(opts: { name: string; email: string; domain: string; orgName: string; isNewOrg: boolean }): void {
120
+ const botToken = process.env.SIGNUP_SLACK_BOT_TOKEN;
121
+ if (!botToken) return;
122
+
123
+ const emoji = opts.isNewOrg ? ":tada:" : ":wave:";
124
+ const action = opts.isNewOrg ? "signed up (new org)" : "joined";
125
+ const text = `${emoji} *${opts.name}* (${opts.email}) ${action} — ${opts.orgName} (${opts.domain})`;
126
+
127
+ fetch("https://slack.com/api/chat.postMessage", {
128
+ method: "POST",
129
+ headers: {
130
+ Authorization: `Bearer ${botToken}`,
131
+ "Content-Type": "application/json",
132
+ },
133
+ body: JSON.stringify({ channel: "#alerts-mql-stream", text }),
134
+ }).catch(() => {});
135
+ }
136
+
137
+ describe("notifySignup — new org", () => {
138
+ test("posts to #alerts-mql-stream with new org emoji and details", async () => {
139
+ process.env.SIGNUP_SLACK_BOT_TOKEN = "xoxb-test-token";
140
+
141
+ notifySignup({ name: "Alice Smith", email: "alice@newco.com", domain: "newco.com", orgName: "Newco", isNewOrg: true });
142
+
143
+ // Wait for the async fetch to resolve
144
+ await new Promise((r) => setTimeout(r, 50));
145
+
146
+ const call = slackCalls.find((c) => c.body.text?.toString().includes("Alice Smith"));
147
+ expect(call).toBeDefined();
148
+ expect(call!.body.channel).toBe("#alerts-mql-stream");
149
+ expect(call!.body.text).toContain(":tada:");
150
+ expect(call!.body.text).toContain("signed up (new org)");
151
+ expect(call!.body.text).toContain("alice@newco.com");
152
+ expect(call!.body.text).toContain("Newco");
153
+ expect(call!.body.text).toContain("newco.com");
154
+ });
155
+ });
156
+
157
+ describe("notifySignup — join existing org", () => {
158
+ test("posts to #alerts-mql-stream with join emoji", async () => {
159
+ process.env.SIGNUP_SLACK_BOT_TOKEN = "xoxb-test-token";
160
+
161
+ notifySignup({ name: "Bob Jones", email: "bob@acme.com", domain: "acme.com", orgName: "Acme", isNewOrg: false });
162
+
163
+ await new Promise((r) => setTimeout(r, 50));
164
+
165
+ const call = slackCalls.find((c) => c.body.text?.toString().includes("Bob Jones"));
166
+ expect(call).toBeDefined();
167
+ expect(call!.body.channel).toBe("#alerts-mql-stream");
168
+ expect(call!.body.text).toContain(":wave:");
169
+ expect(call!.body.text).toContain("joined");
170
+ expect(call!.body.text).not.toContain("signed up");
171
+ });
172
+ });
173
+
174
+ describe("notifySignup — no token", () => {
175
+ test("does not post when SIGNUP_SLACK_BOT_TOKEN is not set", async () => {
176
+ delete process.env.SIGNUP_SLACK_BOT_TOKEN;
177
+
178
+ notifySignup({ name: "Ghost User", email: "ghost@nowhere.com", domain: "nowhere.com", orgName: "Nowhere", isNewOrg: true });
179
+
180
+ await new Promise((r) => setTimeout(r, 50));
181
+
182
+ const call = slackCalls.find((c) => c.body.text?.toString().includes("Ghost User"));
183
+ expect(call).toBeUndefined();
184
+ });
185
+ });
186
+
187
+ describe("notifySignup — Slack API uses correct auth header", () => {
188
+ test("sends Bearer token in Authorization header", async () => {
189
+ process.env.SIGNUP_SLACK_BOT_TOKEN = "xoxb-my-secret-token";
190
+
191
+ // Override fetch to also capture headers
192
+ const capturedHeaders: Record<string, string>[] = [];
193
+ const prevFetch = globalThis.fetch;
194
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
195
+ const url = typeof input === "string" ? input : input.toString();
196
+ if (url.startsWith("https://slack.com/api/")) {
197
+ const headers: Record<string, string> = {};
198
+ if (init?.headers) {
199
+ const h = init.headers as Record<string, string>;
200
+ for (const [k, v] of Object.entries(h)) headers[k] = v;
201
+ }
202
+ capturedHeaders.push(headers);
203
+ return new Response(JSON.stringify({ ok: true }), {
204
+ headers: { "Content-Type": "application/json" },
205
+ });
206
+ }
207
+ return prevFetch(input, init);
208
+ };
209
+
210
+ notifySignup({ name: "Test", email: "test@test.com", domain: "test.com", orgName: "Test", isNewOrg: true });
211
+ await new Promise((r) => setTimeout(r, 50));
212
+
213
+ expect(capturedHeaders.length).toBeGreaterThan(0);
214
+ expect(capturedHeaders[0].Authorization).toBe("Bearer xoxb-my-secret-token");
215
+
216
+ globalThis.fetch = prevFetch;
217
+ });
218
+ });
219
+
220
+ // --- Rollup ---
221
+
222
+ describe("signup rollup message format", () => {
223
+ test("builds correct rollup from recent signups", async () => {
224
+ process.env.SIGNUP_SLACK_BOT_TOKEN = "xoxb-test-token";
225
+
226
+ await createOrg(sql, "org1", "Acme", "acme.com");
227
+ await createOrg(sql, "org2", "Startup", "startup.io");
228
+ await createUser(sql, "u1", "alice@acme.com", "Alice Smith", "org1", "user:alice");
229
+ await createUser(sql, "u2", "bob@startup.io", "Bob Jones", "org2", "user:bob");
230
+ await createUser(sql, "u3", "carol@acme.com", "Carol Lee", "org1", "user:carol");
231
+
232
+ const since = new Date(Date.now() - 60 * 60 * 1000);
233
+ const signups = await getRecentSignups(sql, since, 10);
234
+
235
+ expect(signups).toHaveLength(3);
236
+
237
+ // Simulate building the rollup message (same logic as in app.ts)
238
+ const lines = signups.map((s) => `• *${s.name}* (${s.email}) — ${s.org_name}`);
239
+ const text = `:chart_with_upwards_trend: *${signups.length} signup${signups.length === 1 ? "" : "s"} in the last hour*\n${lines.join("\n")}`;
240
+
241
+ expect(text).toContain("3 signups in the last hour");
242
+ expect(text).toContain("Alice Smith");
243
+ expect(text).toContain("Bob Jones");
244
+ expect(text).toContain("Carol Lee");
245
+ expect(text).toContain("Acme");
246
+ expect(text).toContain("Startup");
247
+
248
+ // Now actually post via the same Slack API path
249
+ fetch("https://slack.com/api/chat.postMessage", {
250
+ method: "POST",
251
+ headers: {
252
+ Authorization: `Bearer ${process.env.SIGNUP_SLACK_BOT_TOKEN}`,
253
+ "Content-Type": "application/json",
254
+ },
255
+ body: JSON.stringify({ channel: "#alerts-mql-stream", text }),
256
+ });
257
+
258
+ await new Promise((r) => setTimeout(r, 50));
259
+
260
+ const rollupCall = slackCalls.find((c) => c.body.text?.toString().includes("signups in the last hour"));
261
+ expect(rollupCall).toBeDefined();
262
+ expect(rollupCall!.body.channel).toBe("#alerts-mql-stream");
263
+ });
264
+
265
+ test("singular form for single signup", async () => {
266
+ await createOrg(sql, "org1", "Acme", "acme.com");
267
+ await createUser(sql, "u1", "solo@acme.com", "Solo User", "org1", "user:solo");
268
+
269
+ const since = new Date(Date.now() - 60 * 60 * 1000);
270
+ const signups = await getRecentSignups(sql, since, 10);
271
+ const text = `:chart_with_upwards_trend: *${signups.length} signup${signups.length === 1 ? "" : "s"} in the last hour*`;
272
+
273
+ expect(text).toContain("1 signup in the last hour");
274
+ expect(text).not.toContain("signups");
275
+ });
276
+
277
+ test("empty rollup produces no Slack call", async () => {
278
+ const since = new Date(Date.now() - 60 * 60 * 1000);
279
+ const signups = await getRecentSignups(sql, since, 10);
280
+
281
+ expect(signups).toHaveLength(0);
282
+ // In the real code, empty signups = early return, no Slack call
283
+ // Verify no calls were made
284
+ expect(slackCalls).toHaveLength(0);
285
+ });
286
+ });