@lightupai/polaris 0.0.52 → 0.0.53
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/Makefile +16 -2
- package/README.md +40 -0
- package/docs/seo-geo.md +146 -0
- package/og-image.png +0 -0
- package/package.json +1 -1
- package/src/service/db.ts +38 -3
- package/src/web/app.ts +91 -6
- package/src/web/layout.ts +38 -4
- package/src/web/pages.ts +31 -21
- package/src/web/views.ts +127 -1
- package/tests/db.test.ts +2 -7
- package/tests/helpers.ts +15 -64
package/Makefile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev dev-up dev-down api web daemon bridge test clean
|
|
1
|
+
.PHONY: dev dev-up dev-down api web daemon bridge test clean prod
|
|
2
2
|
|
|
3
3
|
# Load .env if it exists
|
|
4
4
|
ifneq (,$(wildcard .env))
|
|
@@ -45,15 +45,29 @@ bridge:
|
|
|
45
45
|
else echo "Skipping bridge (no Slack-connected org found)"; fi; \
|
|
46
46
|
fi
|
|
47
47
|
|
|
48
|
+
# Run web locally against prod DB
|
|
49
|
+
# Opens SSH tunnel to prod postgres, starts web app with hot reload, cleans up on Ctrl-C.
|
|
50
|
+
prod:
|
|
51
|
+
@lsof -ti :3000 | xargs kill -9 2>/dev/null || true
|
|
52
|
+
@lsof -ti :5433 | xargs kill -9 2>/dev/null || true
|
|
53
|
+
@PW=$$(ssh deploy@withpolaris.ai "grep POSTGRES_PASSWORD /opt/polaris/.env" | cut -d= -f2); \
|
|
54
|
+
PG_IP=$$(ssh deploy@withpolaris.ai "docker inspect polaris-postgres-1 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'"); \
|
|
55
|
+
echo "Tunneling prod postgres ($$PG_IP:5432) to localhost:5433"; \
|
|
56
|
+
ssh -f -N -L 5433:$$PG_IP:5432 deploy@withpolaris.ai; \
|
|
57
|
+
echo "Starting web app on http://localhost:3000 (prod DB)"; \
|
|
58
|
+
DATABASE_URL=postgres://polaris:$$PW@localhost:5433/polaris npx bun --hot run src/web/serve.ts; \
|
|
59
|
+
lsof -ti :5433 | xargs kill 2>/dev/null || true
|
|
60
|
+
|
|
48
61
|
# Run tests
|
|
49
62
|
test:
|
|
50
63
|
npx bun test
|
|
51
64
|
|
|
52
|
-
# Stop all background processes and Postgres
|
|
65
|
+
# Stop all background processes, tunnels, and Postgres
|
|
53
66
|
clean:
|
|
54
67
|
@lsof -ti :4321 | xargs kill -9 2>/dev/null || true
|
|
55
68
|
@lsof -ti :4322 | xargs kill -9 2>/dev/null || true
|
|
56
69
|
@lsof -ti :3000 | xargs kill -9 2>/dev/null || true
|
|
70
|
+
@lsof -ti :5433 | xargs kill -9 2>/dev/null || true
|
|
57
71
|
@pgrep -f "bridge.ts" | xargs kill -9 2>/dev/null || true
|
|
58
72
|
docker compose down
|
|
59
73
|
@echo "Cleaned up"
|
package/README.md
CHANGED
|
@@ -97,6 +97,46 @@ Copy `.env.example` to `.env` and fill in your credentials. All settings are loa
|
|
|
97
97
|
| `SLACK_APP_TOKEN` | Slack app-level token (for Socket Mode) |
|
|
98
98
|
| `SLACK_REDIRECT_URI` | Slack OAuth callback URL |
|
|
99
99
|
|
|
100
|
+
### Local Google OAuth setup
|
|
101
|
+
|
|
102
|
+
Login — both `polaris login --local` and the dashboard — uses Google SSO, so you need a Google OAuth client. (The repo ships `scripts/setup-google-oauth.sh`, but it overwrites `.env` and its automation is unreliable; set it up manually.)
|
|
103
|
+
|
|
104
|
+
1. Open the [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials) and create or select a project.
|
|
105
|
+
2. **OAuth consent screen** (now called the *Google Auth Platform*): set User type **External**, fill in an app name and support email, and Save.
|
|
106
|
+
3. **Add yourself as a Test user.** In the redesigned console this moved — it's no longer on the consent screen. Go to **APIs & Services → OAuth consent screen → Audience** (left sidebar) — or directly [console.cloud.google.com/auth/audience](https://console.cloud.google.com/auth/audience) — and under **Test users** click **+ Add users**, add the email you'll sign in with, and Save. This is **required** while the app is in *Testing* mode; without it, sign-in is blocked with `access_denied`.
|
|
107
|
+
4. **Create Credentials → OAuth client ID → Web application**.
|
|
108
|
+
5. Under **Authorized redirect URIs** (not "JavaScript origins"), add this **exactly**:
|
|
109
|
+
```
|
|
110
|
+
http://localhost:3000/auth/google/callback
|
|
111
|
+
```
|
|
112
|
+
It must match character-for-character — no trailing slash, `http` not `https`, `localhost` not `127.0.0.1`, port `3000`. This is the value the app sends by default (override with `GOOGLE_REDIRECT_URI`).
|
|
113
|
+
6. Copy the **Client ID** and **Client Secret** into `.env` — edit the existing empty lines, don't recreate the file (you'd lose `POSTGRES_PASSWORD`, the JWT secret, etc.):
|
|
114
|
+
```
|
|
115
|
+
GOOGLE_CLIENT_ID=<your-client-id>.apps.googleusercontent.com
|
|
116
|
+
GOOGLE_CLIENT_SECRET=<your-secret>
|
|
117
|
+
```
|
|
118
|
+
7. Reload so the values take effect: `make clean && make dev`.
|
|
119
|
+
|
|
120
|
+
> Redirect-URI changes can take a few minutes to propagate on Google's side. If you get `redirect_uri_mismatch` immediately after saving, wait ~5 minutes and retry.
|
|
121
|
+
|
|
122
|
+
### Local Slack app setup (optional)
|
|
123
|
+
|
|
124
|
+
Slack is optional — without `SLACK_APP_TOKEN`, `make dev` just skips the bridge. Set it up to mirror sessions to Slack channels. Slack has no API to create apps, so this is manual (`scripts/setup-slack-app.sh` walks you through it and **appends** to `.env`, so it won't clobber existing values).
|
|
125
|
+
|
|
126
|
+
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App → From scratch**. Name it "Polaris" and pick your dev workspace.
|
|
127
|
+
2. **OAuth & Permissions**: under **Redirect URLs** add `http://localhost:3000/slack/callback` and Save. Under **Bot Token Scopes**, add: `channels:manage`, `channels:join`, `channels:read`, `chat:write`, `users:read`, `users:read.email`.
|
|
128
|
+
3. **Socket Mode**: toggle **Enable Socket Mode** on, then generate an app-level token (scope `connections:write`). Copy it — this is `SLACK_APP_TOKEN` (starts with `xapp-`).
|
|
129
|
+
4. **Event Subscriptions**: toggle **Enable Events** on, and under **Subscribe to bot events** add `message.channels`, then Save. (This is what lets Slack messages reach a session.)
|
|
130
|
+
5. **Basic Information**: copy the **Client ID** and **Client Secret**.
|
|
131
|
+
6. Add all three to `.env`:
|
|
132
|
+
```
|
|
133
|
+
SLACK_CLIENT_ID=<client-id>
|
|
134
|
+
SLACK_CLIENT_SECRET=<client-secret>
|
|
135
|
+
SLACK_APP_TOKEN=xapp-<socket-mode-token>
|
|
136
|
+
```
|
|
137
|
+
(`SLACK_REDIRECT_URI` defaults to `http://localhost:3000/slack/callback`.)
|
|
138
|
+
7. Reload: `make clean && make dev`, then click **Connect Slack** on the dashboard to install the bot into your workspace.
|
|
139
|
+
|
|
100
140
|
### Optional
|
|
101
141
|
|
|
102
142
|
| Variable | Default | Description |
|
package/docs/seo-geo.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# SEO & Geo — Tracker
|
|
2
|
+
|
|
3
|
+
## Completed
|
|
4
|
+
|
|
5
|
+
- [x] **Meta tags** — title, description on every page via `SeoOpts` in layout
|
|
6
|
+
- [x] **Open Graph tags** — og:title, og:description, og:image, og:url for rich link previews on Slack/Twitter/LinkedIn
|
|
7
|
+
- [x] **Twitter cards** — summary_large_image format with title, description, image
|
|
8
|
+
- [x] **OG image** — 1200x630 PNG of hero section served at `/og-image.png` with 24h cache
|
|
9
|
+
- [x] **Canonical URL** — set to `https://app.withpolaris.ai`
|
|
10
|
+
- [x] **robots.txt** — allows all crawlers, points to sitemap
|
|
11
|
+
- [x] **sitemap.xml** — lists the landing page
|
|
12
|
+
- [x] **Cloudflare DNS** — domain managed on Cloudflare with MX + SPF records
|
|
13
|
+
- [x] **Email routing** — `*@withpolaris.ai` forwards to `support@lightup.ai`
|
|
14
|
+
|
|
15
|
+
## SEO — To Do
|
|
16
|
+
|
|
17
|
+
### High Priority
|
|
18
|
+
|
|
19
|
+
- [ ] **Submit sitemap to Google Search Console**
|
|
20
|
+
Register `app.withpolaris.ai` at https://search.google.com/search-console.
|
|
21
|
+
Verify ownership via Cloudflare DNS TXT record. Submit sitemap URL.
|
|
22
|
+
|
|
23
|
+
- [ ] **Add analytics**
|
|
24
|
+
No traffic data currently. Options:
|
|
25
|
+
- Plausible (privacy-friendly, lightweight, ~$9/mo)
|
|
26
|
+
- Google Analytics (free, full-featured, heavier)
|
|
27
|
+
- Cloudflare Web Analytics (free, built into Cloudflare dashboard)
|
|
28
|
+
Add the tracking script to the layout.
|
|
29
|
+
|
|
30
|
+
- [ ] **Self-host Tailwind CSS**
|
|
31
|
+
Currently loading Tailwind CDN (~300KB) on every page. Purge and self-host
|
|
32
|
+
to reduce to ~10KB. Improves page speed and Core Web Vitals score.
|
|
33
|
+
Affects Google ranking.
|
|
34
|
+
|
|
35
|
+
### Medium Priority
|
|
36
|
+
|
|
37
|
+
- [ ] **Schema markup (JSON-LD)**
|
|
38
|
+
Add structured data for SoftwareApplication and Organization.
|
|
39
|
+
Helps Google show rich results (product name, pricing, etc.).
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"@context": "https://schema.org",
|
|
43
|
+
"@type": "SoftwareApplication",
|
|
44
|
+
"name": "Polaris",
|
|
45
|
+
"applicationCategory": "DeveloperApplication",
|
|
46
|
+
"operatingSystem": "macOS, Linux",
|
|
47
|
+
"offers": {
|
|
48
|
+
"@type": "Offer",
|
|
49
|
+
"price": "0",
|
|
50
|
+
"priceCurrency": "USD"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- [ ] **Blog / content marketing**
|
|
56
|
+
Long-tail SEO needs content pages targeting search terms:
|
|
57
|
+
- "How to collaborate on Claude Code sessions"
|
|
58
|
+
- "AI session capture for engineering teams"
|
|
59
|
+
- "Multiplayer AI coding with Slack"
|
|
60
|
+
- "AI agent observability for developers"
|
|
61
|
+
- "Gong for AI coding"
|
|
62
|
+
Options: simple `/blog` route with markdown rendering, or a subdomain `blog.withpolaris.ai`.
|
|
63
|
+
|
|
64
|
+
- [ ] **Heading hierarchy audit**
|
|
65
|
+
Ensure proper h1 → h2 → h3 structure on the landing page.
|
|
66
|
+
Only one h1 per page. Check that section headers use correct levels.
|
|
67
|
+
|
|
68
|
+
### Low Priority
|
|
69
|
+
|
|
70
|
+
- [ ] **Favicon**
|
|
71
|
+
No favicon set. Add one for browser tabs and bookmarks.
|
|
72
|
+
Use the Polaris hub icon or a simplified version.
|
|
73
|
+
|
|
74
|
+
- [ ] **404 page**
|
|
75
|
+
Custom 404 page with navigation back to the landing page.
|
|
76
|
+
Currently returns a default error.
|
|
77
|
+
|
|
78
|
+
- [ ] **Page title for dashboard pages**
|
|
79
|
+
Dashboard, profile, and other authenticated pages should have
|
|
80
|
+
descriptive titles (e.g., "Dashboard — Polaris") instead of just "Polaris".
|
|
81
|
+
|
|
82
|
+
- [ ] **Alt text on images**
|
|
83
|
+
The Claude Code PNG icon and any other images need alt text for
|
|
84
|
+
accessibility and SEO.
|
|
85
|
+
|
|
86
|
+
## Geo — To Do
|
|
87
|
+
|
|
88
|
+
### High Priority
|
|
89
|
+
|
|
90
|
+
- [ ] **Enable Cloudflare proxy (orange cloud)**
|
|
91
|
+
DNS records for `app.withpolaris.ai` and `api.withpolaris.ai` are
|
|
92
|
+
likely DNS-only (gray cloud). Switching to proxied gives:
|
|
93
|
+
- Free global CDN (faster loads worldwide)
|
|
94
|
+
- Automatic caching of static assets
|
|
95
|
+
- DDoS protection
|
|
96
|
+
- Web Application Firewall
|
|
97
|
+
Note: Caddy handles HTTPS on the server. Enabling Cloudflare proxy
|
|
98
|
+
means Cloudflare terminates TLS and connects to Caddy. Need to set
|
|
99
|
+
Cloudflare SSL mode to "Full (strict)" and ensure Caddy's certs
|
|
100
|
+
are valid. Test carefully.
|
|
101
|
+
|
|
102
|
+
### Medium Priority
|
|
103
|
+
|
|
104
|
+
- [ ] **Check server location**
|
|
105
|
+
Verify where the Hetzner VPS is located (likely EU — Finland or Germany).
|
|
106
|
+
If most users are US-based, consider:
|
|
107
|
+
- Migrating to a US Hetzner datacenter (Ashburn, VA)
|
|
108
|
+
- Or relying on Cloudflare proxy to cache static content at edge
|
|
109
|
+
|
|
110
|
+
- [ ] **Cache headers for static assets**
|
|
111
|
+
OG image has 24h cache. Landing page HTML has `Cache-Control: no-store`.
|
|
112
|
+
Consider adding short cache (5-10 min) for the landing page since it
|
|
113
|
+
changes infrequently. CSS/JS should have long cache with versioned URLs.
|
|
114
|
+
|
|
115
|
+
### Low Priority
|
|
116
|
+
|
|
117
|
+
- [ ] **Content localization**
|
|
118
|
+
Not needed yet. English only. Revisit if expanding to non-English markets.
|
|
119
|
+
|
|
120
|
+
- [ ] **CDN for the npm package**
|
|
121
|
+
The CLI is published to npm. npm CDN (unpkg, jsdelivr) handles global
|
|
122
|
+
distribution automatically. No action needed.
|
|
123
|
+
|
|
124
|
+
## Target Keywords
|
|
125
|
+
|
|
126
|
+
Primary:
|
|
127
|
+
- "AI coding session recording"
|
|
128
|
+
- "Claude Code collaboration"
|
|
129
|
+
- "AI agent observability"
|
|
130
|
+
- "Slack integration for AI coding"
|
|
131
|
+
- "Gong for AI coding"
|
|
132
|
+
|
|
133
|
+
Secondary:
|
|
134
|
+
- "AI session capture tool"
|
|
135
|
+
- "multiplayer AI coding"
|
|
136
|
+
- "AI coding agent memory"
|
|
137
|
+
- "context graph for AI agents"
|
|
138
|
+
- "engineering knowledge capture AI"
|
|
139
|
+
|
|
140
|
+
## Useful Links
|
|
141
|
+
|
|
142
|
+
- Google Search Console: https://search.google.com/search-console
|
|
143
|
+
- OG image tester: https://www.opengraph.xyz/
|
|
144
|
+
- Twitter card validator: https://cards-dev.twitter.com/validator
|
|
145
|
+
- PageSpeed Insights: https://pagespeed.web.dev/
|
|
146
|
+
- Cloudflare dashboard: https://dash.cloudflare.com/
|
package/og-image.png
ADDED
|
Binary file
|
package/package.json
CHANGED
package/src/service/db.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface Org {
|
|
|
10
10
|
name: string;
|
|
11
11
|
slug: string | null;
|
|
12
12
|
domain: string | null;
|
|
13
|
+
plan: string;
|
|
13
14
|
slack_team_id: string | null;
|
|
14
15
|
slack_bot_token: string | null;
|
|
15
16
|
slack_system_channel_id: string | null;
|
|
@@ -29,13 +30,18 @@ export interface User {
|
|
|
29
30
|
|
|
30
31
|
export async function createDb(connectionString?: string): Promise<Sql> {
|
|
31
32
|
const sql = postgres(connectionString ?? process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris");
|
|
33
|
+
await ensureSchema(sql);
|
|
34
|
+
return sql;
|
|
35
|
+
}
|
|
32
36
|
|
|
37
|
+
export async function ensureSchema(sql: Sql): Promise<void> {
|
|
33
38
|
await sql`
|
|
34
39
|
CREATE TABLE IF NOT EXISTS orgs (
|
|
35
40
|
id TEXT PRIMARY KEY,
|
|
36
41
|
name TEXT NOT NULL,
|
|
37
42
|
slug TEXT UNIQUE,
|
|
38
43
|
domain TEXT,
|
|
44
|
+
plan TEXT NOT NULL DEFAULT 'free',
|
|
39
45
|
slack_team_id TEXT,
|
|
40
46
|
slack_bot_token TEXT,
|
|
41
47
|
slack_system_channel_id TEXT,
|
|
@@ -43,6 +49,9 @@ export async function createDb(connectionString?: string): Promise<Sql> {
|
|
|
43
49
|
)
|
|
44
50
|
`;
|
|
45
51
|
|
|
52
|
+
// Migrate: add plan column if missing
|
|
53
|
+
await sql`ALTER TABLE orgs ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`;
|
|
54
|
+
|
|
46
55
|
await sql`
|
|
47
56
|
CREATE TABLE IF NOT EXISTS users (
|
|
48
57
|
id TEXT PRIMARY KEY,
|
|
@@ -119,19 +128,33 @@ export async function createDb(connectionString?: string): Promise<Sql> {
|
|
|
119
128
|
CREATE INDEX IF NOT EXISTS idx_events_session ON events(project_id, session, timestamp)
|
|
120
129
|
`;
|
|
121
130
|
|
|
122
|
-
|
|
131
|
+
await sql`
|
|
132
|
+
CREATE TABLE IF NOT EXISTS plan_changes (
|
|
133
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
134
|
+
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
135
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
136
|
+
from_plan TEXT NOT NULL,
|
|
137
|
+
to_plan TEXT NOT NULL,
|
|
138
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
139
|
+
)
|
|
140
|
+
`;
|
|
123
141
|
}
|
|
124
142
|
|
|
125
143
|
// --- Orgs ---
|
|
126
144
|
|
|
127
|
-
export async function createOrg(sql: Sql, id: string, name: string, domain?: string): Promise<Org> {
|
|
145
|
+
export async function createOrg(sql: Sql, id: string, name: string, domain?: string, plan?: string): Promise<Org> {
|
|
128
146
|
const [row] = await sql`
|
|
129
|
-
INSERT INTO orgs (id, name, domain) VALUES (${id}, ${name}, ${domain ?? null})
|
|
147
|
+
INSERT INTO orgs (id, name, domain, plan) VALUES (${id}, ${name}, ${domain ?? null}, ${plan ?? "free"})
|
|
130
148
|
RETURNING *
|
|
131
149
|
`;
|
|
132
150
|
return { ...row, created_at: row.created_at.toISOString() } as Org;
|
|
133
151
|
}
|
|
134
152
|
|
|
153
|
+
export async function setOrgPlan(sql: Sql, orgId: string, fromPlan: string, toPlan: string, userId: string): Promise<void> {
|
|
154
|
+
await sql`UPDATE orgs SET plan = ${toPlan} WHERE id = ${orgId}`;
|
|
155
|
+
await sql`INSERT INTO plan_changes (org_id, user_id, from_plan, to_plan) VALUES (${orgId}, ${userId}, ${fromPlan}, ${toPlan})`;
|
|
156
|
+
}
|
|
157
|
+
|
|
135
158
|
export async function getOrg(sql: Sql, id: string): Promise<Org | null> {
|
|
136
159
|
const [row] = await sql`SELECT * FROM orgs WHERE id = ${id}`;
|
|
137
160
|
if (!row) return null;
|
|
@@ -343,6 +366,18 @@ export async function getSessionPromptCounts(sql: Sql, orgId: string): Promise<M
|
|
|
343
366
|
return counts;
|
|
344
367
|
}
|
|
345
368
|
|
|
369
|
+
export async function getDailyPromptCounts(sql: Sql, orgId: string, days = 14): Promise<Array<{ date: string; sender: string; count: number }>> {
|
|
370
|
+
const rows = await sql`
|
|
371
|
+
SELECT date_trunc('day', e.timestamp)::date as date, e.sender, count(*)::int as count
|
|
372
|
+
FROM events e
|
|
373
|
+
WHERE e.org_id = ${orgId} AND e.payload->>'hook_event_name' = 'UserPromptSubmit'
|
|
374
|
+
AND e.timestamp >= now() - ${days + ' days'}::interval
|
|
375
|
+
GROUP BY date, e.sender
|
|
376
|
+
ORDER BY date ASC
|
|
377
|
+
`;
|
|
378
|
+
return rows.map((r) => ({ date: r.date.toISOString().slice(0, 10), sender: r.sender, count: r.count }));
|
|
379
|
+
}
|
|
380
|
+
|
|
346
381
|
export async function setDriver(sql: Sql, orgId: string, project: string, session: string, driver: ParticipantId): Promise<void> {
|
|
347
382
|
await sql`
|
|
348
383
|
UPDATE sessions SET driver = ${driver}
|
package/src/web/app.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
getProjectEvents,
|
|
17
17
|
getRecentSignups,
|
|
18
18
|
listUsers,
|
|
19
|
+
setOrgPlan,
|
|
20
|
+
getDailyPromptCounts,
|
|
19
21
|
type Sql,
|
|
20
22
|
} from "../service/db";
|
|
21
23
|
import { layout, nav } from "./layout";
|
|
@@ -55,6 +57,23 @@ function notifySignup(opts: { name: string; email: string; domain: string; orgNa
|
|
|
55
57
|
}).catch(() => {});
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
export function notifyPlanChange(opts: { name: string; email: string; orgName: string; fromPlan: string; toPlan: string }): void {
|
|
61
|
+
const botToken = process.env.SIGNUP_SLACK_BOT_TOKEN;
|
|
62
|
+
if (!botToken) return;
|
|
63
|
+
|
|
64
|
+
const emoji = opts.toPlan === "free" ? ":arrow_down:" : ":arrow_up:";
|
|
65
|
+
const text = `${emoji} *${opts.name}* (${opts.email}) changed plan: ${opts.fromPlan} → ${opts.toPlan} — ${opts.orgName}`;
|
|
66
|
+
|
|
67
|
+
fetch("https://slack.com/api/chat.postMessage", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${botToken}`,
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({ channel: SIGNUP_CHANNEL, text }),
|
|
74
|
+
}).catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
function startSignupRollup(sql: Sql): void {
|
|
59
78
|
const HOUR = 60 * 60 * 1000;
|
|
60
79
|
|
|
@@ -121,10 +140,44 @@ export function createApp(sql: Sql) {
|
|
|
121
140
|
// Start hourly signup rollup
|
|
122
141
|
startSignupRollup(sql);
|
|
123
142
|
|
|
143
|
+
// --- SEO ---
|
|
144
|
+
|
|
145
|
+
app.get("/og-image.png", async (c) => {
|
|
146
|
+
const file = Bun.file(new URL("../../og-image.png", import.meta.url).pathname);
|
|
147
|
+
return new Response(await file.arrayBuffer(), {
|
|
148
|
+
headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400" },
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
app.get("/robots.txt", (c) => {
|
|
153
|
+
return new Response(
|
|
154
|
+
`User-agent: *\nAllow: /\n\nSitemap: https://app.withpolaris.ai/sitemap.xml`,
|
|
155
|
+
{ headers: { "Content-Type": "text/plain" } }
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
app.get("/sitemap.xml", (c) => {
|
|
160
|
+
return new Response(
|
|
161
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
162
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
163
|
+
<url>
|
|
164
|
+
<loc>https://app.withpolaris.ai</loc>
|
|
165
|
+
<changefreq>weekly</changefreq>
|
|
166
|
+
<priority>1.0</priority>
|
|
167
|
+
</url>
|
|
168
|
+
</urlset>`,
|
|
169
|
+
{ headers: { "Content-Type": "application/xml" } }
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
124
173
|
// --- Landing page ---
|
|
125
174
|
|
|
126
175
|
app.get("/", (c) => {
|
|
127
|
-
return layout(renderLandingPage()
|
|
176
|
+
return layout(renderLandingPage(), "Polaris — It's like Gong for Claude Code sessions", {
|
|
177
|
+
title: "Polaris — It's like Gong for Claude Code sessions",
|
|
178
|
+
description: "Capture every AI coding session. Stream prompts, responses, and tool calls to Slack in real time. Collaborate across agents. Nothing is lost.",
|
|
179
|
+
canonical: "https://app.withpolaris.ai",
|
|
180
|
+
});
|
|
128
181
|
});
|
|
129
182
|
|
|
130
183
|
// --- Auth: single Google SSO flow for both signup and login ---
|
|
@@ -169,6 +222,14 @@ export function createApp(sql: Sql) {
|
|
|
169
222
|
// 1. Existing user → log in
|
|
170
223
|
const existingUser = await getUserByEmail(sql, email);
|
|
171
224
|
if (existingUser) {
|
|
225
|
+
// Upgrade org plan if signing up for a higher tier
|
|
226
|
+
if (stateData.plan && stateData.plan !== "free") {
|
|
227
|
+
const userOrg = await getOrg(sql, existingUser.org_id);
|
|
228
|
+
if (userOrg && userOrg.plan === "free") {
|
|
229
|
+
await setOrgPlan(sql, existingUser.org_id, "free", stateData.plan, existingUser.id);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
172
233
|
const token = await createToken({
|
|
173
234
|
sub: existingUser.id,
|
|
174
235
|
email: existingUser.email,
|
|
@@ -186,6 +247,11 @@ export function createApp(sql: Sql) {
|
|
|
186
247
|
const participantId = `user:${name.toLowerCase().replace(/\s+/g, ".")}`;
|
|
187
248
|
await createUser(sql, userId, email, name, existingOrg.id, participantId);
|
|
188
249
|
|
|
250
|
+
// Upgrade org plan if signing up for a higher tier
|
|
251
|
+
if (stateData.plan && stateData.plan !== "free" && existingOrg.plan === "free") {
|
|
252
|
+
await setOrgPlan(sql, existingOrg.id, "free", stateData.plan, userId);
|
|
253
|
+
}
|
|
254
|
+
|
|
189
255
|
// Notify org's Slack system channel
|
|
190
256
|
postSystemEvent({
|
|
191
257
|
sql,
|
|
@@ -207,7 +273,7 @@ export function createApp(sql: Sql) {
|
|
|
207
273
|
const orgName = domain.split(".")[0].charAt(0).toUpperCase() + domain.split(".")[0].slice(1);
|
|
208
274
|
const orgId = crypto.randomUUID();
|
|
209
275
|
try {
|
|
210
|
-
await createOrg(sql, orgId, orgName, domain);
|
|
276
|
+
await createOrg(sql, orgId, orgName, domain, stateData.plan);
|
|
211
277
|
} catch {
|
|
212
278
|
return layout(renderErrorView("Failed to create team. Please try again.", "Try again", "/login"));
|
|
213
279
|
}
|
|
@@ -253,8 +319,9 @@ export function createApp(sql: Sql) {
|
|
|
253
319
|
}
|
|
254
320
|
} catch { /* _system project may not exist yet */ }
|
|
255
321
|
|
|
256
|
-
// Query team members, projects, sessions, and
|
|
322
|
+
// Query team members, projects, sessions, prompt counts, and daily activity
|
|
257
323
|
const teamMembers = await listUsers(sql, payload.org_id);
|
|
324
|
+
const dailyPrompts = await getDailyPromptCounts(sql, payload.org_id);
|
|
258
325
|
const projects = (await listProjects(sql, payload.org_id)).filter((p) => p.name !== "_system");
|
|
259
326
|
const allSessions = (await listSessions(sql, payload.org_id)).filter((s) => s.project !== "_system");
|
|
260
327
|
const promptCounts = await getSessionPromptCounts(sql, payload.org_id);
|
|
@@ -293,6 +360,8 @@ export function createApp(sql: Sql) {
|
|
|
293
360
|
hasConnectedSession,
|
|
294
361
|
totalPrompts: Array.from(promptCounts.values()).reduce((a, b) => a + b, 0),
|
|
295
362
|
teamMembers: teamMembers.map((u) => ({ name: u.name, email: u.email })),
|
|
363
|
+
plan: org.plan,
|
|
364
|
+
dailyPrompts,
|
|
296
365
|
};
|
|
297
366
|
|
|
298
367
|
if (hasConnectedSession) {
|
|
@@ -334,10 +403,18 @@ export function createApp(sql: Sql) {
|
|
|
334
403
|
const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, orgSlug: "lightup-data" as string | null, email: mockUser.email };
|
|
335
404
|
|
|
336
405
|
const mockTeam = [{ name: mockUser.name, email: mockUser.email }, { name: "Alice Chen", email: "alice@lightup.ai" }, { name: "Laura Mowry", email: "laura@lightup.ai" }];
|
|
406
|
+
const mockSenders = ["user:manu.bansal", "user:alice.chen", "user:laura.mowry"];
|
|
407
|
+
const mockDailyPrompts = mockSenders.flatMap((sender) =>
|
|
408
|
+
Array.from({ length: 14 }, (_, i) => {
|
|
409
|
+
const d = new Date(); d.setDate(d.getDate() - 13 + i);
|
|
410
|
+
return { date: d.toISOString().slice(0, 10), sender, count: Math.floor(Math.random() * 12) + 1 };
|
|
411
|
+
})
|
|
412
|
+
);
|
|
337
413
|
const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0 };
|
|
338
|
-
const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam };
|
|
339
|
-
const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam };
|
|
340
|
-
const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true, totalPrompts: 127, teamMembers: mockTeam };
|
|
414
|
+
const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam, dailyPrompts: mockDailyPrompts };
|
|
415
|
+
const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam, dailyPrompts: mockDailyPrompts };
|
|
416
|
+
const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true, totalPrompts: 127, teamMembers: mockTeam, dailyPrompts: mockDailyPrompts };
|
|
417
|
+
const teamPlan = { ...fresh, plan: "pro" };
|
|
341
418
|
|
|
342
419
|
return layout(`
|
|
343
420
|
<div class="max-w-5xl mx-auto px-6 py-12">
|
|
@@ -377,6 +454,14 @@ export function createApp(sql: Sql) {
|
|
|
377
454
|
</div>
|
|
378
455
|
</section>
|
|
379
456
|
|
|
457
|
+
<section>
|
|
458
|
+
<h2 class="text-lg font-bold text-gray-700 mb-1">Team plan signup</h2>
|
|
459
|
+
<p class="text-sm text-gray-400 mb-4">User signed up via the Team pricing CTA.</p>
|
|
460
|
+
<div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
|
461
|
+
${renderSetupView(teamPlan)}
|
|
462
|
+
</div>
|
|
463
|
+
</section>
|
|
464
|
+
|
|
380
465
|
<section>
|
|
381
466
|
<h2 class="text-lg font-bold text-gray-700 mb-1">Profile view</h2>
|
|
382
467
|
<p class="text-sm text-gray-400 mb-4">User identity, participant ID, API token.</p>
|
package/src/web/layout.ts
CHANGED
|
@@ -4,16 +4,39 @@ export interface NavOpts {
|
|
|
4
4
|
userName?: string;
|
|
5
5
|
orgName?: string;
|
|
6
6
|
email?: string;
|
|
7
|
+
plan?: string;
|
|
8
|
+
banner?: { message: string; style?: "info" | "success" | "warning" };
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
export
|
|
11
|
+
export interface SeoOpts {
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
canonical?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function layout(body: string, title = "Polaris", seo?: SeoOpts): Response {
|
|
18
|
+
const pageTitle = seo?.title ?? title;
|
|
19
|
+
const description = seo?.description ?? "It's like Gong for Claude Code sessions. Capture every prompt, response, and tool call. Stream to Slack. Collaborate in real time.";
|
|
20
|
+
const canonical = seo?.canonical ?? "https://app.withpolaris.ai";
|
|
21
|
+
const ogImage = "https://app.withpolaris.ai/og-image.png";
|
|
10
22
|
return new Response(
|
|
11
23
|
`<!DOCTYPE html>
|
|
12
24
|
<html lang="en">
|
|
13
25
|
<head>
|
|
14
26
|
<meta charset="utf-8">
|
|
15
27
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
16
|
-
<title>${
|
|
28
|
+
<title>${pageTitle}</title>
|
|
29
|
+
<meta name="description" content="${description}">
|
|
30
|
+
<link rel="canonical" href="${canonical}">
|
|
31
|
+
<meta property="og:type" content="website">
|
|
32
|
+
<meta property="og:title" content="${pageTitle}">
|
|
33
|
+
<meta property="og:description" content="${description}">
|
|
34
|
+
<meta property="og:image" content="${ogImage}">
|
|
35
|
+
<meta property="og:url" content="${canonical}">
|
|
36
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
37
|
+
<meta name="twitter:title" content="${pageTitle}">
|
|
38
|
+
<meta name="twitter:description" content="${description}">
|
|
39
|
+
<meta name="twitter:image" content="${ogImage}">
|
|
17
40
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
18
41
|
<script>
|
|
19
42
|
tailwind.config = {
|
|
@@ -53,7 +76,7 @@ export function nav(token?: string, opts?: NavOpts): string {
|
|
|
53
76
|
<div class="w-7 h-7 rounded-full bg-polaris-600 flex items-center justify-center text-white text-xs font-bold">${(opts?.userName ?? "U").charAt(0).toUpperCase()}</div>
|
|
54
77
|
<div class="text-left">
|
|
55
78
|
<p class="text-xs font-medium text-gray-900 leading-tight">${opts?.userName ?? ""}</p>
|
|
56
|
-
<p class="text-[10px] text-gray-400 leading-tight">${opts?.orgName ?? ""}</p>
|
|
79
|
+
<p class="text-[10px] text-gray-400 leading-tight">${opts?.orgName ?? ""}${opts?.plan && opts.plan !== "free" ? ` <span class="inline-flex items-center px-1.5 py-0 rounded-full text-[9px] font-semibold bg-polaris-100 text-polaris-700">${opts.plan.charAt(0).toUpperCase() + opts.plan.slice(1)}</span>` : ""}</p>
|
|
57
80
|
</div>
|
|
58
81
|
<svg class="w-3.5 h-3.5 text-gray-400 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
59
82
|
</button>
|
|
@@ -75,13 +98,24 @@ export function nav(token?: string, opts?: NavOpts): string {
|
|
|
75
98
|
<svg width="14" height="14" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 01-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/><path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009 18z" fill="#34A853"/><path d="M3.964 10.71A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/><path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 00.957 4.958L3.964 6.29C4.672 4.163 6.656 2.58 9 3.58z" fill="#EA4335"/></svg>
|
|
76
99
|
Sign up
|
|
77
100
|
</a>`;
|
|
101
|
+
const bannerColors = {
|
|
102
|
+
info: "bg-polaris-50 border-polaris-200 text-polaris-800",
|
|
103
|
+
success: "bg-green-50 border-green-200 text-green-800",
|
|
104
|
+
warning: "bg-amber-50 border-amber-200 text-amber-800",
|
|
105
|
+
};
|
|
106
|
+
const bannerHtml = opts?.banner
|
|
107
|
+
? `<div class="border-b ${bannerColors[opts.banner.style ?? "info"]}">
|
|
108
|
+
<div class="max-w-5xl mx-auto px-6 py-2 text-sm">${opts.banner.message}</div>
|
|
109
|
+
</div>`
|
|
110
|
+
: "";
|
|
111
|
+
|
|
78
112
|
return `
|
|
79
113
|
<nav class="border-b border-gray-200 bg-white">
|
|
80
114
|
<div class="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
|
|
81
115
|
<a href="${token ? `/dashboard?token=${token}` : "/"}" class="text-lg font-bold tracking-tight text-gray-900">Polaris</a>
|
|
82
116
|
<div class="flex items-center gap-4">${right}</div>
|
|
83
117
|
</div>
|
|
84
|
-
</nav
|
|
118
|
+
</nav>${bannerHtml}`;
|
|
85
119
|
}
|
|
86
120
|
|
|
87
121
|
export const slackIcon = `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.124 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.52 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312zm-2.522 10.124a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.52v-2.523h2.521zm0-1.271a2.527 2.527 0 0 1-2.521-2.521 2.528 2.528 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.522h-6.313z"/></svg>`;
|
package/src/web/pages.ts
CHANGED
|
@@ -374,54 +374,64 @@ export function renderLandingPage(): string {
|
|
|
374
374
|
<span class="text-4xl font-bold text-gray-900">$0</span>
|
|
375
375
|
<span class="text-gray-500 text-sm ml-1">forever</span>
|
|
376
376
|
</div>
|
|
377
|
-
<p class="mt-2 text-sm text-gray-500">
|
|
377
|
+
<p class="mt-2 text-sm text-gray-500">Try Polaris with your team. No credit card required.</p>
|
|
378
378
|
<a href="/signup?plan=free" class="mt-6 block w-full text-center px-4 py-2.5 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition">Get started</a>
|
|
379
379
|
<ul class="mt-6 space-y-3 text-sm text-gray-700">
|
|
380
380
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Unlimited users</li>
|
|
381
381
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>1,000</strong> prompts / month</span></li>
|
|
382
382
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>5 GB</strong> data captured</span></li>
|
|
383
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>7 days session history</li>
|
|
383
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>7 days</strong> session history</span></li>
|
|
384
384
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Community support</li>
|
|
385
385
|
</ul>
|
|
386
386
|
</div>
|
|
387
387
|
|
|
388
|
-
<!--
|
|
388
|
+
<!-- Pro -->
|
|
389
389
|
<div class="bg-white border-2 border-polaris-600 rounded-xl p-8">
|
|
390
|
-
<h3 class="text-sm font-semibold text-polaris-600 uppercase tracking-wider">
|
|
390
|
+
<h3 class="text-sm font-semibold text-polaris-600 uppercase tracking-wider">Pro</h3>
|
|
391
391
|
<div class="mt-4">
|
|
392
|
-
<span class="text-4xl font-bold text-gray-900">$
|
|
392
|
+
<span class="text-4xl font-bold text-gray-900">$29</span>
|
|
393
393
|
<span class="text-gray-500 text-sm ml-1">/month</span>
|
|
394
394
|
</div>
|
|
395
|
-
<p class="mt-2 text-sm text-gray-500">For
|
|
396
|
-
<a href="/signup?plan=
|
|
395
|
+
<p class="mt-2 text-sm text-gray-500">For power users who rely on Polaris daily.</p>
|
|
396
|
+
<a href="/signup?plan=pro" class="mt-6 block w-full text-center px-4 py-2.5 bg-polaris-700 text-white text-sm font-medium rounded-lg hover:bg-polaris-800 transition">Get started</a>
|
|
397
397
|
<ul class="mt-6 space-y-3 text-sm text-gray-700">
|
|
398
398
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Unlimited users</li>
|
|
399
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>
|
|
400
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>
|
|
401
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>
|
|
402
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
399
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>5,000</strong> prompts / month</span></li>
|
|
400
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>25 GB</strong> data captured</span></li>
|
|
401
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>30 days</strong> session history</span></li>
|
|
402
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Community support</li>
|
|
403
403
|
</ul>
|
|
404
404
|
</div>
|
|
405
405
|
|
|
406
|
-
<!--
|
|
406
|
+
<!-- Team -->
|
|
407
407
|
<div class="bg-white border border-gray-200 rounded-xl p-8">
|
|
408
|
-
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">
|
|
408
|
+
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Team</h3>
|
|
409
409
|
<div class="mt-4">
|
|
410
|
-
<span class="text-4xl font-bold text-gray-900"
|
|
410
|
+
<span class="text-4xl font-bold text-gray-900">$49</span>
|
|
411
|
+
<span class="text-gray-500 text-sm ml-1">/month</span>
|
|
411
412
|
</div>
|
|
412
|
-
<p class="mt-2 text-sm text-gray-500">For
|
|
413
|
-
<a href="
|
|
413
|
+
<p class="mt-2 text-sm text-gray-500">For teams that need capacity, history, and support.</p>
|
|
414
|
+
<a href="/signup?plan=team" class="mt-6 block w-full text-center px-4 py-2.5 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition">Get started</a>
|
|
414
415
|
<ul class="mt-6 space-y-3 text-sm text-gray-700">
|
|
415
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
416
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
417
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
418
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
419
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
416
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Unlimited users</li>
|
|
417
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>10,000</strong> prompts / month</span></li>
|
|
418
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>50 GB</strong> data captured</span></li>
|
|
419
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>90 days</strong> session history</span></li>
|
|
420
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Priority support</li>
|
|
420
421
|
</ul>
|
|
421
422
|
</div>
|
|
422
423
|
|
|
423
424
|
</div>
|
|
424
425
|
|
|
426
|
+
<!-- Enterprise banner -->
|
|
427
|
+
<div class="mt-8 bg-white border border-gray-200 rounded-xl p-8 flex flex-col md:flex-row items-center justify-between gap-6">
|
|
428
|
+
<div>
|
|
429
|
+
<h3 class="font-semibold text-gray-900">Enterprise</h3>
|
|
430
|
+
<p class="mt-1 text-sm text-gray-500">Custom limits, SSO, audit logs, compliance, and dedicated support.</p>
|
|
431
|
+
</div>
|
|
432
|
+
<a href="mailto:support@withpolaris.ai?subject=Enterprise%20plan%20inquiry" class="shrink-0 px-5 py-2.5 bg-white border border-gray-300 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-50 transition">Contact us</a>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
425
435
|
<div class="mt-12 text-center text-sm text-gray-400">
|
|
426
436
|
All plans include unlimited users and real-time Slack streaming. No credit card required for Free.
|
|
427
437
|
</div>
|
package/src/web/views.ts
CHANGED
|
@@ -20,10 +20,12 @@ interface ViewContext {
|
|
|
20
20
|
hasConnectedSession: boolean;
|
|
21
21
|
totalPrompts: number;
|
|
22
22
|
teamMembers?: TeamMember[];
|
|
23
|
+
plan?: string;
|
|
24
|
+
dailyPrompts?: Array<{ date: string; sender: string; count: number }>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function navOpts(ctx: ViewContext): NavOpts {
|
|
26
|
-
return { userName: ctx.userName, orgName: ctx.orgName, email: ctx.email };
|
|
28
|
+
return { userName: ctx.userName, orgName: ctx.orgName, email: ctx.email, plan: ctx.plan, banner: bannerForCtx(ctx) };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
// --- Copyable code block ---
|
|
@@ -54,6 +56,16 @@ function statusBadge(label: string, done: boolean): string {
|
|
|
54
56
|
: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">${label}</span>`;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
// --- Banner helpers ---
|
|
60
|
+
|
|
61
|
+
function bannerForCtx(ctx: ViewContext): { message: string; style: "info" | "success" | "warning" } | undefined {
|
|
62
|
+
if (ctx.plan && ctx.plan !== "free") {
|
|
63
|
+
const label = ctx.plan.charAt(0).toUpperCase() + ctx.plan.slice(1);
|
|
64
|
+
return { message: `<strong>${label} plan</strong> — we'll reach out shortly to get you set up. Full access in the meantime.`, style: "info" };
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
// --- Team members ---
|
|
58
70
|
|
|
59
71
|
function renderTeamMembers(members: TeamMember[], currentEmail: string): string {
|
|
@@ -72,6 +84,118 @@ function renderTeamMembers(members: TeamMember[], currentEmail: string): string
|
|
|
72
84
|
</div>`;
|
|
73
85
|
}
|
|
74
86
|
|
|
87
|
+
// --- Daily prompts chart ---
|
|
88
|
+
|
|
89
|
+
function renderDailyPrompts(data: Array<{ date: string; sender: string; count: number }> | undefined): string {
|
|
90
|
+
if (!data || data.length === 0) return "";
|
|
91
|
+
|
|
92
|
+
const id = `heatmap-${Math.random().toString(36).slice(2, 8)}`;
|
|
93
|
+
|
|
94
|
+
// Build date columns for last 14 days
|
|
95
|
+
const dates: string[] = [];
|
|
96
|
+
const now = new Date();
|
|
97
|
+
for (let i = 13; i >= 0; i--) {
|
|
98
|
+
const d = new Date(now);
|
|
99
|
+
d.setDate(d.getDate() - i);
|
|
100
|
+
dates.push(d.toISOString().slice(0, 10));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Totals per day
|
|
104
|
+
const dayTotals = new Map<string, number>();
|
|
105
|
+
let total = 0;
|
|
106
|
+
for (const d of data) {
|
|
107
|
+
dayTotals.set(d.date, (dayTotals.get(d.date) ?? 0) + d.count);
|
|
108
|
+
total += d.count;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Group by sender
|
|
112
|
+
const bySender = new Map<string, Map<string, number>>();
|
|
113
|
+
for (const d of data) {
|
|
114
|
+
if (!bySender.has(d.sender)) bySender.set(d.sender, new Map());
|
|
115
|
+
bySender.get(d.sender)!.set(d.date, d.count);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function intensity(count: number, max: number): string {
|
|
119
|
+
if (count === 0) return "bg-gray-100";
|
|
120
|
+
const ratio = count / max;
|
|
121
|
+
if (ratio < 0.25) return "bg-polaris-100";
|
|
122
|
+
if (ratio < 0.5) return "bg-polaris-200";
|
|
123
|
+
if (ratio < 0.75) return "bg-polaris-400";
|
|
124
|
+
return "bg-polaris-600";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function shortName(sender: string): string {
|
|
128
|
+
const name = sender.replace(/^user:/, "").split(".")[0];
|
|
129
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Team view — histogram bars
|
|
133
|
+
const teamMax = Math.max(...dates.map((d) => dayTotals.get(d) ?? 0), 1);
|
|
134
|
+
const teamBars = dates.map((date) => {
|
|
135
|
+
const count = dayTotals.get(date) ?? 0;
|
|
136
|
+
const pct = Math.max((count / teamMax) * 100, count > 0 ? 12 : 0);
|
|
137
|
+
const label = date.slice(5).replace("-", "/");
|
|
138
|
+
const day = new Date(date + "T12:00:00").toLocaleDateString("en-US", { weekday: "short" });
|
|
139
|
+
return `
|
|
140
|
+
<div class="flex flex-col items-center gap-1 flex-1" title="${day} ${label}: ${count}">
|
|
141
|
+
${count > 0 ? `<span class="text-[9px] text-gray-400 leading-none">${count}</span>` : `<span class="text-[9px] leading-none"> </span>`}
|
|
142
|
+
<div class="w-full flex flex-col justify-end" style="height: 40px">
|
|
143
|
+
<div class="w-full rounded-sm ${count > 0 ? "bg-polaris-400" : "bg-gray-100"}" style="height: ${pct}%"></div>
|
|
144
|
+
</div>
|
|
145
|
+
<span class="text-[8px] text-gray-400 leading-none">${label}</span>
|
|
146
|
+
</div>`;
|
|
147
|
+
}).join("");
|
|
148
|
+
|
|
149
|
+
const teamView = `
|
|
150
|
+
<div class="flex items-end gap-0.5">
|
|
151
|
+
${teamBars}
|
|
152
|
+
</div>`;
|
|
153
|
+
|
|
154
|
+
// Per-user view — each row uses its own max for color scale
|
|
155
|
+
const userRows = Array.from(bySender.entries()).map(([sender, counts]) => {
|
|
156
|
+
const rowMax = Math.max(...dates.map((d) => counts.get(d) ?? 0), 1);
|
|
157
|
+
const cells = dates.map((date) => {
|
|
158
|
+
const count = counts.get(date) ?? 0;
|
|
159
|
+
const day = new Date(date + "T12:00:00").toLocaleDateString("en-US", { weekday: "short" });
|
|
160
|
+
return `<div class="h-5 flex-1 rounded-sm ${intensity(count, rowMax)}" title="${day} ${date.slice(5).replace("-", "/")}: ${count}"></div>`;
|
|
161
|
+
}).join("");
|
|
162
|
+
return `
|
|
163
|
+
<div class="flex items-center gap-2">
|
|
164
|
+
<span class="text-[10px] text-gray-500 w-12 text-right shrink-0 truncate">${shortName(sender)}</span>
|
|
165
|
+
<div class="flex items-center gap-px flex-1">${cells}</div>
|
|
166
|
+
</div>`;
|
|
167
|
+
}).join("");
|
|
168
|
+
|
|
169
|
+
const dateLabels = dates.map((date) => {
|
|
170
|
+
return `<div class="flex-1 text-center text-[8px] text-gray-400 leading-none">${date.slice(5).replace("-", "/")}</div>`;
|
|
171
|
+
}).join("");
|
|
172
|
+
|
|
173
|
+
const userView = `
|
|
174
|
+
<div class="flex flex-col gap-1">
|
|
175
|
+
${userRows}
|
|
176
|
+
<div class="flex items-center gap-2">
|
|
177
|
+
<span class="w-12 shrink-0"></span>
|
|
178
|
+
<div class="flex items-center gap-px flex-1">${dateLabels}</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>`;
|
|
181
|
+
|
|
182
|
+
return `
|
|
183
|
+
<div class="mt-3 bg-white border border-gray-200 rounded-lg px-4 py-3">
|
|
184
|
+
<div class="flex items-center justify-between mb-2">
|
|
185
|
+
<p class="text-xs font-medium text-gray-500">Prompts captured</p>
|
|
186
|
+
<div class="flex items-center gap-2">
|
|
187
|
+
<span class="text-xs text-gray-400">${total} last 14d</span>
|
|
188
|
+
<div class="flex items-center bg-gray-100 rounded-md p-0.5">
|
|
189
|
+
<button id="${id}-btn-team" onclick="document.getElementById('${id}-team').classList.remove('hidden');document.getElementById('${id}-user').classList.add('hidden');this.classList.add('bg-white','shadow-sm','text-gray-700');this.classList.remove('text-gray-400');var o=document.getElementById('${id}-btn-user');o.classList.remove('bg-white','shadow-sm','text-gray-700');o.classList.add('text-gray-400')" class="text-[10px] font-medium px-2 py-0.5 rounded cursor-pointer bg-white shadow-sm text-gray-700">Team</button>
|
|
190
|
+
<button id="${id}-btn-user" onclick="document.getElementById('${id}-user').classList.remove('hidden');document.getElementById('${id}-team').classList.add('hidden');this.classList.add('bg-white','shadow-sm','text-gray-700');this.classList.remove('text-gray-400');var o=document.getElementById('${id}-btn-team');o.classList.remove('bg-white','shadow-sm','text-gray-700');o.classList.add('text-gray-400')" class="text-[10px] font-medium px-2 py-0.5 rounded cursor-pointer text-gray-400">By user</button>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<div id="${id}-team">${teamView}</div>
|
|
195
|
+
<div id="${id}-user" class="hidden">${userView}</div>
|
|
196
|
+
</div>`;
|
|
197
|
+
}
|
|
198
|
+
|
|
75
199
|
// --- Floor section ---
|
|
76
200
|
|
|
77
201
|
type StepState = "done" | "active" | "future";
|
|
@@ -107,6 +231,7 @@ function renderFloorSection(ctx: ViewContext, compact = false, state: StepState
|
|
|
107
231
|
${ctx.orgSlug ? `<span class="text-xs text-gray-400 font-mono">${ctx.orgSlug}</span>` : ''}
|
|
108
232
|
${promptStat}
|
|
109
233
|
</div>
|
|
234
|
+
${renderDailyPrompts(ctx.dailyPrompts)}
|
|
110
235
|
${ctx.teamMembers ? renderTeamMembers(ctx.teamMembers, ctx.email) : ""}
|
|
111
236
|
</div>`;
|
|
112
237
|
}
|
|
@@ -131,6 +256,7 @@ function renderFloorSection(ctx: ViewContext, compact = false, state: StepState
|
|
|
131
256
|
<p class="text-sm font-medium text-gray-900">Slack</p>
|
|
132
257
|
${slugLabel}
|
|
133
258
|
</div>
|
|
259
|
+
${renderDailyPrompts(ctx.dailyPrompts)}
|
|
134
260
|
${ctx.teamMembers ? renderTeamMembers(ctx.teamMembers, ctx.email) : ""}
|
|
135
261
|
</div>`;
|
|
136
262
|
}
|
package/tests/db.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
import { resetTestData } from "./helpers";
|
|
2
3
|
import {
|
|
3
4
|
createDb,
|
|
4
5
|
createOrg,
|
|
@@ -29,13 +30,7 @@ let sql: Sql;
|
|
|
29
30
|
|
|
30
31
|
beforeEach(async () => {
|
|
31
32
|
sql = await createDb(DATABASE_URL);
|
|
32
|
-
await sql
|
|
33
|
-
await sql`DROP TABLE IF EXISTS sessions`;
|
|
34
|
-
await sql`DROP TABLE IF EXISTS projects`;
|
|
35
|
-
await sql`DROP TABLE IF EXISTS users`;
|
|
36
|
-
await sql`DROP TABLE IF EXISTS orgs`;
|
|
37
|
-
await sql.end();
|
|
38
|
-
sql = await createDb(DATABASE_URL);
|
|
33
|
+
await resetTestData(sql);
|
|
39
34
|
await createOrg(sql, "test-org", "Test Org");
|
|
40
35
|
});
|
|
41
36
|
|
package/tests/helpers.ts
CHANGED
|
@@ -1,71 +1,22 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { ensureSchema, type Sql } from "../src/service/db";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Reset test data: drop
|
|
5
|
-
*
|
|
4
|
+
* Reset test data to a clean slate: drop every table in the public schema, then
|
|
5
|
+
* recreate the canonical schema via `ensureSchema` (the same DDL production uses).
|
|
6
|
+
*
|
|
7
|
+
* Dropping dynamically with CASCADE means new tables and foreign keys never break
|
|
8
|
+
* the reset, and recreating via `ensureSchema` means the test schema can't drift
|
|
9
|
+
* from `src/service/db.ts` (previously this hand-maintained its own CREATE TABLEs,
|
|
10
|
+
* which silently went stale when columns/tables like `orgs.plan` and `plan_changes`
|
|
11
|
+
* were added).
|
|
6
12
|
*/
|
|
7
13
|
export async function resetTestData(sql: Sql): Promise<void> {
|
|
8
|
-
await sql
|
|
9
|
-
|
|
10
|
-
await sql`DROP TABLE IF EXISTS projects`;
|
|
11
|
-
await sql`DROP TABLE IF EXISTS users`;
|
|
12
|
-
await sql`DROP TABLE IF EXISTS orgs`;
|
|
13
|
-
await sql`
|
|
14
|
-
CREATE TABLE orgs (
|
|
15
|
-
id TEXT PRIMARY KEY,
|
|
16
|
-
name TEXT NOT NULL,
|
|
17
|
-
slug TEXT UNIQUE,
|
|
18
|
-
domain TEXT,
|
|
19
|
-
slack_team_id TEXT,
|
|
20
|
-
slack_bot_token TEXT,
|
|
21
|
-
slack_system_channel_id TEXT,
|
|
22
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
23
|
-
)
|
|
14
|
+
const tables = await sql<{ tablename: string }[]>`
|
|
15
|
+
SELECT tablename FROM pg_tables WHERE schemaname = 'public'
|
|
24
16
|
`;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
name TEXT NOT NULL,
|
|
30
|
-
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
31
|
-
participant_id TEXT NOT NULL,
|
|
32
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
33
|
-
)
|
|
34
|
-
`;
|
|
35
|
-
await sql`
|
|
36
|
-
CREATE TABLE projects (
|
|
37
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
38
|
-
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
39
|
-
name TEXT NOT NULL,
|
|
40
|
-
slack_channel_id TEXT,
|
|
41
|
-
slack_channel_name TEXT,
|
|
42
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
43
|
-
UNIQUE (org_id, name)
|
|
44
|
-
)
|
|
45
|
-
`;
|
|
46
|
-
await sql`
|
|
47
|
-
CREATE TABLE sessions (
|
|
48
|
-
name TEXT NOT NULL,
|
|
49
|
-
project_id UUID NOT NULL REFERENCES projects(id),
|
|
50
|
-
org_id TEXT NOT NULL,
|
|
51
|
-
driver TEXT,
|
|
52
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
53
|
-
PRIMARY KEY (project_id, name)
|
|
54
|
-
)
|
|
55
|
-
`;
|
|
56
|
-
await sql`
|
|
57
|
-
CREATE TABLE events (
|
|
58
|
-
id UUID PRIMARY KEY,
|
|
59
|
-
org_id TEXT NOT NULL,
|
|
60
|
-
project_id UUID NOT NULL REFERENCES projects(id),
|
|
61
|
-
session TEXT NOT NULL,
|
|
62
|
-
timestamp TIMESTAMPTZ NOT NULL,
|
|
63
|
-
source TEXT NOT NULL,
|
|
64
|
-
sender TEXT NOT NULL,
|
|
65
|
-
payload JSONB NOT NULL
|
|
66
|
-
)
|
|
67
|
-
`;
|
|
68
|
-
await sql`CREATE INDEX idx_events_project ON events(project_id, timestamp)`;
|
|
69
|
-
await sql`CREATE INDEX idx_events_session ON events(project_id, session, timestamp)`;
|
|
17
|
+
for (const { tablename } of tables) {
|
|
18
|
+
await sql`DROP TABLE IF EXISTS ${sql(tablename)} CASCADE`;
|
|
19
|
+
}
|
|
20
|
+
await ensureSchema(sql);
|
|
70
21
|
await sql`INSERT INTO orgs (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`;
|
|
71
22
|
}
|