Skip to content

Commit a5b48b6

Browse files
authored
feat: funnel boot (#2718)
1 parent cfd3f46 commit a5b48b6

File tree

9 files changed

+403
-4
lines changed

9 files changed

+403
-4
lines changed

Diff for: .env

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ MEILI_ORIGIN=http://localhost:7700/
4141
SUBMIT_ARTICLE_THRESHOLD=250
4242
ANALYTICS_URL=http://localhost:5000
4343
NJORD_ORIGIN=http://njord-transactions-server
44+
FREYJA_ORIGIN=http://localhost:7800
4445

4546
MEILI_INDEX=dev
4647
MEILI_TOKEN=dev

Diff for: .infra/Pulumi.adhoc.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ config:
5454
defaultImageUrl: https://res.cloudinary.com/daily-now/image/upload/s--P4t4XyoV--/f_auto/v1722860399/public/Placeholder%2001,https://res.cloudinary.com/daily-now/image/upload/s--VDukGCjf--/f_auto/v1722860399/public/Placeholder%2002,https://res.cloudinary.com/daily-now/image/upload/s--HRgLpUt6--/f_auto/v1722860399/public/Placeholder%2003,https://res.cloudinary.com/daily-now/image/upload/s--foaA6JGU--/f_auto/v1722860399/public/Placeholder%2004,https://res.cloudinary.com/daily-now/image/upload/s--CxzD6vbw--/f_auto/v1722860399/public/Placeholder%2005,https://res.cloudinary.com/daily-now/image/upload/s--ZrL_HSsR--/f_auto/v1722860399/public/Placeholder%2006,https://res.cloudinary.com/daily-now/image/upload/s--1KxV4ohY--/f_auto/v1722860400/public/Placeholder%2007,https://res.cloudinary.com/daily-now/image/upload/s--0_ODbtD2--/f_auto/v1722860399/public/Placeholder%2008,https://res.cloudinary.com/daily-now/image/upload/s--qPvKM23u--/f_auto/v1722860399/public/Placeholder%2009,https://res.cloudinary.com/daily-now/image/upload/s--OHB84bZF--/f_auto/v1722860399/public/Placeholder%2010,https://res.cloudinary.com/daily-now/image/upload/s--2-1xRawN--/f_auto/v1722860399/public/Placeholder%2011,https://res.cloudinary.com/daily-now/image/upload/s--58gMhC4P--/f_auto/v1722860399/public/Placeholder%2012
5555
digestQueueConcurrency: 1000
5656
enablePubsub: true
57+
freyjaOrigin: http://freyja
5758
gcloudProject: local
5859
heimdallOrigin: http://heimdall-api
5960
jwtAudience: Daily Staging

Diff for: .infra/Pulumi.prod.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ config:
5353
digestQueueConcurrency: 1000
5454
experimentationKey:
5555
secure: AAABADH0xT7H6UeMlZG0fUEGjohQ1dvwcbIgJ3WMI5wfqScub3Qp3nvUDhska0DunbFmvj7IrtH07noRL1lCYg==
56+
freyjaOrigin: http://freyja.freyja
5657
growthbookApiConfigClientKey:
5758
secure: AAABAJEgS6b8xZv0j06KOawyNMdkTpGmzKs7Ryed5SofiLp3vSTjxhqQuKSVsaHjR5s=
5859
growthbookClientKey:

Diff for: __tests__/boot.ts

+181-3
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ import setCookieParser from 'set-cookie-parser';
5858
import { postsFixture } from './fixture/post';
5959
import { sourcesFixture } from './fixture/source';
6060
import { SourcePermissions } from '../src/schema/sources';
61-
import { getEncryptedFeatures } from '../src/growthbook';
6261
import { base64 } from 'graphql-relay/utils/base64';
6362
import { cookies } from '../src/cookies';
6463
import { signJwt } from '../src/auth';
@@ -69,11 +68,12 @@ import {
6968
} from '../src/common';
7069
import { saveReturnAlerts } from '../src/schema/alerts';
7170
import { CoresRole, UserVote } from '../src/types';
72-
import { BootAlerts, excludeProperties } from '../src/routes/boot';
71+
import { BootAlerts, excludeProperties, FunnelBoot } from '../src/routes/boot';
7372
import { SubscriptionCycles } from '../src/paddle';
7473
import * as njordCommon from '../src/common/njord';
7574
import { Credits, EntityType } from '@dailydotdev/schema';
7675
import { createClient } from '@connectrpc/connect';
76+
import { FunnelState } from '../src/integrations/freyja';
7777

7878
let app: FastifyInstance;
7979
let con: DataSource;
@@ -169,6 +169,18 @@ const getBootAlert = (data: Alerts): BootAlerts =>
169169
subDays(new Date(), FEED_SURVEY_INTERVAL) > data.lastFeedSettingsFeedback,
170170
}) as BootAlerts;
171171

