Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Configure npm for GitHub Packages
Expand Down Expand Up @@ -81,7 +81,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Configure npm for GitHub Packages
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Configure npm for GitHub Packages
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
/.next/
/out/

# opennext / cloudflare workers build output
/.open-next/

# production
/build

Expand Down
6 changes: 1 addition & 5 deletions app/api/waitlist/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { NextRequest } from 'next/server';

vi.mock('@/lib/ab-testing', () => ({
getVariant: vi.fn(),
trackConversion: vi.fn(),
}));

function buildRequest(body: unknown): NextRequest {
Expand Down Expand Up @@ -94,10 +93,9 @@ describe('POST /api/waitlist', () => {
expect(sent.properties['Signed Up'].date.start).toMatch(
/^\d{4}-\d{2}-\d{2}T/,
);
expect(ab.trackConversion).not.toHaveBeenCalled();
});

it('posts to Notion with Variant and tracks conversion when variant assigned', async () => {
it('posts to Notion with Variant when a variant is assigned', async () => {
const { route, ab } = await importRouteFresh();
(ab.getVariant as ReturnType<typeof vi.fn>).mockResolvedValue('A');
const fetchSpy = vi
Expand All @@ -111,7 +109,6 @@ describe('POST /api/waitlist', () => {
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
);
expect(sent.properties.Variant).toEqual({ select: { name: 'A' } });
expect(ab.trackConversion).toHaveBeenCalledWith('A', 'waitlist_signup');
});

it('returns 500 when Notion API responds non-ok', async () => {
Expand All @@ -125,7 +122,6 @@ describe('POST /api/waitlist', () => {

expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: 'Failed to join waitlist' });
expect(ab.trackConversion).not.toHaveBeenCalled();
});

it("returns 400 'Invalid JSON' when request body is malformed", async () => {
Expand Down
7 changes: 1 addition & 6 deletions app/api/waitlist/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { getVariant, trackConversion } from '@/lib/ab-testing';
import { getVariant } from '@/lib/ab-testing';

const NOTION_TOKEN = process.env.NOTION_TOKEN;
const NOTION_DATABASE_ID = process.env.NOTION_WAITLIST_DB_ID;
Expand Down Expand Up @@ -96,11 +96,6 @@ export async function POST(request: NextRequest) {
throw new Error('Failed to add to Notion');
}

// Track conversion for A/B analysis
if (variant) {
trackConversion(variant, 'waitlist_signup');
}

return NextResponse.json({ success: true });
} catch (error) {
console.error('Waitlist error:', error);
Expand Down
26 changes: 0 additions & 26 deletions lib/ab-testing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,30 +183,4 @@ describe('lib/ab-testing', () => {
expect(getVariantFromRequest(buildRequest('being_ab_variant=C'))).toBeNull();
});
});

describe('trackConversion', () => {
it('logs in development mode', async () => {
vi.stubEnv('NODE_ENV', 'development');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { trackConversion } = await import('./ab-testing');

trackConversion('A', 'waitlist_signup', { source: 'homepage' });

expect(logSpy).toHaveBeenCalledWith('[A/B] Conversion tracked:', {
variant: 'A',
event: 'waitlist_signup',
metadata: { source: 'homepage' },
});
});

it('does not log in production mode', async () => {
vi.stubEnv('NODE_ENV', 'production');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { trackConversion } = await import('./ab-testing');

trackConversion('B', 'download_click');

expect(logSpy).not.toHaveBeenCalled();
});
});
});
36 changes: 0 additions & 36 deletions lib/ab-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,39 +85,3 @@ export function getVariantFromRequest(request: NextRequest): Variant | null {

return null;
}

/**
* Conversion event types we track
*/
export type ConversionEvent =
| 'waitlist_signup'
| 'download_click'
| 'cta_click';

/**
* Tracks a conversion event
* Conversions are stored in Notion alongside the variant for analysis
*
* @param variant - The assigned variant
* @param event - The conversion event type
* @param metadata - Optional additional data
*/
export function trackConversion(
variant: Variant,
event: ConversionEvent,
metadata?: Record<string, string>
): void {
// For now, conversions are tracked by including the variant
// in the Notion database entry (see waitlist API)
//
// Future enhancement: Send to Cloudflare Analytics Engine
// await env.ANALYTICS.writeDataPoint({
// blobs: [variant, event],
// doubles: [1], // count
// indexes: [event],
// });

if (process.env.NODE_ENV === 'development') {
console.log('[A/B] Conversion tracked:', { variant, event, metadata });
}
}
37 changes: 0 additions & 37 deletions lib/gpc.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';

vi.mock('next/headers', () => ({
cookies: vi.fn(),
}));

describe('lib/gpc', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -64,37 +60,4 @@ describe('lib/gpc', () => {
expect(getGpcFromRequest(buildRequest('true'))).toBe(false);
});
});

describe('getGpcFromCookie', () => {
async function setCookieValue(value: string | undefined) {
const { cookies } = await import('next/headers');
(cookies as ReturnType<typeof vi.fn>).mockResolvedValue({
get: vi.fn().mockReturnValue(value === undefined ? undefined : { value }),
});
}

it("returns true when cookie value is '1'", async () => {
await setCookieValue('1');
const { getGpcFromCookie } = await import('./gpc');
expect(await getGpcFromCookie()).toBe(true);
});

it('returns false when the cookie is absent', async () => {
await setCookieValue(undefined);
const { getGpcFromCookie } = await import('./gpc');
expect(await getGpcFromCookie()).toBe(false);
});

it("returns false when the cookie value is '0'", async () => {
await setCookieValue('0');
const { getGpcFromCookie } = await import('./gpc');
expect(await getGpcFromCookie()).toBe(false);
});

it('returns false for an unexpected cookie value', async () => {
await setCookieValue('true');
const { getGpcFromCookie } = await import('./gpc');
expect(await getGpcFromCookie()).toBe(false);
});
});
});
27 changes: 3 additions & 24 deletions lib/gpc.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,24 @@
/**
* Global Privacy Control (GPC) detection.
*
* Reads the `Sec-GPC` request header per the GPC spec
* (https://globalprivacycontrol.org/) and exposes helpers for middleware
* (request-time) and server components (cookie-time).
*
* Only the literal string `'1'` is the affirmative signal; everything else
* — `'0'`, `''`, malformed, absent — is treated as no signal.
* Only the literal string `'1'` is the affirmative signal per the GPC spec
* (https://globalprivacycontrol.org/); everything else is no signal.
*
* @see INFRA-151
*/

