diff --git a/cypress/e2e/feed-isr-caching.cy.ts b/cypress/e2e/feed-isr-caching.cy.ts new file mode 100644 index 00000000..f579ef93 --- /dev/null +++ b/cypress/e2e/feed-isr-caching.cy.ts @@ -0,0 +1,140 @@ +/** + * 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', + feedIds: [TEST_FEED_ID], + }, + }) + .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/docs/feed-detail-caching-flow.md b/docs/feed-detail-caching-flow.md new file mode 100644 index 00000000..bf3a1d0d --- /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 diff --git a/messages/en.json b/messages/en.json index d5ec9866..e5f4180d 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 563e9037..f817b5a7 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/package.json b/package.json index d94f6a8c..d70d1141 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", 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 00000000..b6381eb8 --- /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 00000000..77bf1735 --- /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 00000000..63de6c9a --- /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 ( + + ); +} 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 00000000..d245616a --- /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 00000000..214f40bc --- /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/screens/Feed/StructuredData.functions.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts similarity index 78% rename from src/app/screens/Feed/StructuredData.functions.ts rename to src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts index a1873c3d..bfa27350 100644 --- a/src/app/screens/Feed/StructuredData.functions.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts @@ -1,9 +1,15 @@ +import type { Metadata } from 'next'; +import type { + AllFeedType, + GBFSFeedType, + GTFSFeedType, + GTFSRTFeedType, +} from '../../../../../services/feeds/utils'; import { - type AllFeedType, - type GBFSFeedType, - type GTFSFeedType, - type GTFSRTFeedType, -} from '../../services/feeds/utils'; + formatProvidersSorted, + generatePageTitle, + generateDescriptionMetaTag, +} from '../../../../../screens/Feed/Feed.functions'; /** * Structured data is purely for SEO purposes. @@ -268,3 +274,79 @@ export default function generateFeedStructuredData( 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 00000000..2bba8282 --- /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(); + }, +); 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 8dbf4d13..00000000 --- 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 0850dce2..00000000 --- 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 ( - - ); -} 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 00000000..8850599d --- /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 00000000..ec3c8cad --- /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 00000000..b016f3f7 --- /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 ( + + ); +} diff --git a/src/app/api/revalidate/route.spec.ts b/src/app/api/revalidate/route.spec.ts new file mode 100644 index 00000000..28e81d4d --- /dev/null +++ b/src/app/api/revalidate/route.spec.ts @@ -0,0 +1,513 @@ +/** + * @jest-environment node + */ +import { POST } from './route'; +import { revalidatePath, revalidateTag } from 'next/cache'; + +// Mock Next.js cache +jest.mock('next/cache', () => ({ + revalidatePath: jest.fn(), + revalidateTag: 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 mockRevalidateTag = revalidateTag as jest.MockedFunction< + typeof revalidateTag + >; + 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(mockRevalidateTag).toHaveBeenCalledWith('guest-feeds', 'max'); + 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(mockRevalidateTag).toHaveBeenCalledWith('feed-type-gbfs', 'max'); + 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(mockRevalidateTag).toHaveBeenCalledWith('feed-type-gtfs', 'max'); + 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(mockRevalidateTag).toHaveBeenCalledWith( + 'feed-type-gtfs_rt', + 'max', + ); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/[locale]/feeds/gtfs_rt/[feedId]', + 'layout', + ); + }); + + it('revalidates specific feeds with all type paths and 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', + feedIds: ['feed-1', 'feed-2'], + }), + }); + + const response = await POST(request); + const json = await response.json(); + + 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 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_rt/feed-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/feeds/gtfs_rt/feed-1/map', + ); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/fr/feeds/gtfs_rt/feed-1', + ); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/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/gbfs/feed-1/map', + ); + + // 2 feeds × 3 types × 4 paths = 24 total calls + expect(mockRevalidatePath).toHaveBeenCalledTimes(24); + }); + + it('revalidates a single feed across all feed type 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', + feedIds: ['rt-feed-1'], + }), + }); + + 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'); + + // All 3 feed type paths + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gtfs/rt-feed-1'); + expect(mockRevalidatePath).toHaveBeenCalledWith( + '/feeds/gtfs_rt/rt-feed-1', + ); + expect(mockRevalidatePath).toHaveBeenCalledWith('/feeds/gbfs/rt-feed-1'); + + // 1 feed × 3 types × 4 paths = 12 total calls + expect(mockRevalidatePath).toHaveBeenCalledTimes(12); + }); + + it('revalidates multiple feeds 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', + feedIds: ['feed-a', 'feed-b', 'feed-c'], + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + + // Should invalidate cache tags for each feed + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-a', 'max'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-b', 'max'); + expect(mockRevalidateTag).toHaveBeenCalledWith('feed-feed-c', 'max'); + + // 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 × 3 types × 4 paths = 36 total calls + expect(mockRevalidatePath).toHaveBeenCalledTimes(36); + }); + + it('handles specific-feeds with empty feedIds', 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', + feedIds: [], + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.ok).toBe(true); + expect(mockRevalidateTag).not.toHaveBeenCalled(); + 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(mockRevalidateTag).toHaveBeenCalledWith('guest-feeds', 'max'); + 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 00000000..a03ab320 --- /dev/null +++ b/src/app/api/revalidate/route.ts @@ -0,0 +1,131 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { revalidatePath, revalidateTag } from 'next/cache'; +import { AVAILABLE_LOCALES } from '../../../i18n/routing'; +import { nonEmpty } from '../../utils/config'; + +type RevalidateTypes = + | 'full' + | 'all-feeds' + | 'all-gbfs-feeds' + | 'all-gtfs-rt-feeds' + | 'all-gtfs-feeds' + | 'specific-feeds'; + +interface RevalidateBody { + feedIds: string[]; // only for 'specific-feeds' revalidation type + type: RevalidateTypes; +} + +const defaultRevalidateOptions: RevalidateBody = { + // By default it will revalidate nothing + type: 'specific-feeds', + feedIds: [], +}; + +export async function POST(req: NextRequest): Promise { + const expectedSecret = nonEmpty(process.env.REVALIDATE_SECRET); + 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 == 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 + try { + const body = (await req.json()) as RevalidateBody; + payload = { + ...defaultRevalidateOptions, + ...body, + }; + } catch (parseError) { + console.error( + 'Failed to parse request body, falling back to defaults:', + parseError, + ); + 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') { + revalidateTag('guest-feeds', 'max'); + revalidatePath('/', 'layout'); + } + + // 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'); + } + + // 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.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}`); + pathsToRevalidate.push(`/feeds/gtfs_rt/${id}`); + pathsToRevalidate.push(`/feeds/gbfs/${id}`); + }); + + console.log('Revalidating paths:', pathsToRevalidate); + + pathsToRevalidate.forEach((path) => { + revalidatePath(path); + revalidatePath(path + '/map'); + localPaths.forEach((loc) => { + revalidatePath(`/${loc}${path}`); + revalidatePath(`/${loc}${path}/map`); + }); + }); + } + + return NextResponse.json({ + ok: true, + message: 'Revalidation triggered successfully', + }); + } catch (error) { + console.error('Revalidation failed:', error); + return NextResponse.json( + { + ok: false, + error: 'Failed to revalidate', + }, + { status: 500 }, + ); + } +} diff --git a/src/app/components/CoveredAreaMap.tsx b/src/app/components/CoveredAreaMap.tsx index 28584890..685240ff 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 && ( { 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/interface/RemoteConfig.ts b/src/app/interface/RemoteConfig.ts index ebcc1065..55c908a8 100644 --- a/src/app/interface/RemoteConfig.ts +++ b/src/app/interface/RemoteConfig.ts @@ -17,7 +17,6 @@ export interface RemoteConfigValues { featureFlagBypass: string; enableFeedStatusBadge: boolean; gbfsVersions: string; - enableGtfsVisualizationMap: boolean; /** Max number of data stuff to display on top of the map to avoid overflow */ visualizationMapPreviewDataLimit: number; @@ -43,7 +42,6 @@ export const defaultRemoteConfigValues: RemoteConfigValues = { featureFlagBypass: '', enableFeedStatusBadge: false, gbfsVersions: JSON.stringify(gbfsVersionsDefault), - enableGtfsVisualizationMap: false, visualizationMapFullDataLimit: 5, visualizationMapPreviewDataLimit: 3, enableDetailedCoveredArea: false, diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index dff96c2a..a60c466e 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -122,6 +122,8 @@ 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()}
{ - 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/components/FullMapView.tsx b/src/app/screens/Feed/components/FullMapView.tsx index 419e6778..a448c3d1 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 && ( + + )} diff --git a/src/app/screens/Feed/index.tsx b/src/app/screens/Feed/index.tsx deleted file mode 100644 index 382aa67a..00000000 --- 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); - }} - /> */} - - )} - , - ); -} diff --git a/src/app/utils/auth-server.ts b/src/app/utils/auth-server.ts index 1ec4b051..595dbc18 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. diff --git a/src/app/utils/proxy-helpers.spec.ts b/src/app/utils/proxy-helpers.spec.ts new file mode 100644 index 00000000..6879b7e5 --- /dev/null +++ b/src/app/utils/proxy-helpers.spec.ts @@ -0,0 +1,298 @@ +import { + isFeedDetailPage, + rewriteFeedRequest, + hasLocaleInPathname, + rewriteWithDefaultLocale, + DEFAULT_LOCALE, + AUTHED_PROXY_HEADER, + STATIC_PROXY_HEADER, +} from './proxy-helpers'; + +// Mock the session-jwt module +jest.mock('./session-jwt'); + +// Helper to create mock NextRequest +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +function createMockNextRequest( + overrides: Record = {}, +): Record { + return { + nextUrl: { + clone: jest.fn(function (this: Record) { + const url = new URL('http://localhost'); + url.pathname = '/original'; + return url; + }), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + } as Record, + headers: new Headers(), + cookies: { + get: jest.fn(), + } as Record, + ...overrides, + }; +} +/* eslint-enable @typescript-eslint/consistent-type-assertions */ + +// Mock routing module +jest.mock('../../i18n/routing', () => ({ + routing: { + defaultLocale: 'en', + locales: ['en', 'fr'], + }, +})); + +// 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'); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + NextResponse = nextServer.NextResponse as any; + 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', () => { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + 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/app/utils/proxy-helpers.ts b/src/app/utils/proxy-helpers.ts new file mode 100644 index 00000000..afbfcd98 --- /dev/null +++ b/src/app/utils/proxy-helpers.ts @@ -0,0 +1,135 @@ +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?.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 91eadbff..088be3a8 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,90 @@ 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 != null && + feedDetailPageInfo.feedDataType !== '' && + 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; + + 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 = { diff --git a/src/setupTests.ts b/src/setupTests.ts index b39259da..59e25ba5 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -54,3 +54,37 @@ jest.mock('next-intl/server', () => ({ }), getLocale: jest.fn().mockResolvedValue('en'), })); + +// Mock next/server for middleware tests +jest.mock('next/server', () => { + 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: 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(), + }; +});