172+
jest.mock('../src/growthbook', () => ({
173+
...(jest.requireActual('../src/growthbook') as Record<string, unknown>),
174+
getEncryptedFeatures: () => 'enc',
175+
getUserGrowthBookInstace: () => {
176+
return {
177+
loadFeatures: jest.fn(),
178+
getFeatures: jest.fn(),
179+
getFeatureValue: () => 'gbId',
180+
};
181+
},
182+
}));
183+
172184
beforeAll(async () => {
173185
con = await createOrGetConnection();
174186
state = await initializeGraphQLTesting(() => new MockContext(con));
@@ -177,7 +189,6 @@ beforeAll(async () => {
177189

178190
beforeEach(async () => {
179191
jest.clearAllMocks();
180-
jest.mocked(getEncryptedFeatures).mockReturnValue('enc');
181192
await con.getRepository(User).save(usersFixture[0]);
182193
await con.getRepository(Source).save(sourcesFixture);
183194
await con.getRepository(Post).save(postsFixture);
@@ -1486,3 +1497,170 @@ describe('boot alerts shouldShowFeedFeedback property', () => {
14861497
expect(res.body.alerts.shouldShowFeedFeedback).toBeTruthy();
14871498
});
14881499
});
1500+
1501+
describe('funnel boot', () => {
1502+
const FUNNEL_DATA: FunnelState = {
1503+
session: {
1504+
userId: '1',
1505+
id: 'sessionId',
1506+
currentStep: '5',
1507+
},
1508+
funnel: {
1509+
id: 'funnelId',
1510+
version: 2,
1511+
},
1512+
};
1513+
1514+
const FUNNEL_BOOT_BODY: FunnelBoot = {
1515+
...ANONYMOUS_BODY,
1516+
funnelState: FUNNEL_DATA,
1517+
};
1518+
1519+
it('should return the funnel data for an anonymous user', async () => {
1520+
nock(process.env.FREYJA_ORIGIN)
1521+
.post('/api/sessions', {
1522+
userId: '1',
1523+
funnelId: 'funnelId',
1524+
version: 2,
1525+
})
1526+
.reply(200, JSON.stringify(FUNNEL_DATA));
1527+
1528+
const res = await request(app.server)
1529+
.get(`${BASE_PATH}/funnel?id=funnelId&v=2`)
1530+
.set('User-Agent', TEST_UA)
1531+
.set('Cookie', `${cookies.tracking.key}=1;`)
1532+
.expect(200);
1533+
expect(res.body).toEqual(FUNNEL_BOOT_BODY);
1534+
});
1535+
1536+
it('should return the logged in user', async () => {
1537+
nock(process.env.FREYJA_ORIGIN)
1538+
.post('/api/sessions')
1539+
.reply(200, JSON.stringify(FUNNEL_DATA));
1540+
1541+
const accessToken = await signJwt(
1542+
{
1543+
userId: '1',
1544+
roles: [],
1545+
},
1546+
15 * 60 * 1000,
1547+
);
1548+
1549+
const res = await request(app.server)
1550+
.get(`${BASE_PATH}/funnel?id=funnelId`)
1551+
.set('User-Agent', TEST_UA)
1552+
.set(
1553+
'Cookie',
1554+
`${cookies.auth.key}=${app.signCookie(accessToken.token)};`,
1555+
)
1556+
.expect(200);
1557+
expect(res.body).toEqual({
1558+
...FUNNEL_BOOT_BODY,
1559+
user: excludeProperties(LOGGED_IN_BODY.user, [
1560+
'balance',
1561+
'flags',
1562+
'isPlus',
1563+
'isTeamMember',
1564+
'language',
1565+
'roles',
1566+
'subscriptionFlags',
1567+
]),
1568+
});
1569+
});
1570+
1571+
it('should return anonymous user if jwt is expired', async () => {
1572+
nock(process.env.FREYJA_ORIGIN)
1573+
.post('/api/sessions')
1574+
.reply(200, JSON.stringify(FUNNEL_DATA));
1575+
1576+
const accessToken = await signJwt(
1577+
{
1578+
userId: '1',
1579+
roles: [],
1580+
},
1581+
-15 * 60 * 1000,
1582+
);
1583+
1584+
const res = await request(app.server)
1585+
.get(`${BASE_PATH}/funnel?id=funnelId`)
1586+
.set('User-Agent', TEST_UA)
1587+
.set(
1588+
'Cookie',
1589+
`${cookies.auth.key}=${app.signCookie(accessToken.token)};`,
1590+
)
1591+
.expect(200);
1592+
expect(res.body).toEqual(FUNNEL_BOOT_BODY);
1593+
});
1594+
1595+
it('should set cookie for the new funnel', async () => {
1596+
nock(process.env.FREYJA_ORIGIN)
1597+
.post('/api/sessions')
1598+
.reply(200, JSON.stringify(FUNNEL_DATA));
1599+
1600+
const res = await request(app.server)
1601+
.get(`${BASE_PATH}/funnel?id=funnelId`)
1602+
.set('User-Agent', TEST_UA)
1603+
.set('Cookie', `${cookies.tracking.key}=1;`)
1604+
.expect(200);
1605+
1606+
const cookie = (res.get('set-cookie') as unknown as string[]).find((c) =>
1607+
c.startsWith(cookies.funnel.key),
1608+
);
1609+
expect(cookie?.split(';')[0]).toEqual(
1610+
`${cookies.funnel.key}=${FUNNEL_DATA.session.id}`,
1611+
);
1612+
});
1613+
1614+
it('should load funnel when cookie is present', async () => {
1615+
nock(process.env.FREYJA_ORIGIN)
1616+
.get(`/api/sessions/${FUNNEL_DATA.session.id}`)
1617+
.reply(200, JSON.stringify(FUNNEL_DATA));
1618+
1619+
const res = await request(app.server)
1620+
.get(`${BASE_PATH}/funnel?id=funnelId2`)
1621+
.set('User-Agent', TEST_UA)
1622+
.set(
1623+
'Cookie',
1624+
`${cookies.tracking.key}=1;${cookies.funnel.key}=${FUNNEL_DATA.session.id};`,
1625+
)
1626+
.expect(200);
1627+
expect(res.body).toEqual(FUNNEL_BOOT_BODY);
1628+
});
1629+
1630+
it('should ignore cookie when the user does not match', async () => {
1631+
nock(process.env.FREYJA_ORIGIN)
1632+
.get(`/api/sessions/${FUNNEL_DATA.session.id}`)
1633+
.reply(200, JSON.stringify(FUNNEL_DATA));
1634+
1635+
const clone = structuredClone(FUNNEL_DATA);
1636+
clone.session.userId = '2';
1637+
nock(process.env.FREYJA_ORIGIN)
1638+
.post('/api/sessions')
1639+
.reply(200, JSON.stringify(clone));
1640+
1641+
const res = await request(app.server)
1642+
.get(`${BASE_PATH}/funnel?id=funnelId`)
1643+
.set('User-Agent', TEST_UA)
1644+
.set(
1645+
'Cookie',
1646+
`${cookies.tracking.key}=2;${cookies.funnel.key}=${FUNNEL_DATA.session.id};`,
1647+
)
1648+
.expect(200);
1649+
expect(res.body.funnelState.session.userId).toEqual('2');
1650+
});
1651+
1652+
it('should load funnel id from growthbook', async () => {
1653+
nock(process.env.FREYJA_ORIGIN)
1654+
.post('/api/sessions', {
1655+
userId: '1',
1656+
funnelId: 'gbId',
1657+
})
1658+
.reply(200, JSON.stringify(FUNNEL_DATA));
1659+
1660+
await request(app.server)
1661+
.get(`${BASE_PATH}/funnel`)
1662+
.set('User-Agent', TEST_UA)
1663+
.set('Cookie', `${cookies.tracking.key}=1;`)
1664+
.expect(200);
1665+
});
1666+
});

Diff for: src/cookies.ts

+10
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ export const cookies: {
3636
},
3737
key: 'da3',
3838
},
39+
funnel: {
40+
opts: {
41+
maxAge: 1000 * 60 * 30,
42+
httpOnly: true,
43+
signed: false,
44+
secure: env === 'production',
45+
sameSite: 'lax',
46+
},
47+
key: 'da4',
48+
},
3949
kratos: {
4050
key: 'ory_kratos_session',
4151
opts: {

Diff for: src/integrations/freyja/clients.ts

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { RequestInit } from 'node-fetch';
2+
import { IFreyjaClient, type FunnelState } from './types';
3+
import { GarmrNoopService, IGarmrService, GarmrService } from '../garmr';
4+
import { fetchOptions as globalFetchOptions } from '../../http';
5+
import { fetchParse } from '../retry';
6+
7+
export class FreyjaClient implements IFreyjaClient {
8+
private readonly fetchOptions: RequestInit;
9+
private readonly garmr: IGarmrService;
10+
11+
constructor(
12+
private readonly url: string,
13+
options?: {
14+
fetchOptions?: RequestInit;
15+
garmr?: IGarmrService;
16+
},
17+
) {
18+
const {
19+
fetchOptions = globalFetchOptions,
20+
garmr = new GarmrNoopService(),
21+
} = options || {};
22+
23+
this.fetchOptions = fetchOptions;
24+
this.garmr = garmr;
25+
}
26+
27+
createSession(
28+
userId: string,
29+
funnelId: string,
30+
version?: number,
31+
): Promise<FunnelState> {
32+
return this.garmr.execute(() => {
33+
return fetchParse(`${this.url}/api/sessions`, {
34+
...this.fetchOptions,
35+
method: 'POST',
36+
body: JSON.stringify({ userId, funnelId, version }),
37+
});
38+
});
39+
}
40+
41+
getSession(sessionId: string): Promise<FunnelState> {
42+
return this.garmr.execute(() => {
43+
return fetchParse(`${this.url}/api/sessions/${sessionId}`, {
44+
...this.fetchOptions,
45+
method: 'GET',
46+
});
47+
});
48+
}
49+
}
50+
51+
const garmrFreyjaService = new GarmrService({
52+
service: FreyjaClient.name,
53+
breakerOpts: {
54+
halfOpenAfter: 5 * 1000,
55+
threshold: 0.1,
56+
duration: 10 * 1000,
57+
},
58+
});
59+
60+
export const freyjaClient = new FreyjaClient(process.env.FREYJA_ORIGIN!, {
61+
garmr: garmrFreyjaService,
62+
});

Diff for: src/integrations/freyja/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './types';
2+
export { FreyjaClient, freyjaClient } from './clients';

Diff for: src/integrations/freyja/types.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Keep the type flexible to allow for future changes
2+
export type FunnelState = {
3+
session: {
4+
id: string;
5+
currentStep: string;
6+
userId: string;
7+
} & Record<string, unknown>;
8+
funnel: {
9+
id: string;
10+
version: number;
11+
} & Record<string, unknown>;
12+
};
13+
14+
export interface IFreyjaClient {
15+
createSession(
16+
userId: string,
17+
funnelId: string,
18+
version?: number,
19+
): Promise<FunnelState>;
20+
getSession(sessionId: string): Promise<FunnelState>;
21+
}

0 commit comments

Comments
 (0)