From 2ffb0dd58443a1780bf81d027c4555e823e3f9e9 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 17 Feb 2026 11:39:24 -0500 Subject: [PATCH 01/28] revalidate endpoint created --- src/app/api/revalidate/route.spec.ts | 516 +++++++++++++++++++++++++++ src/app/api/revalidate/route.ts | 118 ++++++ 2 files changed, 634 insertions(+) create mode 100644 src/app/api/revalidate/route.spec.ts create mode 100644 src/app/api/revalidate/route.ts diff --git a/src/app/api/revalidate/route.spec.ts b/src/app/api/revalidate/route.spec.ts new file mode 100644 index 0000000..b342c5f --- /dev/null +++ b/src/app/api/revalidate/route.spec.ts @@ -0,0 +1,516 @@ +/** + * @jest-environment node + */ +import { POST } from './route'; +import { revalidatePath } from 'next/cache'; + +// Mock Next.js cache +jest.mock('next/cache', () => ({ + revalidatePath: jest.fn(), +})); + +// Mock i18n routing +jest.mock('../../../i18n/routing', () => ({ + AVAILABLE_LOCALES: ['en', 'fr'], +})); + +describe('POST /api/revalidate', () => { + const mockRevalidatePath = revalidatePath as jest.MockedFunction< + typeof revalidatePath + >; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('Authentication', () => { + it('returns 500 when REVALIDATE_SECRET is not configured', async () => { + delete process.env.REVALIDATE_SECRET; + + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'some-secret', + }, + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json).toEqual({ + ok: false, + error: 'Server misconfigured: REVALIDATE_SECRET missing', + }); + expect(mockRevalidatePath).not.toHaveBeenCalled(); + }); + + it('returns 401 when header is missing', async () => { + process.env.REVALIDATE_SECRET = 'test-secret'; + + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json).toEqual({ + ok: false, + error: 'Unauthorized', + }); + expect(mockRevalidatePath).not.toHaveBeenCalled(); + }); + + it('returns 401 when secret does not match', async () => { + process.env.REVALIDATE_SECRET = 'correct-secret'; + + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'wrong-secret', + }, + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(401); + expect(json).toEqual({ + ok: false, + error: 'Unauthorized', + }); + expect(mockRevalidatePath).not.toHaveBeenCalled(); + }); + + it('accepts request when secret matches', async () => { + process.env.REVALIDATE_SECRET = 'correct-secret'; + + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'correct-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'full', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json).toEqual({ + ok: true, + message: 'Revalidation triggered successfully', + }); + }); + }); + + describe('Revalidation types', () => { + beforeEach(() => { + process.env.REVALIDATE_SECRET = 'test-secret'; + }); + + it('revalidates full site when type is full', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'full', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + expect(mockRevalidatePath).toHaveBeenCalledWith('/', 'layout'); + }); + + it('revalidates all feed pages when type is all-feeds', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'all-feeds', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/[locale]/feeds/[feedDataType]/[feedId]', + 'layout', + ); + }); + + it('revalidates all GBFS feed pages when type is all-gbfs-feeds', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'all-gbfs-feeds', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/[locale]/feeds/gbfs/[feedId]', + 'layout', + ); + }); + + it('revalidates all GTFS feed pages when type is all-gtfs-feeds', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'all-gtfs-feeds', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/[locale]/feeds/gtfs/[feedId]', + 'layout', + ); + }); + + it('revalidates all GTFS-RT feed pages when type is all-gtfs-rt-feeds', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'all-gtfs-rt-feeds', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/[locale]/feeds/gtfs_rt/[feedId]', + 'layout', + ); + }); + + it('revalidates specific GTFS feeds with localized paths', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'specific-feeds', + gtfsFeedIds: ['feed-1', 'feed-2'], + gtfsRtFeedIds: [], + gbfsFeedIds: [], + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + + // Each feed should revalidate base path + map path + localized versions + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-1/map'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/fr/feeds/gtfs/feed-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/fr/feeds/gtfs/feed-1/map', + ); + + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-2'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-2/map'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/fr/feeds/gtfs/feed-2'); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/fr/feeds/gtfs/feed-2/map', + ); + + // Should be called 8 times total (2 feeds × 4 paths each) + expect(mockRevalidatePath).toHaveBeenCalledTimes(8); + }); + + it('revalidates specific GTFS-RT feeds with localized paths', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'specific-feeds', + gtfsFeedIds: [], + gtfsRtFeedIds: ['rt-feed-1'], + gbfsFeedIds: [], + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs_rt/rt-feed-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs_rt/rt-feed-1/map'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/fr/feeds/gtfs_rt/rt-feed-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/fr/feeds/gtfs_rt/rt-feed-1/map', + ); + expect(mockRevalidatePath).toHaveBeenCalledTimes(4); + }); + + it('revalidates specific GBFS feeds with localized paths', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'specific-feeds', + gtfsFeedIds: [], + gtfsRtFeedIds: [], + gbfsFeedIds: ['gbfs-feed-1'], + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/gbfs-feed-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/gbfs-feed-1/map'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/fr/feeds/gbfs/gbfs-feed-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/fr/feeds/gbfs/gbfs-feed-1/map', + ); + expect(mockRevalidatePath).toHaveBeenCalledTimes(4); + }); + + it('revalidates multiple feed types simultaneously', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'specific-feeds', + gtfsFeedIds: ['gtfs-1'], + gtfsRtFeedIds: ['rt-1'], + gbfsFeedIds: ['gbfs-1'], + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + + // Should revalidate all three feed types + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/gtfs-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs_rt/rt-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/gbfs-1'); + + // 3 feeds × 4 paths each = 12 total calls + expect(mockRevalidatePath).toHaveBeenCalledTimes(12); + }); + + it('handles specific-feeds with empty arrays', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'specific-feeds', + gtfsFeedIds: [], + gtfsRtFeedIds: [], + gbfsFeedIds: [], + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + expect(mockRevalidatePath).not.toHaveBeenCalled(); + }); + }); + + describe('Request body handling', () => { + beforeEach(() => { + process.env.REVALIDATE_SECRET = 'test-secret'; + }); + + it('uses default options when body is missing', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + }, + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + // Default type is 'specific-feeds' with empty arrays + expect(mockRevalidatePath).not.toHaveBeenCalled(); + }); + + it('uses default options when body is invalid JSON', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: 'invalid json{', + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + // Should fall back to default options (specific-feeds with empty arrays) + expect(mockRevalidatePath).not.toHaveBeenCalled(); + }); + + it('accepts partial body with only type specified', async () => { + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'all-feeds', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/[locale]/feeds/[feedDataType]/[feedId]', + 'layout', + ); + }); + }); + + describe('Error handling', () => { + beforeEach(() => { + process.env.REVALIDATE_SECRET = 'test-secret'; + }); + + it('returns 500 when revalidatePath throws an error', async () => { + mockRevalidatePath.mockImplementationOnce(() => { + throw new Error('Revalidation error'); + }); + + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'full', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(500); + expect(json).toEqual({ + ok: false, + error: 'Failed to revalidate', + }); + }); + + it('logs error when revalidation fails', async () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const testError = new Error('Test revalidation error'); + + mockRevalidatePath.mockImplementationOnce(() => { + throw testError; + }); + + const request = new Request('http://localhost:3000/api/revalidate', { + method: 'POST', + headers: { + 'x-revalidate-secret': 'test-secret', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + type: 'full', + }), + }); + + await POST(request); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Revalidation failed:', + testError, + ); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts new file mode 100644 index 0000000..3aebe29 --- /dev/null +++ b/src/app/api/revalidate/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from 'next/server'; +import { revalidatePath } from 'next/cache'; +import { AVAILABLE_LOCALES } from '../../../i18n/routing'; + +type RevalidateTypes = + | 'full' + | 'all-feeds' + | 'all-gbfs-feeds' + | 'all-gtfs-rt-feeds' + | 'all-gtfs-feeds' + | 'specific-feeds'; + +interface RevalidateBody { + gtfsFeedIds: string[]; // optional list of specific feed IDs to revalidate + gtfsRtFeedIds: string[]; // optional list of specific GTFS-RT feed IDs to revalidate + gbfsFeedIds: string[]; // optional list of specific GBFS feed IDs to revalidate + type: RevalidateTypes; // optional, controls scope of revalidation +} + +const defaultRevalidateOptions: RevalidateBody = { + // By default, revalidate the entire site to ensure consistency across pages + type: 'specific-feeds', + gtfsFeedIds: [], + gtfsRtFeedIds: [], + gbfsFeedIds: [], +}; + +function json(status: number, body: Record) { + return NextResponse.json(body, { status }); +} + +export async function POST(req: Request) { + const expectedSecret = process.env.REVALIDATE_SECRET; + if (!expectedSecret) { + return json(500, { + ok: false, + error: 'Server misconfigured: REVALIDATE_SECRET missing', + }); + } + + const providedSecret = req.headers.get('x-revalidate-secret'); + if (!providedSecret || providedSecret !== expectedSecret) { + return json(401, { ok: false, error: 'Unauthorized' }); + } + + let payload: RevalidateBody = { ...defaultRevalidateOptions }; // default to full revalidation if body is missing/invalid + try { + payload = (await req.json()) as RevalidateBody; + } catch { + // Body is optional; allow empty/invalid JSON to keep endpoint robust + payload = { ...defaultRevalidateOptions }; + } + + try { + // clears cache for entire site + if (payload.type === 'full') { + revalidatePath('/', 'layout'); + } + + // clears cache for all feed pages (ISR-cached layout) + if (payload.type === 'all-feeds') { + revalidatePath('/[locale]/feeds/[feedDataType]/[feedId]', 'layout'); + } + + // clears cache for all GBFS feed pages (ISR-cached layout) + if (payload.type === 'all-gbfs-feeds') { + revalidatePath('/[locale]/feeds/gbfs/[feedId]', 'layout'); + } + + // clears cache for all GTFS feed pages (ISR-cached layout) + if (payload.type === 'all-gtfs-feeds') { + revalidatePath('/[locale]/feeds/gtfs/[feedId]', 'layout'); + } + + // clears cache for all GTFS RT feed pages (ISR-cached layout) + if (payload.type === 'all-gtfs-rt-feeds') { + revalidatePath('/[locale]/feeds/gtfs_rt/[feedId]', 'layout'); + } + + // clears cache for specific feed pages (ISR-cached page) + localized paths + if (payload.type === 'specific-feeds') { + const localPaths = AVAILABLE_LOCALES.filter((loc) => loc !== 'en'); + const pathsToRevalidate: string[] = []; + + payload.gtfsFeedIds.forEach((id) => { + pathsToRevalidate.push(`/feeds/gtfs/${id}`); + }); + + payload.gtfsRtFeedIds.forEach((id) => { + pathsToRevalidate.push(`/feeds/gtfs_rt/${id}`); + }); + + payload.gbfsFeedIds.forEach((id) => { + pathsToRevalidate.push(`/feeds/gbfs/${id}`); + }); + + pathsToRevalidate.forEach((path) => { + revalidatePath(path); + revalidatePath(path + '/map'); + localPaths.forEach((loc) => { + revalidatePath(`/${loc}${path}`); + revalidatePath(`/${loc}${path}/map`); + }); + }); + } + + return json(200, { + ok: true, + message: 'Revalidation triggered successfully', + }); + } catch (error) { + console.error('Revalidation failed:', error); + return json(500, { + ok: false, + error: 'Failed to revalidate', + }); + } +} From 794ce97845e599c70ffee1e3cfc7455fb223c440 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 17 Feb 2026 11:51:48 -0500 Subject: [PATCH 02/28] cleanup --- messages/en.json | 2 - messages/fr.json | 2 - src/app/components/CoveredAreaMap.tsx | 54 +- src/app/interface/RemoteConfig.ts | 2 - .../screens/Feed/StructuredData.functions.ts | 270 -------- .../components/FeedNavigationControls.tsx | 17 +- src/app/screens/Feed/index.tsx | 601 ------------------ 7 files changed, 33 insertions(+), 915 deletions(-) delete mode 100644 src/app/screens/Feed/StructuredData.functions.ts delete mode 100644 src/app/screens/Feed/index.tsx diff --git a/messages/en.json b/messages/en.json index d5ec986..e5f4180 100644 --- a/messages/en.json +++ b/messages/en.json @@ -298,8 +298,6 @@ "cancel": "Cancel" }, "fullMapView": { - "disabledTitle": "Full map view disabled", - "disabledDescription": "The full map view feature is disabled at the moment. Please try again later.", "dataBlurb": "The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn't specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they're combined into one for display.", "clearAll": "Clear All", "hideStops": "Hide Stops", diff --git a/messages/fr.json b/messages/fr.json index 563e903..f817b5a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -298,8 +298,6 @@ "cancel": "Cancel" }, "fullMapView": { - "disabledTitle": "Full map view disabled", - "disabledDescription": "The full map view feature is disabled at the moment. Please try again later.", "dataBlurb": "The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn't specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they're combined into one for display.", "clearAll": "Clear All", "hideStops": "Hide Stops", diff --git a/src/app/components/CoveredAreaMap.tsx b/src/app/components/CoveredAreaMap.tsx index 2858489..685240f 100644 --- a/src/app/components/CoveredAreaMap.tsx +++ b/src/app/components/CoveredAreaMap.tsx @@ -165,7 +165,6 @@ const CoveredAreaMap: React.FC = ({ // for gtfs feeds if ( feed?.data_type === 'gtfs' && - config.enableGtfsVisualizationMap && routesJsonLoadingStatus != 'failed' && boundingBox != undefined ) { @@ -278,17 +277,11 @@ const CoveredAreaMap: React.FC = ({ const latestAutodiscoveryUrl = getGbfsLatestVersionVisualizationUrl(); const enableGtfsVisualizationView = useMemo(() => { return ( - config.enableGtfsVisualizationMap && feed?.data_type === 'gtfs' && routesJsonLoadingStatus != 'failed' && boundingBox != undefined ); - }, [ - feed?.data_type, - config.enableGtfsVisualizationMap, - routesJsonLoadingStatus, - boundingBox, - ]); + }, [feed?.data_type, routesJsonLoadingStatus, boundingBox]); return ( = ({ )} {feed?.data_type === 'gtfs' && ( - {view === 'gtfsVisualizationView' && - config.enableGtfsVisualizationMap && ( - - )} + {view === 'gtfsVisualizationView' && ( + + )} = ({ onChange={handleViewChange} size='small' > - {config.enableGtfsVisualizationMap && ( - - - - - - )} + + + + + {config.enableDetailedCoveredArea && ( ; - -function getBasicStructuredData( - feed: AllFeedType, - description: string, -): StructureDataInterface { - const dataTypeNaming = - feed?.data_type === 'gtfs_rt' ? 'GTFS Realtime' : feed?.data_type; - - const structuredData: StructureDataInterface = { - '@context': 'https://schema.org', - '@type': 'Dataset', - name: `${dataTypeNaming ?? ''} Feed for ${feed?.provider}`, - description, - url: `https://mobilitydatabase.org/feeds/${feed?.data_type}/${feed?.id}`, - license: feed?.source_info?.license_url, - creator: { - '@type': 'Organization', - name: feed?.provider, - }, - provider: { - '@type': 'Organization', - name: 'MobilityData', - url: 'https://mobilitydata.org/', - }, - }; - return structuredData; -} - -function generateLocationStructuredData( - feed: GTFSFeedType | GBFSFeedType, - bb: { - minimum_latitude?: number; - minimum_longitude?: number; - maximum_latitude?: number; - maximum_longitude?: number; - }, -): StructureDataInterface { - const municipalities = - feed?.locations - ?.map((location) => location.municipality) - .filter((municipality) => municipality !== undefined) ?? []; - const name = - municipalities.length > 0 - ? `${municipalities.slice(0, 3).join(', ')}${ - municipalities.length > 3 ? ', and others' : '' - }` - : 'Transit coverage area'; - return { - '@type': 'Place', - name, - geo: { - '@type': 'GeoShape', - box: `${bb.minimum_latitude} ${bb.minimum_longitude} ${bb.maximum_latitude} ${bb.maximum_longitude}`, - }, - }; -} - -function getGtfsStructuredData( - feed: GTFSFeedType, - description: string, -): StructureDataInterface { - const structuredGtfsData: StructureDataInterface = { - ...getBasicStructuredData(feed, description), - identifier: feed?.id, - keywords: [ - 'gtfs', - 'data', - 'public transit', - 'schedule data', - 'transportation', - ], - distribution: { - '@type': 'DataDownload', - encodingFormat: 'application/zip', - contentUrl: feed?.latest_dataset?.hosted_url, - }, - - dateModified: feed?.latest_dataset?.downloaded_at ?? feed?.created_at, - }; - - if (feed?.latest_dataset?.hosted_url != null) { - structuredGtfsData.distribution = { - '@type': 'DataDownload', - encodingFormat: 'application/zip', - contentUrl: feed?.latest_dataset?.hosted_url, - }; - } - - if (feed?.latest_dataset?.bounding_box != null) { - structuredGtfsData.spatialCoverage = generateLocationStructuredData( - feed, - feed?.latest_dataset?.bounding_box, - ); - } - - if (feed?.latest_dataset?.validation_report?.features != null) { - structuredGtfsData.variableMeasured = - feed.latest_dataset.validation_report.features.map((feature) => ({ - '@type': 'PropertyValue', - name: feature, - })); - } - - return structuredGtfsData; -} - -function getGbfsStructuredData( - feed: GBFSFeedType, - description: string, -): StructureDataInterface { - const structuredGbfsData: StructureDataInterface = { - ...getBasicStructuredData(feed, description), - identifier: feed?.system_id, - keywords: [ - 'GBFS', - 'shared mobility', - 'micromobility', - 'bike share', - 'scooter share', - 'real-time data', - ], - creator: { - '@type': 'Organization', - name: feed?.provider, - url: feed?.provider_url, - }, - spatialCoverage: feed?.locations?.map((location) => ({ - '@type': 'Place', - name: 'Location for ' + feed?.provider, - address: { - '@type': 'PostalAddress', - addressLocality: location.municipality, - addressRegion: location.subdivision_name, - addressCountry: location.country_code, - }, - })), - dateModified: feed?.versions?.[0]?.created_at ?? feed?.created_at, - }; - - if (feed?.versions != null && feed?.versions.length > 0) { - structuredGbfsData.hasPart = feed.versions.map((version) => ({ - '@type': 'DataFeed', - name: - `GBFS ${version.version} Feed` + - (version.source === 'autodiscovery' ? ' - Autodiscovery Url' : ''), - url: version.endpoints?.find((endpoint) => endpoint.name === 'gbfs')?.url, - encodingFormat: 'application/json', - })); - } - - return structuredGbfsData; -} - -function getGtfsRtStructuredData( - feed: GTFSRTFeedType, - description: string, - relatedFeeds?: AllFeedType[], - relatedGtfsFeeds?: GTFSRTFeedType[], -): StructureDataInterface { - const associatedGtfsFeed: GTFSFeedType = relatedFeeds?.find( - (relatedFeed) => relatedFeed?.data_type === 'gtfs', - ); - - const structuredGtfsRtData: StructureDataInterface = { - ...getBasicStructuredData(feed, description), - identifier: feed?.id, - keywords: [ - 'GTFS Realtime', - 'public transit', - 'real-time data', - 'trip updates', - 'vehicle positions', - 'service alerts', - ], - distribution: { - '@type': 'DataDownload', - encodingFormat: 'application/x-protobuf', - contentUrl: feed?.source_info?.producer_url, - }, - dateModified: feed?.created_at, - hasPart: [], - }; - - if (associatedGtfsFeed?.latest_dataset?.bounding_box != null) { - structuredGtfsRtData.spatialCoverage = generateLocationStructuredData( - feed, - associatedGtfsFeed?.latest_dataset?.bounding_box, - ); - } - - if (associatedGtfsFeed != null) { - (structuredGtfsRtData.hasPart as unknown[]).push({ - '@type': 'Dataset', - name: `GTFS Static Feed for ${feed?.provider}`, - url: `https://mobilitydatabase.org/feeds/gtfs/${associatedGtfsFeed.id}`, - distribution: { - '@type': 'DataDownload', - encodingFormat: 'application/zip', - contentUrl: associatedGtfsFeed.source_info?.producer_url, - }, - }); - } - if (relatedGtfsFeeds != null && relatedGtfsFeeds.length > 0) { - relatedGtfsFeeds.forEach((relatedFeed) => { - let name = `GTFS Realtime Feed for ${relatedFeed?.provider}`; - - if (relatedFeed?.entity_types != null) { - if (relatedFeed.entity_types.includes('sa')) { - name = `GTFS Realtime Service Alerts for ${relatedFeed?.provider}`; - } else if (relatedFeed.entity_types.includes('tu')) { - name = `GTFS Realtime Trip Updates for ${relatedFeed?.provider}`; - } else if (relatedFeed.entity_types.includes('vp')) { - name = `GTFS Realtime Vehicle Positions for ${relatedFeed?.provider}`; - } - } - - (structuredGtfsRtData.hasPart as unknown[]).push({ - '@type': 'Dataset', - name, - url: `https://mobilitydatabase.org/feeds/gtfs_rt/${relatedFeed?.id}`, - distribution: { - '@type': 'DataDownload', - encodingFormat: 'application/x-protobuf', - contentUrl: relatedFeed?.source_info?.producer_url, - }, - }); - }); - } - - return structuredGtfsRtData; -} - -export default function generateFeedStructuredData( - feed: AllFeedType, - description: string, - // For gtfs rt - relatedFeeds?: AllFeedType[], - relatedGtfsFeeds?: GTFSFeedType[], -): StructureDataInterface | undefined { - let structuredData: StructureDataInterface | undefined; - if (feed?.data_type === 'gtfs') { - structuredData = getGtfsStructuredData(feed as GTFSFeedType, description); - } else if (feed?.data_type === 'gbfs') { - structuredData = getGbfsStructuredData(feed as GBFSFeedType, description); - } else if (feed?.data_type === 'gtfs_rt') { - structuredData = getGtfsRtStructuredData( - feed as GTFSRTFeedType, - description, - relatedFeeds, - relatedGtfsFeeds, - ); - } - - return structuredData; -} diff --git a/src/app/screens/Feed/components/FeedNavigationControls.tsx b/src/app/screens/Feed/components/FeedNavigationControls.tsx index 8a30878..85ad21c 100644 --- a/src/app/screens/Feed/components/FeedNavigationControls.tsx +++ b/src/app/screens/Feed/components/FeedNavigationControls.tsx @@ -1,17 +1,21 @@ +'use client'; + import { Button, Grid, Typography } from '@mui/material'; import { ChevronLeft } from '@mui/icons-material'; -import { getTranslations } from 'next-intl/server'; +import { useTranslations } from 'next-intl'; +import { useRouter } from '../../../../i18n/navigation'; interface Props { feedDataType: string; feedId: string; } -export default async function FeedNavigationControls({ +export default function FeedNavigationControls({ feedDataType, feedId, -}: Props): Promise { - const t = await getTranslations('common'); +}: Props): React.ReactElement { + const t = useTranslations('common'); + const router = useRouter(); return ( @@ -20,8 +24,9 @@ export default async function FeedNavigationControls({ size='large' startIcon={} color={'inherit'} - component={'a'} - href='/feeds' + onClick={() => { + router.back(); + }} > {t('back')} diff --git a/src/app/screens/Feed/index.tsx b/src/app/screens/Feed/index.tsx deleted file mode 100644 index 382aa67..0000000 --- a/src/app/screens/Feed/index.tsx +++ /dev/null @@ -1,601 +0,0 @@ -// This component is deprecated in favor of FeedView -// They should have the same functionality - -import * as React from 'react'; -import { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; -import { useSelector } from 'react-redux'; -import { - Box, - Container, - CssBaseline, - Typography, - Button, - Grid, - Skeleton, -} from '@mui/material'; -import { useTheme } from '@mui/material/styles'; -import { ChevronLeft } from '@mui/icons-material'; -import { useAppDispatch } from '../../hooks'; -import { loadingFeed, loadingRelatedFeeds } from '../../store/feed-reducer'; -import { - selectIsAnonymous, - selectIsAuthenticated, - selectUserProfile, -} from '../../store/profile-selectors'; -import { - selectFeedData, - selectFeedLoadingStatus, - selectRelatedFeedsData, - selectRelatedGtfsRTFeedsData, - selectAutoDiscoveryUrl, - selectFeedBoundingBox, -} from '../../store/feed-selectors'; -import { clearDataset, loadingDataset } from '../../store/dataset-reducer'; -import { - selectDatasetsData, - selectDatasetsLoadingStatus, - selectHasLoadedAllDatasets, - selectLatestDatasetsData, -} from '../../store/dataset-selectors'; -import AssociatedFeeds from './components/AssociatedFeeds'; -import { WarningContentBox } from '../../components/WarningContentBox'; -import { useTranslations } from 'next-intl'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import { - ctaContainerStyle, - feedDetailContentContainerStyle, -} from './Feed.styles'; -import CoveredAreaMap from '../../components/CoveredAreaMap'; - -import { - formatProvidersSorted, - generatePageTitle, - generateDescriptionMetaTag, -} from './Feed.functions'; -import FeedTitle from './components/FeedTitle'; -import OfficialChip from '../../components/OfficialChip'; -import { - type GBFSFeedType, - type GTFSFeedType, - type GTFSRTFeedType, -} from '../../services/feeds/utils'; -import DownloadIcon from '@mui/icons-material/Download'; -import GbfsVersions from './components/GbfsVersions'; -import generateFeedStructuredData from './StructuredData.functions'; -import ReactGA from 'react-ga4'; -import FeedSummary from './components/FeedSummary'; - -const wrapComponent = ( - feedLoadingStatus: string, - descriptionMeta: string | undefined, - feedDataType: string | undefined, - feedId: string | undefined, - structuredData: Record | undefined, - child: React.ReactElement, -): React.ReactElement => { - const t = useTranslations('feeds'); - const theme = useTheme(); - return ( - - - - - {feedLoadingStatus === 'error' && <>{t('errorLoadingFeed')}} - {feedLoadingStatus !== 'error' ? child : null} - - - - ); -}; - -const handleDownloadLatestClick = (): void => { - ReactGA.event({ - category: 'engagement', - action: 'download_latest_dataset', - label: 'Download Latest Dataset', - }); -}; - -const handleOpenFullQualityReportClick = (): void => { - ReactGA.event({ - category: 'engagement', - action: 'open_full_quality_report', - label: 'Open Full Quality Report', - }); -}; - -export default function Feed(): React.ReactElement { - const t = useTranslations('feeds'); - const dispatch = useAppDispatch(); - const { feedId, feedDataType } = useParams(); - const user = useSelector(selectUserProfile); - const feedLoadingStatus = useSelector(selectFeedLoadingStatus); - const datasetLoadingStatus = useSelector(selectDatasetsLoadingStatus); - const dataTypeSelector = useSelector(selectFeedData)?.data_type; - const feedType = feedDataType ?? dataTypeSelector; - const relatedFeeds = useSelector(selectRelatedFeedsData); - const relatedGtfsRtFeeds = useSelector(selectRelatedGtfsRTFeedsData); - const datasets = useSelector(selectDatasetsData); - const hasLoadedAllDatasets = useSelector(selectHasLoadedAllDatasets); - const latestDataset = useSelector(selectLatestDatasetsData); - const boundingBox = useSelector(selectFeedBoundingBox); - const feed = useSelector(selectFeedData); - const gbfsAutodiscoveryUrl = useSelector(selectAutoDiscoveryUrl); - const needsToLoadFeed = feed === undefined || feed?.id !== feedId; - const isAuthenticatedOrAnonymous = - useSelector(selectIsAuthenticated) || useSelector(selectIsAnonymous); - const sortedProviders = formatProvidersSorted(feed?.provider ?? ''); - const DATASET_CALL_LIMIT = 10; - const [structuredData, setStructuredData] = React.useState< - Record | undefined - >(); - - React.useMemo(() => { - const structuredData = generateFeedStructuredData( - feed, - generateDescriptionMetaTag( - t, - sortedProviders, - feed?.data_type, - feed?.feed_name, - ), - relatedFeeds, - relatedGtfsRtFeeds, - ); - setStructuredData(structuredData); - }, [feed, relatedFeeds, relatedGtfsRtFeeds]); - - const loadDatasets = (offset: number): void => { - if (feedId != undefined && hasLoadedAllDatasets === false) { - dispatch( - loadingDataset({ - feedId, - offset, - limit: DATASET_CALL_LIMIT, - }), - ); - } - }; - - useEffect(() => { - if (user != undefined && feedId != undefined && needsToLoadFeed) { - dispatch(clearDataset()); - dispatch(loadingFeed({ feedId, feedDataType })); - if (feedDataType === 'gtfs') { - loadDatasets(0); - } - } - }, [isAuthenticatedOrAnonymous, needsToLoadFeed]); - - useEffect(() => { - if (needsToLoadFeed) { - return; - } - document.title = generatePageTitle( - sortedProviders, - feed.data_type, - feed?.feed_name, - ); - if ( - feed?.data_type === 'gtfs_rt' && - feedLoadingStatus === 'loaded' && - (feed as GTFSRTFeedType)?.feed_references != undefined - ) { - dispatch( - loadingRelatedFeeds({ - feedIds: (feed as GTFSRTFeedType)?.feed_references ?? [], - }), - ); - } - if ( - feedId != undefined && - feed?.data_type === 'gtfs' && - feedLoadingStatus === 'loaded' && - datasets == undefined - ) { - loadDatasets(0); - } - return () => { - document.title = 'Mobility Database'; - }; - }, [feed, needsToLoadFeed]); - - // The feedId parameter doesn't match the feedId in the store, so we need to load the feed and only render the loading message. - const areDatasetsLoading = - feed?.data_type === 'gtfs' && - datasetLoadingStatus === 'loading' && - datasets == undefined; - const isCurrenltyLoadingFeed = - feedLoadingStatus === 'loading' || areDatasetsLoading; - if (needsToLoadFeed || isCurrenltyLoadingFeed) { - return wrapComponent( - feedLoadingStatus, - undefined, - feedType, - feedId, - structuredData, - - - - - - - - - - - - - - , - ); - } - const hasDatasets = datasets != undefined && datasets.length > 0; - const hasFeedRedirect = - feed?.redirects != undefined && feed?.redirects.length > 0; - const downloadLatestUrl = - feed?.data_type === 'gtfs' - ? (feed as GTFSFeedType)?.latest_dataset?.hosted_url - : feed?.source_info?.producer_url; - - const gbfsOpenFeedUrlElement = (): React.JSX.Element => { - if (gbfsAutodiscoveryUrl == undefined) { - return <>; - } - return ( - - ); - }; - - return wrapComponent( - feedLoadingStatus, - generateDescriptionMetaTag( - t, - sortedProviders, - feed.data_type, - feed?.feed_name, - ), - feedType, - feedId, - structuredData, - - - - - - - - / - - /{' '} - {feed.data_type === 'gbfs' - ? feed?.id?.replace('gbfs-', '') - : feed?.id} - - - - - - - {feed?.feed_name !== '' && feed?.data_type === 'gtfs' && ( - - - {feed?.feed_name} - - - )} - - {/* {feed?.data_type === 'gtfs' && ( - - )} */} - {feed?.data_type === 'gtfs_rt' && feed.official === true && ( - - - - )} - - {latestDataset?.validation_report?.validated_at != undefined && ( - - {`${t('qualityReportUpdated')}: ${new Date( - latestDataset.validation_report.validated_at, - ).toDateString()}`} - - )} - {feed?.official_updated_at != undefined && ( - - {`${t('officialFeedUpdated')}: ${new Date( - feed?.official_updated_at, - ).toDateString()}`} - - )} - {feed.external_ids?.some((eId) => eId.source === 'tld') === true && ( - - {t('dataAttribution')}{' '} - - Transitland - - - )} - {feed.external_ids?.some((eId) => eId.source === 'ntd') === true && ( - - {t('dataAttribution')} - {' the United States '} - - National Transit Database - - - )} - - - {feed?.data_type === 'gtfs_rt' && - (feed as GTFSRTFeedType)?.entity_types != undefined && ( - - - {' '} - {((feed as GTFSRTFeedType)?.entity_types ?? []) - .map( - (entityType) => - ({ - tu: t('common:gtfsRealtimeEntities.tripUpdates'), - vp: t('common:gtfsRealtimeEntities.vehiclePositions'), - sa: t('common:gtfsRealtimeEntities.serviceAlerts'), - })[entityType], - ) - .join(' ' + t('common:and') + ' ')} - - - )} - {feedType === 'gtfs' && - datasetLoadingStatus === 'loaded' && - !hasDatasets && - !hasFeedRedirect && ( - - {t.rich('unableToDownloadFeed', { - link: (chunks) => ( - - ), - })} - - )} - {hasFeedRedirect && ( - - - {t.rich('feedHasBeenReplaced', { - link: (chunks) => ( - - ), - })} - - - )} - - {feedType === 'gtfs' && downloadLatestUrl != undefined && ( - - )} - {latestDataset?.validation_report?.url_html != undefined && ( - - )} - {feed?.data_type === 'gbfs' && <>{gbfsOpenFeedUrlElement()}} - - - - {(feed?.data_type === 'gtfs' || feed.data_type === 'gbfs') && ( - - )} - - - - - - {feed?.data_type === 'gtfs_rt' && relatedFeeds != undefined && ( - f?.id !== feed.id)} - gtfsRtFeeds={relatedGtfsRtFeeds.filter((f) => f?.id !== feed.id)} - /> - )} - - - - {feed?.data_type === 'gbfs' && ( - - )} - - {feed?.data_type === 'gtfs' && hasDatasets && ( - - {/* { - loadDatasets(offset); - }} - /> */} - - )} - , - ); -} From 07d7edd5a0b6a92934875ff3b0e5cc548167b11c Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 17 Feb 2026 13:11:31 -0500 Subject: [PATCH 03/28] full map view data refactor --- .../screens/Feed/components/FullMapView.tsx | 344 ++++++------------ 1 file changed, 121 insertions(+), 223 deletions(-) diff --git a/src/app/screens/Feed/components/FullMapView.tsx b/src/app/screens/Feed/components/FullMapView.tsx index 419e677..a448c3d 100644 --- a/src/app/screens/Feed/components/FullMapView.tsx +++ b/src/app/screens/Feed/components/FullMapView.tsx @@ -6,7 +6,6 @@ import { Button, Chip, useTheme, - Skeleton, Alert, AlertTitle, Stack, @@ -17,7 +16,7 @@ import { ToggleButtonGroup, ToggleButton, } from '@mui/material'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { GtfsVisualizationMap } from '../../../components/GtfsVisualizationMap'; import CloseIcon from '@mui/icons-material/Close'; import NestedCheckboxList, { @@ -32,28 +31,21 @@ import { StyledChipFilterContainer, StyledMapControlPanel, } from '../Map.styles'; -import { - selectFeedData, - selectFeedLoadingStatus, - selectGtfsDatasetRoutesJson, - selectGtfsDatasetRoutesLoadingStatus, - selectGtfsDatasetRouteTypes, - selectFeedBoundingBox, - selectIsAnonymous, - selectIsAuthenticated, - selectUserProfile, -} from '../../../store/selectors'; -import { useSelector } from 'react-redux'; import { useParams, useRouter } from 'next/navigation'; -import { clearDataset } from '../../../store/dataset-reducer'; -import { useAppDispatch } from '../../../hooks'; import { useRemoteConfig } from '../../../context/RemoteConfigProvider'; import { getRouteTypeTranslatedName } from '../../../constants/RouteTypes'; -import { loadingFeed } from '../../../store/feed-reducer'; import type { GTFSFeedType } from '../../../services/feeds/utils'; import RouteSelector from '../../../components/RouteSelector'; +import type { FeedData } from '../../../[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data'; +import { getBoundingBox } from '../Feed.functions'; + +interface FullMapViewProps { + feedData: FeedData; +} -export default function FullMapView(): React.ReactElement { +export default function FullMapView({ + feedData, +}: FullMapViewProps): React.ReactElement { const t = useTranslations('feeds'); const tCommon = useTranslations('common'); const { config } = useRemoteConfig(); @@ -63,28 +55,14 @@ export default function FullMapView(): React.ReactElement { const feedId = params.feedId as string; const theme = useTheme(); - const dispatch = useAppDispatch(); - - const user = useSelector(selectUserProfile); - const feed = useSelector(selectFeedData); - const needsToLoadFeed = - feed === undefined || (feed?.id != null && feed?.id !== feedId); - const feedLoadingStatus = useSelector(selectFeedLoadingStatus); - const routesJsonLoadingStatus = useSelector( - selectGtfsDatasetRoutesLoadingStatus, - ); - const isAuthenticatedOrAnonymous = - useSelector(selectIsAuthenticated) || useSelector(selectIsAnonymous); - - const gtfsFeed = useSelector(selectFeedData) as GTFSFeedType | undefined; + const { feed, routes, routeTypes } = feedData; + const gtfsFeed = feed as GTFSFeedType; const latestDatasetLite = { hosted_url: gtfsFeed?.latest_dataset?.hosted_url, id: gtfsFeed?.latest_dataset?.id, }; - const boundingBox = useSelector(selectFeedBoundingBox); - const routes = useSelector(selectGtfsDatasetRoutesJson); - const routeTypes = useSelector(selectGtfsDatasetRouteTypes); + const boundingBox = getBoundingBox(gtfsFeed); const [filteredRoutes, setFilteredRoutes] = useState([]); const [filteredRouteTypeIds, setFilteredRouteTypeIds] = useState( @@ -97,7 +75,6 @@ export default function FullMapView(): React.ReactElement { /* style panel state */ const [stylePanelOpen, setStylePanelOpen] = useState(false); - // Presets: small=3, medium=5, large=7 (works nicely with your current map defaults) const [stopPreset, setStopPreset] = useState< 'small' | 'medium' | 'large' | 'custom' >('small'); @@ -110,15 +87,7 @@ export default function FullMapView(): React.ReactElement { ? 5 : stopPreset === 'large' ? 7 - : customStopRadius; // 'custom' - - // kick off feed loading when user or feedId changes and we need to load the feed - useEffect(() => { - if (isAuthenticatedOrAnonymous && feedId != null && needsToLoadFeed) { - dispatch(clearDataset()); - dispatch(loadingFeed({ feedId })); - } - }, [dispatch, isAuthenticatedOrAnonymous, user, feedId, needsToLoadFeed]); + : customStopRadius; const clearAllFilters = (): void => { setFilteredRoutes([]); @@ -147,37 +116,21 @@ export default function FullMapView(): React.ReactElement { }; }) as CheckboxStructure[]; - const isFetchingFeed = needsToLoadFeed || feedLoadingStatus === 'loading'; - - const isFetchingRoutes = routesJsonLoadingStatus === 'loading'; - - const isGtfsFeed = (feed as GTFSFeedType)?.data_type === 'gtfs'; - const feedError = feedLoadingStatus === 'error'; - const routesError = routesJsonLoadingStatus === 'failed'; - const hasLoadingError = feedError || routesError; - const isLoading = !hasLoadingError && (isFetchingFeed || isFetchingRoutes); - const missingBboxAfterLoad = - boundingBox == null && - !isLoading && - feedLoadingStatus === 'loaded' && - routesJsonLoadingStatus === 'loaded'; - - const hasError = - feedError || - routesError || - (!isGtfsFeed && feed == null) || - missingBboxAfterLoad; + const isGtfsFeed = feed?.data_type === 'gtfs'; + const hasError = !isGtfsFeed || feed == null || boundingBox == null; const errorDetails = useMemo(() => { const messages: string[] = []; - if (feedError) messages.push(t('visualizationMapErrors.noFeedMetadata')); - if (routesError) messages.push(t('visualizationMapErrors.noRoutesData')); - if (feed != null && !isGtfsFeed) + if (feed == null) { + messages.push(t('visualizationMapErrors.noFeedMetadata')); + } else if (!isGtfsFeed) { messages.push(t('visualizationMapErrors.invalidDataType')); - if (missingBboxAfterLoad) + } + if (boundingBox == null) { messages.push(t('visualizationMapErrors.noBoundingBox')); + } return messages; - }, [feedError, routesError, isGtfsFeed, feed, missingBboxAfterLoad, t]); + }, [feed, isGtfsFeed, boundingBox, t]); const renderFilterChips = (): React.ReactElement => ( @@ -240,39 +193,6 @@ export default function FullMapView(): React.ReactElement { ); - const renderPanelSkeleton = (): React.ReactElement => ( - - - {[...Array(5)].map((_, i) => ( - - ))} - - - - {[...Array(3)].map((_, i) => ( - - ))} - - ); - - const renderMapSkeleton = (): React.ReactElement => ( - - ); - const renderError = (): React.ReactElement => ( ); - if (!config.enableGtfsVisualizationMap) { - return ( - - - {t('fullMapView.disabledTitle')} - {t('fullMapView.disabledDescription')} - - - ); - } - return ( <> - {isLoading ? ( - renderPanelSkeleton() - ) : ( - <> - { - const nextTypeIds = checkboxData - .map((item) => - item.checked ? (item?.props?.routeTypeId ?? '') : '', - ) - .filter((item) => item !== ''); - - setFilteredRouteTypeIds(nextTypeIds); - - // Keep only route IDs that match the newly selected route types. - // If no type is selected, allow any route (don't force-clear). - if (nextTypeIds.length > 0) { - setFilteredRoutes((prev) => - prev.filter((rid) => - nextTypeIds.includes(getRouteType(rid) ?? ''), - ), - ); - } - }} - /> + { + const nextTypeIds = checkboxData + .map((item) => + item.checked ? (item?.props?.routeTypeId ?? '') : '', + ) + .filter((item) => item !== ''); + + setFilteredRouteTypeIds(nextTypeIds); + + // Keep only route IDs that match the newly selected route types. + // If no type is selected, allow any route (don't force-clear). + if (nextTypeIds.length > 0) { + setFilteredRoutes((prev) => + prev.filter((rid) => + nextTypeIds.includes(getRouteType(rid) ?? ''), + ), + ); + } + }} + /> - - {t('fullMapView.headers.visibility')} - - { - setHideStops(checkboxData[0].checked); - }} - /> + + {t('fullMapView.headers.visibility')} + + { + setHideStops(checkboxData[0].checked); + }} + /> - - {t('fullMapView.headers.routes')} - - - filteredRouteTypeIds.length === 0 || - filteredRouteTypeIds.includes(r.routeType ?? ''), - ) ?? [] - } - selectedRouteIds={filteredRoutes} - onSelectionChange={(val) => { - // Ensure selections remain valid under the current type filter. - const filteredVal = val.filter( - (v) => - filteredRouteTypeIds.length === 0 || - filteredRouteTypeIds.includes(getRouteType(v) ?? ''), - ); - setFilteredRoutes(filteredVal); - }} - /> - - - - - )} + + {t('fullMapView.headers.routes')} + + + filteredRouteTypeIds.length === 0 || + filteredRouteTypeIds.includes(r.routeType ?? ''), + ) ?? [] + } + selectedRouteIds={filteredRoutes} + onSelectionChange={(val) => { + // Ensure selections remain valid under the current type filter. + const filteredVal = val.filter( + (v) => + filteredRouteTypeIds.length === 0 || + filteredRouteTypeIds.includes(getRouteType(v) ?? ''), + ); + setFilteredRoutes(filteredVal); + }} + /> + + + - {isLoading && renderMapSkeleton()} - - {!isLoading && hasError && renderError()} - - {!isLoading && - !hasError && - config.enableGtfsVisualizationMap && - boundingBox != null && ( - - )} + {hasError && renderError()} + + {!hasError && boundingBox != null && ( + + )} From fb79f3055cb9326273cb60ed76b7daabaff4b491 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 17 Feb 2026 13:12:29 -0500 Subject: [PATCH 04/28] auth server stronger guest accomodation --- src/app/utils/auth-server.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/utils/auth-server.ts b/src/app/utils/auth-server.ts index 1ec4b05..595dbc1 100644 --- a/src/app/utils/auth-server.ts +++ b/src/app/utils/auth-server.ts @@ -98,7 +98,10 @@ export async function getGcipIdToken( nonEmpty(getEnvConfig('GCIP_SERVICE_UID')) ?? nonEmpty(getEnvConfig('NEXT_GCIP_SERVICE_UID')) ?? 'iap-service-caller'; - const customClaims: Record = { service: true }; + const customClaims: Record = { + service: true, + isGuest: true, + }; if (userInfo?.uid != undefined) { // Attach the end-user session information as metadata so downstream // services can attribute calls without changing API signatures. @@ -125,6 +128,10 @@ export async function getGcipIdToken( return idToken; } +export async function getGuestGcipIdToken(): Promise { + return await getGcipIdToken(undefined); +} + /** * Returns a GCIP ID token suitable for IAP-protected API calls. * This avoids trusting client tokens and keeps credentials server-side only. From f90d179b2a0b4248f798ebfd2045b05ab04df5d8 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 08:40:36 -0500 Subject: [PATCH 05/28] ISR caching e2e tests --- cypress/e2e/feed-isr-caching.cy.ts | 145 +++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 cypress/e2e/feed-isr-caching.cy.ts diff --git a/cypress/e2e/feed-isr-caching.cy.ts b/cypress/e2e/feed-isr-caching.cy.ts new file mode 100644 index 0000000..b3d977c --- /dev/null +++ b/cypress/e2e/feed-isr-caching.cy.ts @@ -0,0 +1,145 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +/** + * Feed ISR Caching e2e tests (unauthenticated users) + * + * Architecture overview: + * - Unauthenticated users are routed by proxy.ts to /[locale]/feeds/[type]/[id]/static/ + * - That route uses `force-static` + `revalidate: 1209600` (14 days) + * - On the first visit, Next.js renders the page and caches it + * - On subsequent visits, Next.js serves the cached HTML without re-rendering + * + * How we detect cache hits/misses: + * - Next.js sets the `x-nextjs-cache` response header on ISR routes: + * MISS → page was freshly rendered (first visit or after revalidation) + * HIT → page was served from the ISR cache + * STALE → page was served from stale cache while revalidation runs in background + * - We intercept the browser's GET request to the feed page and inspect this header. + * + */ + +export {}; + +const TEST_FEED_ID = 'test-516'; +const TEST_FEED_DATA_TYPE = 'gtfs'; +const FEED_URL = `/feeds/${TEST_FEED_DATA_TYPE}/${TEST_FEED_ID}`; + +/** + * Calls the /api/revalidate endpoint to bust the ISR cache for the test feed. + * This simulates what happens when the backend triggers a revalidation webhook + * (e.g. after a feed update), which in production invalidates the cached page. + * + * The REVALIDATE_SECRET must match the value set in the Next.js server's env. + * It is read from Cypress env (loaded from .env.development via cypress.config.ts). + */ +function revalidateTestFeed(): void { + const secret = Cypress.env('REVALIDATE_SECRET') as string; + cy.request({ + method: 'POST', + url: '/api/revalidate', + headers: { + 'x-revalidate-secret': secret, + 'content-type': 'application/json', + }, + body: { + type: 'specific-feeds', + gtfsFeedIds: [TEST_FEED_ID], + gtfsRtFeedIds: [], + gbfsFeedIds: [], + }, + }) + .its('status') + .should('eq', 200); +} + +describe('Feed ISR Caching - Unauthenticated', () => { + /** + * Ensure the ISR cache is busted before the suite runs so we always + * start from a known MISS state, regardless of prior test runs. + */ + before(() => { + revalidateTestFeed(); + }); + + describe('First visit (cache MISS)', () => { + it('should render the page dynamically on the first visit', () => { + // Intercept the page request and capture the x-nextjs-cache header. + // The alias lets us assert on the response after cy.visit() resolves. + cy.intercept('GET', FEED_URL).as('feedPageRequest'); + + cy.visit(FEED_URL, { timeout: 30000 }); + + // Wait for the page request and assert the cache header is MISS. + // On the very first visit (or after revalidation), Next.js renders + // the page fresh and populates the ISR cache. + cy.wait('@feedPageRequest') + .its('response.headers.x-nextjs-cache') + // MISS means the page was freshly rendered (not served from cache). + // STALE is also acceptable here if a prior cached version existed but + // was invalidated — Next.js serves stale while revalidating in background. + .should('be.oneOf', ['MISS', 'STALE']); + + // Sanity check: the page content is actually rendered + cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should( + 'contain', + 'Metropolitan Transit Authority (MTA)', + ); + }); + }); + + describe('Second visit (cache HIT)', () => { + it('should serve the page from the ISR cache on a revisit', () => { + // Intercept the page request again for the second visit. + cy.intercept('GET', FEED_URL).as('feedPageCacheHit'); + + // Visit the same URL again — Next.js should now serve from ISR cache. + cy.visit(FEED_URL, { timeout: 30000 }); + + cy.wait('@feedPageCacheHit') + .its('response.headers.x-nextjs-cache') + // HIT means the page was served from the ISR cache without re-rendering. + .should('eq', 'HIT'); + + // Content should still be correct when served from cache + cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should( + 'contain', + 'Metropolitan Transit Authority (MTA)', + ); + }); + }); + + describe('After revalidation (cache MISS again)', () => { + it('should bust the ISR cache when the revalidate endpoint is called', () => { + // First, confirm the page is currently cached (HIT) before we bust it. + cy.intercept('GET', FEED_URL).as('feedPageBeforeRevalidate'); + cy.visit(FEED_URL, { timeout: 30000 }); + cy.wait('@feedPageBeforeRevalidate') + .its('response.headers.x-nextjs-cache') + .should('eq', 'HIT'); + + // Trigger cache invalidation via the revalidate API endpoint. + // This simulates a backend webhook call after a feed update. + revalidateTestFeed(); + + // Visit the page again — the cache was busted, so Next.js should + // re-render the page (MISS or STALE). + cy.intercept('GET', FEED_URL).as('feedPageAfterRevalidate'); + cy.visit(FEED_URL, { timeout: 30000 }); + + cy.wait('@feedPageAfterRevalidate') + .its('response.headers.x-nextjs-cache') + // After revalidation, the cache is invalidated. Next.js will either: + // - MISS: render fresh immediately + // - STALE: serve the old cache while re-rendering in background + // Either way, the cache was busted — a HIT here would be a failure. + .should('be.oneOf', ['MISS', 'STALE']); + + // Content should still be correct after revalidation + cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should( + 'contain', + 'Metropolitan Transit Authority (MTA)', + ); + }); + }); +}); diff --git a/package.json b/package.json index d94f6a8..d70d114 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "test": "jest", "test:watch": "jest --watch", "test:ci": "CI=true jest", - "e2e:setup": "concurrently -k -n \"next,firebase\" -c \"cyan,magenta\" \"NEXT_PUBLIC_API_MOCKING=enabled next dev -p 3001\" \"firebase emulators:start --only auth --project mobility-feeds-dev\"", + "e2e:setup": "next build && concurrently -k -n \"next,firebase\" -c \"cyan,magenta\" \"NEXT_PUBLIC_API_MOCKING=enabled next start -p 3001\" \"firebase emulators:start --only auth --project mobility-feeds-dev\"", "e2e:run": "CYPRESS_BASE_URL=http://localhost:3001 cypress run", "e2e:open": "CYPRESS_BASE_URL=http://localhost:3001 cypress open", "firebase:auth:emulator:dev": "firebase emulators:start --only auth --project mobility-feeds-dev", From ca2a888ecb8991be9367f9f08124e7b3c4587f60 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 08:42:39 -0500 Subject: [PATCH 06/28] debugging parameters --- src/app/components/LogoutConfirmModal.tsx | 4 +--- src/app/screens/Feed/FeedView.tsx | 4 ++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/components/LogoutConfirmModal.tsx b/src/app/components/LogoutConfirmModal.tsx index 681d2c5..e086e81 100644 --- a/src/app/components/LogoutConfirmModal.tsx +++ b/src/app/components/LogoutConfirmModal.tsx @@ -10,7 +10,7 @@ import { Dialog, } from '@mui/material'; import React from 'react'; -import { useAppDispatch, useRehydrated } from '../hooks'; +import { useAppDispatch } from '../hooks'; import { logout } from '../store/profile-reducer'; import { SIGN_OUT_TARGET } from '../constants/Navigation'; import { useRouter } from 'next/navigation'; @@ -26,7 +26,6 @@ export default function ConfirmModal({ }: ConfirmModalProps): React.ReactElement { const dispatch = useAppDispatch(); const router = useRouter(); - const isRehydrated = useRehydrated(); const confirmLogout = (): void => { dispatch( logout({ @@ -66,7 +65,6 @@ export default function ConfirmModal({ color='primary' variant='contained' data-cy='confirmSignOutButton' - disabled={!isRehydrated} > Confirm diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index dff96c2..404e559 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -122,6 +122,10 @@ export default async function FeedView({ sx={{ width: '100%', m: 'auto', px: 0 }} maxWidth='xl' > + {/* TODO: remove this timestamp after confirming ISR is working in production and providing real value to users (e.g. helps with debugging feed updates) */} +
+ Generated at: {new Date().toISOString()} +
Date: Wed, 18 Feb 2026 09:39:39 -0500 Subject: [PATCH 07/28] revalidate endpoint tags --- src/app/api/revalidate/route.spec.ts | 50 ++++++++++++++++++++++++---- src/app/api/revalidate/route.ts | 14 ++++++-- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/app/api/revalidate/route.spec.ts b/src/app/api/revalidate/route.spec.ts index b342c5f..23f12e0 100644 --- a/src/app/api/revalidate/route.spec.ts +++ b/src/app/api/revalidate/route.spec.ts @@ -2,11 +2,12 @@ * @jest-environment node */ import { POST } from './route'; -import { revalidatePath } from 'next/cache'; +import { revalidatePath, revalidateTag } from 'next/cache'; // Mock Next.js cache jest.mock('next/cache', () => ({ revalidatePath: jest.fn(), + revalidateTag: jest.fn(), })); // Mock i18n routing @@ -18,6 +19,9 @@ describe('POST /api/revalidate', () => { const mockRevalidatePath = revalidatePath as jest.MockedFunction< typeof revalidatePath >; + const mockRevalidateTag = revalidateTag as jest.MockedFunction< + typeof revalidateTag + >; const originalEnv = process.env; beforeEach(() => { @@ -157,6 +161,7 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); + expect(mockRevalidateTag).toHaveBeenCalledWith('guest-feeds', 'max'); expect(mockRevalidatePath).toHaveBeenCalledWith( '/[locale]/feeds/[feedDataType]/[feedId]', 'layout', @@ -180,6 +185,7 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-type-gbfs', 'max'); expect(mockRevalidatePath).toHaveBeenCalledWith( '/[locale]/feeds/gbfs/[feedId]', 'layout', @@ -203,6 +209,7 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-type-gtfs', 'max'); expect(mockRevalidatePath).toHaveBeenCalledWith( '/[locale]/feeds/gtfs/[feedId]', 'layout', @@ -226,6 +233,10 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); + expect(mockRevalidateTag).toHaveBeenCalledWith( + 'feed-type-gtfs_rt', + 'max', + ); expect(mockRevalidatePath).toHaveBeenCalledWith( '/[locale]/feeds/gtfs_rt/[feedId]', 'layout', @@ -253,6 +264,10 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); + // Should invalidate cache tags for each feed + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-1', 'max'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-2', 'max'); + // Each feed should revalidate base path + map path + localized versions expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-1'); expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-1/map'); @@ -293,9 +308,16 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); - expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs_rt/rt-feed-1'); - expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs_rt/rt-feed-1/map'); - expect(mockRevalidatePath).toHaveBeenCalledWith('/fr/feeds/gtfs_rt/rt-feed-1'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-rt-feed-1', 'max'); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/feeds/gtfs_rt/rt-feed-1', + ); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/feeds/gtfs_rt/rt-feed-1/map', + ); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/fr/feeds/gtfs_rt/rt-feed-1', + ); expect(mockRevalidatePath).toHaveBeenCalledWith( '/fr/feeds/gtfs_rt/rt-feed-1/map', ); @@ -323,9 +345,16 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); - expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/gbfs-feed-1'); - expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/gbfs-feed-1/map'); - expect(mockRevalidatePath).toHaveBeenCalledWith('/fr/feeds/gbfs/gbfs-feed-1'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-gbfs-feed-1', 'max'); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/feeds/gbfs/gbfs-feed-1', + ); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/feeds/gbfs/gbfs-feed-1/map', + ); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/fr/feeds/gbfs/gbfs-feed-1', + ); expect(mockRevalidatePath).toHaveBeenCalledWith( '/fr/feeds/gbfs/gbfs-feed-1/map', ); @@ -353,6 +382,11 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); + // Should invalidate cache tags for each feed + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-gtfs-1', 'max'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-rt-1', 'max'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-gbfs-1', 'max'); + // Should revalidate all three feed types expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/gtfs-1'); expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs_rt/rt-1'); @@ -382,6 +416,7 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); + expect(mockRevalidateTag).not.toHaveBeenCalled(); expect(mockRevalidatePath).not.toHaveBeenCalled(); }); }); @@ -444,6 +479,7 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); + expect(mockRevalidateTag).toHaveBeenCalledWith('guest-feeds', 'max'); expect(mockRevalidatePath).toHaveBeenCalledWith( '/[locale]/feeds/[feedDataType]/[feedId]', 'layout', diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 3aebe29..de4e645 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { revalidatePath } from 'next/cache'; +import { revalidatePath, revalidateTag } from 'next/cache'; import { AVAILABLE_LOCALES } from '../../../i18n/routing'; type RevalidateTypes = @@ -47,10 +47,13 @@ export async function POST(req: Request) { try { payload = (await req.json()) as RevalidateBody; } catch { - // Body is optional; allow empty/invalid JSON to keep endpoint robust payload = { ...defaultRevalidateOptions }; } + // NOTE + // revalidatePath = triggers revalidation for entire page cache + // revalidateTag = triggers revalidation for API calls using `unstable_cache` with matching tags (e.g., feed-123, guest-feeds) + try { // clears cache for entire site if (payload.type === 'full') { @@ -59,21 +62,25 @@ export async function POST(req: Request) { // clears cache for all feed pages (ISR-cached layout) if (payload.type === 'all-feeds') { + revalidateTag('guest-feeds', 'max'); revalidatePath('/[locale]/feeds/[feedDataType]/[feedId]', 'layout'); } // clears cache for all GBFS feed pages (ISR-cached layout) if (payload.type === 'all-gbfs-feeds') { + revalidateTag('feed-type-gbfs', 'max'); revalidatePath('/[locale]/feeds/gbfs/[feedId]', 'layout'); } // clears cache for all GTFS feed pages (ISR-cached layout) if (payload.type === 'all-gtfs-feeds') { + revalidateTag('feed-type-gtfs', 'max'); revalidatePath('/[locale]/feeds/gtfs/[feedId]', 'layout'); } // clears cache for all GTFS RT feed pages (ISR-cached layout) if (payload.type === 'all-gtfs-rt-feeds') { + revalidateTag('feed-type-gtfs_rt', 'max'); revalidatePath('/[locale]/feeds/gtfs_rt/[feedId]', 'layout'); } @@ -83,14 +90,17 @@ export async function POST(req: Request) { const pathsToRevalidate: string[] = []; payload.gtfsFeedIds.forEach((id) => { + revalidateTag(`feed-${id}`, 'max'); pathsToRevalidate.push(`/feeds/gtfs/${id}`); }); payload.gtfsRtFeedIds.forEach((id) => { + revalidateTag(`feed-${id}`, 'max'); pathsToRevalidate.push(`/feeds/gtfs_rt/${id}`); }); payload.gbfsFeedIds.forEach((id) => { + revalidateTag(`feed-${id}`, 'max'); pathsToRevalidate.push(`/feeds/gbfs/${id}`); }); From e64e7841689fd10c64f40cf9071eceba72bde37d Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 09:43:26 -0500 Subject: [PATCH 08/28] feed detail data funcitons --- .../[feedId]/lib/feed-data-shared.ts | 254 +++++++++++++ .../[feedDataType]/[feedId]/lib/feed-data.ts | 67 ++++ .../[feedId]/lib/generate-feed-metadata.ts | 352 ++++++++++++++++++ .../[feedId]/lib/guest-feed-data.ts | 52 +++ 4 files changed, 725 insertions(+) create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts new file mode 100644 index 0000000..d245616 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts @@ -0,0 +1,254 @@ +/** + * Shared feed data fetching implementations. + * Used by both authenticated and guest feed data loaders. + */ + +import 'server-only'; +import { + getFeed, + getGtfsFeed, + getGbfsFeed, + getGtfsRtFeed, + getGtfsFeedDatasets, + getGtfsFeedRoutes, + getGtfsFeedAssociatedGtfsRtFeeds, +} from '../../../../../services/feeds'; +import { + type GTFSFeedType, + type GTFSRTFeedType, + type AllFeedType, +} from '../../../../../services/feeds/utils'; +import type { components } from '../../../../../services/feeds/types'; +import type { GtfsRoute } from '../../../../../types'; + +type DatasetType = components['schemas']['GtfsDataset']; + +export interface FeedDataResult { + feed: AllFeedType; + feedDataType: string; + initialDatasets: DatasetType[]; + relatedFeeds: GTFSFeedType[]; + relatedGtfsRtFeeds: GTFSRTFeedType[]; + totalRoutes?: number; + routeTypes?: string[]; + routes?: GtfsRoute[]; +} + +/** + * Fetch feed by type with provided credentials. + */ +export async function fetchFeedByType( + feedDataType: string, + feedId: string, + accessToken: string, + userContextJwt: string | undefined, +): Promise { + let feed; + if (feedDataType === 'gtfs') { + feed = await getGtfsFeed(feedId, accessToken, userContextJwt); + } else if (feedDataType === 'gtfs_rt') { + feed = await getGtfsRtFeed(feedId, accessToken, userContextJwt); + } else if (feedDataType === 'gbfs') { + feed = await getGbfsFeed(feedId, accessToken, userContextJwt); + } else { + feed = await getFeed(feedId, accessToken, userContextJwt); + } + return feed; +} + +/** + * Fetch datasets for a GTFS feed. + */ +export async function fetchDatasets( + feedId: string, + accessToken: string, + userContextJwt: string | undefined, +): Promise { + try { + const datasets = await getGtfsFeedDatasets( + feedId, + accessToken, + { limit: 10 }, + userContextJwt, + ); + return datasets ?? []; + } catch (e) { + return []; + } +} + +/** + * Fetch related feeds for GTFS-RT feeds. + * Returns both GTFS and GTFS-RT related feeds. + */ +export async function fetchRelatedFeeds( + feedReferences: string[], + accessToken: string, + userContextJwt: string | undefined, +): Promise<{ gtfsFeeds: GTFSFeedType[]; gtfsRtFeeds: GTFSRTFeedType[] }> { + try { + const feedPromises = feedReferences.map( + async (feedId) => + await getFeed(feedId, accessToken, userContextJwt).catch( + () => undefined, + ), + ); + const feeds = await Promise.all(feedPromises); + + const validFeeds = feeds.filter((f) => f !== undefined); + const gtfsFeeds = validFeeds.filter( + (f) => f?.data_type === 'gtfs', + ) as GTFSFeedType[]; + const gtfsRtFeeds = validFeeds.filter( + (f) => f?.data_type === 'gtfs_rt', + ) as GTFSRTFeedType[]; + + return { gtfsFeeds, gtfsRtFeeds }; + } catch (e) { + return { gtfsFeeds: [], gtfsRtFeeds: [] }; + } +} + +/** + * Fetch routes data for GTFS feeds. + * Returns total routes count, unique route types, and routes array. + */ +export async function fetchRoutesData( + feedId: string, + datasetId: string, +): Promise<{ + totalRoutes?: number; + routeTypes?: string[]; + routes?: GtfsRoute[]; +}> { + try { + const routes = await getGtfsFeedRoutes(feedId, datasetId); + if (routes == null) { + return { + totalRoutes: undefined, + routeTypes: undefined, + routes: undefined, + }; + } + + const totalRoutes = routes.length; + + // Extract unique route types and sort them + const uniqueRouteTypesSet = new Set(); + for (const route of routes) { + const raw = route.routeType; + const routeTypeStr = raw == null ? undefined : String(raw).trim(); + if (routeTypeStr != null) { + uniqueRouteTypesSet.add(routeTypeStr); + } + } + + const routeTypes = Array.from(uniqueRouteTypesSet).sort((a, b) => { + const validNumberA = a.trim() !== '' && Number.isFinite(Number(a)); + const validNumberB = b.trim() !== '' && Number.isFinite(Number(b)); + if (!validNumberA && !validNumberB) return a.localeCompare(b); + if (!validNumberA || !validNumberB) return validNumberA ? -1 : 1; + return Number(a) - Number(b); + }); + + return { totalRoutes, routeTypes, routes }; + } catch (e) { + return { + totalRoutes: undefined, + routeTypes: undefined, + routes: undefined, + }; + } +} + +/** + * Fetch all data needed for a feed page. + * Core implementation used by both authenticated and guest loaders. + */ +export async function fetchCompleteFeedDataImpl( + feedDataType: string, + feedId: string, + accessToken: string, + userContextJwt: string | undefined, +): Promise { + // Fetch core feed data + const feed = await fetchFeedByType( + feedDataType, + feedId, + accessToken, + userContextJwt, + ); + if (feed == null) { + throw new Error(`Feed ${feedId} not found`); + } + + // Fetch datasets and routes in parallel for GTFS feeds + let initialDatasets: DatasetType[] = []; + let totalRoutes: number | undefined; + let routeTypes: string[] | undefined; + let routes: GtfsRoute[] | undefined; + + if (feedDataType === 'gtfs') { + const [datasetsResult, routesResult] = await Promise.all([ + fetchDatasets(feedId, accessToken, userContextJwt), + fetchRoutesData( + feedId, + (feed as GTFSFeedType)?.visualization_dataset_id ?? '', + ), + ]); + initialDatasets = datasetsResult; + totalRoutes = routesResult.totalRoutes; + routeTypes = routesResult.routeTypes; + routes = routesResult.routes; + } + + // Fetch related feeds for GTFS-RT + let gtfsFeedsRelated: GTFSFeedType[] = []; + let gtfsRtFeedsRelated: GTFSRTFeedType[] = []; + + if (feed.data_type === 'gtfs_rt') { + const gtfsRtFeed = feed as GTFSRTFeedType; + const { gtfsFeeds, gtfsRtFeeds } = await fetchRelatedFeeds( + gtfsRtFeed?.feed_references ?? [], + accessToken, + userContextJwt, + ); + + const associatedGtfsRtFeedsArrays = await Promise.all( + gtfsFeeds.map( + async (gtfsFeed) => + await getGtfsFeedAssociatedGtfsRtFeeds( + gtfsFeed?.id ?? '', + accessToken, + userContextJwt, + ), + ), + ); + + gtfsFeedsRelated = gtfsFeeds; + + // Deduplicate GTFS-RT feeds + const allGtfsRtFeeds = [ + ...gtfsRtFeeds, + ...associatedGtfsRtFeedsArrays.flat(), + ]; + const uniqueGtfsRtFeedsMap = new Map(); + allGtfsRtFeeds.forEach((feedItem) => { + if (feedItem?.id != null) { + uniqueGtfsRtFeedsMap.set(feedItem.id, feedItem); + } + }); + gtfsRtFeedsRelated = Array.from(uniqueGtfsRtFeedsMap.values()); + } + + return { + feed, + feedDataType, + initialDatasets, + relatedFeeds: gtfsFeedsRelated, + relatedGtfsRtFeeds: gtfsRtFeedsRelated, + totalRoutes, + routeTypes, + routes, + }; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts new file mode 100644 index 0000000..1b89fb3 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts @@ -0,0 +1,67 @@ +/** + * Authenticated data fetching functions for feed pages. + * These functions use React's cache() to deduplicate requests across components. + * For per-user server-side caching, use unstable_cache with user ID in cache key. + */ + +import { cache } from 'react'; +import { unstable_cache } from 'next/cache'; +import { + getSSRAccessToken, + getUserContextJwtFromCookie, + getCurrentUserFromCookie, +} from '../../../../../utils/auth-server'; +import { + fetchCompleteFeedDataImpl, + type FeedDataResult, +} from './feed-data-shared'; + +export type FeedData = FeedDataResult; + +/** + * Fetch all data needed for a feed page. + * + * Caching strategy: + * - React cache(): Deduplicates within a single request (layout + page) + * - unstable_cache with user ID: Server-side cache per user across navigations + * + * Each user gets their own cached version that persists across page navigations + * (e.g., /feeds/gtfs/mdb-123 → /feeds/gtfs/mdb-123/map) + * + * Revalidation is short due to the per-user-per-feed cache, but can be adjusted based on needs. + */ +export const fetchCompleteFeedData = cache( + async ( + feedDataType: string, + feedId: string, + ): Promise => { + const [accessToken, userContextJwt, user] = await Promise.all([ + getSSRAccessToken(), + getUserContextJwtFromCookie(), + getCurrentUserFromCookie(), + ]); + const userId = user?.uid ?? 'anonymous'; + + const cachedFetch = unstable_cache( + async () => { + return await fetchCompleteFeedDataImpl( + feedDataType, + feedId, + accessToken, + userContextJwt, + ); + }, + [`feed-complete-${feedDataType}-${feedId}-${userId}`], // unique cache key per user + { + tags: [`feed-${feedId}`, `user-${userId}`, `feed-type-${feedDataType}`], + revalidate: 600, // 10 minutes - to revisit based on user usage / cache storage availability + }, + ); + + try { + return await cachedFetch(); + } catch (e) { + return undefined; + } + }, +); diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts new file mode 100644 index 0000000..bfa2735 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts @@ -0,0 +1,352 @@ +import type { Metadata } from 'next'; +import type { + AllFeedType, + GBFSFeedType, + GTFSFeedType, + GTFSRTFeedType, +} from '../../../../../services/feeds/utils'; +import { + formatProvidersSorted, + generatePageTitle, + generateDescriptionMetaTag, +} from '../../../../../screens/Feed/Feed.functions'; + +/** + * Structured data is purely for SEO purposes. + * It helps search engines understand the content of the page better. + * It is not used in the application logic. + * It is not displayed to the user. + */ + +type StructureDataInterface = Record; + +function getBasicStructuredData( + feed: AllFeedType, + description: string, +): StructureDataInterface { + const dataTypeNaming = + feed?.data_type === 'gtfs_rt' ? 'GTFS Realtime' : feed?.data_type; + + const structuredData: StructureDataInterface = { + '@context': 'https://schema.org', + '@type': 'Dataset', + name: `${dataTypeNaming ?? ''} Feed for ${feed?.provider}`, + description, + url: `https://mobilitydatabase.org/feeds/${feed?.data_type}/${feed?.id}`, + license: feed?.source_info?.license_url, + creator: { + '@type': 'Organization', + name: feed?.provider, + }, + provider: { + '@type': 'Organization', + name: 'MobilityData', + url: 'https://mobilitydata.org/', + }, + }; + return structuredData; +} + +function generateLocationStructuredData( + feed: GTFSFeedType | GBFSFeedType, + bb: { + minimum_latitude?: number; + minimum_longitude?: number; + maximum_latitude?: number; + maximum_longitude?: number; + }, +): StructureDataInterface { + const municipalities = + feed?.locations + ?.map((location) => location.municipality) + .filter((municipality) => municipality !== undefined) ?? []; + const name = + municipalities.length > 0 + ? `${municipalities.slice(0, 3).join(', ')}${ + municipalities.length > 3 ? ', and others' : '' + }` + : 'Transit coverage area'; + return { + '@type': 'Place', + name, + geo: { + '@type': 'GeoShape', + box: `${bb.minimum_latitude} ${bb.minimum_longitude} ${bb.maximum_latitude} ${bb.maximum_longitude}`, + }, + }; +} + +function getGtfsStructuredData( + feed: GTFSFeedType, + description: string, +): StructureDataInterface { + const structuredGtfsData: StructureDataInterface = { + ...getBasicStructuredData(feed, description), + identifier: feed?.id, + keywords: [ + 'gtfs', + 'data', + 'public transit', + 'schedule data', + 'transportation', + ], + distribution: { + '@type': 'DataDownload', + encodingFormat: 'application/zip', + contentUrl: feed?.latest_dataset?.hosted_url, + }, + + dateModified: feed?.latest_dataset?.downloaded_at ?? feed?.created_at, + }; + + if (feed?.latest_dataset?.hosted_url != null) { + structuredGtfsData.distribution = { + '@type': 'DataDownload', + encodingFormat: 'application/zip', + contentUrl: feed?.latest_dataset?.hosted_url, + }; + } + + if (feed?.latest_dataset?.bounding_box != null) { + structuredGtfsData.spatialCoverage = generateLocationStructuredData( + feed, + feed?.latest_dataset?.bounding_box, + ); + } + + if (feed?.latest_dataset?.validation_report?.features != null) { + structuredGtfsData.variableMeasured = + feed.latest_dataset.validation_report.features.map((feature) => ({ + '@type': 'PropertyValue', + name: feature, + })); + } + + return structuredGtfsData; +} + +function getGbfsStructuredData( + feed: GBFSFeedType, + description: string, +): StructureDataInterface { + const structuredGbfsData: StructureDataInterface = { + ...getBasicStructuredData(feed, description), + identifier: feed?.system_id, + keywords: [ + 'GBFS', + 'shared mobility', + 'micromobility', + 'bike share', + 'scooter share', + 'real-time data', + ], + creator: { + '@type': 'Organization', + name: feed?.provider, + url: feed?.provider_url, + }, + spatialCoverage: feed?.locations?.map((location) => ({ + '@type': 'Place', + name: 'Location for ' + feed?.provider, + address: { + '@type': 'PostalAddress', + addressLocality: location.municipality, + addressRegion: location.subdivision_name, + addressCountry: location.country_code, + }, + })), + dateModified: feed?.versions?.[0]?.created_at ?? feed?.created_at, + }; + + if (feed?.versions != null && feed?.versions.length > 0) { + structuredGbfsData.hasPart = feed.versions.map((version) => ({ + '@type': 'DataFeed', + name: + `GBFS ${version.version} Feed` + + (version.source === 'autodiscovery' ? ' - Autodiscovery Url' : ''), + url: version.endpoints?.find((endpoint) => endpoint.name === 'gbfs')?.url, + encodingFormat: 'application/json', + })); + } + + return structuredGbfsData; +} + +function getGtfsRtStructuredData( + feed: GTFSRTFeedType, + description: string, + relatedFeeds?: AllFeedType[], + relatedGtfsFeeds?: GTFSRTFeedType[], +): StructureDataInterface { + const associatedGtfsFeed: GTFSFeedType = relatedFeeds?.find( + (relatedFeed) => relatedFeed?.data_type === 'gtfs', + ); + + const structuredGtfsRtData: StructureDataInterface = { + ...getBasicStructuredData(feed, description), + identifier: feed?.id, + keywords: [ + 'GTFS Realtime', + 'public transit', + 'real-time data', + 'trip updates', + 'vehicle positions', + 'service alerts', + ], + distribution: { + '@type': 'DataDownload', + encodingFormat: 'application/x-protobuf', + contentUrl: feed?.source_info?.producer_url, + }, + dateModified: feed?.created_at, + hasPart: [], + }; + + if (associatedGtfsFeed?.latest_dataset?.bounding_box != null) { + structuredGtfsRtData.spatialCoverage = generateLocationStructuredData( + feed, + associatedGtfsFeed?.latest_dataset?.bounding_box, + ); + } + + if (associatedGtfsFeed != null) { + (structuredGtfsRtData.hasPart as unknown[]).push({ + '@type': 'Dataset', + name: `GTFS Static Feed for ${feed?.provider}`, + url: `https://mobilitydatabase.org/feeds/gtfs/${associatedGtfsFeed.id}`, + distribution: { + '@type': 'DataDownload', + encodingFormat: 'application/zip', + contentUrl: associatedGtfsFeed.source_info?.producer_url, + }, + }); + } + if (relatedGtfsFeeds != null && relatedGtfsFeeds.length > 0) { + relatedGtfsFeeds.forEach((relatedFeed) => { + let name = `GTFS Realtime Feed for ${relatedFeed?.provider}`; + + if (relatedFeed?.entity_types != null) { + if (relatedFeed.entity_types.includes('sa')) { + name = `GTFS Realtime Service Alerts for ${relatedFeed?.provider}`; + } else if (relatedFeed.entity_types.includes('tu')) { + name = `GTFS Realtime Trip Updates for ${relatedFeed?.provider}`; + } else if (relatedFeed.entity_types.includes('vp')) { + name = `GTFS Realtime Vehicle Positions for ${relatedFeed?.provider}`; + } + } + + (structuredGtfsRtData.hasPart as unknown[]).push({ + '@type': 'Dataset', + name, + url: `https://mobilitydatabase.org/feeds/gtfs_rt/${relatedFeed?.id}`, + distribution: { + '@type': 'DataDownload', + encodingFormat: 'application/x-protobuf', + contentUrl: relatedFeed?.source_info?.producer_url, + }, + }); + }); + } + + return structuredGtfsRtData; +} + +export default function generateFeedStructuredData( + feed: AllFeedType, + description: string, + // For gtfs rt + relatedFeeds?: AllFeedType[], + relatedGtfsFeeds?: GTFSFeedType[], +): StructureDataInterface | undefined { + let structuredData: StructureDataInterface | undefined; + if (feed?.data_type === 'gtfs') { + structuredData = getGtfsStructuredData(feed as GTFSFeedType, description); + } else if (feed?.data_type === 'gbfs') { + structuredData = getGbfsStructuredData(feed as GBFSFeedType, description); + } else if (feed?.data_type === 'gtfs_rt') { + structuredData = getGtfsRtStructuredData( + feed as GTFSRTFeedType, + description, + relatedFeeds, + relatedGtfsFeeds, + ); + } + + return structuredData; +} + +interface GenerateFeedMetadataParams { + feed: AllFeedType | undefined; + t: (key: string) => string; + gtfsFeeds?: GTFSFeedType[]; + gtfsRtFeeds?: GTFSRTFeedType[]; +} + +/** + * Shared metadata generation logic for feed pages (authed and static). + * + * @param feed - The feed data\ + * @param t - Translation function + * @param gtfsFeeds - Related GTFS feeds (for GTFS-RT) + * @param gtfsRtFeeds - Related GTFS-RT feeds + */ +export function generateFeedMetadata({ + feed, + t, + gtfsFeeds = [], + gtfsRtFeeds = [], +}: GenerateFeedMetadataParams): Metadata { + if (feed == null) { + return { + title: 'Feed Not Found | Mobility Database', + }; + } + const feedDataType = feed.data_type; + const feedId = feed.id; + const sortedProviders = formatProvidersSorted(feed?.provider ?? ''); + const title = generatePageTitle( + sortedProviders, + feedDataType as 'gtfs' | 'gtfs_rt' | 'gbfs', + (feed as { feed_name?: string })?.feed_name, + ); + const description = generateDescriptionMetaTag( + t, + sortedProviders, + feedDataType as 'gtfs' | 'gtfs_rt' | 'gbfs', + (feed as { feed_name?: string })?.feed_name, + ); + + // Generate structured data for SEO + const structuredData = generateFeedStructuredData( + feed, + description, + gtfsFeeds, + gtfsRtFeeds, + ); + + return { + title, + description, + openGraph: { + title, + description, + url: `https://mobilitydatabase.org/feeds/${feedDataType}/${feedId}`, + siteName: 'Mobility Database', + type: 'website', + }, + twitter: { + card: 'summary', + title, + description, + }, + alternates: { + canonical: `/feeds/${feedDataType}/${feedId}`, + }, + other: { + // Structured data for JSON-LD + ...(structuredData != null && { + 'script:ld+json': JSON.stringify(structuredData), + }), + }, + }; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts new file mode 100644 index 0000000..2bba828 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts @@ -0,0 +1,52 @@ +/** + * Guest (ISR-cacheable) data fetching functions for feed pages. + * + * CRITICAL: These functions must NOT call cookies() or headers() directly or + * indirectly. Use getGuestGcipIdToken() which is cache-safe. + * + */ + +import 'server-only'; +import { cache } from 'react'; +import { unstable_cache } from 'next/cache'; +import { getGuestGcipIdToken } from '../../../../../utils/auth-server'; +import { + fetchCompleteFeedDataImpl, + type FeedDataResult, +} from './feed-data-shared'; + +/** + * Fetch complete feed data for ISR pages. + * + * IMPORTANT: If the API call fails, the error is thrown (not swallowed), + * ensuring that ISR does NOT cache an error page. + * + * Caching strategy: + * - React cache(): Deduplicates within a single request (layout + page) + * - unstable_cache: Server-side cache shared across all users and route segments + * (e.g., /feeds/gtfs/mdb-123 and /feeds/gtfs/mdb-123/map share the same data) + * + * @throws Error if feed is not found or API fails + */ +export const fetchGuestFeedData = cache( + async (feedDataType: string, feedId: string): Promise => { + const cachedFetch = unstable_cache( + async () => { + const accessToken = await getGuestGcipIdToken(); + return await fetchCompleteFeedDataImpl( + feedDataType, + feedId, + accessToken, + undefined, // no user context for guest + ); + }, + [`feed-guest-${feedDataType}-${feedId}`], // unique cache key + { + tags: [`feed-${feedId}`, `feed-type-${feedDataType}`, 'guest-feeds'], + revalidate: 1209600, // 14 days - public feed data is relatively stable (revalidate on demand via API when feed is updated) + }, + ); + + return await cachedFetch(); + }, +); From fb5d6b1cc3f40e25f5f5b059542ab1360e9771cf Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 09:43:37 -0500 Subject: [PATCH 09/28] cleanup --- .../[feedDataType]/[feedId]/map/page.tsx | 8 - .../feeds/[feedDataType]/[feedId]/page.tsx | 278 ------------------ 2 files changed, 286 deletions(-) delete mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/map/page.tsx delete mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/page.tsx diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/map/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/map/page.tsx deleted file mode 100644 index 8dbf4d1..0000000 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/map/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import FullMapView from '../../../../../screens/Feed/components/FullMapView'; -import { type ReactElement } from 'react'; - -export default function FullMapViewPage(): ReactElement { - // TODO: room for improvement: pass necessary props instead of fetching inside - // Also think of a way to manage data if gotten from the previous page - return ; -} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/page.tsx deleted file mode 100644 index 0850dce..0000000 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/page.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { type ReactElement } from 'react'; -import { cache } from 'react'; -import FeedView from '../../../../screens/Feed/FeedView'; -import { - getFeed, - getGtfsFeed, - getGbfsFeed, - getGtfsRtFeed, - getGtfsFeedDatasets, - getGtfsFeedRoutes, - getGtfsFeedAssociatedGtfsRtFeeds, -} from '../../../../services/feeds'; -import { notFound } from 'next/navigation'; -import type { Metadata, ResolvingMetadata } from 'next'; - -import { - type GTFSFeedType, - type GTFSRTFeedType, -} from '../../../../services/feeds/utils'; -import { - formatProvidersSorted, - generatePageTitle, - generateDescriptionMetaTag, -} from '../../../../screens/Feed/Feed.functions'; -import generateFeedStructuredData from '../../../../screens/Feed/StructuredData.functions'; -import { getTranslations } from 'next-intl/server'; -import { - getSSRAccessToken, - getUserContextJwtFromCookie, -} from '../../../../utils/auth-server'; - -interface Props { - params: Promise<{ feedDataType: string; feedId: string }>; -} - -const fetchFeedData = cache( - async (feedDataType: string, feedId: string, accessToken: string) => { - try { - const userContextJwt = await getUserContextJwtFromCookie(); - let feed; - if (feedDataType === 'gtfs') { - feed = await getGtfsFeed(feedId, accessToken, userContextJwt); - } else if (feedDataType === 'gtfs_rt') { - feed = await getGtfsRtFeed(feedId, accessToken, userContextJwt); - } else if (feedDataType === 'gbfs') { - feed = await getGbfsFeed(feedId, accessToken, userContextJwt); - } else { - feed = await getFeed(feedId, accessToken, userContextJwt); - } - return feed; - } catch (e) { - return undefined; - } - }, -); - -const fetchInitialDatasets = cache( - async (feedId: string, accessToken: string) => { - try { - const userContextJwt = await getUserContextJwtFromCookie(); - const datasets = await getGtfsFeedDatasets( - feedId, - accessToken, - { - limit: 10, - }, - userContextJwt, - ); - return datasets; - } catch (e) { - return []; - } - }, -); - -const fetchRelatedFeeds = cache( - async (feedReferences: string[], accessToken: string) => { - try { - const userContextJwt = await getUserContextJwtFromCookie(); - const feedPromises = feedReferences.map( - async (feedId) => - await getFeed(feedId, accessToken, userContextJwt).catch((e) => { - return undefined; - }), - ); - const feeds = await Promise.all(feedPromises); - // Filter out failed fetches and separate by type - const validFeeds = feeds.filter((f) => f !== undefined); - const gtfsFeeds = validFeeds.filter((f) => f?.data_type === 'gtfs'); - const gtfsRtFeeds = validFeeds.filter((f) => f?.data_type === 'gtfs_rt'); - return { gtfsFeeds, gtfsRtFeeds }; - } catch (e) { - return { gtfsFeeds: [], gtfsRtFeeds: [] }; - } - }, -); - -// TODO: extract this logic -const fetchRoutesData = cache(async (feedId: string, datasetId: string) => { - try { - const routes = await getGtfsFeedRoutes(feedId, datasetId); - if (routes == null) { - return { totalRoutes: undefined, routeTypes: undefined }; - } - const totalRoutes = routes.length; - // Extract unique route types and sort them - const uniqueRouteTypesSet = new Set(); - for (const route of routes) { - const raw = route.routeType; - const routeTypeStr = raw == null ? undefined : String(raw).trim(); - if (routeTypeStr != null) { - uniqueRouteTypesSet.add(routeTypeStr); - } - } - const routeTypes = Array.from(uniqueRouteTypesSet).sort((a, b) => { - const validNumberA = a.trim() !== '' && Number.isFinite(Number(a)); - const validNumberB = b.trim() !== '' && Number.isFinite(Number(b)); - if (!validNumberA && !validNumberB) return a.localeCompare(b); - if (!validNumberA || !validNumberB) return validNumberA ? -1 : 1; - return Number(a) - Number(b); - }); - return { totalRoutes, routeTypes }; - } catch (e) { - return { totalRoutes: undefined, routeTypes: undefined }; - } -}); - -export async function generateMetadata( - { params }: Props, - parent: ResolvingMetadata, -): Promise { - const { feedId, feedDataType } = await params; - const accessToken = await getSSRAccessToken(); - const t = await getTranslations(); - - const feed = await fetchFeedData(feedDataType, feedId, accessToken); - - if (feed == null) { - return { - title: 'Feed Not Found | Mobility Database', - }; - } - - // Fetch related feeds for GTFS-RT to generate complete structured data - const { gtfsFeeds, gtfsRtFeeds } = - feedDataType === 'gtfs_rt' && - (feed as GTFSRTFeedType)?.feed_references != null - ? await fetchRelatedFeeds( - (feed as GTFSRTFeedType)?.feed_references ?? [], - accessToken, - ) - : { gtfsFeeds: [], gtfsRtFeeds: [] }; - - const sortedProviders = formatProvidersSorted(feed?.provider ?? ''); - const title = generatePageTitle( - sortedProviders, - feed.data_type as 'gtfs' | 'gtfs_rt' | 'gbfs', - (feed as { feed_name?: string })?.feed_name, - ); - const description = generateDescriptionMetaTag( - t, - sortedProviders, - feed.data_type as 'gtfs' | 'gtfs_rt' | 'gbfs', - (feed as { feed_name?: string })?.feed_name, - ); - - // Generate structured data for SEO - const structuredData = generateFeedStructuredData( - feed, - description, - gtfsFeeds, - gtfsRtFeeds, - ); - - return { - title, - description, - openGraph: { - title, - description, - url: `https://mobilitydatabase.org/feeds/${feedDataType}/${feedId}`, - siteName: 'Mobility Database', - type: 'website', - }, - twitter: { - card: 'summary', - title, - description, - }, - alternates: { - canonical: `/feeds/${feedDataType}/${feedId}`, - }, - other: { - // Structured data for JSON-LD - ...(structuredData != null && { - 'script:ld+json': JSON.stringify(structuredData), - }), - }, - }; -} - -export default async function FeedPage({ - params, -}: Props): Promise { - const { feedId, feedDataType } = await params; - const accessToken = await getSSRAccessToken(); - - const feedPromise = fetchFeedData(feedDataType, feedId, accessToken); - const datasetsPromise = - feedDataType === 'gtfs' - ? fetchInitialDatasets(feedId, accessToken) - : Promise.resolve([]); - - const [feed, initialDatasets] = await Promise.all([ - feedPromise, - datasetsPromise, - ]); - - if (feed == null) { - notFound(); - } - - let gtfsFeedsRelated: GTFSFeedType[] = []; - let gtfsRtFeedsRelated: GTFSRTFeedType[] = []; - if (feed.data_type === 'gtfs_rt') { - const gtfsRtFeed: GTFSRTFeedType = feed; - // TODO: optimize to avoid double fetching. Need a new endpoint - const { gtfsFeeds, gtfsRtFeeds } = await fetchRelatedFeeds( - gtfsRtFeed?.feed_references ?? [], - accessToken, - ); - - const userContextJwt = await getUserContextJwtFromCookie(); - const associatedGtfsRtFeedsArrays = await Promise.all( - gtfsFeeds.map( - async (gtfsFeed) => - await getGtfsFeedAssociatedGtfsRtFeeds( - gtfsFeed?.id ?? '', - accessToken, - userContextJwt, - ), - ), - ); - gtfsFeedsRelated = gtfsFeeds; - const allGtfsRtFeeds = [ - ...gtfsRtFeeds, - ...associatedGtfsRtFeedsArrays.flat(), - ]; - const uniqueGtfsRtFeedsMap = new Map(); - allGtfsRtFeeds.forEach((feedItem) => { - if (feedItem?.id != null) { - uniqueGtfsRtFeedsMap.set(feedItem.id, feedItem); - } - }); - gtfsRtFeedsRelated = Array.from(uniqueGtfsRtFeedsMap.values()); - } - - // Fetch routes data for GTFS feeds - const { totalRoutes, routeTypes } = - feedDataType === 'gtfs' - ? await fetchRoutesData( - feedId, - (feed as GTFSFeedType)?.visualization_dataset_id ?? '', - ) - : { totalRoutes: undefined, routeTypes: undefined }; - - return ( - - ); -} From 6d26245f8dcc9c00cb630ddb1a8c6b1112673e14 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 09:47:57 -0500 Subject: [PATCH 10/28] static non auth feed detail page --- .../[feedDataType]/[feedId]/static/layout.tsx | 48 ++++++++++++++ .../[feedId]/static/map/page.tsx | 31 +++++++++ .../[feedDataType]/[feedId]/static/page.tsx | 66 +++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/static/map/page.tsx create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/static/page.tsx diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx new file mode 100644 index 0000000..8850599 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/layout.tsx @@ -0,0 +1,48 @@ +import { type ReactNode } from 'react'; +import { notFound } from 'next/navigation'; +import { fetchGuestFeedData } from '../lib/guest-feed-data'; + +/** + * ISR caching: revalidate cached HTML every 14 days. + * This enables static generation for feed detail pages without generateStaticParams. + * The revalidation logic will happen through the revalidation API when feed data is updated (triggered from GCP). + */ +export const dynamic = 'force-static'; +export const revalidate = 1209600; // 14 days in seconds + +interface Props { + children: ReactNode; + params: Promise<{ locale: string; feedDataType: string; feedId: string }>; +} + +/** + * Shared layout for GUEST (ISR-cacheable) feed pages. + * + * IMPORTANT: This layout must NOT call cookies() or headers() to remain + * ISR-compatible. Uses fetchGuestFeedData which uses a cache-friendly token. + * + * SECURITY: Direct access to /static/ routes is blocked at the proxy level + * (middleware returns 404). This route is only accessible via proxy rewrite + * from the clean URLs like /feeds/gtfs/mdb-123. + * NOTE: In the future we will use private route `_static` but due to our legacy handler `[...slug]` catching all routes, we need to use a public route and block access at the proxy for now. + * + */ +export default async function StaticFeedLayout({ + children, + params, +}: Props): Promise { + const { feedId, feedDataType } = await params; + + let feedData; + try { + feedData = await fetchGuestFeedData(feedDataType, feedId); + } catch { + notFound(); + } + + if (feedData == null) { + notFound(); + } + + return <>{children}; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/map/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/map/page.tsx new file mode 100644 index 0000000..ec3c8ca --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/map/page.tsx @@ -0,0 +1,31 @@ +import FullMapView from '../../../../../../screens/Feed/components/FullMapView'; +import { type ReactElement } from 'react'; +import { fetchGuestFeedData } from '../../lib/guest-feed-data'; +import { type FeedDataResult } from '../../lib/feed-data-shared'; + +interface Props { + params: Promise<{ locale: string; feedDataType: string; feedId: string }>; +} + +/** + * Full map view page for feed visualization (GUEST/ISR-cacheable version). + * + * IMPORTANT: This page does NOT call cookies() or headers() to remain + * ISR-compatible. User session is not available in guest route. + * + */ +export default async function StaticFullMapViewPage({ + params, +}: Props): Promise { + const { feedId, feedDataType } = await params; + + let feedData: FeedDataResult; + try { + feedData = await fetchGuestFeedData(feedDataType, feedId); + } catch (e) { + console.error(`[StaticFullMapViewPage] Failed to fetch feed ${feedId}:`, e); + return
Feed not found
; + } + + return ; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/page.tsx new file mode 100644 index 0000000..b016f3f --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/page.tsx @@ -0,0 +1,66 @@ +import { type ReactElement } from 'react'; +import FeedView from '../../../../../screens/Feed/FeedView'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { getTranslations } from 'next-intl/server'; +import { fetchGuestFeedData } from '../lib/guest-feed-data'; +import { generateFeedMetadata } from '../lib/generate-feed-metadata'; +import { type FeedDataResult } from '../lib/feed-data-shared'; + +interface Props { + params: Promise<{ feedDataType: string; feedId: string }>; +} + +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata, +): Promise { + const { feedId, feedDataType } = await params; + const t = await getTranslations(); + + // Use guest (ISR-safe) fetcher - same cache as page component + const feedData = await fetchGuestFeedData(feedDataType, feedId); + + return generateFeedMetadata({ + feed: feedData.feed, + t, + gtfsFeeds: feedData.relatedFeeds, + gtfsRtFeeds: feedData.relatedGtfsRtFeeds, + }); +} + +export default async function StaticFeedPage({ + params, +}: Props): Promise { + const { feedId, feedDataType } = await params; + // Use guest (ISR-safe) fetcher - cached by feedId + feedDataType only + // If API fails, error is thrown (not cached as error page for 24h) + let feedData: FeedDataResult; + try { + feedData = await fetchGuestFeedData(feedDataType, feedId); + } catch (e) { + // Layout should have caught non-existent feeds, but handle edge case + console.error(`[StaticFeedPage] Failed to fetch feed ${feedId}:`, e); + return
Feed not found
; + } + + const { + feed, + initialDatasets, + relatedFeeds, + relatedGtfsRtFeeds, + totalRoutes, + routeTypes, + } = feedData; + + return ( + + ); +} From 4c2419a0f0980d52cc284fb4108f2d339b848a30 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 11:30:46 -0500 Subject: [PATCH 11/28] proxy setup for routing auth and non auth pages --- src/app/utils/proxy-helpers.ts | 127 +++++++++++++++++++++++++++++++++ src/proxy.ts | 96 ++++++++++++++++++++----- 2 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 src/app/utils/proxy-helpers.ts diff --git a/src/app/utils/proxy-helpers.ts b/src/app/utils/proxy-helpers.ts new file mode 100644 index 0000000..be5f38a --- /dev/null +++ b/src/app/utils/proxy-helpers.ts @@ -0,0 +1,127 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { routing } from '../../i18n/routing'; +import { verifySessionToken } from './session-jwt'; + +/** + * Default locale for the application. + */ +export const DEFAULT_LOCALE = routing.defaultLocale; + +/** + * Headers used to mark requests that were rewritten by the proxy. + * The respective layouts check for these headers to prevent direct access. + */ +export const AUTHED_PROXY_HEADER = 'x-mdb-authed-proxy'; +export const STATIC_PROXY_HEADER = 'x-mdb-static-proxy'; + +// ============================================================================ +// Feed Detail Page Detection +// ============================================================================ + +export interface FeedDetailPageInfo { + match: boolean; + locale?: string; + feedDataType?: string; + feedId?: string; + subPath?: string; +} + +/** + * Check if the request is for a feed detail page. + * Match: /feeds/gtfs/mdb-123, /feeds/gtfs_rt/mdb-456/map, etc. + * Also matches with locale prefix: /en/feeds/gtfs/mdb-123 + */ +export function isFeedDetailPage(pathname: string): FeedDetailPageInfo { + const feedDetailRegex = + /^\/(en|fr)?\/?(feeds)\/(gtfs|gtfs_rt|gbfs)\/([^/?]+)(\/[^?]*)?$/; + const match = pathname.match(feedDetailRegex); + if (match == null) { + return { match: false }; + } + return { + match: true, + locale: match[1], + feedDataType: match[3], + feedId: match[4], + subPath: match[5] ?? '', + }; +} + +// ============================================================================ +// Auth State Detection +// ============================================================================ + +/** + * Check if the request is from an authenticated user (not a guest). + */ +export function isAuthenticatedNotGuest(request: NextRequest): boolean { + const sessionCookie = request.cookies.get('md_session'); + const userData = verifySessionToken(sessionCookie?.value ?? ''); + const isAuthenticated = userData != null ? !userData.isGuest : false; + + return isAuthenticated; +} + +// ============================================================================ +// Request Rewriting +// ============================================================================ + +/** + * Create a new Headers object with a custom header set to '1'. + */ +function createRequestWithHeader( + request: NextRequest, + headerName: string, +): Headers { + const headers = new Headers(request.headers); + headers.set(headerName, '1'); + return headers; +} + +export interface RewriteFeedRequestParams { + locale: string; + feedDataType: string; + feedId: string; + subPath: string; + routeType: 'authed' | 'static'; +} + +/** + * Rewrite a feed detail request to either /authed or /static route based on auth status. + */ +export function rewriteFeedRequest( + request: NextRequest, + { locale, feedDataType, feedId, subPath, routeType }: RewriteFeedRequestParams, +): NextResponse { + const url = request.nextUrl.clone(); + const headerName = routeType === 'authed' ? AUTHED_PROXY_HEADER : STATIC_PROXY_HEADER; + url.pathname = `/${locale}/feeds/${feedDataType}/${feedId}/${routeType}${subPath}`; + + const headers = createRequestWithHeader(request, headerName); + + return NextResponse.rewrite(url, { + request: { headers }, + }); +} + +// ============================================================================ +// Locale Routing +// ============================================================================ + +/** + * Check if the pathname already contains a supported locale. + */ +export function hasLocaleInPathname(pathname: string): boolean { + return routing.locales.some( + (locale: string) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`, + ); +} + +/** + * Internally rewrite a URL to include the default locale. + * Browser URL remains unchanged, but server routes with the locale segment. + */ +export function rewriteWithDefaultLocale(url: URL): NextResponse { + url.pathname = `/${routing.defaultLocale}${url.pathname}`; + return NextResponse.rewrite(url); +} diff --git a/src/proxy.ts b/src/proxy.ts index 91eadbf..1126c71 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,5 +1,14 @@ import { NextResponse, type NextRequest } from 'next/server'; -import { routing } from './i18n/routing'; +import { + isFeedDetailPage, + isAuthenticatedNotGuest, + rewriteFeedRequest, + hasLocaleInPathname, + rewriteWithDefaultLocale, + AUTHED_PROXY_HEADER, + STATIC_PROXY_HEADER, + DEFAULT_LOCALE, +} from './app/utils/proxy-helpers'; /** * IMPORTANT: The logic of this proxy will be tested once the [...slug] route is removed @@ -7,32 +16,87 @@ import { routing } from './i18n/routing'; */ /** - * Internationalization proxy following the Next.js i18n guide. + * Internationalization and auth-routing proxy following the Next.js i18n guide. * @see https://nextjs.org/docs/app/guides/internationalization * - * Behavior: - * - If a supported locale already exists in the pathname, continue without redirect - * - If no locale in pathname, internally rewrite to default locale path + * Routing behavior (in order of precedence): + * 1. SECURITY: Direct access to /authed/ routes without proxy header → layout calls notFound() + * 2. Direct access to /static/ routes without proxy header → returns 404 + * 3. Feed detail pages with auth session → rewrite to /authed route (dynamic, non-cached) + * 4. Feed detail pages without auth → rewrite to /static route (dynamic, ISR-cacheable) + * 5. If supported locale exists in pathname → pass through unchanged + * 6. Otherwise → internally rewrite to include default locale + * + * See src/app/utils/proxy-helpers.ts for routing helper functions. */ export default function proxy(request: NextRequest): NextResponse { const { pathname } = request.nextUrl; - // Check if any supported locale already exists in the pathname - const pathnameHasLocale = routing.locales.some( - (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`, - ); + // === Protected route checks === + + // Allow /authed/ routes through - layout will validate proxy header and call notFound() if invalid + if (pathname.includes('/authed')) { + if (request.headers.get(AUTHED_PROXY_HEADER) !== '1') { + console.log( + 'Direct access to /authed/ route (layout will handle):', + pathname, + ); + } + return NextResponse.next(); + } + + // Block direct access to /static/ routes (static layout can't check headers due to ISR constraints) + if (pathname.includes('/static')) { + if (request.headers.get(STATIC_PROXY_HEADER) !== '1') { + console.log('Blocked direct access to /static/ route:', pathname); + return new NextResponse(null, { status: 404 }); + } + return NextResponse.next(); + } + + // === Feed detail page auth routing === + + const feedDetailPageInfo = isFeedDetailPage(pathname); + if ( + feedDetailPageInfo.match && + feedDetailPageInfo.feedDataType && + feedDetailPageInfo.feedId + ) { + const isAuthenticated = isAuthenticatedNotGuest(request); + const locale = feedDetailPageInfo.locale ?? DEFAULT_LOCALE; + const { feedDataType, feedId, subPath = '' } = feedDetailPageInfo; + + if (isAuthenticated) { + // Authenticated: render dynamically with fresh data + return rewriteFeedRequest(request, { + locale, + feedDataType, + feedId, + subPath, + routeType: 'authed', + }); + } + + // Guest: rewrite to static route for ISR caching + return rewriteFeedRequest(request, { + locale, + feedDataType, + feedId, + subPath, + routeType: 'static', + }); + } + + // === Locale routing === - // If locale exists in path, let it through - if (pathnameHasLocale) { + // If locale already in path, continue as-is + if (hasLocaleInPathname(pathname)) { return NextResponse.next(); } - // No locale in pathname - rewrite to include default locale internally - // This allows the [locale] segment to receive the default locale - // without changing the URL the user sees + // No locale: internally rewrite to include default locale (URL stays unchanged for user) const url = request.nextUrl.clone(); - url.pathname = `/${routing.defaultLocale}${pathname}`; - return NextResponse.rewrite(url); + return rewriteWithDefaultLocale(url); } export const config = { From eb4b5ce5e39b92152acb7869f6fbdf01769b12e4 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 11:30:59 -0500 Subject: [PATCH 12/28] proxy tests --- src/app/utils/proxy-helpers.spec.ts | 290 ++++++++++++++++++++++++++++ src/setupTests.ts | 10 + 2 files changed, 300 insertions(+) create mode 100644 src/app/utils/proxy-helpers.spec.ts diff --git a/src/app/utils/proxy-helpers.spec.ts b/src/app/utils/proxy-helpers.spec.ts new file mode 100644 index 0000000..c677906 --- /dev/null +++ b/src/app/utils/proxy-helpers.spec.ts @@ -0,0 +1,290 @@ +import type { NextRequest, NextResponse as NextResponseType } from 'next/server'; +import { + isFeedDetailPage, + isAuthenticatedNotGuest, + rewriteFeedRequest, + hasLocaleInPathname, + rewriteWithDefaultLocale, + DEFAULT_LOCALE, + AUTHED_PROXY_HEADER, + STATIC_PROXY_HEADER, +} from './proxy-helpers'; +import * as sessionJwt from './session-jwt'; + +// Mock the session-jwt module +jest.mock('./session-jwt'); + +// Helper to create mock NextRequest +function createMockNextRequest(overrides: Partial = {}): any { + return { + nextUrl: { + clone: jest.fn(function (this: any) { + const url = new URL('http://localhost'); + url.pathname = '/original'; + return url; + }), + } as any, + headers: new Headers(), + cookies: { + get: jest.fn(), + } as any, + ...overrides, + }; +} + +// Mock routing module +jest.mock('../../i18n/routing', () => ({ + routing: { + defaultLocale: 'en', + locales: ['en', 'fr'], + }, +})); + +// Spy on NextResponse.rewrite +let rewriteSpy: jest.SpyInstance; +let NextResponse: any; + +describe('proxy-helpers', () => { + beforeAll(async () => { + // Dynamically import NextResponse to avoid module loading issues + const nextServer = await import('next/server'); + NextResponse = nextServer.NextResponse; + rewriteSpy = jest.spyOn(NextResponse, 'rewrite'); + }); + + afterEach(() => { + rewriteSpy.mockClear(); + }); + + afterAll(() => { + rewriteSpy.mockRestore(); + }); + + // ============================================================================ + // isFeedDetailPage Tests + // ============================================================================ + + describe('isFeedDetailPage', () => { + it('should match feed detail page without locale prefix', () => { + const result = isFeedDetailPage('/feeds/gtfs/mdb-123'); + expect(result).toEqual({ + match: true, + locale: undefined, + feedDataType: 'gtfs', + feedId: 'mdb-123', + subPath: '', + }); + }); + + it('should match feed detail page with locale prefix', () => { + const result = isFeedDetailPage('/en/feeds/gtfs_rt/test-456'); + expect(result).toEqual({ + match: true, + locale: 'en', + feedDataType: 'gtfs_rt', + feedId: 'test-456', + subPath: '', + }); + }); + + it('should match feed detail page with subpath', () => { + const result = isFeedDetailPage('/feeds/gbfs/mdb-789/map'); + expect(result).toEqual({ + match: true, + locale: undefined, + feedDataType: 'gbfs', + feedId: 'mdb-789', + subPath: '/map', + }); + }); + + it('should match feed detail page with multi-level subpath', () => { + const result = isFeedDetailPage('/fr/feeds/gtfs/mdb-123/details/info'); + expect(result).toEqual({ + match: true, + locale: 'fr', + feedDataType: 'gtfs', + feedId: 'mdb-123', + subPath: '/details/info', + }); + }); + + it('should not match non-feed paths', () => { + const result = isFeedDetailPage('/home'); + expect(result).toEqual({ match: false }); + }); + + it('should not match feed paths with invalid data types', () => { + const result = isFeedDetailPage('/feeds/invalid/mdb-123'); + expect(result).toEqual({ match: false }); + }); + + it('should not match incomplete feed paths', () => { + const result = isFeedDetailPage('/feeds/gtfs'); + expect(result).toEqual({ match: false }); + }); + + it('should handle paths with query strings gracefully', () => { + const result = isFeedDetailPage('/feeds/gtfs/mdb-123?param=value'); + expect(result).toEqual({ match: false }); + }); + }); + + // ============================================================================ + // rewriteFeedRequest Tests + // ============================================================================ + + describe('rewriteFeedRequest', () => { + let mockRequest: any; + + beforeEach(() => { + mockRequest = createMockNextRequest(); + jest.clearAllMocks(); + }); + + it('should construct correct pathname for authed route', () => { + const clonedUrl = new URL('http://localhost'); + (mockRequest.nextUrl.clone as jest.Mock).mockReturnValue(clonedUrl); + + rewriteFeedRequest(mockRequest, { + locale: 'en', + feedDataType: 'gtfs_rt', + feedId: 'mdb-789', + subPath: '/map', + routeType: 'authed', + }); + + expect(clonedUrl.pathname).toBe('/en/feeds/gtfs_rt/mdb-789/authed/map'); + }); + + it('should construct correct pathname for static route', () => { + const clonedUrl = new URL('http://localhost'); + (mockRequest.nextUrl.clone as jest.Mock).mockReturnValue(clonedUrl); + + rewriteFeedRequest(mockRequest, { + locale: 'fr', + feedDataType: 'gbfs', + feedId: 'test-456', + subPath: '', + routeType: 'static', + }); + + expect(clonedUrl.pathname).toBe('/fr/feeds/gbfs/test-456/static'); + }); + + it('should set authed proxy header in rewrite request', () => { + const clonedUrl = new URL('http://localhost'); + (mockRequest.nextUrl.clone as jest.Mock).mockReturnValue(clonedUrl); + + rewriteFeedRequest(mockRequest, { + locale: 'en', + feedDataType: 'gtfs', + feedId: 'mdb-123', + subPath: '', + routeType: 'authed', + }); + + const callArgs = rewriteSpy.mock.calls[0]; + const requestHeaders = callArgs[1].request.headers; + expect(requestHeaders.get(AUTHED_PROXY_HEADER)).toBe('1'); + }); + + it('should set static proxy header in rewrite request', () => { + const clonedUrl = new URL('http://localhost'); + (mockRequest.nextUrl.clone as jest.Mock).mockReturnValue(clonedUrl); + + rewriteFeedRequest(mockRequest, { + locale: 'fr', + feedDataType: 'gbfs', + feedId: 'test-456', + subPath: '', + routeType: 'static', + }); + + const callArgs = rewriteSpy.mock.calls[0]; + const requestHeaders = callArgs[1].request.headers; + expect(requestHeaders.get(STATIC_PROXY_HEADER)).toBe('1'); + }); + }); + + // ============================================================================ + // hasLocaleInPathname Tests + // ============================================================================ + + describe('hasLocaleInPathname', () => { + it('should return true for /en/ prefix', () => { + const result = hasLocaleInPathname('/en/feeds/gtfs/mdb-123'); + expect(result).toBe(true); + }); + + it('should return true for /fr/ prefix', () => { + const result = hasLocaleInPathname('/fr/feeds/gtfs/mdb-123'); + expect(result).toBe(true); + }); + + it('should return true for locale-only path /en', () => { + const result = hasLocaleInPathname('/en'); + expect(result).toBe(true); + }); + + it('should return true for locale-only path /fr', () => { + const result = hasLocaleInPathname('/fr'); + expect(result).toBe(true); + }); + + it('should return false for paths without locale', () => { + const result = hasLocaleInPathname('/feeds/gtfs/mdb-123'); + expect(result).toBe(false); + }); + + it('should return false for unsupported locales', () => { + const result = hasLocaleInPathname('/de/feeds/gtfs/mdb-123'); + expect(result).toBe(false); + }); + + it('should return false for locale-like substrings in path', () => { + const result = hasLocaleInPathname('/feeds/en/mdb-123'); + expect(result).toBe(false); + }); + + it('should return false for empty path', () => { + const result = hasLocaleInPathname('/'); + expect(result).toBe(false); + }); + }); + + // ============================================================================ + // rewriteWithDefaultLocale Tests + // ============================================================================ + + describe('rewriteWithDefaultLocale', () => { + let mockUrl: URL; + + beforeEach(() => { + mockUrl = new URL('http://localhost/feeds/gtfs/mdb-123'); + }); + + it('should prepend default locale to pathname', () => { + rewriteWithDefaultLocale(mockUrl); + expect(mockUrl.pathname).toBe(`/${DEFAULT_LOCALE}/feeds/gtfs/mdb-123`); + }); + + it('should prepend default locale to root path', () => { + mockUrl = new URL('http://localhost/'); + rewriteWithDefaultLocale(mockUrl); + expect(mockUrl.pathname).toBe(`/${DEFAULT_LOCALE}/`); + }); + + it('should handle paths with multiple segments', () => { + mockUrl = new URL('http://localhost/feeds/gbfs/test-456/map/details'); + rewriteWithDefaultLocale(mockUrl); + expect(mockUrl.pathname).toBe(`/${DEFAULT_LOCALE}/feeds/gbfs/test-456/map/details`); + }); + + it('should return NextResponse', () => { + const mockUrl = new URL('http://localhost/feeds/gtfs/mdb-123'); + const result = rewriteWithDefaultLocale(mockUrl); + expect(result).toBeDefined(); + }); + }); +}); diff --git a/src/setupTests.ts b/src/setupTests.ts index b39259d..6046790 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -54,3 +54,13 @@ jest.mock('next-intl/server', () => ({ }), getLocale: jest.fn().mockResolvedValue('en'), })); + +// Mock next/server for middleware tests +jest.mock('next/server', () => ({ + NextResponse: { + next: jest.fn(() => ({})), + rewrite: jest.fn((url, config) => ({})), + redirect: jest.fn((url) => ({})), + }, + NextRequest: jest.fn(), +})); From a8028a29840472973d21d34d1939c1805bfdca30 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 12:37:29 -0500 Subject: [PATCH 13/28] fix tests --- src/setupTests.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/setupTests.ts b/src/setupTests.ts index 6046790..ec83bd6 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -56,11 +56,22 @@ jest.mock('next-intl/server', () => ({ })); // Mock next/server for middleware tests -jest.mock('next/server', () => ({ - NextResponse: { - next: jest.fn(() => ({})), - rewrite: jest.fn((url, config) => ({})), - redirect: jest.fn((url) => ({})), - }, - NextRequest: jest.fn(), -})); +jest.mock('next/server', () => { + const createMockResponse = (data?: any, init?: any) => ({ + body: data, + status: init?.status || 200, + json: jest.fn().mockResolvedValue(data), + ok: (init?.status || 200) < 400, + headers: new Headers(init?.headers), + }); + + return { + NextResponse: { + next: jest.fn(() => createMockResponse()), + rewrite: jest.fn((url, config) => createMockResponse()), + redirect: jest.fn((url) => createMockResponse()), + json: jest.fn((data, init) => createMockResponse(data, init)), + }, + NextRequest: jest.fn(), + }; +}); From f3d78c6f5dadeec1255bb196bb6936d8fc7ff7db Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 12:43:31 -0500 Subject: [PATCH 14/28] authenticated feed detail page --- .../[feedDataType]/[feedId]/authed/layout.tsx | 50 +++++++++++++ .../[feedId]/authed/map/page.tsx | 38 ++++++++++ .../[feedDataType]/[feedId]/authed/page.tsx | 70 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx new file mode 100644 index 0000000..b6381eb --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/layout.tsx @@ -0,0 +1,50 @@ +import { type ReactNode } from 'react'; +import { notFound } from 'next/navigation'; +import { headers } from 'next/headers'; +import { fetchCompleteFeedData } from '../lib/feed-data'; +import { AUTHED_PROXY_HEADER } from '../../../../../utils/proxy-helpers'; + +/** + * Force dynamic rendering for authenticated route. + * This allows cookie() and headers() access. + */ +export const dynamic = 'force-dynamic'; + +interface Props { + children: ReactNode; + params: Promise<{ feedDataType: string; feedId: string }>; +} + +/** + * Shared layout for AUTHENTICATED feed pages. + * + * This route is reached via proxy rewrite when a session cookie exists. + * It uses cookie-based auth to attach user identity to API calls. + * + * SECURITY: This route is protected from direct access by checking for a + * custom header that only the proxy sets. Direct navigation to /authed/... + * will return 404. + * + */ +export default async function AuthedFeedLayout({ + children, + params, +}: Props): Promise { + // Block direct access - only allow requests that came through the proxy + const headersList = await headers(); + if (headersList.get(AUTHED_PROXY_HEADER) !== '1') { + notFound(); + } + + const { feedId, feedDataType } = await params; + + // Fetch complete feed data (cached per-user) + // This will be reused by child pages without additional API calls + const feedData = await fetchCompleteFeedData(feedDataType, feedId); + + if (feedData == null) { + notFound(); + } + + return <>{children}; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx new file mode 100644 index 0000000..77bf173 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx @@ -0,0 +1,38 @@ +import FullMapView from '../../../../../../screens/Feed/components/FullMapView'; +import { type ReactElement } from 'react'; +import { fetchCompleteFeedData } from '../../lib/feed-data'; + +interface Props { + params: Promise<{ feedDataType: string; feedId: string }>; +} + +/** + * Force dynamic rendering for authenticated route. + * This allows cookie() and headers() access. + */ +export const dynamic = 'force-dynamic'; + +/** + * Full map view page for AUTHENTICATED users. + * + * This route is reached via proxy rewrite when a session cookie exists. + * Uses cookie-based auth to: + * - Attach user identity to API calls + * - Provide user session to FullMapView for user-specific features + * + * Pre-fetches feed data server-side (cached per-request via React cache()) + * before rendering. FullMapView uses Redux for client-side state management. + */ +export default async function AuthedFullMapViewPage({ + params, +}: Props): Promise { + const { feedId, feedDataType } = await params; + + const feedData = await fetchCompleteFeedData(feedDataType, feedId); + + if (feedData == null) { + return
Feed not found
; + } + + return ; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx new file mode 100644 index 0000000..63de6c9 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx @@ -0,0 +1,70 @@ +import { type ReactElement } from 'react'; +import FeedView from '../../../../../screens/Feed/FeedView'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { getTranslations } from 'next-intl/server'; +import { fetchCompleteFeedData } from '../lib/feed-data'; +import { generateFeedMetadata } from '../lib/generate-feed-metadata'; + +interface Props { + params: Promise<{ locale: string; feedDataType: string; feedId: string }>; +} + +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata, +): Promise { + const { locale, feedId, feedDataType } = await params; + const t = await getTranslations({ locale }); + + // Use complete feed data fetcher - same cache as page component + const feedData = await fetchCompleteFeedData(feedDataType, feedId); + + return generateFeedMetadata({ + feed: feedData?.feed, + t, + gtfsFeeds: feedData?.relatedFeeds ?? [], + gtfsRtFeeds: feedData?.relatedGtfsRtFeeds ?? [], + }); +} + +/** + * Feed detail page for AUTHENTICATED users. + * + * This route is reached via proxy rewrite when a session cookie exists. + * Uses cookie-based auth to attach user identity to API calls, enabling + * user-specific features and access control. + * + * Data is fetched via React cache() for per-request deduplication. + */ +export default async function AuthedFeedPage({ + params, +}: Props): Promise { + const { feedId, feedDataType } = await params; + + const feedData = await fetchCompleteFeedData(feedDataType, feedId); + + if (feedData == null) { + return
Feed not found
; + } + + const { + feed, + initialDatasets, + relatedFeeds, + relatedGtfsRtFeeds, + totalRoutes, + routeTypes, + } = feedData; + + return ( + + ); +} From 2291a0553dcac1cef39cf2f14fe7a72fe45beabc Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 13:38:12 -0500 Subject: [PATCH 15/28] feed detail page cache sequence diagram --- docs/feed-detail-caching-flow.md | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/feed-detail-caching-flow.md diff --git a/docs/feed-detail-caching-flow.md b/docs/feed-detail-caching-flow.md new file mode 100644 index 0000000..bf3a1d0 --- /dev/null +++ b/docs/feed-detail-caching-flow.md @@ -0,0 +1,68 @@ +```mermaid +sequenceDiagram + autonumber + actor U as User + participant B as Browser + participant P as Next.js Proxy / Middleware + participant EC as Edge CDN (Page Cache) + participant S as Static Feed Pages (anon: /feeds/... and /feeds/.../map) + participant D as Dynamic Feed Pages (authed) + participant FC as Next Fetch Cache (Data Cache) + participant API as External Feed API + participant GCP as GCP Workflow + participant RV as Next.js Revalidate Endpoint + + rect rgb(235,245,255) + note over U,API: Request flow (feed detail page) + + U->>B: Navigate to /feeds/{type}/{id} (or /map) + B->>P: HTTP GET /feeds/{type}/{id}[/{subpath}] + + P->>P: Check cookie "session_md" + alt Not authenticated (no/invalid session_md) + P->>EC: Lookup cached page response (key: full path) + alt Page Cache HIT (edge) + EC-->>B: Return cached HTML/headers + else Page Cache MISS + EC->>S: Render static page (anon) + note over S,FC: 1) Fetch data (cache to speed /map <-> base nav)\n2) Render page\n3) Cache full page at edge + S->>FC: fetch(feedData, cache key = feedId + public) (revalidate: e.g., 2 week) + alt Data Cache HIT + FC-->>S: Return cached data + else Data Cache MISS + FC->>API: GET feed data (public) + API-->>FC: Feed data + FC-->>S: Cached data stored + end + S-->>EC: Store rendered page (TTL ~ 2 week) + EC-->>B: Return rendered HTML + end + + else Authenticated (valid session_md) + P->>D: Route to dynamic authed page + note over D,FC: Cache only the API call for 10 minutes\n(per-user-per-feed) + D->>FC: fetch(feedData, cache key = userId + feedId) (revalidate: 10 min) + alt Per-user Data Cache HIT (<=10 min) + FC-->>D: Return cached user-scoped data + else Per-user Data Cache MISS + FC->>API: GET feed data (authed token) + API-->>FC: Feed data + FC-->>D: Cached data stored (10 min) + end + D-->>B: Return fresh HTML (no shared edge page cache) + note over D,B: Authed page response should be private (not shared)\nbut data calls are cached per-user-per-feed + end + end + + rect rgb(255,245,235) + note over GCP,RV: External revalidation (invalidate anon caches + data caches) + + GCP->>GCP: Detect feed changes (diff / updated_at) + GCP->>RV: POST /api/revalidate (paths or tags) + secret + RV->>EC: Invalidate edge page cache (anon paths: base + /map) + RV->>FC: Invalidate data cache (public feed data tag/key) + FC-->>RV: OK + EC-->>RV: OK + RV-->>GCP: 200 success + end +``` \ No newline at end of file From 61a11234382da5effdafccfd7fdb9650a7f11f52 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 13:38:30 -0500 Subject: [PATCH 16/28] lint fix --- .../[feedDataType]/[feedId]/lib/feed-data.ts | 2 +- src/app/api/revalidate/route.ts | 36 ++++++++++--------- src/app/screens/Feed/FeedView.tsx | 4 +-- src/app/utils/proxy-helpers.spec.ts | 26 +++++++++----- src/app/utils/proxy-helpers.ts | 16 ++++++--- src/proxy.ts | 6 ++-- src/setupTests.ts | 33 +++++++++++------ 7 files changed, 77 insertions(+), 46 deletions(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts index 1b89fb3..214f40b 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts @@ -27,7 +27,7 @@ export type FeedData = FeedDataResult; * * Each user gets their own cached version that persists across page navigations * (e.g., /feeds/gtfs/mdb-123 → /feeds/gtfs/mdb-123/map) - * + * * Revalidation is short due to the per-user-per-feed cache, but can be adjusted based on needs. */ export const fetchCompleteFeedData = cache( diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index de4e645..2eb1d2b 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -25,22 +25,21 @@ const defaultRevalidateOptions: RevalidateBody = { gbfsFeedIds: [], }; -function json(status: number, body: Record) { - return NextResponse.json(body, { status }); -} - -export async function POST(req: Request) { +export async function POST(req: Request): Promise { const expectedSecret = process.env.REVALIDATE_SECRET; - if (!expectedSecret) { - return json(500, { - ok: false, - error: 'Server misconfigured: REVALIDATE_SECRET missing', - }); + if (expectedSecret == null) { + return NextResponse.json( + { ok: false, error: 'Server misconfigured: REVALIDATE_SECRET missing' }, + { status: 500 }, + ); } const providedSecret = req.headers.get('x-revalidate-secret'); - if (!providedSecret || providedSecret !== expectedSecret) { - return json(401, { ok: false, error: 'Unauthorized' }); + if (providedSecret == null || providedSecret !== expectedSecret) { + return NextResponse.json( + { ok: false, error: 'Unauthorized' }, + { status: 401 }, + ); } let payload: RevalidateBody = { ...defaultRevalidateOptions }; // default to full revalidation if body is missing/invalid @@ -114,15 +113,18 @@ export async function POST(req: Request) { }); } - return json(200, { + return NextResponse.json({ ok: true, message: 'Revalidation triggered successfully', }); } catch (error) { console.error('Revalidation failed:', error); - return json(500, { - ok: false, - error: 'Failed to revalidate', - }); + return NextResponse.json( + { + ok: false, + error: 'Failed to revalidate', + }, + { status: 500 }, + ); } } diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 404e559..a60c466 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -123,9 +123,7 @@ export default async function FeedView({ maxWidth='xl' > {/* TODO: remove this timestamp after confirming ISR is working in production and providing real value to users (e.g. helps with debugging feed updates) */} -
- Generated at: {new Date().toISOString()} -
+
Generated at: {new Date().toISOString()}
= {}): any { +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +function createMockNextRequest( + overrides: Record = {}, +): Record { return { nextUrl: { - clone: jest.fn(function (this: any) { + clone: jest.fn(function (this: Record) { const url = new URL('http://localhost'); url.pathname = '/original'; return url; }), - } as any, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + } as Record, headers: new Headers(), cookies: { get: jest.fn(), - } as any, + } as Record, ...overrides, }; } +/* eslint-enable @typescript-eslint/consistent-type-assertions */ // Mock routing module jest.mock('../../i18n/routing', () => ({ @@ -41,14 +43,17 @@ jest.mock('../../i18n/routing', () => ({ })); // Spy on NextResponse.rewrite +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ let rewriteSpy: jest.SpyInstance; +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ let NextResponse: any; describe('proxy-helpers', () => { beforeAll(async () => { // Dynamically import NextResponse to avoid module loading issues const nextServer = await import('next/server'); - NextResponse = nextServer.NextResponse; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + NextResponse = nextServer.NextResponse as any; rewriteSpy = jest.spyOn(NextResponse, 'rewrite'); }); @@ -135,6 +140,7 @@ describe('proxy-helpers', () => { // ============================================================================ describe('rewriteFeedRequest', () => { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ let mockRequest: any; beforeEach(() => { @@ -278,7 +284,9 @@ describe('proxy-helpers', () => { it('should handle paths with multiple segments', () => { mockUrl = new URL('http://localhost/feeds/gbfs/test-456/map/details'); rewriteWithDefaultLocale(mockUrl); - expect(mockUrl.pathname).toBe(`/${DEFAULT_LOCALE}/feeds/gbfs/test-456/map/details`); + expect(mockUrl.pathname).toBe( + `/${DEFAULT_LOCALE}/feeds/gbfs/test-456/map/details`, + ); }); it('should return NextResponse', () => { diff --git a/src/app/utils/proxy-helpers.ts b/src/app/utils/proxy-helpers.ts index be5f38a..49462ec 100644 --- a/src/app/utils/proxy-helpers.ts +++ b/src/app/utils/proxy-helpers.ts @@ -57,7 +57,7 @@ export function isFeedDetailPage(pathname: string): FeedDetailPageInfo { export function isAuthenticatedNotGuest(request: NextRequest): boolean { const sessionCookie = request.cookies.get('md_session'); const userData = verifySessionToken(sessionCookie?.value ?? ''); - const isAuthenticated = userData != null ? !userData.isGuest : false; + const isAuthenticated = !(userData?.isGuest === true); return isAuthenticated; } @@ -91,10 +91,17 @@ export interface RewriteFeedRequestParams { */ export function rewriteFeedRequest( request: NextRequest, - { locale, feedDataType, feedId, subPath, routeType }: RewriteFeedRequestParams, + { + locale, + feedDataType, + feedId, + subPath, + routeType, + }: RewriteFeedRequestParams, ): NextResponse { const url = request.nextUrl.clone(); - const headerName = routeType === 'authed' ? AUTHED_PROXY_HEADER : STATIC_PROXY_HEADER; + const headerName = + routeType === 'authed' ? AUTHED_PROXY_HEADER : STATIC_PROXY_HEADER; url.pathname = `/${locale}/feeds/${feedDataType}/${feedId}/${routeType}${subPath}`; const headers = createRequestWithHeader(request, headerName); @@ -113,7 +120,8 @@ export function rewriteFeedRequest( */ export function hasLocaleInPathname(pathname: string): boolean { return routing.locales.some( - (locale: string) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`, + (locale: string) => + pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`, ); } diff --git a/src/proxy.ts b/src/proxy.ts index 1126c71..b029e3f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -59,8 +59,10 @@ export default function proxy(request: NextRequest): NextResponse { const feedDetailPageInfo = isFeedDetailPage(pathname); if ( feedDetailPageInfo.match && - feedDetailPageInfo.feedDataType && - feedDetailPageInfo.feedId + feedDetailPageInfo.feedDataType != null && + feedDetailPageInfo.feedDataType !== '' && + feedDetailPageInfo.feedId != null && + feedDetailPageInfo.feedId !== '' ) { const isAuthenticated = isAuthenticatedNotGuest(request); const locale = feedDetailPageInfo.locale ?? DEFAULT_LOCALE; diff --git a/src/setupTests.ts b/src/setupTests.ts index ec83bd6..59e25ba 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -57,20 +57,33 @@ jest.mock('next-intl/server', () => ({ // Mock next/server for middleware tests jest.mock('next/server', () => { - const createMockResponse = (data?: any, init?: any) => ({ - body: data, - status: init?.status || 200, - json: jest.fn().mockResolvedValue(data), - ok: (init?.status || 200) < 400, - headers: new Headers(init?.headers), - }); + const createMockResponse = ( + data?: unknown, + init?: Record, + ): Record => { + const status = typeof init?.status === 'number' ? init.status : 200; + const statusValue = typeof init?.status === 'number' ? init.status : 200; + return { + body: data, + status, + json: jest.fn().mockResolvedValue(data), + ok: statusValue < 400, + headers: new Headers( + typeof init?.headers === 'object' + ? (init.headers as HeadersInit) + : undefined, + ), + }; + }; return { NextResponse: { next: jest.fn(() => createMockResponse()), - rewrite: jest.fn((url, config) => createMockResponse()), - redirect: jest.fn((url) => createMockResponse()), - json: jest.fn((data, init) => createMockResponse(data, init)), + rewrite: jest.fn((url: unknown, config: unknown) => createMockResponse()), + redirect: jest.fn((url: unknown) => createMockResponse()), + json: jest.fn((data: unknown, init: unknown) => + createMockResponse(data, init as Record), + ), }, NextRequest: jest.fn(), }; From 1c65b72f548d5aa1199751d269608db01ed254ab Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 14:16:44 -0500 Subject: [PATCH 17/28] updated comment --- src/app/api/revalidate/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 2eb1d2b..336877a 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -18,7 +18,7 @@ interface RevalidateBody { } const defaultRevalidateOptions: RevalidateBody = { - // By default, revalidate the entire site to ensure consistency across pages + // By default it will revalidate nothing type: 'specific-feeds', gtfsFeedIds: [], gtfsRtFeedIds: [], From 0532a1d26fa2e05d30718882e8592a96674528e9 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 14:46:31 -0500 Subject: [PATCH 18/28] logic fix --- src/app/api/revalidate/route.ts | 4 ++-- src/app/utils/proxy-helpers.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 336877a..6d7465d 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -26,8 +26,8 @@ const defaultRevalidateOptions: RevalidateBody = { }; export async function POST(req: Request): Promise { - const expectedSecret = process.env.REVALIDATE_SECRET; - if (expectedSecret == null) { + const expectedSecret = String(process.env.REVALIDATE_SECRET); + if (expectedSecret === '') { return NextResponse.json( { ok: false, error: 'Server misconfigured: REVALIDATE_SECRET missing' }, { status: 500 }, diff --git a/src/app/utils/proxy-helpers.ts b/src/app/utils/proxy-helpers.ts index 49462ec..afbfcd9 100644 --- a/src/app/utils/proxy-helpers.ts +++ b/src/app/utils/proxy-helpers.ts @@ -57,7 +57,7 @@ export function isFeedDetailPage(pathname: string): FeedDetailPageInfo { export function isAuthenticatedNotGuest(request: NextRequest): boolean { const sessionCookie = request.cookies.get('md_session'); const userData = verifySessionToken(sessionCookie?.value ?? ''); - const isAuthenticated = !(userData?.isGuest === true); + const isAuthenticated = userData?.isGuest === false; return isAuthenticated; } From 87b2baf0c9270905a5dd782cff98bfdf0ff5d09d Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 14:55:14 -0500 Subject: [PATCH 19/28] logic fix --- src/app/api/revalidate/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 6d7465d..39d60a3 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { revalidatePath, revalidateTag } from 'next/cache'; import { AVAILABLE_LOCALES } from '../../../i18n/routing'; +import { nonEmpty } from '../../utils/config'; type RevalidateTypes = | 'full' @@ -26,8 +27,8 @@ const defaultRevalidateOptions: RevalidateBody = { }; export async function POST(req: Request): Promise { - const expectedSecret = String(process.env.REVALIDATE_SECRET); - if (expectedSecret === '') { + const expectedSecret = nonEmpty(process.env.REVALIDATE_SECRET); + if (expectedSecret == null) { return NextResponse.json( { ok: false, error: 'Server misconfigured: REVALIDATE_SECRET missing' }, { status: 500 }, From 6bf4e699196e2d65990291d6aea75dd2ec67966b Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 15:26:46 -0500 Subject: [PATCH 20/28] revalidate route payload validation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/api/revalidate/route.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 39d60a3..82b2aa2 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -89,17 +89,21 @@ export async function POST(req: Request): Promise { const localPaths = AVAILABLE_LOCALES.filter((loc) => loc !== 'en'); const pathsToRevalidate: string[] = []; - payload.gtfsFeedIds.forEach((id) => { + const gtfsFeedIds = Array.isArray(payload.gtfsFeedIds) ? payload.gtfsFeedIds : []; + const gtfsRtFeedIds = Array.isArray(payload.gtfsRtFeedIds) ? payload.gtfsRtFeedIds : []; + const gbfsFeedIds = Array.isArray(payload.gbfsFeedIds) ? payload.gbfsFeedIds : []; + + gtfsFeedIds.forEach((id) => { revalidateTag(`feed-${id}`, 'max'); pathsToRevalidate.push(`/feeds/gtfs/${id}`); }); - payload.gtfsRtFeedIds.forEach((id) => { + gtfsRtFeedIds.forEach((id) => { revalidateTag(`feed-${id}`, 'max'); pathsToRevalidate.push(`/feeds/gtfs_rt/${id}`); }); - payload.gbfsFeedIds.forEach((id) => { + gbfsFeedIds.forEach((id) => { revalidateTag(`feed-${id}`, 'max'); pathsToRevalidate.push(`/feeds/gbfs/${id}`); }); From c3a091d8981c67e1ed1da6f2c31239fbda704376 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Feb 2026 15:46:49 -0500 Subject: [PATCH 21/28] lint and comments --- src/app/api/revalidate/route.ts | 12 +++++++++--- src/proxy.ts | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 82b2aa2..9098294 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -89,9 +89,15 @@ export async function POST(req: Request): Promise { const localPaths = AVAILABLE_LOCALES.filter((loc) => loc !== 'en'); const pathsToRevalidate: string[] = []; - const gtfsFeedIds = Array.isArray(payload.gtfsFeedIds) ? payload.gtfsFeedIds : []; - const gtfsRtFeedIds = Array.isArray(payload.gtfsRtFeedIds) ? payload.gtfsRtFeedIds : []; - const gbfsFeedIds = Array.isArray(payload.gbfsFeedIds) ? payload.gbfsFeedIds : []; + const gtfsFeedIds = Array.isArray(payload.gtfsFeedIds) + ? payload.gtfsFeedIds + : []; + const gtfsRtFeedIds = Array.isArray(payload.gtfsRtFeedIds) + ? payload.gtfsRtFeedIds + : []; + const gbfsFeedIds = Array.isArray(payload.gbfsFeedIds) + ? payload.gbfsFeedIds + : []; gtfsFeedIds.forEach((id) => { revalidateTag(`feed-${id}`, 'max'); diff --git a/src/proxy.ts b/src/proxy.ts index b029e3f..088be3a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -64,6 +64,7 @@ export default function proxy(request: NextRequest): NextResponse { feedDetailPageInfo.feedId != null && feedDetailPageInfo.feedId !== '' ) { + // NOTE: For extra performance gain we could set a cookie 'isGuest' so we don't need to parse and read the session const isAuthenticated = isAuthenticatedNotGuest(request); const locale = feedDetailPageInfo.locale ?? DEFAULT_LOCALE; const { feedDataType, feedId, subPath = '' } = feedDetailPageInfo; From 57325540f44c160c066e754fd682273ae2807a45 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 19 Feb 2026 08:12:50 -0500 Subject: [PATCH 22/28] e2e tidy --- cypress/e2e/feed-isr-caching.cy.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/cypress/e2e/feed-isr-caching.cy.ts b/cypress/e2e/feed-isr-caching.cy.ts index b3d977c..363bea4 100644 --- a/cypress/e2e/feed-isr-caching.cy.ts +++ b/cypress/e2e/feed-isr-caching.cy.ts @@ -1,6 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/triple-slash-reference -/// - /** * Feed ISR Caching e2e tests (unauthenticated users) * From 3750f5d5de1dcbc52ea382f549583f01bd506d9c Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 19 Feb 2026 08:23:32 -0500 Subject: [PATCH 23/28] revalidate full update --- src/app/api/revalidate/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 9098294..9c2574b 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -57,6 +57,7 @@ export async function POST(req: Request): Promise { try { // clears cache for entire site if (payload.type === 'full') { + revalidateTag('guest-feeds', 'max'); revalidatePath('/', 'layout'); } From 56038bc8c534da8cb9bbb6326e36e372e863ffe7 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 23 Feb 2026 13:37:25 -0500 Subject: [PATCH 24/28] more generic feed revalidation type --- src/app/api/revalidate/route.spec.ts | 113 +++++++++------------------ src/app/api/revalidate/route.ts | 34 ++------ 2 files changed, 45 insertions(+), 102 deletions(-) diff --git a/src/app/api/revalidate/route.spec.ts b/src/app/api/revalidate/route.spec.ts index 23f12e0..209cc26 100644 --- a/src/app/api/revalidate/route.spec.ts +++ b/src/app/api/revalidate/route.spec.ts @@ -243,7 +243,7 @@ describe('POST /api/revalidate', () => { ); }); - it('revalidates specific GTFS feeds with localized paths', async () => { + it('revalidates specific feeds with all type paths and localized paths', async () => { const request = new Request('http://localhost:3000/api/revalidate', { method: 'POST', headers: { @@ -252,9 +252,7 @@ describe('POST /api/revalidate', () => { }, body: JSON.stringify({ type: 'specific-feeds', - gtfsFeedIds: ['feed-1', 'feed-2'], - gtfsRtFeedIds: [], - gbfsFeedIds: [], + feedIds: ['feed-1', 'feed-2'], }), }); @@ -268,63 +266,37 @@ describe('POST /api/revalidate', () => { expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-1', 'max'); expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-2', 'max'); - // Each feed should revalidate base path + map path + localized versions + // Each feed revalidates all 3 feed types (gtfs, gtfs_rt, gbfs) × base + map + /fr + /fr/map expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-1'); expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-1/map'); expect(mockRevalidatePath).toHaveBeenCalledWith('/fr/feeds/gtfs/feed-1'); expect(mockRevalidatePath).toHaveBeenCalledWith( '/fr/feeds/gtfs/feed-1/map', ); - - expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-2'); - expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-2/map'); - expect(mockRevalidatePath).toHaveBeenCalledWith('/fr/feeds/gtfs/feed-2'); expect(mockRevalidatePath).toHaveBeenCalledWith( - '/fr/feeds/gtfs/feed-2/map', + '/feeds/gtfs_rt/feed-1', ); - - // Should be called 8 times total (2 feeds × 4 paths each) - expect(mockRevalidatePath).toHaveBeenCalledTimes(8); - }); - - it('revalidates specific GTFS-RT feeds with localized paths', async () => { - const request = new Request('http://localhost:3000/api/revalidate', { - method: 'POST', - headers: { - 'x-revalidate-secret': 'test-secret', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - type: 'specific-feeds', - gtfsFeedIds: [], - gtfsRtFeedIds: ['rt-feed-1'], - gbfsFeedIds: [], - }), - }); - - const response = await POST(request); - const json = await response.json(); - - expect(response.status).toBe(200); - expect(json.ok).toBe(true); - - expect(mockRevalidateTag).toHaveBeenCalledWith('feed-rt-feed-1', 'max'); expect(mockRevalidatePath).toHaveBeenCalledWith( - '/feeds/gtfs_rt/rt-feed-1', + '/feeds/gtfs_rt/feed-1/map', ); expect(mockRevalidatePath).toHaveBeenCalledWith( - '/feeds/gtfs_rt/rt-feed-1/map', + '/fr/feeds/gtfs_rt/feed-1', ); expect(mockRevalidatePath).toHaveBeenCalledWith( - '/fr/feeds/gtfs_rt/rt-feed-1', + '/fr/feeds/gtfs_rt/feed-1/map', ); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/feed-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/feed-1/map'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/fr/feeds/gbfs/feed-1'); expect(mockRevalidatePath).toHaveBeenCalledWith( - '/fr/feeds/gtfs_rt/rt-feed-1/map', + '/fr/feeds/gbfs/feed-1/map', ); - expect(mockRevalidatePath).toHaveBeenCalledTimes(4); + + // 2 feeds × 3 types × 4 paths = 24 total calls + expect(mockRevalidatePath).toHaveBeenCalledTimes(24); }); - it('revalidates specific GBFS feeds with localized paths', async () => { + it('revalidates a single feed across all feed type paths', async () => { const request = new Request('http://localhost:3000/api/revalidate', { method: 'POST', headers: { @@ -333,9 +305,7 @@ describe('POST /api/revalidate', () => { }, body: JSON.stringify({ type: 'specific-feeds', - gtfsFeedIds: [], - gtfsRtFeedIds: [], - gbfsFeedIds: ['gbfs-feed-1'], + feedIds: ['rt-feed-1'], }), }); @@ -345,23 +315,20 @@ describe('POST /api/revalidate', () => { expect(response.status).toBe(200); expect(json.ok).toBe(true); - expect(mockRevalidateTag).toHaveBeenCalledWith('feed-gbfs-feed-1', 'max'); - expect(mockRevalidatePath).toHaveBeenCalledWith( - '/feeds/gbfs/gbfs-feed-1', - ); - expect(mockRevalidatePath).toHaveBeenCalledWith( - '/feeds/gbfs/gbfs-feed-1/map', - ); - expect(mockRevalidatePath).toHaveBeenCalledWith( - '/fr/feeds/gbfs/gbfs-feed-1', - ); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-rt-feed-1', 'max'); + + // All 3 feed type paths + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/rt-feed-1'); expect(mockRevalidatePath).toHaveBeenCalledWith( - '/fr/feeds/gbfs/gbfs-feed-1/map', + '/feeds/gtfs_rt/rt-feed-1', ); - expect(mockRevalidatePath).toHaveBeenCalledTimes(4); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/rt-feed-1'); + + // 1 feed × 3 types × 4 paths = 12 total calls + expect(mockRevalidatePath).toHaveBeenCalledTimes(12); }); - it('revalidates multiple feed types simultaneously', async () => { + it('revalidates multiple feeds simultaneously', async () => { const request = new Request('http://localhost:3000/api/revalidate', { method: 'POST', headers: { @@ -370,9 +337,7 @@ describe('POST /api/revalidate', () => { }, body: JSON.stringify({ type: 'specific-feeds', - gtfsFeedIds: ['gtfs-1'], - gtfsRtFeedIds: ['rt-1'], - gbfsFeedIds: ['gbfs-1'], + feedIds: ['feed-a', 'feed-b', 'feed-c'], }), }); @@ -383,20 +348,20 @@ describe('POST /api/revalidate', () => { expect(json.ok).toBe(true); // Should invalidate cache tags for each feed - expect(mockRevalidateTag).toHaveBeenCalledWith('feed-gtfs-1', 'max'); - expect(mockRevalidateTag).toHaveBeenCalledWith('feed-rt-1', 'max'); - expect(mockRevalidateTag).toHaveBeenCalledWith('feed-gbfs-1', 'max'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-a', 'max'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-b', 'max'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-c', 'max'); - // Should revalidate all three feed types - expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/gtfs-1'); - expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs_rt/rt-1'); - expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/gbfs-1'); + // Each feed revalidates all 3 type paths + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/feed-a'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs_rt/feed-a'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/feed-a'); - // 3 feeds × 4 paths each = 12 total calls - expect(mockRevalidatePath).toHaveBeenCalledTimes(12); + // 3 feeds × 3 types × 4 paths = 36 total calls + expect(mockRevalidatePath).toHaveBeenCalledTimes(36); }); - it('handles specific-feeds with empty arrays', async () => { + it('handles specific-feeds with empty feedIds', async () => { const request = new Request('http://localhost:3000/api/revalidate', { method: 'POST', headers: { @@ -405,9 +370,7 @@ describe('POST /api/revalidate', () => { }, body: JSON.stringify({ type: 'specific-feeds', - gtfsFeedIds: [], - gtfsRtFeedIds: [], - gbfsFeedIds: [], + feedIds: [], }), }); diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 9c2574b..c679cc5 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -12,18 +12,14 @@ type RevalidateTypes = | 'specific-feeds'; interface RevalidateBody { - gtfsFeedIds: string[]; // optional list of specific feed IDs to revalidate - gtfsRtFeedIds: string[]; // optional list of specific GTFS-RT feed IDs to revalidate - gbfsFeedIds: string[]; // optional list of specific GBFS feed IDs to revalidate - type: RevalidateTypes; // optional, controls scope of revalidation + feedIds: string[]; // only for 'specific-feeds' revalidation type + type: RevalidateTypes; } const defaultRevalidateOptions: RevalidateBody = { // By default it will revalidate nothing type: 'specific-feeds', - gtfsFeedIds: [], - gtfsRtFeedIds: [], - gbfsFeedIds: [], + feedIds: [], }; export async function POST(req: Request): Promise { @@ -45,7 +41,7 @@ export async function POST(req: Request): Promise { let payload: RevalidateBody = { ...defaultRevalidateOptions }; // default to full revalidation if body is missing/invalid try { - payload = (await req.json()) as RevalidateBody; + payload = { ...defaultRevalidateOptions, ...(await req.json()) as RevalidateBody }; } catch { payload = { ...defaultRevalidateOptions }; } @@ -90,31 +86,15 @@ export async function POST(req: Request): Promise { const localPaths = AVAILABLE_LOCALES.filter((loc) => loc !== 'en'); const pathsToRevalidate: string[] = []; - const gtfsFeedIds = Array.isArray(payload.gtfsFeedIds) - ? payload.gtfsFeedIds - : []; - const gtfsRtFeedIds = Array.isArray(payload.gtfsRtFeedIds) - ? payload.gtfsRtFeedIds - : []; - const gbfsFeedIds = Array.isArray(payload.gbfsFeedIds) - ? payload.gbfsFeedIds - : []; - - gtfsFeedIds.forEach((id) => { + payload.feedIds.forEach((id) => { revalidateTag(`feed-${id}`, 'max'); + // The id will try to revalidate all feed types with that id, but that's necessary since we don't know the feed type here and it's not a big deal if we revalidate some non-existent pages pathsToRevalidate.push(`/feeds/gtfs/${id}`); - }); - - gtfsRtFeedIds.forEach((id) => { - revalidateTag(`feed-${id}`, 'max'); pathsToRevalidate.push(`/feeds/gtfs_rt/${id}`); - }); - - gbfsFeedIds.forEach((id) => { - revalidateTag(`feed-${id}`, 'max'); pathsToRevalidate.push(`/feeds/gbfs/${id}`); }); + pathsToRevalidate.forEach((path) => { revalidatePath(path); revalidatePath(path + '/map'); From 08c1595ce43ad3fe03c444b226f173d904da0fa9 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 23 Feb 2026 13:40:46 -0500 Subject: [PATCH 25/28] lint fix --- src/app/api/revalidate/route.spec.ts | 4 +--- src/app/api/revalidate/route.ts | 6 ++++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/api/revalidate/route.spec.ts b/src/app/api/revalidate/route.spec.ts index 209cc26..28e81d4 100644 --- a/src/app/api/revalidate/route.spec.ts +++ b/src/app/api/revalidate/route.spec.ts @@ -273,9 +273,7 @@ describe('POST /api/revalidate', () => { expect(mockRevalidatePath).toHaveBeenCalledWith( '/fr/feeds/gtfs/feed-1/map', ); - expect(mockRevalidatePath).toHaveBeenCalledWith( - '/feeds/gtfs_rt/feed-1', - ); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs_rt/feed-1'); expect(mockRevalidatePath).toHaveBeenCalledWith( '/feeds/gtfs_rt/feed-1/map', ); diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index c679cc5..056a6ae 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -41,7 +41,10 @@ export async function POST(req: Request): Promise { let payload: RevalidateBody = { ...defaultRevalidateOptions }; // default to full revalidation if body is missing/invalid try { - payload = { ...defaultRevalidateOptions, ...(await req.json()) as RevalidateBody }; + payload = { + ...defaultRevalidateOptions, + ...((await req.json()) as RevalidateBody), + }; } catch { payload = { ...defaultRevalidateOptions }; } @@ -94,7 +97,6 @@ export async function POST(req: Request): Promise { pathsToRevalidate.push(`/feeds/gbfs/${id}`); }); - pathsToRevalidate.forEach((path) => { revalidatePath(path); revalidatePath(path + '/map'); From 840c85741f5df7a0ed371a45236d46c3cdb464f9 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 23 Feb 2026 13:54:47 -0500 Subject: [PATCH 26/28] cypress test fix --- cypress/e2e/feed-isr-caching.cy.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cypress/e2e/feed-isr-caching.cy.ts b/cypress/e2e/feed-isr-caching.cy.ts index 363bea4..f579ef9 100644 --- a/cypress/e2e/feed-isr-caching.cy.ts +++ b/cypress/e2e/feed-isr-caching.cy.ts @@ -41,9 +41,7 @@ function revalidateTestFeed(): void { }, body: { type: 'specific-feeds', - gtfsFeedIds: [TEST_FEED_ID], - gtfsRtFeedIds: [], - gbfsFeedIds: [], + feedIds: [TEST_FEED_ID], }, }) .its('status') From eb9849aac4cd2c38a7cc5644e9e5c54d146f6571 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 23 Feb 2026 14:38:51 -0500 Subject: [PATCH 27/28] debugging revalidate path on vercel --- src/app/api/revalidate/route.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 056a6ae..5e4c0b5 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { NextResponse, type NextRequest } from 'next/server'; import { revalidatePath, revalidateTag } from 'next/cache'; import { AVAILABLE_LOCALES } from '../../../i18n/routing'; import { nonEmpty } from '../../utils/config'; @@ -22,7 +22,7 @@ const defaultRevalidateOptions: RevalidateBody = { feedIds: [], }; -export async function POST(req: Request): Promise { +export async function POST(req: NextRequest): Promise { const expectedSecret = nonEmpty(process.env.REVALIDATE_SECRET); if (expectedSecret == null) { return NextResponse.json( @@ -41,14 +41,25 @@ export async function POST(req: Request): Promise { let payload: RevalidateBody = { ...defaultRevalidateOptions }; // default to full revalidation if body is missing/invalid try { + const body = (await req.json()) as RevalidateBody; + console.log('Parsed request body:', JSON.stringify(body)); payload = { ...defaultRevalidateOptions, - ...((await req.json()) as RevalidateBody), + ...body, }; - } catch { + } catch (parseError) { + console.error( + 'Failed to parse request body, falling back to defaults:', + parseError, + ); payload = { ...defaultRevalidateOptions }; } + console.log('Resolved payload:', JSON.stringify(payload)); + + // For debugging: log the type of revalidation being performed + console.log(`Performing revalidation of type: ${payload.type}`); + // NOTE // revalidatePath = triggers revalidation for entire page cache // revalidateTag = triggers revalidation for API calls using `unstable_cache` with matching tags (e.g., feed-123, guest-feeds) @@ -97,6 +108,8 @@ export async function POST(req: Request): Promise { pathsToRevalidate.push(`/feeds/gbfs/${id}`); }); + console.log('Revalidating paths:', pathsToRevalidate); + pathsToRevalidate.forEach((path) => { revalidatePath(path); revalidatePath(path + '/map'); From 5dffd843b8ccfb286e52c1a52762d3894316b7db Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 23 Feb 2026 15:02:21 -0500 Subject: [PATCH 28/28] remove debugging --- src/app/api/revalidate/route.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 5e4c0b5..a03ab32 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -42,7 +42,6 @@ export async function POST(req: NextRequest): Promise { let payload: RevalidateBody = { ...defaultRevalidateOptions }; // default to full revalidation if body is missing/invalid try { const body = (await req.json()) as RevalidateBody; - console.log('Parsed request body:', JSON.stringify(body)); payload = { ...defaultRevalidateOptions, ...body, @@ -55,11 +54,6 @@ export async function POST(req: NextRequest): Promise { payload = { ...defaultRevalidateOptions }; } - console.log('Resolved payload:', JSON.stringify(payload)); - - // For debugging: log the type of revalidation being performed - console.log(`Performing revalidation of type: ${payload.type}`); - // NOTE // revalidatePath = triggers revalidation for entire page cache // revalidateTag = triggers revalidation for API calls using `unstable_cache` with matching tags (e.g., feed-123, guest-feeds)