diff --git a/.changeset/small-weeks-call.md b/.changeset/small-weeks-call.md new file mode 100644 index 000000000..54a2de21f --- /dev/null +++ b/.changeset/small-weeks-call.md @@ -0,0 +1,5 @@ +--- +'@guardian/support-dotcom-components': patch +--- + +optional sectionId and tagsId properties added to HeaderTargeting model diff --git a/cdk/lib/__snapshots__/dotcom-components.test.ts.snap b/cdk/lib/__snapshots__/dotcom-components.test.ts.snap index 3f9c90fc4..8cab1e150 100644 --- a/cdk/lib/__snapshots__/dotcom-components.test.ts.snap +++ b/cdk/lib/__snapshots__/dotcom-components.test.ts.snap @@ -1498,6 +1498,7 @@ exports[`The DotcomComponents stack matches the snapshot 1`] = ` "arn:aws:s3:::support-admin-console/TEST/banner-deploy/*", "arn:aws:s3:::support-admin-console/TEST/channel-switches.json", "arn:aws:s3:::support-admin-console/TEST/configured-amounts-v3.json", + "arn:aws:s3:::support-admin-console/TEST/exclusions.json", "arn:aws:s3:::support-admin-console/TEST/guardian-weekly-propensity-test/*", "arn:aws:s3:::support-admin-console/PROD/auxia-credentials.json", ], diff --git a/cdk/lib/dotcom-components.ts b/cdk/lib/dotcom-components.ts index a52b58dfb..ca00ded89 100644 --- a/cdk/lib/dotcom-components.ts +++ b/cdk/lib/dotcom-components.ts @@ -263,6 +263,7 @@ sudo amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:/opt/aws/amazon- `${this.stage}/banner-deploy/*`, `${this.stage}/channel-switches.json`, `${this.stage}/configured-amounts-v3.json`, + `${this.stage}/exclusions.json`, `${this.stage}/guardian-weekly-propensity-test/*`, `PROD/auxia-credentials.json`, ], diff --git a/src/server/api/bannerRouter.ts b/src/server/api/bannerRouter.ts index 3477d6c33..909559621 100644 --- a/src/server/api/bannerRouter.ts +++ b/src/server/api/bannerRouter.ts @@ -11,6 +11,7 @@ import type { Tracking, } from '../../shared/types'; import { channelFromBannerChannel } from '../../shared/types'; +import type { ExclusionSettings } from '../channelExclusions'; import type { ChannelSwitches } from '../channelSwitches'; import type { Auxia, GetTreatmentsAttributes } from '../lib/auxia'; import { getChoiceCardsSettings } from '../lib/choiceCards/choiceCards'; @@ -31,7 +32,7 @@ import type { BannerDeployTimesProvider } from '../tests/banners/bannerDeployTim import { selectBannerTest } from '../tests/banners/bannerSelection'; import { getDesignForVariant } from '../tests/banners/channelBannerTests'; import type { Debug } from '../tests/epics/epicSelection'; -import { shouldSuppressBannerForSectionDate } from '../utils/bannerSectionSuppression'; +import { inExclusions } from '../utils/channelExclusionsMatcher'; import type { ValueProvider } from '../utils/valueReloader'; interface BannerDataResponse { @@ -58,6 +59,7 @@ export const buildBannerRouter = ( mParticle: MParticle, okta: Okta, auxia: Auxia, + channelExclusions: ValueProvider, ): Router => { const router = Router(); @@ -73,7 +75,7 @@ export const buildBannerRouter = ( ): Promise => { const { enableBanners, enableHardcodedBannerTests, enableScheduledBannerDeploys } = channelSwitches.get(); - const now = new Date(); + const channelExclusionsData = channelExclusions.get(); if (!enableBanners) { return {}; @@ -83,7 +85,7 @@ export const buildBannerRouter = ( return {}; } - if (shouldSuppressBannerForSectionDate(targeting.sectionId, now)) { + if (inExclusions(targeting, channelExclusionsData.banner)) { return {}; } @@ -96,7 +98,7 @@ export const buildBannerRouter = ( enableScheduledDeploys: enableScheduledBannerDeploys, banditData: banditData.get(), getMParticleProfile, - now, + now: new Date(), forcedTestVariant: params.force, checkAuxiaSuppression, }); diff --git a/src/server/api/epicRouter.ts b/src/server/api/epicRouter.ts index 272c4b2bf..80f1260b1 100644 --- a/src/server/api/epicRouter.ts +++ b/src/server/api/epicRouter.ts @@ -12,6 +12,7 @@ import type { Tracking, WeeklyArticleLog, } from '../../shared/types'; +import type { ExclusionSettings } from '../channelExclusions'; import type { ChannelSwitches } from '../channelSwitches'; import { getChoiceCardsSettings } from '../lib/choiceCards/choiceCards'; import { getDeviceType } from '../lib/deviceType'; @@ -31,6 +32,7 @@ import { selectAmountsTestVariant } from '../selection/ab'; import type { BanditData } from '../selection/banditData'; import type { Debug } from '../tests/epics/epicSelection'; import { findForcedTestAndVariant, findTestAndVariant } from '../tests/epics/epicSelection'; +import { inExclusions } from '../utils/channelExclusionsMatcher'; import { logWarn } from '../utils/logging'; import type { ValueProvider } from '../utils/valueReloader'; @@ -61,6 +63,7 @@ export const buildEpicRouter = ( promotions: ValueProvider, mParticle: MParticle, okta: Okta, + channelExclusions: ValueProvider, ): Router => { const router = Router(); @@ -92,6 +95,7 @@ export const buildEpicRouter = ( getMParticleProfile: () => Promise, ): Promise => { const { enableEpics, enableSuperMode, enableHardcodedEpicTests } = channelSwitches.get(); + const channelExclusionsData = channelExclusions.get(); if (!enableEpics) { return {}; } @@ -100,6 +104,15 @@ export const buildEpicRouter = ( return {}; } + if ( + inExclusions( + { ...targeting, tagIds: targeting.tags.map(({ id }) => id) }, + channelExclusionsData.epic, + ) + ) { + return {}; + } + const targetingMvtId = targeting.mvtId ?? 1; const tests = diff --git a/src/server/api/gutterRouter.ts b/src/server/api/gutterRouter.ts index 9fd8fb306..4ea9b6ea8 100644 --- a/src/server/api/gutterRouter.ts +++ b/src/server/api/gutterRouter.ts @@ -7,6 +7,7 @@ import type { TestTracking, Tracking, } from '../../shared/types'; +import type { ExclusionSettings } from '../channelExclusions'; import type { ChannelSwitches } from '../channelSwitches'; import { getDeviceType } from '../lib/deviceType'; import { baseUrl } from '../lib/env'; @@ -16,6 +17,7 @@ import { pageIdIsExcluded } from '../lib/targeting'; import { buildGutterCampaignCode } from '../lib/tracking'; import { bodyContainsAllFields } from '../middleware'; import { selectGutterTest } from '../tests/gutters/gutterSelection'; +import { inExclusions } from '../utils/channelExclusionsMatcher'; import type { ValueProvider } from '../utils/valueReloader'; interface GutterDataResponse { @@ -31,6 +33,7 @@ interface GutterDataResponse { export const buildGutterRouter = ( channelSwitches: ValueProvider, tests: ValueProvider, + channelExclusions: ValueProvider, ): Router => { const router = Router(); @@ -41,6 +44,7 @@ export const buildGutterRouter = ( req: express.Request, ): GutterDataResponse => { const { enableGutterLiveblogs } = channelSwitches.get(); + const channelExclusionsData = channelExclusions.get(); if (!enableGutterLiveblogs) { return {}; } @@ -49,6 +53,10 @@ export const buildGutterRouter = ( return {}; } + if (inExclusions(targeting, channelExclusionsData.gutterAsk)) { + return {}; + } + const testSelection = selectGutterTest( targeting, tests.get(), diff --git a/src/server/api/headerRouter.ts b/src/server/api/headerRouter.ts index 6c7c7e49a..8df8434a7 100644 --- a/src/server/api/headerRouter.ts +++ b/src/server/api/headerRouter.ts @@ -7,6 +7,7 @@ import type { TestTracking, Tracking, } from '../../shared/types'; +import type { ExclusionSettings } from '../channelExclusions'; import type { ChannelSwitches } from '../channelSwitches'; import { getDeviceType } from '../lib/deviceType'; import { baseUrl } from '../lib/env'; @@ -16,6 +17,7 @@ import { getQueryParams } from '../lib/params'; import type { Params } from '../lib/params'; import { bodyContainsAllFields } from '../middleware'; import { selectHeaderTest } from '../tests/headers/headerSelection'; +import { inExclusions } from '../utils/channelExclusionsMatcher'; import type { ValueProvider } from '../utils/valueReloader'; interface HeaderDataResponse { @@ -33,6 +35,7 @@ export const buildHeaderRouter = ( tests: ValueProvider, mParticle: MParticle, okta: Okta, + channelExclusions: ValueProvider, ): Router => { const router = Router(); @@ -44,9 +47,15 @@ export const buildHeaderRouter = ( getMParticleProfile: () => Promise, ): Promise => { const { enableHeaders } = channelSwitches.get(); + const channelExclusionsData = channelExclusions.get(); if (!enableHeaders) { return {}; } + + if (inExclusions(targeting, channelExclusionsData.header)) { + return {}; + } + const testSelection = await selectHeaderTest( targeting, tests.get(), diff --git a/src/server/channelExclusions.ts b/src/server/channelExclusions.ts new file mode 100644 index 000000000..24cae16f1 --- /dev/null +++ b/src/server/channelExclusions.ts @@ -0,0 +1,52 @@ +import { isProd } from './lib/env'; +import { logWarn } from './utils/logging'; +import { fetchS3Data } from './utils/S3'; +import type { ValueReloader } from './utils/valueReloader'; +import { buildReloader } from './utils/valueReloader'; + +export interface DateRange { + start: string; // ISO date "YYYY-MM-DD", inclusive + end: string; // ISO date "YYYY-MM-DD", inclusive +} + +export interface ExclusionRule { + name: string; + sectionIds?: string[]; + tagIds?: string[]; + dateRange?: DateRange; + contentTypes?: Array<'Fronts' | 'Articles'>; +} + +export interface ChannelExclusions { + rules: ExclusionRule[]; +} + +export interface ExclusionSettings { + epic?: ChannelExclusions; + banner?: ChannelExclusions; + gutterAsk?: ChannelExclusions; + header?: ChannelExclusions; +} + +const emptyExclusions: ExclusionSettings = {}; + +const getExclusions = async (): Promise => { + try { + const data = await fetchS3Data( + 'support-admin-console', + `${isProd ? 'PROD' : 'CODE'}/exclusions.json`, + ); + const parsed = JSON.parse(data) as ExclusionSettings; + return parsed; + } catch (error) { + logWarn( + `Failed to load exclusions config from S3: ${String(error)}. Proceeding with no exclusions.`, + ); + return emptyExclusions; + } +}; + +const buildChannelExclusionsReloader = (): Promise> => + buildReloader(getExclusions, 60); + +export { buildChannelExclusionsReloader }; diff --git a/src/server/server.ts b/src/server/server.ts index 1e377dc50..e92fefe80 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -8,6 +8,7 @@ import { buildEpicRouter } from './api/epicRouter'; import { buildGutterRouter } from './api/gutterRouter'; import { buildHeaderRouter } from './api/headerRouter'; import { buildTickerRouter } from './api/tickerRouter'; +import { buildChannelExclusionsReloader } from './channelExclusions'; import { buildChannelSwitchesReloader } from './channelSwitches'; import { buildChoiceCardAmountsReloader } from './choiceCardAmounts'; import { Auxia } from './lib/auxia'; @@ -88,6 +89,7 @@ const buildApp = async (): Promise => { gutterLiveblogTests, productCatalog, promotions, + channelExclusions, ] = await Promise.all([ buildChannelSwitchesReloader(), buildSuperModeArticlesReloader(), @@ -102,6 +104,7 @@ const buildApp = async (): Promise => { buildGutterLiveblogTestsReloader(), buildProductCatalogReloader(), buildPromotionsReloader(), + buildChannelExclusionsReloader(), ]); const banditData = await buildBanditDataReloader(articleEpicTests, bannerTests); @@ -129,6 +132,7 @@ const buildApp = async (): Promise => { promotions, mParticle, okta, + channelExclusions, ), ); app.use( @@ -145,13 +149,14 @@ const buildApp = async (): Promise => { mParticle, okta, auxia, + channelExclusions, ), ); - app.use(buildHeaderRouter(channelSwitches, headerTests, mParticle, okta)); + app.use(buildHeaderRouter(channelSwitches, headerTests, mParticle, okta, channelExclusions)); app.use(buildAuxiaProxyRouter(channelSwitches, auxiaConfig)); - app.use(buildGutterRouter(channelSwitches, gutterLiveblogTests)); + app.use(buildGutterRouter(channelSwitches, gutterLiveblogTests, channelExclusions)); app.use(buildTickerRouter(tickerData)); diff --git a/src/server/utils/bannerSectionSuppression.test.ts b/src/server/utils/bannerSectionSuppression.test.ts deleted file mode 100644 index 562d60a22..000000000 --- a/src/server/utils/bannerSectionSuppression.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { shouldSuppressBannerForSectionDate } from './bannerSectionSuppression'; - -describe('shouldSuppressBannerForSectionDate', () => { - it('suppresses sport banners from 10 to 13 March 2026', () => { - expect( - shouldSuppressBannerForSectionDate('sport', new Date('2026-03-10T12:00:00.000Z')), - ).toBe(true); - expect( - shouldSuppressBannerForSectionDate('sport', new Date('2026-03-11T12:00:00.000Z')), - ).toBe(true); - expect( - shouldSuppressBannerForSectionDate('sport', new Date('2026-03-12T12:00:00.000Z')), - ).toBe(true); - expect( - shouldSuppressBannerForSectionDate('sport', new Date('2026-03-13T12:00:00.000Z')), - ).toBe(true); - }); - - it('suppresses fashion banners from 13 to 25 March 2026', () => { - expect( - shouldSuppressBannerForSectionDate('fashion', new Date('2026-03-13T12:00:00.000Z')), - ).toBe(true); - expect( - shouldSuppressBannerForSectionDate('fashion', new Date('2026-03-14T12:00:00.000Z')), - ).toBe(true); - expect( - shouldSuppressBannerForSectionDate('fashion', new Date('2026-03-20T12:00:00.000Z')), - ).toBe(true); - expect( - shouldSuppressBannerForSectionDate('fashion', new Date('2026-03-24T12:00:00.000Z')), - ).toBe(true); - expect( - shouldSuppressBannerForSectionDate('fashion', new Date('2026-03-25T12:00:00.000Z')), - ).toBe(true); - }); - - it('does not suppress on other dates or sections', () => { - expect( - shouldSuppressBannerForSectionDate('sport', new Date('2026-03-09T12:00:00.000Z')), - ).toBe(false); - expect( - shouldSuppressBannerForSectionDate('sport', new Date('2026-03-14T12:00:00.000Z')), - ).toBe(false); - expect( - shouldSuppressBannerForSectionDate('fashion', new Date('2026-03-12T12:00:00.000Z')), - ).toBe(false); - expect( - shouldSuppressBannerForSectionDate('fashion', new Date('2026-03-26T12:00:00.000Z')), - ).toBe(false); - expect( - shouldSuppressBannerForSectionDate('news', new Date('2026-03-13T12:00:00.000Z')), - ).toBe(false); - expect( - shouldSuppressBannerForSectionDate(undefined, new Date('2026-03-13T12:00:00.000Z')), - ).toBe(false); - }); - - it('matches section ids case-insensitively', () => { - expect( - shouldSuppressBannerForSectionDate('SPORT', new Date('2026-03-10T12:00:00.000Z')), - ).toBe(true); - }); -}); diff --git a/src/server/utils/bannerSectionSuppression.ts b/src/server/utils/bannerSectionSuppression.ts deleted file mode 100644 index 6710bc361..000000000 --- a/src/server/utils/bannerSectionSuppression.ts +++ /dev/null @@ -1,30 +0,0 @@ -const marchDateRange = (startDay: number, endDay: number): Set => { - const dates = new Set(); - for (let day = startDay; day <= endDay; day += 1) { - dates.add(`2026-03-${day.toString().padStart(2, '0')}`); - } - return dates; -}; - -const sectionBannerSuppressionDates = new Map>([ - ['sport', marchDateRange(10, 13)], - ['fashion', marchDateRange(13, 25)], -]); - -const toIsoDate = (date: Date): string => date.toISOString().slice(0, 10); - -export const shouldSuppressBannerForSectionDate = ( - sectionId: string | undefined, - date: Date, -): boolean => { - if (!sectionId) { - return false; - } - - const suppressedDates = sectionBannerSuppressionDates.get(sectionId.toLowerCase()); - if (!suppressedDates) { - return false; - } - - return suppressedDates.has(toIsoDate(date)); -}; diff --git a/src/server/utils/channelExclusionsMatcher.test.ts b/src/server/utils/channelExclusionsMatcher.test.ts new file mode 100644 index 000000000..8e6675d48 --- /dev/null +++ b/src/server/utils/channelExclusionsMatcher.test.ts @@ -0,0 +1,113 @@ +import type { Targeting } from './channelExclusionsMatcher'; +import { inExclusions } from './channelExclusionsMatcher'; + +const baseTargeting: Targeting = {}; + +describe('inExclusions', () => { + it('returns false when no rules are provided', () => { + expect(inExclusions(baseTargeting, undefined)).toBe(false); + expect(inExclusions(baseTargeting, { rules: [] })).toBe(false); + }); + + it('matches section ids case-insensitively', () => { + const targeting: Targeting = { + sectionId: 'Sport', + }; + + expect( + inExclusions(targeting, { + rules: [{ name: 'section-rule', sectionIds: ['sport'] }], + }), + ).toBe(true); + }); + + it('matches tag ids case-insensitively', () => { + const targeting: Targeting = { + tagIds: ['tone/news'], + }; + + expect( + inExclusions(targeting, { + rules: [{ name: 'tag-rule', tagIds: ['TONE/NEWS'] }], + }), + ).toBe(true); + }); + + it('applies date range checks', () => { + expect( + inExclusions(baseTargeting, { + rules: [ + { + name: 'active-range', + dateRange: { start: '1900-01-01', end: '2999-12-31' }, + }, + ], + }), + ).toBe(true); + + expect( + inExclusions(baseTargeting, { + rules: [ + { + name: 'inactive-range', + dateRange: { start: '1900-01-01', end: '1900-01-02' }, + }, + ], + }), + ).toBe(false); + }); + + it('uses contentType to evaluate content type', () => { + const frontTargeting: Targeting = { + contentType: 'Network Front', + }; + const articleTargeting: Targeting = { + contentType: 'Article', + }; + + expect( + inExclusions(frontTargeting, { + rules: [{ name: 'front-only', contentTypes: ['Fronts'] }], + }), + ).toBe(true); + expect( + inExclusions(articleTargeting, { + rules: [{ name: 'front-only', contentTypes: ['Fronts'] }], + }), + ).toBe(false); + + expect( + inExclusions(frontTargeting, { + rules: [{ name: 'any-content-empty', contentTypes: [] }], + }), + ).toBe(true); + expect( + inExclusions(articleTargeting, { + rules: [{ name: 'any-content-empty', contentTypes: [] }], + }), + ).toBe(true); + + expect( + inExclusions(frontTargeting, { + rules: [{ name: 'both-content-types', contentTypes: ['Fronts', 'Articles'] }], + }), + ).toBe(true); + expect( + inExclusions(articleTargeting, { + rules: [{ name: 'both-content-types', contentTypes: ['Fronts', 'Articles'] }], + }), + ).toBe(true); + }); + + it('matches tag ids case-insensitively (politics)', () => { + const targeting: Targeting = { + tagIds: ['politics/politics'], + }; + + expect( + inExclusions(targeting, { + rules: [{ name: 'epic-tag-rule', tagIds: ['POLITICS/POLITICS'] }], + }), + ).toBe(true); + }); +}); diff --git a/src/server/utils/channelExclusionsMatcher.ts b/src/server/utils/channelExclusionsMatcher.ts new file mode 100644 index 000000000..e57392b91 --- /dev/null +++ b/src/server/utils/channelExclusionsMatcher.ts @@ -0,0 +1,80 @@ +import type { ChannelExclusions, DateRange, ExclusionRule } from '../channelExclusions'; + +export interface Targeting { + tagIds?: string[]; + sectionId?: string; + contentType?: string; +} + +const toIsoDate = (date: Date): string => date.toISOString().slice(0, 10); + +const inDateRange = (date: string, range: DateRange): boolean => + date >= range.start && date <= range.end; + +const getSectionId = (targeting: Targeting): string | undefined => { + if ('sectionId' in targeting) { + return targeting.sectionId; + } +}; + +const getTagIds = (targeting: Targeting): string[] => { + if ('tagIds' in targeting) { + return targeting.tagIds ?? []; + } + return []; +}; + +const FRONT_CONTENT_TYPES = ['Network Front', 'Section']; + +const getContentType = (targeting: Targeting): 'Fronts' | 'Articles' => { + const contentType = 'contentType' in targeting ? targeting.contentType : undefined; + return contentType && FRONT_CONTENT_TYPES.includes(contentType) ? 'Fronts' : 'Articles'; +}; + +const matchesRule = (targeting: Targeting, rule: ExclusionRule): boolean => { + const now = new Date(); + const currentDate = toIsoDate(now); + const sectionId = getSectionId(targeting)?.toLowerCase(); + const tagIds = new Set(getTagIds(targeting).map((tagId) => tagId.toLowerCase())); + const contentType = getContentType(targeting); + + if (rule.sectionIds?.length) { + if (!sectionId) { + return false; + } + const hasSection = rule.sectionIds.some((id) => id.toLowerCase() === sectionId); + if (!hasSection) { + return false; + } + } + + if (rule.tagIds?.length) { + const hasTag = rule.tagIds.some((id) => tagIds.has(id.toLowerCase())); + if (!hasTag) { + return false; + } + } + + if (rule.dateRange && !inDateRange(currentDate, rule.dateRange)) { + return false; + } + + if (rule.contentTypes?.length) { + if (!rule.contentTypes.includes(contentType)) { + return false; + } + } + + return true; +}; + +export const inExclusions = ( + targeting: Targeting, + exclusionSettings?: ChannelExclusions, +): boolean => { + const rules = exclusionSettings?.rules; + if (!rules?.length) { + return false; + } + return rules.some((rule) => matchesRule(targeting, rule)); +}; diff --git a/src/shared/types/targeting/header.ts b/src/shared/types/targeting/header.ts index a12659b84..0fde05d9d 100644 --- a/src/shared/types/targeting/header.ts +++ b/src/shared/types/targeting/header.ts @@ -8,6 +8,8 @@ export interface HeaderTargeting { purchaseInfo?: PurchaseInfo; isSignedIn: boolean; inHoldbackGroup?: boolean; + tagIds?: string[]; + sectionId?: string; } export type HeaderPayload = {