Skip to content

Commit 242cdf0

Browse files
committed
feat(referrals): route Kilo Pass Impact attribution
1 parent 1fb759d commit 242cdf0

17 files changed

Lines changed: 969 additions & 75 deletions

File tree

apps/web/src/app/api/impact-advocate/token/route.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { headers } from 'next/headers';
2-
import { NextResponse } from 'next/server';
2+
import { type NextRequest, NextResponse } from 'next/server';
33

44
import { referral_codes } from '@kilocode/db/schema';
55
import { db } from '@/lib/drizzle';
66
import { getUserFromAuth } from '@/lib/user/server';
77
import {
8+
getImpactAdvocateProgramKeyForProduct,
89
getImpactAdvocateWidgetId,
910
issueImpactAdvocateVerifiedAccessToken,
1011
} from '@/lib/impact/advocate';
@@ -13,6 +14,7 @@ import {
1314
localeFromHeaders,
1415
queueImpactAdvocateSelfRegistration,
1516
} from '@/lib/impact/referral';
17+
import { ImpactReferralProduct } from '@kilocode/db/schema-types';
1618

1719
/**
1820
* Internal Kilo referral code (kept for legacy/internal attribution flows in
@@ -28,7 +30,15 @@ async function ensureInternalReferralCode(userId: string): Promise<void> {
2830
.onConflictDoNothing({ target: [referral_codes.kilo_user_id] });
2931
}
3032

31-
export async function GET() {
33+
function parseRequestedProduct(request: NextRequest): ImpactReferralProduct | null {
34+
const product = request.nextUrl.searchParams.get('product')?.trim();
35+
if (!product) return ImpactReferralProduct.KiloClaw;
36+
if (product === ImpactReferralProduct.KiloClaw) return ImpactReferralProduct.KiloClaw;
37+
if (product === ImpactReferralProduct.KiloPass) return ImpactReferralProduct.KiloPass;
38+
return null;
39+
}
40+
41+
export async function GET(request: NextRequest) {
3242
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
3343
if (authFailedResponse) {
3444
return authFailedResponse;
@@ -38,7 +48,13 @@ export async function GET() {
3848
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
3949
}
4050

41-
const token = issueImpactAdvocateVerifiedAccessToken(user);
51+
const product = parseRequestedProduct(request);
52+
if (!product) {
53+
return NextResponse.json({ error: 'Unsupported Impact Advocate product' }, { status: 400 });
54+
}
55+
const programKey = getImpactAdvocateProgramKeyForProduct(product);
56+
57+
const token = issueImpactAdvocateVerifiedAccessToken(user, new Date(), { programKey });
4258
if (!token) {
4359
return NextResponse.json({ error: 'Impact Advocate is not configured' }, { status: 503 });
4460
}
@@ -53,6 +69,7 @@ export async function GET() {
5369
// page loads via dedupe key.
5470
const requestHeaders = await headers();
5571
await queueImpactAdvocateSelfRegistration({
72+
programKey,
5673
user,
5774
locale: localeFromHeaders(requestHeaders),
5875
countryCode: countryCodeFromHeaders(requestHeaders),
@@ -70,6 +87,6 @@ export async function GET() {
7087

7188
return NextResponse.json({
7289
token,
73-
widgetId: getImpactAdvocateWidgetId(),
90+
widgetId: getImpactAdvocateWidgetId({ programKey }),
7491
});
7592
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { GET } from './route';
2+
3+
describe('GET /api/marketing-tags/impact', () => {
4+
const originalImpactUttId = process.env.NEXT_PUBLIC_IMPACT_UTT_ID;
5+
6+
afterEach(() => {
7+
if (originalImpactUttId === undefined) {
8+
delete process.env.NEXT_PUBLIC_IMPACT_UTT_ID;
9+
} else {
10+
process.env.NEXT_PUBLIC_IMPACT_UTT_ID = originalImpactUttId;
11+
}
12+
});
13+
14+
it('returns the Impact UTT bootstrap script when the public UTT id is configured', async () => {
15+
process.env.NEXT_PUBLIC_IMPACT_UTT_ID = 'A-KILO-PASS-UTT';
16+
17+
const response = GET();
18+
19+
expect(response.status).toBe(200);
20+
expect(response.headers.get('Content-Type')).toBe('application/javascript; charset=utf-8');
21+
const script = await response.text();
22+
expect(script).toContain('utt.impactcdn.com');
23+
expect(script).toContain('A-KILO-PASS-UTT');
24+
});
25+
26+
it('does not serve an Impact UTT script when the public UTT id is unconfigured', () => {
27+
delete process.env.NEXT_PUBLIC_IMPACT_UTT_ID;
28+
29+
const response = GET();
30+
31+
expect(response.status).toBe(404);
32+
});
33+
});

apps/web/src/app/users/after-sign-in/route.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ jest.mock('@/lib/credit-campaigns', () => ({
4242

4343
import { getAffiliateAttribution } from '@/lib/affiliate-attribution';
4444
import { recordAffiliateAttributionAndQueueParentEvent } from '@/lib/impact/affiliate-events';
45+
import {
46+
queueImpactAdvocateParticipantRegistration,
47+
recordImpactAffiliateTouch,
48+
recordImpactReferralTouch,
49+
} from '@/lib/impact/referral';
4550
import { getUserFromAuth } from '@/lib/user/server';
4651
import { GET } from './route';
4752

@@ -50,6 +55,11 @@ const mockRecordAffiliateAttributionAndQueueParentEvent = jest.mocked(
5055
recordAffiliateAttributionAndQueueParentEvent
5156
);
5257
const mockGetUserFromAuth = jest.mocked(getUserFromAuth);
58+
const mockQueueImpactAdvocateParticipantRegistration = jest.mocked(
59+
queueImpactAdvocateParticipantRegistration
60+
);
61+
const mockRecordImpactAffiliateTouch = jest.mocked(recordImpactAffiliateTouch);
62+
const mockRecordImpactReferralTouch = jest.mocked(recordImpactReferralTouch);
5363

5464
describe('GET /users/after-sign-in', () => {
5565
beforeEach(() => {
@@ -64,6 +74,81 @@ describe('GET /users/after-sign-in', () => {
6474
} as Awaited<ReturnType<typeof getUserFromAuth>>);
6575
});
6676

77+
it('records and queues Kilo Pass referral touches from Kilo Pass referral-page callback paths', async () => {
78+
const response = await GET(
79+
new NextRequest(
80+
'http://localhost:3000/users/after-sign-in?callbackPath=%2Fsubscriptions%2Fkilo-pass%2Frefer&_saasquatch=pass-cookie&rsCode=PASSCODE'
81+
)
82+
);
83+
84+
expect(response.status).toBe(307);
85+
expect(response.headers.get('location')).toBe(
86+
'http://localhost:3000/subscriptions/kilo-pass/refer'
87+
);
88+
expect(mockRecordImpactReferralTouch).toHaveBeenCalledWith(
89+
expect.objectContaining({
90+
userId: 'user-after-sign-in',
91+
touch: expect.objectContaining({
92+
product: 'kilo_pass',
93+
programKey: 'kilo_pass',
94+
opaqueTrackingValue: 'pass-cookie',
95+
}),
96+
})
97+
);
98+
expect(mockQueueImpactAdvocateParticipantRegistration).toHaveBeenCalledWith(
99+
expect.objectContaining({
100+
user: expect.objectContaining({ id: 'user-after-sign-in' }),
101+
referralTouch: expect.objectContaining({
102+
product: 'kilo_pass',
103+
programKey: 'kilo_pass',
104+
opaqueTrackingValue: 'pass-cookie',
105+
}),
106+
})
107+
);
108+
});
109+
110+
it('records Kilo Pass affiliate touches from Kilo Pass callback paths', async () => {
111+
const response = await GET(
112+
new NextRequest(
113+
'http://localhost:3000/users/after-sign-in?callbackPath=%2Fsubscriptions%2Fkilo-pass&im_ref=impact-click'
114+
)
115+
);
116+
117+
expect(response.status).toBe(307);
118+
expect(mockRecordImpactAffiliateTouch).toHaveBeenCalledWith(
119+
expect.objectContaining({
120+
userId: 'user-after-sign-in',
121+
product: 'kilo_pass',
122+
touch: expect.objectContaining({
123+
product: 'kilo_pass',
124+
trackingId: 'impact-click',
125+
}),
126+
})
127+
);
128+
});
129+
130+
it('preserves Impact tracking parameters through unauthenticated OAuth redirects', async () => {
131+
mockGetUserFromAuth.mockResolvedValueOnce({ user: null } as Awaited<
132+
ReturnType<typeof getUserFromAuth>
133+
>);
134+
135+
const response = await GET(
136+
new NextRequest(
137+
'http://localhost:3000/users/after-sign-in?callbackPath=%2Fsubscriptions%2Fkilo-pass%2Frefer&signup=true&_saasquatch=pass-cookie&rsCode=PASSCODE&im_ref=impact-click&utm_campaign=launch'
138+
)
139+
);
140+
141+
expect(response.status).toBe(307);
142+
const location = new URL(response.headers.get('location') ?? '');
143+
expect(location.pathname).toBe('/users/sign_in');
144+
expect(location.searchParams.get('callbackPath')).toBe('/subscriptions/kilo-pass/refer');
145+
expect(location.searchParams.get('signup')).toBe('true');
146+
expect(location.searchParams.get('_saasquatch')).toBe('pass-cookie');
147+
expect(location.searchParams.get('rsCode')).toBe('PASSCODE');
148+
expect(location.searchParams.get('im_ref')).toBe('impact-click');
149+
expect(location.searchParams.get('utm_campaign')).toBe('launch');
150+
});
151+
67152
it('continues redirect flow when affiliate attribution lookup fails', async () => {
68153
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
69154
mockGetAffiliateAttribution.mockRejectedValueOnce(new Error('affiliate lookup unavailable'));

apps/web/src/app/users/after-sign-in/route.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ export async function GET(request: NextRequest) {
199199
isTrackingValueAccepted: affiliateTouch.isTrackingValueAccepted,
200200
});
201201
await recordImpactAffiliateTouch({
202+
product: affiliateTouch.product,
202203
userId: user.id,
203204
touch: affiliateTouch,
204205
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { User } from '@kilocode/db/schema';
2+
3+
const mockUseEffect = jest.fn((effect: () => void | (() => void), _deps?: unknown[]) => effect());
4+
const mockUseUser = jest.fn();
5+
6+
jest.mock('react', () => ({
7+
...jest.requireActual('react'),
8+
useEffect: (effect: () => void | (() => void), deps?: unknown[]) => mockUseEffect(effect, deps),
9+
}));
10+
11+
jest.mock('@/hooks/useUser', () => ({
12+
useUser: () => mockUseUser(),
13+
}));
14+
15+
jest.mock('@/lib/impact/debug', () => ({
16+
logImpactReferralDebug: jest.fn(),
17+
}));
18+
19+
import { ImpactIdentify } from './ImpactIdentify';
20+
21+
const TEST_USER = {
22+
id: 'user_123',
23+
google_user_email: ' Logged.In@Example.COM ',
24+
} as User;
25+
26+
function createLocalStorage() {
27+
const values = new Map<string, string>();
28+
return {
29+
getItem: jest.fn((key: string) => values.get(key) ?? null),
30+
setItem: jest.fn((key: string, value: string) => {
31+
values.set(key, value);
32+
}),
33+
};
34+
}
35+
36+
async function waitForIreCalls(ire: jest.Mock, expectedCallCount: number) {
37+
for (let attempt = 0; attempt < 20; attempt += 1) {
38+
if (ire.mock.calls.length >= expectedCallCount) return;
39+
await new Promise(resolve => setTimeout(resolve, 10));
40+
}
41+
}
42+
43+
describe('ImpactIdentify', () => {
44+
let originalWindow: typeof globalThis.window | undefined;
45+
let originalCrypto: Crypto;
46+
47+
beforeEach(() => {
48+
jest.clearAllMocks();
49+
originalWindow = globalThis.window;
50+
originalCrypto = globalThis.crypto;
51+
Object.defineProperty(globalThis, 'crypto', {
52+
configurable: true,
53+
value: {
54+
...originalCrypto,
55+
randomUUID: jest.fn(() => 'anonymous-profile-id'),
56+
subtle: originalCrypto.subtle,
57+
},
58+
});
59+
});
60+
61+
afterEach(() => {
62+
jest.restoreAllMocks();
63+
Object.defineProperty(globalThis, 'crypto', {
64+
configurable: true,
65+
value: originalCrypto,
66+
});
67+
Object.defineProperty(globalThis, 'window', {
68+
configurable: true,
69+
value: originalWindow,
70+
});
71+
});
72+
73+
it('identifies anonymous visitors with empty customer fields and a stable first-party profile id', async () => {
74+
const ire = jest.fn();
75+
const localStorage = createLocalStorage();
76+
Object.defineProperty(globalThis, 'window', {
77+
configurable: true,
78+
value: { ire, localStorage },
79+
});
80+
mockUseUser.mockReturnValue({ data: null });
81+
82+
ImpactIdentify();
83+
await waitForIreCalls(ire, 1);
84+
ImpactIdentify();
85+
await waitForIreCalls(ire, 2);
86+
87+
expect(ire).toHaveBeenNthCalledWith(1, 'identify', {
88+
customerId: '',
89+
customerEmail: '',
90+
customProfileId: 'kilo-anon:anonymous-profile-id',
91+
});
92+
expect(ire).toHaveBeenNthCalledWith(2, 'identify', {
93+
customerId: '',
94+
customerEmail: '',
95+
customProfileId: 'kilo-anon:anonymous-profile-id',
96+
});
97+
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
98+
});
99+
100+
it('identifies logged-in users with Kilo user id, SHA-1 email hash, and user-derived profile id', async () => {
101+
const ire = jest.fn();
102+
Object.defineProperty(globalThis, 'window', {
103+
configurable: true,
104+
value: { ire, localStorage: createLocalStorage() },
105+
});
106+
mockUseUser.mockReturnValue({ data: TEST_USER });
107+
108+
ImpactIdentify();
109+
await waitForIreCalls(ire, 1);
110+
111+
expect(ire).toHaveBeenCalledWith('identify', {
112+
customerId: 'user_123',
113+
customerEmail: '155b33cbec67ea77560d6ad79d7245d9b7c285e3',
114+
customProfileId: 'kilo-user:user_123',
115+
});
116+
});
117+
});

apps/web/src/lib/config.server.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,16 @@ export const IMPACT_ACCOUNT_SID = getEnvVariable('IMPACT_ACCOUNT_SID') || '';
5252
export const IMPACT_AUTH_TOKEN = getEnvVariable('IMPACT_AUTH_TOKEN') || '';
5353
export const IMPACT_CAMPAIGN_ID = getEnvVariable('IMPACT_CAMPAIGN_ID') || '';
5454
export const IMPACT_ADVOCATE_TENANT_ALIAS = getEnvVariable('IMPACT_ADVOCATE_TENANT_ALIAS') || '';
55-
export const IMPACT_ADVOCATE_PROGRAM_ID = getEnvVariable('IMPACT_ADVOCATE_PROGRAM_ID') || '';
5655
export const IMPACT_ADVOCATE_ACCOUNT_SID = getEnvVariable('IMPACT_ADVOCATE_ACCOUNT_SID') || '';
5756
export const IMPACT_ADVOCATE_AUTH_TOKEN = getEnvVariable('IMPACT_ADVOCATE_AUTH_TOKEN') || '';
58-
export const IMPACT_ADVOCATE_WIDGET_ID = getEnvVariable('IMPACT_ADVOCATE_WIDGET_ID') || '';
57+
export const IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID =
58+
getEnvVariable('IMPACT_ADVOCATE_KILOCLAW_PROGRAM_ID') || '';
59+
export const IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID =
60+
getEnvVariable('IMPACT_ADVOCATE_KILOCLAW_WIDGET_ID') || '';
61+
export const IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID =
62+
getEnvVariable('IMPACT_ADVOCATE_KILO_PASS_PROGRAM_ID') || '';
63+
export const IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID =
64+
getEnvVariable('IMPACT_ADVOCATE_KILO_PASS_WIDGET_ID') || '';
5965
export const IMPACT_ADVOCATE_API_BASE_URL =
6066
getEnvVariable('IMPACT_ADVOCATE_API_BASE_URL') || 'https://app.referralsaasquatch.com';
6167
export const IMPACT_ADVOCATE_DEBUG_LOGGING =

apps/web/src/lib/getSignInCallbackUrl.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ describe('getSignInCallbackUrl', () => {
9898
)
9999
).toBe(true);
100100
});
101+
102+
test('accepts Kilo Pass referral paths', () => {
103+
expect(isValidCallbackPath('/subscriptions/kilo-pass')).toBe(true);
104+
expect(isValidCallbackPath('/subscriptions/kilo-pass/refer')).toBe(true);
105+
});
101106
});
102107

103108
describe('invalid paths', () => {
@@ -276,6 +281,21 @@ describe('getSignInCallbackUrl', () => {
276281
'/users/after-sign-in?_saasquatch=opaque-referral-cookie&rsCode=ref-code&utm_source=invite&utm_medium=link&utm_campaign=saasquatch&callbackPath=%2Fclaw%2Fnew'
277282
);
278283
});
284+
285+
test('preserves Kilo Pass callback paths and referral UTM metadata', () => {
286+
const result = getSignInCallbackUrl({
287+
callbackPath: '/subscriptions/kilo-pass/refer',
288+
_saasquatch: 'opaque-referral-cookie',
289+
rsCode: 'ref-code',
290+
utm_source: 'invite',
291+
utm_medium: 'link',
292+
utm_campaign: 'saasquatch',
293+
});
294+
295+
expect(result).toBe(
296+
'/users/after-sign-in?_saasquatch=opaque-referral-cookie&rsCode=ref-code&utm_source=invite&utm_medium=link&utm_campaign=saasquatch&callbackPath=%2Fsubscriptions%2Fkilo-pass%2Frefer'
297+
);
298+
});
279299
});
280300

281301
describe('stripHost', () => {

apps/web/src/lib/getSignInCallbackUrl.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export function isValidCallbackPath(path: string): boolean {
2323
path === '/claw' ||
2424
path.startsWith('/claw/') ||
2525
path.startsWith('/cloud') ||
26+
path === '/subscriptions/kilo-pass' ||
27+
path.startsWith('/subscriptions/kilo-pass/') ||
2628
path.startsWith('/integrations/') ||
2729
// Admin-managed URL bonus campaigns. Stricter shape enforcement
2830
// (slug format, prefix-match guard) happens in

0 commit comments

Comments
 (0)