diff --git a/src/server/api/auxiaProxyRouter.ts b/src/server/api/auxiaProxyRouter.ts index 92654f499..e7032da2d 100644 --- a/src/server/api/auxiaProxyRouter.ts +++ b/src/server/api/auxiaProxyRouter.ts @@ -2,92 +2,22 @@ import express, { Router } from 'express'; import { isProd } from '../lib/env'; import { getSsmValue } from '../utils/ssm'; import { bodyContainsAllFields } from '../middleware'; - -// -------------------------------- -// Basic Types -// -------------------------------- +import { + buildGetTreatmentsRequestPayload, + guDefaultGateGetTreatmentsResponseData, + AuxiaAPIGetTreatmentsResponseData, + isValidContentType, + isValidSection, + isValidTagIdCollection, + buildAuxiaProxyGetTreatmentsResponseData, + buildLogTreatmentInteractionRequestPayload, +} from '../signin-gate/lib'; export interface AuxiaRouterConfig { apiKey: string; projectId: string; } -// -------------------------------- -// Auxia API Types -// -------------------------------- - -interface AuxiaAPIContextualAttributeString { - key: string; - stringValue: string; -} - -interface AuxiaAPIContextualAttributeBoolean { - key: string; - boolValue: boolean; -} - -interface AuxiaAPIContextualAttributeInteger { - key: string; - integerValue: number; -} - -type AuxiaAPIGenericContexualAttribute = - | AuxiaAPIContextualAttributeString - | AuxiaAPIContextualAttributeBoolean - | AuxiaAPIContextualAttributeInteger; - -interface AuxiaAPISurface { - surface: string; - maximumTreatmentCount: number; -} - -interface AuxiaAPIUserTreatment { - treatmentId: string; - treatmentTrackingId: string; - rank: string; - contentLanguageCode: string; - treatmentContent: string; - treatmentType: string; - surface: string; -} - -interface AuxiaAPIGetTreatmentsRequestPayload { - projectId: string; - userId: string; - contextualAttributes: AuxiaAPIGenericContexualAttribute[]; - surfaces: AuxiaAPISurface[]; - languageCode: string; -} - -interface AuxiaAPILogTreatmentInteractionRequestPayload { - projectId: string; - userId: string; - treatmentTrackingId: string; - treatmentId: string; - surface: string; - interactionType: string; - interactionTimeMicros: number; - actionName: string; -} - -interface AuxiaAPIGetTreatmentsResponseData { - responseId: string; - userTreatments: AuxiaAPIUserTreatment[]; -} - -// -------------------------------- -// Proxy Types -// -------------------------------- - -interface AuxiaProxyGetTreatmentsResponseData { - responseId: string; - userTreatment?: AuxiaAPIUserTreatment; -} - -// -------------------------------- -// Proxy Common Functions -// -------------------------------- - export const getAuxiaRouterConfig = async (): Promise => { const stage = isProd ? 'PROD' : 'CODE'; @@ -107,138 +37,6 @@ export const getAuxiaRouterConfig = async (): Promise => { }); }; -// -------------------------------- -// Proxy Implementation GetTreatments -// -------------------------------- - -const buildGetTreatmentsRequestPayload = ( - projectId: string, - browserId: string, - isSupporter: boolean, - dailyArticleCount: number, - articleIdentifier: string, - editionId: string, -): AuxiaAPIGetTreatmentsRequestPayload => { - // For the moment we are hard coding the data provided in contextualAttributes and surfaces. - return { - projectId: projectId, - userId: browserId, // In our case the userId is the browserId. - contextualAttributes: [ - { - key: 'is_supporter', - boolValue: isSupporter, - }, - { - key: 'daily_article_count', - integerValue: dailyArticleCount, - }, - { - key: 'article_identifier', - stringValue: articleIdentifier, - }, - { - key: 'edition', - stringValue: editionId, - }, - ], - surfaces: [ - { - surface: 'ARTICLE_PAGE', - maximumTreatmentCount: 1, - }, - ], - languageCode: 'en-GB', - }; -}; - -const guDefaultShouldShowTheGate = (daily_article_count: number): boolean => { - // We show the GU gate every 10 pageviews - return daily_article_count % 10 == 0; -}; - -const guDefaultGateGetTreatmentsResponseData = ( - daily_article_count: number, - gateDismissCount: number, -): AuxiaAPIGetTreatmentsResponseData => { - // This function is called in the case of non consenting users, which is detected by the absence of the browserId. - - const responseId = ''; // This value is not important, it is not used by the client. - - // First we enforce the GU policy of not showing the gate if the user has dismissed it more than 5 times. - // (We do not want users to have to dismiss the gate 6 times) - - if (gateDismissCount > 5) { - return { - responseId, - userTreatments: [], - }; - } - - // Then to prevent showing the gate too many times, we only show the gate every 10 pages views - - if (!guDefaultShouldShowTheGate(daily_article_count)) { - return { - responseId, - userTreatments: [], - }; - } - - // We are now clear to show the default gu gate. - - const title = 'Register: it’s quick and easy'; - const subtitle = 'It’s still free to read – this is not a paywall'; - const body = - 'We’re committed to keeping our quality reporting open. By registering and providing us with insight into your preferences, you’re helping us to engage with you more deeply, and that allows us to keep our journalism free for all.'; - const secondCtaName = 'I’ll do it later'; - const treatmentContent = { - title, - subtitle, - body, - first_cta_name: 'Sign in', - first_cta_link: 'https://profile.theguardian.com/signin?', - second_cta_name: secondCtaName, - second_cta_link: 'https://profile.theguardian.com/signin?', - }; - const treatmentContentEncoded = JSON.stringify(treatmentContent); - const userTreatment: AuxiaAPIUserTreatment = { - treatmentId: 'default-treatment-id', - treatmentTrackingId: 'default-treatment-tracking-id', - rank: '1', - contentLanguageCode: 'en-GB', - treatmentContent: treatmentContentEncoded, - treatmentType: 'DISMISSABLE_SIGN_IN_GATE', - surface: 'ARTICLE_PAGE', - }; - const data: AuxiaAPIGetTreatmentsResponseData = { - responseId, - userTreatments: [userTreatment], - }; - return data; -}; - -const isValidContentType = (contentType: string): boolean => { - const validTypes = ['Article']; - return validTypes.includes(contentType); -}; - -const isValidSection = (sectionId: string): boolean => { - const invalidSections = [ - 'about', - 'info', - 'membership', - 'help', - 'guardian-live-australia', - 'gnm-archive', - ]; - return !invalidSections.some((section: string): boolean => sectionId === section); -}; - -const isValidTagIdCollection = (tagIds: string[]): boolean => { - const invalidTagIds = ['info/newsletter-sign-up']; - // Check that no tagId is in the invalidTagIds list. - return !tagIds.some((tagId: string): boolean => invalidTagIds.includes(tagId)); -}; - const callGetTreatments = async ( apiKey: string, projectId: string, @@ -316,45 +114,6 @@ const callGetTreatments = async ( } }; -const buildAuxiaProxyGetTreatmentsResponseData = ( - auxiaData: AuxiaAPIGetTreatmentsResponseData, -): AuxiaProxyGetTreatmentsResponseData | undefined => { - // Note the small difference between AuxiaAPIResponseData and AuxiaProxyResponseData - // In the case of AuxiaProxyResponseData, we have an optional userTreatment field, instead of an array of userTreatments. - // This is to reflect the what the client expect semantically. - - return { - responseId: auxiaData.responseId, - userTreatment: auxiaData.userTreatments[0], - }; -}; - -// -------------------------------- -// LogTreatmentInteraction Implementation -// -------------------------------- - -const buildLogTreatmentInteractionRequestPayload = ( - projectId: string, - browserId: string, - treatmentTrackingId: string, - treatmentId: string, - surface: string, - interactionType: string, - interactionTimeMicros: number, - actionName: string, -): AuxiaAPILogTreatmentInteractionRequestPayload => { - return { - projectId: projectId, - userId: browserId, // In our case the userId is the browserId. - treatmentTrackingId, - treatmentId, - surface, - interactionType, - interactionTimeMicros, - actionName, - }; -}; - const callLogTreatmentInteration = async ( apiKey: string, projectId: string, diff --git a/src/server/signin-gate/lib.test.ts b/src/server/signin-gate/lib.test.ts new file mode 100644 index 000000000..9b06f0648 --- /dev/null +++ b/src/server/signin-gate/lib.test.ts @@ -0,0 +1,226 @@ +import { + guDefaultShouldShowTheGate, + buildGetTreatmentsRequestPayload, + guGateAsAnAuxiaAPIUserTreatment, + guDefaultGateGetTreatmentsResponseData, + isValidContentType, + isValidSection, + isValidTagIdCollection, + buildAuxiaProxyGetTreatmentsResponseData, + buildLogTreatmentInteractionRequestPayload, +} from './lib'; + +describe('guDefaultShouldShowTheGate', () => { + it('should return false if a non multiple of 10', () => { + expect(guDefaultShouldShowTheGate(21)).toBe(false); + }); + + it('should return true if a multiple of 10', () => { + expect(guDefaultShouldShowTheGate(30)).toBe(true); + }); +}); + +describe('buildGetTreatmentsRequestPayload', () => { + it('should return return the right payload', () => { + const projectId = 'projectId'; + const browserId = 'browserId'; + const isSupporter = true; + const dailyArticleCount = 21; + const articleIdentifier = 'articleIdentifier'; + const editionId = 'UK'; + + const expectedAnswer = { + projectId, + userId: browserId, + contextualAttributes: [ + { + key: 'is_supporter', + boolValue: isSupporter, + }, + { + key: 'daily_article_count', + integerValue: dailyArticleCount, + }, + { + key: 'article_identifier', + stringValue: articleIdentifier, + }, + { + key: 'edition', + stringValue: editionId, + }, + ], + surfaces: [ + { + surface: 'ARTICLE_PAGE', + maximumTreatmentCount: 1, + }, + ], + languageCode: 'en-GB', + }; + + const returnedAnswer = buildGetTreatmentsRequestPayload( + projectId, + browserId, + isSupporter, + dailyArticleCount, + articleIdentifier, + editionId, + ); + expect(returnedAnswer).toStrictEqual(expectedAnswer); + }); +}); + +describe('guDefaultGateGetTreatmentsResponseData', () => { + const guGateAsAuxiaUserTreatment1 = guGateAsAnAuxiaAPIUserTreatment(); + + it('should not return gate data if the number of gate dismissal is less than 5 (or equal to 5)', () => { + // We are setting the daily article count to a value which would allow for the gate to be shown + const daily_article_count = 0; + const expectAnswer = { + responseId: '', + userTreatments: [guGateAsAuxiaUserTreatment1], + }; + expect(guDefaultGateGetTreatmentsResponseData(daily_article_count, 5)).toStrictEqual( + expectAnswer, + ); + }); + it('should not return gate data if the number of gate dismissal is more than 5', () => { + // We are setting the daily article count to a value which would allow for the gate to be shown + const daily_article_count = 0; + const expectAnswer = { + responseId: '', + userTreatments: [], + }; + expect(guDefaultGateGetTreatmentsResponseData(daily_article_count, 6)).toStrictEqual( + expectAnswer, + ); + }); + it('should not return gate data if the daily article count is not a multiple of 10', () => { + // We are setting the gateDismissCount to a value which would allow for the gate to be shown + const gateDismissCount = 0; + const expectAnswer = { + responseId: '', + userTreatments: [], + }; + expect(guDefaultGateGetTreatmentsResponseData(1, gateDismissCount)).toStrictEqual( + expectAnswer, + ); + }); + it('should not return gate data if the daily article count is a multiple of 10', () => { + // We are setting the gateDismissCount to a value which would allow for the gate to be shown + const gateDismissCount = 0; + const expectAnswer = { + responseId: '', + userTreatments: [guGateAsAuxiaUserTreatment1], + }; + expect(guDefaultGateGetTreatmentsResponseData(10, gateDismissCount)).toStrictEqual( + expectAnswer, + ); + }); +}); + +describe('isValidContentType', () => { + it('accepts Article', () => { + expect(isValidContentType('Article')).toBe(true); + }); + + it('does not accepts NonArticle', () => { + expect(isValidContentType('NonArticle')).toBe(false); + }); +}); + +describe('isValidSection', () => { + it('accepts news', () => { + expect(isValidSection('news')).toBe(true); + }); + + it('does not accept `about`', () => { + // `about` is taken from the list of hard coded invalid sections + expect(isValidSection('about')).toBe(false); + }); +}); + +describe('isValidTagIdCollection', () => { + it('accepts `random`', () => { + expect(isValidTagIdCollection(['random/random', 'random/otherRandom'])).toBe(true); + }); + + it('does not accept `info/newsletter-sign-up`', () => { + // `info/newsletter-sign-up` is taken from the list of hard coded invalid sections + expect(isValidTagIdCollection(['info/newsletter-sign-up', 'random/otherRandom'])).toBe( + false, + ); + }); +}); + +describe('buildAuxiaProxyGetTreatmentsResponseData', () => { + it('build things correctly, in the case of a provided treatment', () => { + const auxiaData = { + responseId: 'responseId', + userTreatments: [ + { + treatmentId: 'treatmentId', + treatmentTrackingId: 'treatmentTrackingId', + rank: 'rank', + contentLanguageCode: 'contentLanguageCode', + treatmentContent: 'treatmentContent', + treatmentType: 'treatmentType', + surface: 'surface', + }, + ], + }; + const expectedAnswer = { + responseId: 'responseId', + userTreatment: { + treatmentId: 'treatmentId', + treatmentTrackingId: 'treatmentTrackingId', + rank: 'rank', + contentLanguageCode: 'contentLanguageCode', + treatmentContent: 'treatmentContent', + treatmentType: 'treatmentType', + surface: 'surface', + }, + }; + expect(buildAuxiaProxyGetTreatmentsResponseData(auxiaData)).toStrictEqual(expectedAnswer); + }); + + it('build things correctly, in the case of no treatment', () => { + const auxiaData = { + responseId: 'responseId', + userTreatments: [], + }; + const expectedAnswer = { + responseId: 'responseId', + userTreatment: undefined, + }; + expect(buildAuxiaProxyGetTreatmentsResponseData(auxiaData)).toStrictEqual(expectedAnswer); + }); +}); + +describe('buildLogTreatmentInteractionRequestPayload', () => { + it('', () => { + const expectedAnswer = { + projectId: 'projectId', + userId: 'browserId', + treatmentTrackingId: 'treatmentTrackingId', + treatmentId: 'treatmentId', + surface: 'surface', + interactionType: 'interactionType', + interactionTimeMicros: 123456789, + actionName: 'actionName', + }; + expect( + buildLogTreatmentInteractionRequestPayload( + 'projectId', + 'browserId', + 'treatmentTrackingId', + 'treatmentId', + 'surface', + 'interactionType', + 123456789, + 'actionName', + ), + ).toStrictEqual(expectedAnswer); + }); +}); diff --git a/src/server/signin-gate/lib.ts b/src/server/signin-gate/lib.ts new file mode 100644 index 000000000..cfa6022a5 --- /dev/null +++ b/src/server/signin-gate/lib.ts @@ -0,0 +1,231 @@ +interface AuxiaAPIContextualAttributeString { + key: string; + stringValue: string; +} + +interface AuxiaAPIContextualAttributeBoolean { + key: string; + boolValue: boolean; +} + +interface AuxiaAPIContextualAttributeInteger { + key: string; + integerValue: number; +} + +type AuxiaAPIGenericContexualAttribute = + | AuxiaAPIContextualAttributeString + | AuxiaAPIContextualAttributeBoolean + | AuxiaAPIContextualAttributeInteger; + +interface AuxiaAPISurface { + surface: string; + maximumTreatmentCount: number; +} + +interface AuxiaProxyGetTreatmentsResponseData { + responseId: string; + userTreatment?: AuxiaAPIUserTreatment; +} + +interface AuxiaAPILogTreatmentInteractionRequestPayload { + projectId: string; + userId: string; + treatmentTrackingId: string; + treatmentId: string; + surface: string; + interactionType: string; + interactionTimeMicros: number; + actionName: string; +} + +export interface AuxiaAPIUserTreatment { + treatmentId: string; + treatmentTrackingId: string; + rank: string; + contentLanguageCode: string; + treatmentContent: string; + treatmentType: string; + surface: string; +} + +export interface AuxiaAPIGetTreatmentsRequestPayload { + projectId: string; + userId: string; + contextualAttributes: AuxiaAPIGenericContexualAttribute[]; + surfaces: AuxiaAPISurface[]; + languageCode: string; +} + +export interface AuxiaAPIGetTreatmentsResponseData { + responseId: string; + userTreatments: AuxiaAPIUserTreatment[]; +} + +export const guDefaultShouldShowTheGate = (daily_article_count: number): boolean => { + // We show the GU gate every 10 pageviews + // Note: this behavior was arbitrarily decided by Pascal at the beginning of the Auxia project. + // Note that the value of gateDismissCount (see guDefaultGateGetTreatmentsResponseData) overrides it. + return daily_article_count % 10 == 0; +}; + +export const buildGetTreatmentsRequestPayload = ( + projectId: string, + browserId: string, + isSupporter: boolean, + dailyArticleCount: number, + articleIdentifier: string, + editionId: string, +): AuxiaAPIGetTreatmentsRequestPayload => { + // For the moment we are hard coding the data provided in contextualAttributes and surfaces. + return { + projectId: projectId, + userId: browserId, // In our case the userId is the browserId. + contextualAttributes: [ + { + key: 'is_supporter', + boolValue: isSupporter, + }, + { + key: 'daily_article_count', + integerValue: dailyArticleCount, + }, + { + key: 'article_identifier', + stringValue: articleIdentifier, + }, + { + key: 'edition', + stringValue: editionId, + }, + ], + surfaces: [ + { + surface: 'ARTICLE_PAGE', + maximumTreatmentCount: 1, + }, + ], + languageCode: 'en-GB', + }; +}; + +export const guGateAsAnAuxiaAPIUserTreatment = (): AuxiaAPIUserTreatment => { + const title = 'Register: it’s quick and easy'; + const subtitle = 'It’s still free to read – this is not a paywall'; + const body = + 'We’re committed to keeping our quality reporting open. By registering and providing us with insight into your preferences, you’re helping us to engage with you more deeply, and that allows us to keep our journalism free for all.'; + const secondCtaName = 'I’ll do it later'; + const treatmentContent = { + title, + subtitle, + body, + first_cta_name: 'Sign in', + first_cta_link: 'https://profile.theguardian.com/signin?', + second_cta_name: secondCtaName, + second_cta_link: 'https://profile.theguardian.com/signin?', + }; + const treatmentContentEncoded = JSON.stringify(treatmentContent); + return { + treatmentId: 'default-treatment-id', + treatmentTrackingId: 'default-treatment-tracking-id', + rank: '1', + contentLanguageCode: 'en-GB', + treatmentContent: treatmentContentEncoded, + treatmentType: 'DISMISSABLE_SIGN_IN_GATE', + surface: 'ARTICLE_PAGE', + }; +}; + +export const guDefaultGateGetTreatmentsResponseData = ( + daily_article_count: number, + gateDismissCount: number, +): AuxiaAPIGetTreatmentsResponseData => { + // This function is called in the case of non consenting users, which is detected by the absence of the browserId. + + const responseId = ''; // This value is not important, it is not used by the client. + + // First we enforce the GU policy of not showing the gate if the user has dismissed it more than 5 times. + // (We do not want users to have to dismiss the gate 6 times) + + if (gateDismissCount > 5) { + return { + responseId, + userTreatments: [], + }; + } + + // Then to prevent showing the gate too many times, we only show the gate every 10 pages views + + if (!guDefaultShouldShowTheGate(daily_article_count)) { + return { + responseId, + userTreatments: [], + }; + } + + // We are now clear to show the default gu gate. + + const data: AuxiaAPIGetTreatmentsResponseData = { + responseId, + userTreatments: [guGateAsAnAuxiaAPIUserTreatment()], + }; + return data; +}; + +export const isValidContentType = (contentType: string): boolean => { + const validTypes = ['Article']; + return validTypes.includes(contentType); +}; + +export const isValidSection = (sectionId: string): boolean => { + const invalidSections = [ + 'about', + 'info', + 'membership', + 'help', + 'guardian-live-australia', + 'gnm-archive', + ]; + return !invalidSections.includes(sectionId); +}; + +export const isValidTagIdCollection = (tagIds: string[]): boolean => { + const invalidTagIds = ['info/newsletter-sign-up']; + // Check that no tagId is in the invalidTagIds list. + return !tagIds.some((tagId: string): boolean => invalidTagIds.includes(tagId)); +}; + +export const buildAuxiaProxyGetTreatmentsResponseData = ( + auxiaData: AuxiaAPIGetTreatmentsResponseData, +): AuxiaProxyGetTreatmentsResponseData | undefined => { + // Note the small difference between AuxiaAPIResponseData and AuxiaProxyResponseData + // In the case of AuxiaProxyResponseData, we have an optional userTreatment field, instead of an array of userTreatments. + // This is to reflect the what the client expect semantically. + + return { + responseId: auxiaData.responseId, + userTreatment: auxiaData.userTreatments[0], + }; +}; + +export const buildLogTreatmentInteractionRequestPayload = ( + projectId: string, + browserId: string, + treatmentTrackingId: string, + treatmentId: string, + surface: string, + interactionType: string, + interactionTimeMicros: number, + actionName: string, +): AuxiaAPILogTreatmentInteractionRequestPayload => { + return { + projectId: projectId, + userId: browserId, // In our case the userId is the browserId. + treatmentTrackingId, + treatmentId, + surface, + interactionType, + interactionTimeMicros, + actionName, + }; +};