Skip to content

Commit 905c9dc

Browse files
committed
feat: enhance user management and analytics integration
1 parent 3f5c127 commit 905c9dc

20 files changed

Lines changed: 135 additions & 550 deletions

.github/workflows/deploy-supabase-functions.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ jobs:
5151
supabase functions deploy platform-access
5252
supabase functions deploy delete-account
5353
supabase functions deploy waitlist-welcome --no-verify-jwt
54+
supabase functions deploy sync-contacts

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ See reference doc for full checklists (JS, Python, pseudocode, sound, insight, t
103103
- **OAuth UX** — Google Identity Services (PKCE popup on `/auth/google/callback`); web uses `signInWithIdToken`, not `signInWithOAuth`; `googleIdentity.js` manages GIS script loading, nonce creation, and popup flow
104104
- **Service layer**`src/services/authService.js`, `profileService.js`, `entitlementService.js`, `accessService.js`, `googleTokenExchange.js`; components use `AuthContext` / `useAuth`, never import Supabase directly
105105
- **Platform access** — signed-in users pass through `checkPlatformAccess()` → Supabase Edge Function `platform-access` (account ban gate); **fail-open** on transport/invoke errors, **fail-closed** only when `allowed: false` + `reason: account_banned`; signup path uses `before-signup` / `post-signup` hooks (ban logic fail-closed)
106-
- **Postgres-portable schema**`profiles` keyed to `auth.users`; RLS on public tables; client-writable columns: `display_name`, `avatar_preference` only; `avatar_url` is OAuth/trigger-populated (not client-writable); `plan` and future `referral_*` / `pro_*` columns are service role / webhook only
106+
- **Postgres-portable schema**`profiles` keyed to `auth.users`; RLS on public tables; client-writable columns: `display_name`, `avatar_preference` only; `avatar_url` is OAuth/trigger-populated (not client-writable); `plan` is service role only. Through 0.5.0: **no** LemonSqueezy, subscriptions, referrals, or `user_sessions` tables in tree
107+
- **Pro through 0.5.0** — waitlist demand only (`/pro` + `waitlist`); no checkout or Pro entitlements beyond waitlist capture
108+
- **Analytics (PostHog)**`src/services/analytics.js` + `analyticsEvents.js`; SPA pageviews via `capture_pageview: 'history_change'`; `disable_surveys: true`; session replay sampled (`sampleRate: 0.2`). Growth events: `waitlist_joined`, `upgrade_limit_hit` (plus existing `sign_in_completed`). Anonymous viz limit stays hardcoded at `12` (no feature-flag A/B)
109+
- **Email (Resend)**`waitlist-welcome` (one transactional welcome per waitlist join); `sync-contacts` (JWT-authenticated contact upsert on `SIGNED_IN` only — no broadcasts in 0.5.0). Stay under Resend free daily cap (100/day)
107110
- **Profile settings** — private route `/settings/profile` (`RequireAuth`); `updateProfile()` in `profileService.js`; security boundary = RLS row scope + `REVOKE UPDATE` + `GRANT UPDATE (display_name, avatar_preference)`; tabbed UI with profile/notifications/connections tabs; DiceBear notionists avatar fallback
108111
- **Session**`getSession()`, `onAuthStateChange()`; `AuthProvider` in `src/main.jsx`; request-dedup via `requestRef`
109112
- **Tiered access model** — Anonymous (no account) gets limited access to drive sign-in conversion; Free account (Google sign-in) unlocks the full platform

docs/AGENTS_REFERENCE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@
9999
- `supabase/functions/post-signup/` — post-signup side effects
100100
- `supabase/functions/platform-access/` — signed-in ban check (`accessService.checkPlatformAccess()`; fail-open on transport)
101101
- `supabase/functions/waitlist-welcome/` — Pro waitlist confirmation email via Resend (fail-open; invoked after client insert)
102+
- `supabase/functions/sync-contacts/` — JWT-authenticated Resend contact upsert on sign-in (fail-open; identity from JWT only)
102103
- `supabase/functions/delete-account/` — self-service account deletion
104+
- **Not in 0.5.0:** LemonSqueezy webhook, subscriptions / usage_events / referrals / user_sessions tables (dropped via `20260720180000_drop_premature_saas_scaffolding.sql`)
105+
- Analytics: `src/services/analytics.js`, `src/services/analyticsEvents.js` — PostHog SPA pageviews + growth events `waitlist_joined`, `upgrade_limit_hit`; surveys disabled in SDK
103106
- Context: `src/contexts/AuthProvider.jsx`, `src/hooks/useAuth.js`
104107
- Avatar resolution: `src/utils/resolveUserAvatar.js` (`resolveUserAvatar`, `resolveDisplayName`, DiceBear notionists style)
105108
- Components: `src/components/UserMenu.jsx`, `src/components/UserAvatar.jsx`, `src/components/RequireAuth.jsx`
@@ -118,7 +121,7 @@
118121
| `avatar_url` | no | OAuth / trigger-populated HTTPS URL |
119122
| `avatar_preference` | yes | `google` (default) \| `generated` |
120123

121-
Future (v0.6.0, not shipped): `username` (unique, set-once RLS), public `/u/:username` route; referral/billing columns (`referral_code`, `referred_by`, `referral_count`, `pro_months_earned`, `pro_expires_at`) — service role only.
124+
Future (post-0.5.0, not shipped): `username` (unique, set-once RLS), public `/u/:username` route; any referral/billing columns — new migrations then, not restore of dropped scaffolding. Through 0.5.0 Pro remains waitlist-only.
122125

123126
### Personal learning tables
124127

public/_headers

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Referrer-Policy: strict-origin-when-cross-origin
66
Permissions-Policy: camera=(), microphone=(), geolocation=(), identity-credentials-get=(self "https://accounts.google.com")
77
# Pyodide CDN origins: jsDelivr below; custom VITE_PYODIDE_CDN_BASE origins appended at build (vite.config.js)
8-
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://*.posthog.com https://static.cloudflareinsights.com https://cdn.jsdelivr.net https://accounts.google.com https://app.lemonsqueezy.com; connect-src 'self' blob: https://*.posthog.com https://e.bayanflow.com https://e.dev.bayanflow.com https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co https://accounts.google.com https://oauth2.googleapis.com https://api.lemonsqueezy.com; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com https://accounts.google.com https://app.lemonsqueezy.com; object-src 'none'; base-uri 'self'; form-action 'self'
8+
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://*.posthog.com https://static.cloudflareinsights.com https://cdn.jsdelivr.net https://accounts.google.com; connect-src 'self' blob: https://*.posthog.com https://e.bayanflow.com https://e.dev.bayanflow.com https://cloudflareinsights.com https://api.github.com https://cdn.jsdelivr.net https://www.remotion.pro https://qketsapzqpzmccljfjcm.supabase.co https://accounts.google.com https://oauth2.googleapis.com; img-src 'self' data: blob: https://api.producthunt.com https://lh3.googleusercontent.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' data:; worker-src 'self' blob:; media-src 'self' blob:; frame-src https://www.youtube-nocookie.com https://accounts.google.com; object-src 'none'; base-uri 'self'; form-action 'self'
99
Link: <https://bayanflow.com/sitemap.xml>; rel="sitemap"
1010
Link: </.well-known/api-catalog>; rel="api-catalog"
1111
Link: </.well-known/agent-card.json>; rel="service-desc"

scripts/cspHeaders.js

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -173,36 +173,3 @@ export function assertAuthCspDirectives(csp, source) {
173173

174174
return { connectSrc: connectSrc ?? '', imgSrc: imgSrc ?? '' };
175175
}
176-
177-
/**
178-
* Assert CSP directives required for LemonSqueezy billing integration.
179-
* @param {string} csp
180-
* @param {string} source - Label for error messages (e.g. "public/_headers")
181-
* @returns {{ scriptSrc: string; connectSrc: string; frameSrc: string }}
182-
*/
183-
export function assertBillingCspDirectives(csp, source) {
184-
const directives = parseCspDirectives(csp);
185-
186-
const scriptSrc = directives.get('script-src') ?? '';
187-
if (!scriptSrc.includes('https://app.lemonsqueezy.com')) {
188-
throw new Error(
189-
`${source}: script-src must include https://app.lemonsqueezy.com (LemonSqueezy checkout)`
190-
);
191-
}
192-
193-
const connectSrc = directives.get('connect-src') ?? '';
194-
if (!connectSrc.includes('https://api.lemonsqueezy.com')) {
195-
throw new Error(
196-
`${source}: connect-src must include https://api.lemonsqueezy.com (LemonSqueezy API)`
197-
);
198-
}
199-
200-
const frameSrc = directives.get('frame-src') ?? '';
201-
if (!frameSrc.includes('https://app.lemonsqueezy.com')) {
202-
throw new Error(
203-
`${source}: frame-src must include https://app.lemonsqueezy.com (LemonSqueezy checkout overlay)`
204-
);
205-
}
206-
207-
return { scriptSrc, connectSrc, frameSrc };
208-
}

src/contexts/AuthProvider.jsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,20 @@ import { getSupabaseClient } from '@/lib/supabaseClient';
2323
/** @typedef {'account_banned' | null} AccessBlockReason */
2424

2525
/**
26-
* Sync user contact to Resend for email automation (fire-and-forget).
27-
* @param {object} user
28-
* @param {object} profile
26+
* Sync signed-in user to Resend (fire-and-forget).
27+
* Identity is taken from the JWT inside the edge function — body is metadata only.
28+
* @param {object} [profile]
2929
*/
30-
function syncContactToResend(user, profile) {
30+
function syncContactToResend(profile) {
3131
const supabase = getSupabaseClient();
3232
if (!supabase) return;
3333

3434
void supabase.functions.invoke('sync-contacts', {
3535
method: 'POST',
3636
body: {
37-
email: user.email,
38-
userId: user.id,
3937
plan: profile?.plan || 'free',
4038
displayName: profile?.displayName || '',
41-
language: document.documentElement.lang,
39+
language: document.documentElement.lang || 'en',
4240
},
4341
});
4442
}
@@ -216,7 +214,8 @@ export function AuthProvider({ children }) {
216214
plan: null,
217215
});
218216
trackSignInCompleted();
219-
syncContactToResend(nextSession.user, {
217+
syncContactToResend({
218+
plan: null,
220219
displayName:
221220
nextSession.user.user_metadata?.full_name ||
222221
nextSession.user.user_metadata?.name,

src/contexts/AuthProvider.test.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
resetSupabaseMocks,
88
supabaseAuthMock,
99
supabaseFromMock,
10+
supabaseFunctionsInvokeMock,
1011
authStateChangeCallbackRef,
1112
} from '../test/supabaseMock.js';
1213

@@ -339,6 +340,15 @@ describe('AuthProvider', () => {
339340
});
340341
expect(screen.getByTestId('display-name')).toHaveTextContent('New User');
341342
expect(resetAllSessionCounters).toHaveBeenCalledTimes(1);
343+
expect(supabaseFunctionsInvokeMock).toHaveBeenCalledWith(
344+
'sync-contacts',
345+
expect.objectContaining({
346+
method: 'POST',
347+
body: expect.objectContaining({
348+
displayName: 'New User',
349+
}),
350+
})
351+
);
342352
});
343353

344354
it('does not reset session counters on INITIAL_SESSION hydrate', async () => {

src/security/cspHeaders.test.js

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@
77
import fs from 'node:fs';
88
import path from 'node:path';
99
import { fileURLToPath } from 'node:url';
10-
import { describe, it, expect, vi, beforeEach } from 'vitest';
10+
import { describe, it, vi, beforeEach } from 'vitest';
1111
import {
1212
assertAnalyticsCspDirectives,
1313
assertAuthCspDirectives,
1414
assertVideoExportCspDirectives,
15-
assertBillingCspDirectives,
1615
extractCspFromHeadersFile,
1716
parseCspDirectives,
1817
} from '../../scripts/cspHeaders.js';
@@ -46,8 +45,4 @@ describe('CSP security headers', () => {
4645
it('public/_headers allows PostHog analytics', () => {
4746
assertAnalyticsCspDirectives(headersCsp, 'public/_headers');
4847
});
49-
50-
it('public/_headers allows LemonSqueezy billing', () => {
51-
assertBillingCspDirectives(headersCsp, 'public/_headers');
52-
});
5348
});

src/services/analytics.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export function initPostHog() {
5252
rageclick: true,
5353
person_profiles: 'identified_only',
5454
session_recording: {
55+
// Sample to stretch the free-tier 5k recordings/month.
56+
sampleRate: 0.2,
5557
maskTextSelector: '.ph-no-capture, [data-sensitive]',
5658
maskAllInputs: true,
5759
maskAllMedia: true,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import {
9+
trackWaitlistJoined,
10+
trackUpgradeLimitHit,
11+
WAITLIST_JOINED,
12+
UPGRADE_LIMIT_HIT,
13+
} from './analyticsEvents.js';
14+
import { captureEvent } from './analytics.js';
15+
16+
vi.mock('./analytics.js', () => ({
17+
captureEvent: vi.fn(),
18+
}));
19+
20+
describe('analyticsEvents growth conversions', () => {
21+
beforeEach(() => {
22+
vi.mocked(captureEvent).mockClear();
23+
});
24+
25+
it('tracks waitlist_joined with source and position', () => {
26+
trackWaitlistJoined('pro_page', 3);
27+
expect(captureEvent).toHaveBeenCalledWith(WAITLIST_JOINED, {
28+
source: 'pro_page',
29+
position: 3,
30+
});
31+
});
32+
33+
it('tracks upgrade_limit_hit with limit', () => {
34+
trackUpgradeLimitHit(12);
35+
expect(captureEvent).toHaveBeenCalledWith(UPGRADE_LIMIT_HIT, {
36+
limit: 12,
37+
});
38+
});
39+
});

0 commit comments

Comments
 (0)