import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';

export const GPC_COOKIE_NAME = 'being_gpc';
export const GPC_COOKIE_MAX_AGE = 24 * 60 * 60; // 24 hours in seconds
export const GPC_REQUEST_HEADER = 'sec-gpc';
export const GPC_RESPONSE_HEADER = 'X-GPC-Honored';

/**
* Returns true when the header value is the affirmative GPC signal.
* Per spec, only the literal string `'1'` qualifies.
*/
export function isGpcSignal(headerValue: string | null | undefined): boolean {
return headerValue === '1';
}

/**
* Reads the GPC signal from a NextRequest (for use in middleware).
*/
/** Reads the GPC signal from a NextRequest (for middleware). */
export function getGpcFromRequest(request: NextRequest): boolean {
return isGpcSignal(request.headers.get(GPC_REQUEST_HEADER));
}

/**
* Reads the cached GPC signal from cookies (for use in Server Components).
* The cookie is set/cleared by middleware on every request, so this reflects
* the current request's signal as of the most recent middleware pass.
*/
export async function getGpcFromCookie(): Promise<boolean> {
const cookieStore = await cookies();
return cookieStore.get(GPC_COOKIE_NAME)?.value === '1';
}
1 change: 0 additions & 1 deletion lib/posthog/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
*/

export const POSTHOG_HOST = 'https://eu.i.posthog.com';
export const POSTHOG_ASSETS_HOST = 'https://eu-assets.i.posthog.com';

export const POSTHOG_KEY: string | undefined = process.env.NEXT_PUBLIC_POSTHOG_KEY;

Expand Down
17 changes: 5 additions & 12 deletions lib/posthog/events.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
/**
* PostHog event helpers — type-safe wrappers for the PR #1 event set.
* PostHog event helpers.
*
* Design notes:
* - No `posthog-js` import at module scope — these helpers reach the
* PostHog instance via `window.__posthog`, which is only set after
* PosthogProvider has dynamic-imported the library AND init'd it.
* - If PostHog never loaded (GPC kill, missing key, SSR), every helper
* is a silent no-op. No throws, no console noise.
* - PR #1 event set: $pageview (PostHog default — fires automatically
* on init via capture_pageview: true), trackWaitlistSubmitted,
* trackWaitlistFailed.
* - Future helpers (cta_clicked, crisis_resource_clicked, ab_variant_assigned,
* identifyByEmailHash) ship in PR #2+.
* No `posthog-js` import at module scope — helpers reach the instance via
* `window.__posthog`, only set after PosthogProvider dynamic-imports + inits.
* If PostHog never loaded (GPC kill, missing key, SSR), every helper is a
* silent no-op.
*/

declare global {
Expand Down
18 changes: 14 additions & 4 deletions middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,19 +131,29 @@ describe('middleware — GPC detection', () => {
expect(response.headers.get('X-GPC-Honored')).toBeNull();
});

it('clears the being_gpc cookie when the header is absent', async () => {
it('clears the being_gpc cookie when the header is absent but the cookie is present', async () => {
const { middleware, ab } = await importFresh();
(ab.getVariantFromRequest as ReturnType<typeof vi.fn>).mockReturnValue('A');

const response = middleware(buildRequest());
// Request carries a stale being_gpc cookie but no Sec-GPC header.
const response = middleware(buildRequest({ cookie: 'being_gpc=1' }));

// NextResponse.cookies.delete() emits a Set-Cookie with empty value and
// an expired date (the exact attrs are framework-internal — we only
// assert the deletion signal: an empty-value cookie was emitted).
// an expired date — we assert only the deletion signal (empty value).
const cookie = response.cookies.get('being_gpc');
expect(cookie?.value).toBe('');
});

it('does NOT emit a being_gpc Set-Cookie when neither header nor cookie is present', async () => {
const { middleware, ab } = await importFresh();
(ab.getVariantFromRequest as ReturnType<typeof vi.fn>).mockReturnValue('A');

// Common path: no Sec-GPC header, no existing cookie → no Set-Cookie at all.
const response = middleware(buildRequest());

expect(response.cookies.get('being_gpc')).toBeUndefined();
});

it('coexists with A/B variant assignment (both signals set on same response)', async () => {
const { middleware, ab } = await importFresh();
(ab.getVariantFromRequest as ReturnType<typeof vi.fn>).mockReturnValue(null);
Expand Down
5 changes: 4 additions & 1 deletion middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export function middleware(request: NextRequest) {
httpOnly: false, // Client reads it to render the GpcNotice
});
response.headers.set(GPC_RESPONSE_HEADER, '1');
} else {
} else if (request.cookies.has(GPC_COOKIE_NAME)) {
// Only emit a clearing Set-Cookie when the cookie actually exists —
// avoids a needless Set-Cookie (and the cache-busting it causes) on the
// common no-GPC path where there's nothing to clear.
response.cookies.delete(GPC_COOKIE_NAME);
}

Expand Down
Loading