diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index d6d549844ce..d1d43804f2f 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -12,7 +12,10 @@ import { useConfig } from '../../components/ConfigContext'; import { isProd } from '../../components/marketing/lib/stage'; import type { StageType } from '../../types/config'; import type { TagType } from '../../types/tag'; -import { addFeastRecipeToSavedFromWebList } from '../feast/savedFromWeb'; +import { + addFeastRecipeToSavedFromWebList, + removeFeastRecipeFromSavedFromWebList, +} from '../feast/savedFromWeb'; import { getAuthState, getOptionsHeaders } from '../identity'; import type { CandidateConfig, CanShowResult } from '../messagePicker'; import { useAuthStatus } from '../useAuthStatus'; @@ -506,6 +509,7 @@ enum BrazeBannersSystemMessageType { DismissBanner = 'BRAZE_BANNERS_SYSTEM:DISMISS_BANNER', GetContext = 'BRAZE_BANNERS_SYSTEM:GET_CONTEXT', SaveFeastRecipeById = 'BRAZE_BANNERS_SYSTEM:SAVE_FEAST_RECIPE_BY_ID', + UnsaveFeastRecipeById = 'BRAZE_BANNERS_SYSTEM:UNSAVE_FEAST_RECIPE_BY_ID', } /** @@ -713,6 +717,33 @@ export const BrazeBannersSystemDisplay = ({ [authStatus], ); + const unsaveFeastRecipeById = useCallback( + async (feastRecipeId: string): Promise => { + if (authStatus.kind === 'SignedIn') { + const success = await removeFeastRecipeFromSavedFromWebList( + authStatus.accessToken.accessToken, + feastRecipeId, + ); + + if (success) { + brazeBannersSystemLogger.info( + 'Successfully removed recipe from the "Saved from web" list:', + feastRecipeId, + ); + } else { + brazeBannersSystemLogger.warn( + 'Failed to remove recipe from the "Saved from web" list:', + feastRecipeId, + ); + } + + return success; + } + return false; + }, + [authStatus], + ); + /** * Subscribes the user to a newsletter via the Identity API. * Only attempts to subscribe if the user is signed in and a newsletter ID is provided. @@ -1013,6 +1044,10 @@ export const BrazeBannersSystemDisplay = ({ type: BrazeBannersSystemMessageType.SaveFeastRecipeById; feastRecipeId?: string; } + | { + type: BrazeBannersSystemMessageType.UnsaveFeastRecipeById; + feastRecipeId?: string; + } >, ) => { const iframe = containerRef.current?.querySelector('iframe'); @@ -1194,6 +1229,34 @@ export const BrazeBannersSystemDisplay = ({ } break; } + case BrazeBannersSystemMessageType.UnsaveFeastRecipeById: { + meta.braze.logBannerClick( + meta.banner, + 'unsave_feast_recipe_button', + ); + const { feastRecipeId } = event.data; + if (feastRecipeId !== undefined) { + void unsaveFeastRecipeById(feastRecipeId).then( + (success) => { + postMessageToBrazeBanner( + BrazeBannersSystemMessageType.UnsaveFeastRecipeById, + { + success, + }, + ); + meta.braze.logCustomEvent( + 'braze_banner_unsave_feast_recipe', + { + placementId: meta.banner.placementId, + feastRecipeId, + success, + }, + ); + }, + ); + } + break; + } } }; @@ -1213,6 +1276,7 @@ export const BrazeBannersSystemDisplay = ({ postMessageToBrazeBanner, context, saveFeastRecipeById, + unsaveFeastRecipeById, ]); // Log Impressions when the banner is seen, using the hasBeenSeen value from the useIsInView hook diff --git a/dotcom-rendering/src/lib/feast/savedFromWeb.test.ts b/dotcom-rendering/src/lib/feast/savedFromWeb.test.ts index 6edffcd8350..f04ea87fd9b 100644 --- a/dotcom-rendering/src/lib/feast/savedFromWeb.test.ts +++ b/dotcom-rendering/src/lib/feast/savedFromWeb.test.ts @@ -1,6 +1,7 @@ import { addFeastRecipeToSavedFromWebList, getFeastSavedFromTheWebRecipes, + removeFeastRecipeFromSavedFromWebList, } from './savedFromWeb'; describe('savedFromWeb', () => { @@ -265,4 +266,89 @@ describe('savedFromWeb', () => { expect(result).toBe(false); }); }); + + describe('removeFeastRecipeFromSavedFromWebList', () => { + it('calls the Feast API directly with DELETE and a bearer token', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 204, + }); + + const result = await removeFeastRecipeFromSavedFromWebList( + 'token-o', + 'recipe-1', + ); + + const [url, requestInit]: [string, RequestInit | undefined] = ( + global.fetch as jest.Mock + ).mock.calls[0]; + expect(url).toBe( + 'https://recipes.code.dev-guardianapis.com/persist/v2/saved-from-web/recipe-1', + ); + expect(requestInit?.method).toBe('DELETE'); + const headers = (requestInit?.headers ?? {}) as Record< + string, + string + >; + expect(headers.Authorization).toBe('Bearer token-o'); + expect(result).toBe(true); + }); + + it('treats a 204 response for an already absent recipe as success', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 204, + }); + + const result = await removeFeastRecipeFromSavedFromWebList( + 'token-p', + 'absent-recipe', + ); + + expect(result).toBe(true); + }); + + it('retries a 503 response with exponential backoff', async () => { + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Unavailable', + }) + .mockResolvedValueOnce({ ok: true, status: 204 }); + + const result = await removeFeastRecipeFromSavedFromWebList( + 'token-q', + 'recipe-1', + ); + + expect(result).toBe(true); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('invalidates cached saved status after a successful removal', async () => { + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { recipeId: 'recipe-1', lastModified: '2026-01-01' }, + ], + }) + .mockResolvedValueOnce({ ok: true, status: 204 }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }); + + await getFeastSavedFromTheWebRecipes('user-r', 'token-r', [ + 'recipe-1', + ]); + await removeFeastRecipeFromSavedFromWebList('token-r', 'recipe-1'); + const result = await getFeastSavedFromTheWebRecipes( + 'user-r', + 'token-r', + ['recipe-1'], + ); + + expect(result).toEqual(new Set()); + expect(global.fetch).toHaveBeenCalledTimes(3); + }); + }); }); diff --git a/dotcom-rendering/src/lib/feast/savedFromWeb.ts b/dotcom-rendering/src/lib/feast/savedFromWeb.ts index 4d7de21d99f..9bff4dd76d0 100644 --- a/dotcom-rendering/src/lib/feast/savedFromWeb.ts +++ b/dotcom-rendering/src/lib/feast/savedFromWeb.ts @@ -36,6 +36,27 @@ const savedFromWebCache = new Map>>(); type SavedFromWebItem = { recipeId: string; lastModified: string }; +const MAX_503_RETRIES = 3; +const INITIAL_RETRY_DELAY_MS = 100; + +const fetchWith503Retry = async ( + input: RequestInfo | URL, + init?: RequestInit, +): Promise => { + for (let attempt = 0; attempt <= MAX_503_RETRIES; attempt += 1) { + const response = await fetch(input, init); + if (response.status !== 503 || attempt === MAX_503_RETRIES) { + return response; + } + + await new Promise((resolve) => { + setTimeout(resolve, INITIAL_RETRY_DELAY_MS * 2 ** attempt); + }); + } + + throw new Error('Unreachable'); +}; + /** * Performs (and caches) the underlying request for a given cache key. The * cache entry is written synchronously, before the fetch resolves, so @@ -54,7 +75,7 @@ const fetchSavedFromWebRecipes = ( const promise = (async (): Promise> => { try { - const response = await fetch( + const response = await fetchWith503Retry( `${getFeastApiBaseUrl()}${FEAST_SAVED_RECIPES_PATH}?ids=${encodeURIComponent(idsParam)}`, { headers: { @@ -147,7 +168,7 @@ export const addFeastRecipeToSavedFromWebList = async ( recipeId: string, ): Promise => { try { - const response = await fetch( + const response = await fetchWith503Retry( `${getFeastApiBaseUrl()}${FEAST_SAVED_RECIPES_PATH}/${encodeURIComponent(recipeId)}`, { method: 'PUT', @@ -167,6 +188,7 @@ export const addFeastRecipeToSavedFromWebList = async ( return false; } + savedFromWebCache.clear(); return true; } catch (error) { console.error( @@ -176,3 +198,43 @@ export const addFeastRecipeToSavedFromWebList = async ( return false; } }; + +/** + * Removes a recipe from the reader's "Saved from web" list. Idempotent: + * removing an absent recipe succeeds without creating a list. + */ +export const removeFeastRecipeFromSavedFromWebList = async ( + accessToken: string, + recipeId: string, +): Promise => { + try { + const response = await fetchWith503Retry( + `${getFeastApiBaseUrl()}${FEAST_SAVED_RECIPES_PATH}/${encodeURIComponent(recipeId)}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + 'X-GU-IS-OAUTH': 'true', + }, + }, + ); + + if (!response.ok) { + console.error( + '[removeFeastRecipeFromSavedFromWebList] Failed to remove saved recipe:', + response.status, + response.statusText, + ); + return false; + } + + savedFromWebCache.clear(); + return true; + } catch (error) { + console.error( + '[removeFeastRecipeFromSavedFromWebList] Error removing saved recipe:', + error, + ); + return false; + } +};