diff --git a/__tests__/boot.ts b/__tests__/boot.ts index bbbd0408c4..5b93a220ee 100644 --- a/__tests__/boot.ts +++ b/__tests__/boot.ts @@ -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, @@ -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 () => { @@ -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') diff --git a/__tests__/feeds.ts b/__tests__/feeds.ts index 0a746cea53..7d47d67678 100644 --- a/__tests__/feeds.ts +++ b/__tests__/feeds.ts @@ -2,6 +2,7 @@ import { createSquadWelcomePost, feedToFilters, maxFeedNameLength, + ONE_DAY_IN_SECONDS, Ranking, updateFlagsStatement, } from '../src/common'; @@ -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'; @@ -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; @@ -113,6 +117,7 @@ beforeAll(async () => { beforeEach(async () => { loggedUser = null; isPlus = false; + trackingId = undefined; await saveFixtures(con, User, usersFixture); await saveFixtures(con, AdvancedSettings, advancedSettings); @@ -130,6 +135,7 @@ beforeEach(async () => { { value: 'data', status: 'allow' }, ]); await deleteKeysByPattern('feeds:*'); + await deleteKeysByPattern('boot:cpa_source:*'); }); afterAll(() => app.close()); @@ -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, { @@ -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(); diff --git a/src/betterAuth.ts b/src/betterAuth.ts index a9fb7a53bb..8383364907 100644 --- a/src/betterAuth.ts +++ b/src/betterAuth.ts @@ -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'; @@ -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 = { @@ -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: { @@ -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}`); diff --git a/src/config.ts b/src/config.ts index 36cefb9feb..4cea620a5f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,6 +59,7 @@ export enum StorageKey { ParticipantCount = 'participant_count', HasLiveRooms = 'has_live_rooms', DailyFeed = 'daily', + CpaSource = 'cpa_source', } export const generateStorageKey = ( diff --git a/src/entity/user/User.ts b/src/entity/user/User.ts index 95e4da4131..b0022c92d0 100644 --- a/src/entity/user/User.ts +++ b/src/entity/user/User.ts @@ -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; diff --git a/src/entity/user/utils.ts b/src/entity/user/utils.ts index 7c5e1ccb58..34e2283598 100644 --- a/src/entity/user/utils.ts +++ b/src/entity/user/utils.ts @@ -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' @@ -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, diff --git a/src/integrations/feed/cpaSource.ts b/src/integrations/feed/cpaSource.ts new file mode 100644 index 0000000000..2793e2e5ff --- /dev/null +++ b/src/integrations/feed/cpaSource.ts @@ -0,0 +1,19 @@ +import { generateStorageKey, StorageKey, StorageTopic } from '../../config'; +import { getRedisObject } from '../../redis'; + +// Reads the CPA campaign source cached by boot (see getEngagementCreatives), +// keyed by the user/tracking id, so feed queries can forward it to the feed +// service as `cpa_source`. Returns undefined when nothing is cached. +export const getCpaSource = async ( + id?: string | null, +): Promise => { + if (!id) { + return undefined; + } + + return ( + (await getRedisObject( + generateStorageKey(StorageTopic.Boot, StorageKey.CpaSource, id), + )) ?? undefined + ); +}; diff --git a/src/integrations/feed/types.ts b/src/integrations/feed/types.ts index 9491ba0810..07b4f57dde 100644 --- a/src/integrations/feed/types.ts +++ b/src/integrations/feed/types.ts @@ -102,6 +102,9 @@ export type FeedConfig = { allowed_languages?: string[]; experience_level?: string; country?: string; + // CPA campaign source id, forwarded from the redis source->user mapping + // that boot caches when skadi returns an engagement creative with a source. + cpa_source?: string; config?: { [key: string]: unknown; }; diff --git a/src/integrations/skadi/clients.ts b/src/integrations/skadi/clients.ts index 1d067dab70..0f9d6223a9 100644 --- a/src/integrations/skadi/clients.ts +++ b/src/integrations/skadi/clients.ts @@ -35,9 +35,15 @@ export class SkadiClient implements ISkadiClient { metadata: { USERID: string; }, + options?: { + cid?: string; + }, ): Promise> { + const url = options?.cid + ? `${this.url}/private?cid=${encodeURIComponent(options.cid)}` + : `${this.url}/private`; return this.garmr.execute(({ signal }) => { - return fetchParse(`${this.url}/private`, { + return fetchParse(url, { ...this.fetchOptions, method: 'POST', headers: { diff --git a/src/integrations/skadi/types.ts b/src/integrations/skadi/types.ts index d8ffd113db..fa39acb4b6 100644 --- a/src/integrations/skadi/types.ts +++ b/src/integrations/skadi/types.ts @@ -32,6 +32,12 @@ export type EngagementCreative = { tools: string[]; keywords: string[]; tags: string[]; + // Prominent placements a campaign opted into (e.g. 'top_banner', + // 'feed_strip'). Optional: existing creatives won't carry it. + placements?: string[]; + // CPA campaign source id. When present, boot caches a source->user mapping + // in redis so feed queries can forward it as `cpa_source` for attribution. + source_id?: string; }; export interface ISkadiClient { @@ -40,5 +46,10 @@ export interface ISkadiClient { metadata: { USERID: string; }, + options?: { + // Campaign id forwarded as a `cid` query param (e.g. from the user's + // referralOrigin) so skadi can target a specific campaign. + cid?: string; + }, ): Promise>; } diff --git a/src/mocks/skadi/engagement.ts b/src/mocks/skadi/engagement.ts new file mode 100644 index 0000000000..f1dd6c23fd --- /dev/null +++ b/src/mocks/skadi/engagement.ts @@ -0,0 +1,50 @@ +import type { + EngagementCreative, + SkadiResponse, +} from '../../integrations/skadi/types'; + +const logo = { + dark: 'https://cdn.simpleicons.org/googlecloud/white', + light: 'https://cdn.simpleicons.org/googlecloud/4285F4', +}; + +const mockEngagementCreative: EngagementCreative = { + gen_id: 'mock-engagement-gen-id', + promoted_name: 'Google Cloud', + promoted_body: + 'Get $300 in free credits to build, test, and ship your next project on Google Cloud, on us.', + promoted_cta: 'Claim credits', + promoted_url: 'https://cloud.google.com/free', + promoted_logo_img: logo, + promoted_icon_img: logo, + promoted_gradient_start: { dark: '#4285F4', light: '#4285F4' }, + promoted_gradient_end: { dark: '#34A853', light: '#34A853' }, + // Drives the mentioned-tools widget on the post page (one sponsored tool per + // entry). Kept to recognizable Google Cloud products. + tools: ['BigQuery', 'Vertex AI', 'Gemini', 'Kubernetes', 'Cloud Run'], + // Highlighted-word scanner looks these up in post titles/content first + // (falls back to tags). Common words so they actually appear in the feed. + keywords: ['AI', 'cloud', 'Kubernetes', 'serverless', 'Gemini', 'deploy'], + // Tag overlap is what lights up the branded upvote animation + sponsored + // tag chip. Broad, common dev tags so the micro-interactions fire across a + // normal feed (this is a test mock — a real campaign would scope these). + tags: [ + 'cloud', + 'ai', + 'devops', + 'kubernetes', + 'machine-learning', + 'webdev', + 'javascript', + ], + placements: ['top_banner', 'feed_strip'], + source_id: 'mock-cpa-source', +}; + +export const mockSkadiEngagementResponse: SkadiResponse<{ + engagement: EngagementCreative; +}> = { + type: 'engagement', + value: { engagement: mockEngagementCreative }, + generation_id: 'mock-engagement-gen-id', +}; diff --git a/src/routes/boot.ts b/src/routes/boot.ts index 7d3a441cfc..c3bffdeca6 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -105,6 +105,8 @@ import { skadiEngagementClient, type EngagementCreative, } from '../integrations/skadi'; +import { isMockEnabled } from '../mocks/common'; +import { mockSkadiEngagementResponse } from '../mocks/skadi/engagement'; import { LiveRoom } from '../entity/LiveRoom'; import { LiveRoomStatus } from '../common/schema/liveRooms'; @@ -549,12 +551,14 @@ export function getReferralFromCookie({ const [referralId, referralOrigin] = joinReferralCookie.split(':'); - if (!referralId || !referralOrigin) { + // referralOrigin (the campaign) is required; referralId (the referring user) + // is optional — campaign-only links (e.g. onboarding) set `:campaign`. + if (!referralOrigin) { return undefined; } return { - referralId, + referralId: referralId || undefined, referralOrigin, }; } @@ -708,23 +712,48 @@ const getLocation = async ( const getEngagementCreatives = async ( userId: string, + cid?: string, ): Promise => { - if (!remoteConfig.vars.engagementAdsEnabled) { + const mocked = isMockEnabled(); + + if (!mocked && !remoteConfig.vars.engagementAdsEnabled) { return []; } try { - const response = await skadiEngagementClient.getAd('default_engagement', { - USERID: userId, - }); + const response = mocked + ? mockSkadiEngagementResponse + : await skadiEngagementClient.getAd( + 'default_engagement', + { USERID: userId }, + { cid }, + ); if (!response.value?.engagement || !response.generation_id) { return []; } + const { engagement } = response.value; + + // Cache the CPA source->user mapping so feed queries can forward it as + // `cpa_source`. Short-lived (24h) and refreshed on every boot that detects + // a source, so the latest campaign source wins. Isolated in its own + // try/catch: a cache write failure must never drop the creative itself. + if (engagement.source_id && userId) { + try { + await setRedisObjectWithExpiry( + generateStorageKey(StorageTopic.Boot, StorageKey.CpaSource, userId), + engagement.source_id, + ONE_DAY_IN_SECONDS, + ); + } catch (error) { + logger.error({ userId, err: error }, 'failed to cache cpa source'); + } + } + return [ { - ...response.value.engagement, + ...engagement, gen_id: response.generation_id, }, ]; @@ -818,7 +847,10 @@ const loggedInBoot = async ({ getBalanceBoot({ userId }), getClickbaitTries({ userId }), getAnonymousTheme(userId), - getEngagementCreatives(userId), + getEngagementCreatives( + userId, + getReferralFromCookie({ req })?.referralOrigin, + ), getLiveRoomsBoot(con), getDailyBoot({ userId }), ]); @@ -1023,7 +1055,10 @@ const anonymousBoot = async ( getAnonymousFirstVisit(req.trackingId), getExperimentation({ userId: req.trackingId, con, ...geo }), getAnonymousTheme(req.trackingId), - getEngagementCreatives(req.trackingId ?? ''), + getEngagementCreatives( + req.trackingId ?? '', + getReferralFromCookie({ req })?.referralOrigin, + ), getLiveRoomsBoot(con), ]); diff --git a/src/schema/feedV2.ts b/src/schema/feedV2.ts index f08df43eb7..0edd127ec0 100644 --- a/src/schema/feedV2.ts +++ b/src/schema/feedV2.ts @@ -19,6 +19,7 @@ import { Ranking, } from '../common'; import { HighlightsCanonical } from '../entity'; +import { getCpaSource } from '../integrations/feed/cpaSource'; export type FeedV2Args = FeedArgs & { unreadOnly: boolean; @@ -252,12 +253,15 @@ export const feedV2QueryResolver: IFieldResolver< cursor: args.after || undefined, }; const allowedPostTypes = getFeedV2AllowedPostTypes(args.supportedTypes); + const id = ctx.userId || ctx.trackingId; + const cpaSource = await getCpaSource(id); const response = await getForYouFeedGenerator(args).generate(ctx, { - user_id: ctx.userId || ctx.trackingId, + user_id: id, page_size: page.limit, offset: 0, cursor: page.cursor, allowed_post_types: allowedPostTypes, + cpa_source: cpaSource, highlights_limit: supportsHighlights(args) ? args.highlightsLimit || undefined : undefined, diff --git a/src/schema/feeds.ts b/src/schema/feeds.ts index de087b814a..1c7619424d 100644 --- a/src/schema/feeds.ts +++ b/src/schema/feeds.ts @@ -74,6 +74,7 @@ import { import { getRedisObject, setRedisObjectWithExpiry } from '../redis'; import { remoteConfig } from '../remoteConfig'; import { generateStorageKey, StorageKey, StorageTopic } from '../config'; +import { getCpaSource } from '../integrations/feed/cpaSource'; import { DEFAULT_TIMEZONE, secondsUntilNextHourInTimezone, @@ -1679,7 +1680,7 @@ const feedResolverV2Local: IFieldResolver< const feedResolverCursor = feedResolver< unknown, - FeedArgs & { generator: FeedGenerator }, + FeedArgs & { generator: FeedGenerator; withCpaSource?: boolean }, CursorPage, FeedResponse >( @@ -1693,18 +1694,24 @@ const feedResolverCursor = feedResolver< feedCursorPageGenerator(30, 50), (ctx, args, page, builder) => builder, { - fetchQueryParams: ( + fetchQueryParams: async ( ctx, - args: FeedArgs & { generator: FeedGenerator }, + args: FeedArgs & { generator: FeedGenerator; withCpaSource?: boolean }, page, - ) => - args.generator.generate(ctx, { - user_id: ctx.userId || ctx.trackingId, + ) => { + const id = ctx.userId || ctx.trackingId; + // Forward the cached CPA campaign source (see boot) so the feed service + // can attribute/allocate against it. Scoped to feeds that opt in. + const cpaSource = args.withCpaSource ? await getCpaSource(id) : undefined; + return args.generator.generate(ctx, { + user_id: id, page_size: page.limit, offset: 0, cursor: page.cursor, allowed_post_types: args.supportedTypes, - }), + cpa_source: cpaSource, + }); + }, warnOnPartialFirstPage: true, // Feed service should take care of this removeNonPublicThresholdSquads: false, @@ -1913,6 +1920,7 @@ export const resolvers: IResolvers = { args.ranking === Ranking.TIME ? feedGenerators['time']! : feedGenerators['popular']!, + withCpaSource: true, }, ctx, info,