@lightupai/polaris 0.0.51 → 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 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 |
@@ -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/
@@ -0,0 +1,167 @@
1
+ # Signup Flow — Design & Todos
2
+
3
+ ## Current State
4
+
5
+ User clicks "Get started" on the landing page → Google OAuth → user/org created in DB → redirect to dashboard. No plan differentiation, no onboarding, no Slack connection prompt. New users land on an empty dashboard with no guidance.
6
+
7
+ ## Target State
8
+
9
+ A user who signs up should experience a smooth path from landing page to their first live session:
10
+
11
+ 1. Choose a plan (or default to free)
12
+ 2. Sign up with Google
13
+ 3. Connect their Slack workspace
14
+ 4. Install the CLI
15
+ 5. Join a channel and start their first session
16
+
17
+ Each step should feel intentional, not accidental. The user should never be left wondering "what do I do next?"
18
+
19
+ ---
20
+
21
+ ## Design
22
+
23
+ ### Plan Selection → Signup
24
+
25
+ The pricing cards on the landing page link to `/signup?plan=free` or `/signup?plan=team`. Enterprise goes to `mailto:support@withpolaris.ai`. The plan parameter is stored in the OAuth state, survives the Google redirect, and is persisted on the org record after signup.
26
+
27
+ If no plan is specified (e.g., user clicks the nav "Sign up" button), default to `free`.
28
+
29
+ ### Post-Signup Routing
30
+
31
+ After Google OAuth completes, the callback handler should route based on the user's state:
32
+
33
+ | Scenario | Route to |
34
+ |---|---|
35
+ | Existing user (login) | Dashboard |
36
+ | New user, existing org | Dashboard (org already set up) |
37
+ | New user, new org, no Slack | Onboarding: Connect Slack |
38
+ | New user, new org, Slack connected | Onboarding: Install CLI |
39
+
40
+ ### Onboarding Flow
41
+
42
+ A dedicated onboarding page replaces the empty dashboard for new orgs. Three steps, shown as a checklist:
43
+
44
+ **Step 1: Connect Slack**
45
+ - Large "Add to Slack" button
46
+ - Explain: "Polaris uses Slack as the collaboration layer. Connect your workspace to get started."
47
+ - After connecting, auto-advance to Step 2
48
+
49
+ **Step 2: Install the CLI**
50
+ - Show `npx @lightupai/polaris` with a copy button
51
+ - Explain: "Run this on your machine to set up hooks and log in."
52
+ - A "I've installed it" button to advance (or auto-detect via CLI auth callback)
53
+
54
+ **Step 3: Start a session**
55
+ - Show `/polaris join #your-channel` with a copy button
56
+ - Explain: "Run this in Claude Code to start streaming your session."
57
+ - Link to dashboard
58
+
59
+ The existing `renderWelcomePage` and `renderSetupView` functions can be repurposed for this flow.
60
+
61
+ ### Welcome Email
62
+
63
+ After a new user signs up, send a welcome email to their address from `hello@withpolaris.ai`:
64
+
65
+ - Welcome to Polaris
66
+ - Quick start: Connect Slack → Install CLI → Join a channel
67
+ - Link to community (GitHub Discussions)
68
+ - Link to support (support@withpolaris.ai)
69
+
70
+ Requires an email sending service (Resend, SendGrid, or Postmark). Cloudflare email routing only handles inbound — outbound needs a transactional email provider.
71
+
72
+ ### Plan Enforcement
73
+
74
+ Plans are stored on the org record. Limits are enforced at the event ingestion layer:
75
+
76
+ | | Free | Team | Enterprise |
77
+ |---|---|---|---|
78
+ | Users | Unlimited | Unlimited | Unlimited |
79
+ | Prompts/month | 1,000 | 10,000 | Custom |
80
+ | Data captured | 5 GB | 50 GB | Custom |
81
+ | Retention | 7 days | 90 days | Custom |
82
+
83
+ **Enforcement approach:**
84
+ - Count user prompts per org per calendar month (query events table)
85
+ - Track cumulative data volume per org per month
86
+ - Run a daily cleanup job to delete events beyond the retention window
87
+ - When a limit is reached, soft-block: stop capturing new events but keep Slack streaming active. Show upgrade prompt in dashboard and optionally in Slack.
88
+
89
+ ### Upgrade Flow
90
+
91
+ When a free-tier org approaches or hits their limit:
92
+
93
+ 1. Dashboard shows a usage bar with current/max for prompts and data
94
+ 2. At 80%, show a yellow warning: "You've used 80% of your free prompts this month"
95
+ 3. At 100%, show upgrade prompt: "You've reached your free plan limit. Upgrade to Team for 10x capacity."
96
+ 4. Upgrade button links to Stripe Checkout for the $49/mo Team plan
97
+ 5. After successful payment, Stripe webhook updates the org's plan to `team`
98
+
99
+ ### Billing (Stripe)
100
+
101
+ **Setup needed:**
102
+ - Stripe product: "Polaris Team"
103
+ - Stripe price: $49/month, recurring
104
+ - Checkout session: created when user clicks "Upgrade" or selects Team plan during signup
105
+ - Webhook endpoint: `/stripe/webhook` to handle `checkout.session.completed`, `customer.subscription.deleted`, `invoice.payment_failed`
106
+ - Org record fields: `stripe_customer_id`, `stripe_subscription_id`, `plan`, `plan_status`
107
+
108
+ **Downgrade:**
109
+ - User cancels in Stripe customer portal
110
+ - Webhook fires `customer.subscription.deleted`
111
+ - Org plan reverts to `free`
112
+ - Free plan limits take effect at next billing cycle
113
+
114
+ ---
115
+
116
+ ## Todos
117
+
118
+ ### Phase 1: Foundation (do first)
119
+
120
+ - [ ] **Store selected plan on org record**
121
+ Add a `plan` column to the orgs table (free/team/enterprise, default 'free'). Populate from the `?plan` query param during signup. Show current plan on the dashboard.
122
+
123
+ - [ ] **Prompt Slack connection immediately after signup**
124
+ For new orgs without Slack connected, route to setup view with prominent "Add to Slack" button instead of empty dashboard. Ensure the flow from signup → setup is seamless.
125
+
126
+ - [ ] **Post-signup onboarding flow**
127
+ After a brand new org signup, redirect to onboarding page (not dashboard). Three steps: Connect Slack → Install CLI → Join a channel. Repurpose existing `renderWelcomePage`/`renderSetupView`. Auto-advance when each step is completed.
128
+
129
+ ### Phase 2: Billing
130
+
131
+ - [ ] **Stripe integration for Team plan billing**
132
+ Stripe product/price setup, checkout session on Team plan selection, webhook handler for payment events, store subscription status on org. Handle both new signups selecting Team and existing free users upgrading.
133
+
134
+ - [ ] **Enforce free tier usage limits**
135
+ Prompt counter per org per month, data volume tracking, auto-cleanup of events beyond retention window. Soft-block at limits (stop capture, keep streaming, show upgrade prompt).
136
+
137
+ - [ ] **Upgrade prompt when limits are reached**
138
+ Usage bars on dashboard, warning at 80%, upgrade prompt at 100%. Link to Stripe checkout. Also notify in Slack when org approaches limit.
139
+
140
+ - [ ] **Plan management page in dashboard**
141
+ Show current plan, usage stats (prompts, data, retention), upgrade/downgrade buttons, Stripe customer portal link. Accessible from profile dropdown.
142
+
143
+ ### Phase 3: Polish
144
+
145
+ - [ ] **Welcome email on signup**
146
+ Pick a transactional email service (Resend recommended — simple API, good deliverability, free tier). Send welcome email with quick-start steps and community link. Send from hello@withpolaris.ai.
147
+
148
+ ---
149
+
150
+ ## Dependencies
151
+
152
+ ```
153
+ Store plan on org ──→ Enforce limits ──→ Upgrade prompt
154
+ ──→ Stripe billing ──→ Plan management page
155
+ ──→ Onboarding flow
156
+
157
+ Prompt Slack connection ──→ Onboarding flow
158
+
159
+ Welcome email (independent — needs email service selection)
160
+ ```
161
+
162
+ ## Open Questions
163
+
164
+ - Should we allow plan changes mid-month, or only at billing cycle boundaries?
165
+ - Should free-tier users who hit limits lose access to existing session data, or just stop capturing new data?
166
+ - Do we want a 14-day free trial of Team, or keep it strictly free-then-pay?
167
+ - Should the CLI install step in onboarding auto-detect completion (via the `/auth/cli` callback), or require manual confirmation?
package/og-image.png ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightupai/polaris",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "polaris": "bin/polaris",
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
- return sql;
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";
@@ -36,13 +38,31 @@ import {
36
38
 
37
39
  const SIGNUP_CHANNEL = "#alerts-mql-stream";
38
40
 
39
- function notifySignup(opts: { name: string; email: string; domain: string; orgName: string; isNewOrg: boolean }): void {
41
+ function notifySignup(opts: { name: string; email: string; domain: string; orgName: string; isNewOrg: boolean; plan?: string }): void {
40
42
  const botToken = process.env.SIGNUP_SLACK_BOT_TOKEN;
41
43
  if (!botToken) return;
42
44
 
43
45
  const emoji = opts.isNewOrg ? ":tada:" : ":wave:";
44
46
  const action = opts.isNewOrg ? "signed up (new org)" : "joined";
45
- const text = `${emoji} *${opts.name}* (${opts.email}) ${action} ${opts.orgName} (${opts.domain})`;
47
+ const planTag = opts.plan ? ` [${opts.plan}]` : "";
48
+ const text = `${emoji} *${opts.name}* (${opts.email}) ${action} — ${opts.orgName} (${opts.domain})${planTag}`;
49
+
50
+ fetch("https://slack.com/api/chat.postMessage", {
51
+ method: "POST",
52
+ headers: {
53
+ Authorization: `Bearer ${botToken}`,
54
+ "Content-Type": "application/json",
55
+ },
56
+ body: JSON.stringify({ channel: SIGNUP_CHANNEL, text }),
57
+ }).catch(() => {});
58
+ }
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}`;
46
66
 
47
67
  fetch("https://slack.com/api/chat.postMessage", {
48
68
  method: "POST",
@@ -91,7 +111,7 @@ function getGoogle(): Google {
91
111
  );
92
112
  }
93
113
 
94
- const oauthStates = new Map<string, { type: "login" | "signup"; codeVerifier: string; timestamp: number }>();
114
+ const oauthStates = new Map<string, { type: "login" | "signup"; codeVerifier: string; timestamp: number; plan?: string }>();
95
115
  const cliCallbackPorts = new Map<string, number>(); // state → CLI local server port
96
116
 
97
117
  // If this auth flow was initiated by the CLI, redirect the token to the CLI's local server.
@@ -120,19 +140,54 @@ export function createApp(sql: Sql) {
120
140
  // Start hourly signup rollup
121
141
  startSignupRollup(sql);
122
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
+
123
173
  // --- Landing page ---
124
174
 
125
175
  app.get("/", (c) => {
126
- 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
+ });
127
181
  });
128
182
 
129
183
  // --- Auth: single Google SSO flow for both signup and login ---
130
184
 
131
- function startGoogleAuth(c: { redirect: (url: string) => Response }) {
185
+ function startGoogleAuth(c: { req: { query: (k: string) => string | undefined }; redirect: (url: string) => Response }) {
132
186
  const google = getGoogle();
133
187
  const state = crypto.randomUUID();
134
188
  const codeVerifier = crypto.randomUUID();
135
- oauthStates.set(state, { type: "login", codeVerifier, timestamp: Date.now() });
189
+ const plan = c.req.query("plan");
190
+ oauthStates.set(state, { type: "login", codeVerifier, timestamp: Date.now(), plan });
136
191
  const url = google.createAuthorizationURL(state, codeVerifier, ["openid", "email", "profile"]);
137
192
  return c.redirect(url.toString());
138
193
  }
@@ -167,6 +222,14 @@ export function createApp(sql: Sql) {
167
222
  // 1. Existing user → log in
168
223
  const existingUser = await getUserByEmail(sql, email);
169
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
+
170
233
  const token = await createToken({
171
234
  sub: existingUser.id,
172
235
  email: existingUser.email,
@@ -184,6 +247,11 @@ export function createApp(sql: Sql) {
184
247
  const participantId = `user:${name.toLowerCase().replace(/\s+/g, ".")}`;
185
248
  await createUser(sql, userId, email, name, existingOrg.id, participantId);
186
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
+
187
255
  // Notify org's Slack system channel
188
256
  postSystemEvent({
189
257
  sql,
@@ -195,7 +263,7 @@ export function createApp(sql: Sql) {
195
263
  }).catch(() => {});
196
264
 
197
265
  // Notify internal team
198
- notifySignup({ name, email, domain, orgName: existingOrg.name, isNewOrg: false });
266
+ notifySignup({ name, email, domain, orgName: existingOrg.name, isNewOrg: false, plan: stateData.plan });
199
267
 
200
268
  const token = await createToken({ sub: userId, email, name, org_id: existingOrg.id, participant_id: participantId });
201
269
  return authRedirect(c, state, token);
@@ -205,7 +273,7 @@ export function createApp(sql: Sql) {
205
273
  const orgName = domain.split(".")[0].charAt(0).toUpperCase() + domain.split(".")[0].slice(1);
206
274
  const orgId = crypto.randomUUID();
207
275
  try {
208
- await createOrg(sql, orgId, orgName, domain);
276
+ await createOrg(sql, orgId, orgName, domain, stateData.plan);
209
277
  } catch {
210
278
  return layout(renderErrorView("Failed to create team. Please try again.", "Try again", "/login"));
211
279
  }
@@ -214,7 +282,7 @@ export function createApp(sql: Sql) {
214
282
  await createUser(sql, userId, email, name, orgId, participantId);
215
283
 
216
284
  // Notify internal team
217
- notifySignup({ name, email, domain, orgName, isNewOrg: true });
285
+ notifySignup({ name, email, domain, orgName, isNewOrg: true, plan: stateData.plan });
218
286
 
219
287
  const token = await createToken({ sub: userId, email, name, org_id: orgId, participant_id: participantId });
220
288
  return authRedirect(c, state, token);
@@ -251,8 +319,9 @@ export function createApp(sql: Sql) {
251
319
  }
252
320
  } catch { /* _system project may not exist yet */ }
253
321
 
254
- // Query team members, projects, sessions, and prompt counts
322
+ // Query team members, projects, sessions, prompt counts, and daily activity
255
323
  const teamMembers = await listUsers(sql, payload.org_id);
324
+ const dailyPrompts = await getDailyPromptCounts(sql, payload.org_id);
256
325
  const projects = (await listProjects(sql, payload.org_id)).filter((p) => p.name !== "_system");
257
326
  const allSessions = (await listSessions(sql, payload.org_id)).filter((s) => s.project !== "_system");
258
327
  const promptCounts = await getSessionPromptCounts(sql, payload.org_id);
@@ -291,6 +360,8 @@ export function createApp(sql: Sql) {
291
360
  hasConnectedSession,
292
361
  totalPrompts: Array.from(promptCounts.values()).reduce((a, b) => a + b, 0),
293
362
  teamMembers: teamMembers.map((u) => ({ name: u.name, email: u.email })),
363
+ plan: org.plan,
364
+ dailyPrompts,
294
365
  };
295
366
 
296
367
  if (hasConnectedSession) {
@@ -332,10 +403,18 @@ export function createApp(sql: Sql) {
332
403
  const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, orgSlug: "lightup-data" as string | null, email: mockUser.email };
333
404
 
334
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
+ );
335
413
  const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0 };
336
- const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam };
337
- const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam };
338
- 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" };
339
418
 
340
419
  return layout(`
341
420
  <div class="max-w-5xl mx-auto px-6 py-12">
@@ -375,6 +454,14 @@ export function createApp(sql: Sql) {
375
454
  </div>
376
455
  </section>
377
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
+
378
465
  <section>
379
466
  <h2 class="text-lg font-bold text-gray-700 mb-1">Profile view</h2>
380
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 function layout(body: string, title = "Polaris"): Response {
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>${title}</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">For individuals and small teams getting started.</p>
378
- <a href="/signup" 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>
377
+ <p class="mt-2 text-sm text-gray-500">Try Polaris with your team. No credit card required.</p>
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
- <!-- Team -->
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">Team</h3>
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">$49</span>
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 teams that need more capacity, history, and search.</p>
396
- <a href="/signup" 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>
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>10,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>50 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>90 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>Priority support</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>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
- <!-- Enterprise -->
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">Enterprise</h3>
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">Custom</span>
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 organizations with compliance and scale needs.</p>
413
- <a href="mailto:hello@withpolaris.ai" class="mt-6 block w-full text-center px-4 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>
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>Everything in Team</li>
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>Custom usage limits</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>SSO, audit logs, compliance</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>Custom integrations</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>Dedicated support</li>
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">&nbsp;</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`DROP TABLE IF EXISTS events`;
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 { Sql } from "../src/service/db";
1
+ import { ensureSchema, type Sql } from "../src/service/db";
2
2
 
3
3
  /**
4
- * Reset test data: drop and recreate all tables using the canonical schema.
5
- * This ensures tests always match the current schema in db.ts.
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`DROP TABLE IF EXISTS events`;
9
- await sql`DROP TABLE IF EXISTS sessions`;
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
- await sql`
26
- CREATE TABLE users (
27
- id TEXT PRIMARY KEY,
28
- email TEXT NOT NULL UNIQUE,
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
  }