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
53 changes: 52 additions & 1 deletion __tests__/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { cookies } from '../src/cookies';
import { signJwt } from '../src/auth';
import {
DEFAULT_TIMEZONE,
ONE_DAY_IN_SECONDS,
submitArticleThreshold,
THREE_MONTHS_IN_SECONDS,
updateFlagsStatement,
Expand Down Expand Up @@ -2366,9 +2367,10 @@ describe('engagement creatives', () => {
gen_id: GENERATION_ID,
};

afterEach(() => {
afterEach(async () => {
nock.cleanAll();
remoteConfig.vars.engagementAdsEnabled = true;
await deleteKeysByPattern('boot:cpa_source:*');
});

it('should not fetch engagement creatives when remote config is disabled', async () => {
Expand Down Expand Up @@ -2416,6 +2418,55 @@ describe('engagement creatives', () => {
expect(res.body.engagementCreatives).toEqual([expectedCreative]);
});

it('should cache the cpa source->user mapping with a 24h expiry when skadi returns a source_id', async () => {
const cpaKey = generateStorageKey(
StorageTopic.Boot,
StorageKey.CpaSource,
'1',
);

nock(process.env.SKADI_ORIGIN)
.post('/private')
.reply(200, {
generation_id: GENERATION_ID,
value: {
engagement: { ...skadiEngagementPayload, source_id: 'cpa-source-1' },
},
});

await request(app.server)
.get(BASE_PATH)
.set('User-Agent', TEST_UA)
.set('Cookie', await mockLoggedInCookie())
.expect(200);

expect(await getRedisObject(cpaKey)).toEqual('cpa-source-1');
expect(await getRedisObjectExpiry(cpaKey)).toBeLessThanOrEqual(
ONE_DAY_IN_SECONDS,
);
expect(await getRedisObjectExpiry(cpaKey)).toBeGreaterThanOrEqual(
ONE_DAY_IN_SECONDS - 10,
);
});

it('should not cache a cpa source when skadi returns no source_id', async () => {
const cpaKey = generateStorageKey(
StorageTopic.Boot,
StorageKey.CpaSource,
'1',
);

nock(process.env.SKADI_ORIGIN).post('/private').reply(200, skadiResponse);

await request(app.server)
.get(BASE_PATH)
.set('User-Agent', TEST_UA)
.set('Cookie', await mockLoggedInCookie())
.expect(200);

expect(await getRedisObject(cpaKey)).toBeNull();
});

it('should return empty array when skadi returns no generation_id', async () => {
nock(process.env.SKADI_ORIGIN)
.post('/private')
Expand Down
104 changes: 102 additions & 2 deletions __tests__/feeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createSquadWelcomePost,
feedToFilters,
maxFeedNameLength,
ONE_DAY_IN_SECONDS,
Ranking,
updateFlagsStatement,
} from '../src/common';
Expand Down Expand Up @@ -62,7 +63,8 @@ import {
} from './fixture/post';
import { keywordsFixture } from './fixture/keywords';
import nock from 'nock';
import { deleteKeysByPattern } from '../src/redis';
import { deleteKeysByPattern, setRedisObjectWithExpiry } from '../src/redis';
import { generateStorageKey, StorageKey, StorageTopic } from '../src/config';
import { DataSource } from 'typeorm';
import createOrGetConnection from '../src/db';
import { randomUUID } from 'crypto';
Expand Down Expand Up @@ -100,11 +102,13 @@ let state: GraphQLTestingState;
let client: GraphQLTestClient;
let loggedUser: string = null;
let isPlus: boolean = false;
let trackingId: string | undefined;

beforeAll(async () => {
con = await createOrGetConnection();
state = await initializeGraphQLTesting(
() => new MockContext(con, loggedUser, [], null, false, isPlus),
() =>
new MockContext(con, loggedUser, [], null, false, isPlus, '', trackingId),
);
client = state.client;
app = state.app;
Expand All @@ -113,6 +117,7 @@ beforeAll(async () => {
beforeEach(async () => {
loggedUser = null;
isPlus = false;
trackingId = undefined;

await saveFixtures(con, User, usersFixture);
await saveFixtures(con, AdvancedSettings, advancedSettings);
Expand All @@ -130,6 +135,7 @@ beforeEach(async () => {
{ value: 'data', status: 'allow' },
]);
await deleteKeysByPattern('feeds:*');
await deleteKeysByPattern('boot:cpa_source:*');
});

afterAll(() => app.close());
Expand Down Expand Up @@ -495,6 +501,72 @@ describe('query anonymousFeed', () => {
expect(res.data).toMatchSnapshot();
});

it('should forward the cached cpa_source to the feed service', async () => {
loggedUser = '1';
await setRedisObjectWithExpiry(
generateStorageKey(StorageTopic.Boot, StorageKey.CpaSource, '1'),
'cpa-source-1',
ONE_DAY_IN_SECONDS,
);

nock('http://localhost:6000')
.post('/api/feed', {
total_pages: 1,
page_size: 10,
fresh_page_size: '4',
offset: 0,
user_id: '1',
source_types: ['machine', 'squad', 'user'],
allowed_languages: ['en'],
feed_config_name: 'popular',
min_day_range: 14,
allowed_content_curations: [
'news',
'release',
'opinion',
'comparison',
'story',
],
cpa_source: 'cpa-source-1',
})
.reply(200, {
data: [{ post_id: 'p1' }, { post_id: 'p4' }],
cursor: 'b',
});
const res = await client.query(QUERY, {
variables: { ...variables, version: 2 },
});
expect(res.errors).toBeFalsy();
expect(res.data).toBeTruthy();
});

it('should forward the cached cpa_source keyed by tracking id for an anonymous user', async () => {
loggedUser = null;
trackingId = 'anon-track-1';
await setRedisObjectWithExpiry(
generateStorageKey(StorageTopic.Boot, StorageKey.CpaSource, trackingId),
'cpa-source-anon',
ONE_DAY_IN_SECONDS,
);

nock('http://localhost:6000')
.post('/api/feed', (body) => {
expect(body.user_id).toEqual('anon-track-1');
expect(body.cpa_source).toEqual('cpa-source-anon');
return true;
})
.reply(200, {
data: [{ post_id: 'p1' }, { post_id: 'p4' }],
cursor: 'b',
});

const res = await client.query(QUERY, {
variables: { ...variables, version: 2 },
});
expect(res.errors).toBeFalsy();
expect(res.data).toBeTruthy();
});

it('should safetly handle a case where the feed is empty', async () => {
loggedUser = '1';
nock('http://localhost:6000').post('/api/feed').reply(200, {
Expand Down Expand Up @@ -1230,6 +1302,34 @@ describe('query feedV2', () => {
expect(res.data.feedV2.edges).toHaveLength(1);
});

it('should forward the cached cpa_source to the feed service', async () => {
loggedUser = '1';
await saveFeedFixtures();
await setRedisObjectWithExpiry(
generateStorageKey(StorageTopic.Boot, StorageKey.CpaSource, '1'),
'cpa-source-1',
ONE_DAY_IN_SECONDS,
);

nock('http://localhost:6002')
.post('/config')
.reply(200, { user_id: '1', config: { providers: {} } });
nock('http://localhost:6000')
.post('/api/feed', (body) => {
expect(body.cpa_source).toEqual('cpa-source-1');
return true;
})
.reply(200, {
data: [{ post_id: 'p1' }],
cursor: 'b',
});

const res = await client.query(QUERY, { variables });

expect(res.errors).toBeFalsy();
expect(res.data.feedV2.edges).toHaveLength(1);
});

it('should return mixed post and highlight items', async () => {
loggedUser = '1';
await saveFeedFixtures();
Expand Down
68 changes: 58 additions & 10 deletions src/betterAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import {
NotificationPreferenceStatus,
NotificationType,
} from './notifications/common';
import { addClaimableItemsToUser } from './entity/user/utils';
import {
addClaimableItemsToUser,
allowedCloudProviders,
} from './entity/user/utils';
import { claimAnonOpportunities } from './common/opportunity/user';
import { triggerTypedEvent } from './common/typedPubsub';
import { assignIntroQuestsToUser } from './common/quest/intro';
Expand Down Expand Up @@ -150,16 +153,18 @@ const parseTrackingIdFromCookieHeader = (

const parseReferralFromCookieHeader = (
cookieHeader: string,
): { referralId: string; referralOrigin: string } | undefined => {
): { referralId?: string; referralOrigin: string } | undefined => {
const value = parseCookieValue(cookieHeader, JOIN_REFERRAL_COOKIE_KEY);
if (!value) {
return undefined;
}
const [referralId, referralOrigin] = value.split(':');
if (referralId && referralOrigin) {
return { referralId, referralOrigin };
// referralOrigin (the campaign) is required; referralId (the referring user)
// is optional — campaign-only links set `:campaign`.
if (!referralOrigin) {
return undefined;
}
return undefined;
return { referralId: referralId || undefined, referralOrigin };
};

type BetterAuthDbHookContext = {
Expand Down Expand Up @@ -517,6 +522,25 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => {
input: userExperienceLevelSchema,
},
},
company: {
type: 'string',
required: false,
fieldName: 'company',
validator: {
input: z.string().max(100),
},
},
title: {
type: 'string',
required: false,
fieldName: 'title',
validator: {
input: z.string().max(100),
},
},
// cloudProvider is intentionally NOT a column-backed additional field —
// it's stored in the `flags` JSONB and written from the signup body in
// the create.after hook below.
},
},
session: {
Expand Down Expand Up @@ -567,14 +591,38 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => {
[user.id],
);

const setClauses: string[] = [
`flags = CASE WHEN flags = '{}' THEN '{"trustScore": 1, "vordr": false}' ELSE flags END`,
];
const hookCtx = ctx as BetterAuthDbHookContext;
const body = hookCtx?.body;

// Campaign onboarding stores cloud provider in the `flags` JSONB
// (not a dedicated column). Validate against the allowed list and
// merge it into flags alongside the default trustScore/vordr.
const rawCloudProvider = (
body as { cloudProvider?: unknown } | undefined
)?.cloudProvider;
const cloudProvider =
typeof rawCloudProvider === 'string' &&
(allowedCloudProviders as readonly string[]).includes(
rawCloudProvider,
)
? rawCloudProvider
: null;

const values: unknown[] = [user.id];
let paramIndex = 2;

const hookCtx = ctx as BetterAuthDbHookContext;
const body = hookCtx?.body;
const baseFlags = `CASE WHEN flags = '{}' THEN '{"trustScore": 1, "vordr": false}'::jsonb ELSE flags END`;
const setClauses: string[] = [];
if (cloudProvider) {
setClauses.push(
`flags = (${baseFlags}) || $${paramIndex}::jsonb`,
);
values.push(JSON.stringify({ cloudProvider }));
paramIndex++;
} else {
setClauses.push(`flags = ${baseFlags}`);
}

const addField = (column: string, value: unknown): void => {
if (typeof value === 'string' && value.length > 0) {
setClauses.push(`"${column}" = $${paramIndex}`);
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export enum StorageKey {
ParticipantCount = 'participant_count',
HasLiveRooms = 'has_live_rooms',
DailyFeed = 'daily',
CpaSource = 'cpa_source',
}

export const generateStorageKey = (
Expand Down
3 changes: 3 additions & 0 deletions src/entity/user/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export type UserFlags = Partial<{
updatedAt: string;
};
tagChipFeedsSeededAt: string | null;
// Current cloud provider from campaign onboarding (e.g. aws/gcp/azure/other/
// none). Stored here rather than a column since it's optional campaign data.
cloudProvider: string | null;
}>;

export type UserFlagsPublic = Pick<UserFlags, 'showPlusGift'>;
Expand Down
22 changes: 22 additions & 0 deletions src/entity/user/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ import {
NotificationType,
} from '../../notifications/common';

// Allowed values for the optional onboarding "current cloud provider" field
// (collected on campaign funnels). Kept in app code, not the DB, so it's easy
// to evolve. Keep this list in sync with the frontend dropdown options.
export const allowedCloudProviders = [
'aws',
'gcp',
'azure',
'other',
'none',
] as const;

// Max length for free-text profile fields (company / job title).
const profileTextLimit = 100;

export type AddUserData = Pick<
User,
| 'id'
Expand Down Expand Up @@ -490,6 +504,14 @@ export const validateUserUpdate = async (
throw new ValidationError(JSON.stringify({ language: 'invalid language' }));
}

(['company', 'title'] as const).forEach((key) => {
if ((data[key]?.length || 0) > profileTextLimit) {
throw new ValidationError(
JSON.stringify({ [key]: `${key} is too long` }),
);
}
});

(
['name', 'twitter', 'github', 'hashnode'] as (keyof Pick<
GQLUpdateUserInput,
Expand Down
Loading
Loading