From 77fdc3b27398bfcc3dd183fb2652ac58d3516e1d Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 25 Mar 2026 09:31:57 -0400 Subject: [PATCH 1/7] admin section in feed view --- .../[feedDataType]/[feedId]/authed/page.tsx | 11 +++++++++- src/app/components/ContentBox.tsx | 6 ++++++ src/app/screens/Feed/FeedView.tsx | 20 +++++++++++++++++++ src/app/utils/auth-server.ts | 4 ++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx index 0fcad8b2..032046d3 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx @@ -5,6 +5,10 @@ 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'; +import { + getCurrentUserFromCookie, + isMobilityDatabaseAdmin, +} from '../../../../../utils/auth-server'; interface Props { params: Promise<{ locale: string; feedDataType: string; feedId: string }>; @@ -40,7 +44,11 @@ export default async function AuthedFeedPage({ }: Props): Promise { const { feedId, feedDataType } = await params; - const feedData = await fetchCompleteFeedData(feedDataType, feedId); + const [userData, feedData] = await Promise.all([ + getCurrentUserFromCookie(), + fetchCompleteFeedData(feedDataType, feedId), + ]); + const isAdmin = isMobilityDatabaseAdmin(userData?.email); if (feedData == null) { return
Feed not found
; @@ -69,6 +77,7 @@ export default async function AuthedFeedPage({ relatedGtfsRtFeeds={relatedGtfsRtFeeds} totalRoutes={totalRoutes} routeTypes={routeTypes} + isMobilityDatabaseAdmin={isAdmin} /> ); diff --git a/src/app/components/ContentBox.tsx b/src/app/components/ContentBox.tsx index d151cfef..b8360f60 100644 --- a/src/app/components/ContentBox.tsx +++ b/src/app/components/ContentBox.tsx @@ -3,6 +3,7 @@ import { Box, Typography, type SxProps } from '@mui/material'; export interface ContentBoxProps { title: string; + subtile?: React.ReactNode; width?: Record; outlineColor: string; padding?: Partial; @@ -45,6 +46,11 @@ export const ContentBox = ( {props.action != null && props.action} )} + {props.subtile != null && ( + + {props.subtile} + + )} {props.children} ); diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 3c22a099..2b5eda3d 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -24,10 +24,12 @@ import { type GTFSRTFeedType, } from '../../services/feeds/utils'; import ClientDownloadButton from './components/ClientDownloadButton'; +import RevalidateCacheButton from './components/RevalidateCacheButton'; import { type components } from '../../services/feeds/types'; import ClientQualityReportButton from './components/ClientQualityReportButton'; import { getBoundingBox } from './Feed.functions'; import dynamic from 'next/dynamic'; +import { ContentBox } from '../../components/ContentBox'; const CoveredAreaMap = dynamic( async () => @@ -68,6 +70,7 @@ interface Props { relatedGtfsRtFeeds?: GTFSRTFeedType[]; totalRoutes?: number; routeTypes?: string[]; + isMobilityDatabaseAdmin?: boolean; } type LatestDatasetFull = components['schemas']['GtfsDataset'] | undefined; @@ -79,6 +82,7 @@ export default async function FeedView({ relatedGtfsRtFeeds = [], totalRoutes, routeTypes, + isMobilityDatabaseAdmin = false, }: Props): Promise { if (feed == undefined) notFound(); @@ -418,6 +422,22 @@ export default async function FeedView({ + {isMobilityDatabaseAdmin && ( + + This section is only visible to Mobility Data employees with an{' '} + @mobilitydata.org email address. It contains tools + for debugging and managing feed data + + } + outlineColor='background.paper' + sx={{ mt: 4, backgroundColor: 'background.paper' }} + > + + + )} ); } diff --git a/src/app/utils/auth-server.ts b/src/app/utils/auth-server.ts index 595dbc18..459d5d60 100644 --- a/src/app/utils/auth-server.ts +++ b/src/app/utils/auth-server.ts @@ -225,3 +225,7 @@ export async function getUserContextJwtFromCookie(): Promise< return undefined; } } + +export function isMobilityDatabaseAdmin(email: string | undefined): boolean { + return email?.endsWith('@mobilitydata.org') === true; +} From f508b98dae27cd0a98ba90ebaf4be774963936f9 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 25 Mar 2026 09:32:13 -0400 Subject: [PATCH 2/7] ability to revalidate from feed page --- src/app/api/revalidate/route.ts | 74 ++++--------------- src/app/screens/Feed/actions.ts | 18 +++++ .../Feed/components/RevalidateCacheButton.tsx | 51 +++++++++++++ src/app/utils/revalidate-feeds.ts | 62 ++++++++++++++++ 4 files changed, 147 insertions(+), 58 deletions(-) create mode 100644 src/app/screens/Feed/actions.ts create mode 100644 src/app/screens/Feed/components/RevalidateCacheButton.tsx create mode 100644 src/app/utils/revalidate-feeds.ts diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index d04f9b71..2e0d7356 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -1,7 +1,13 @@ import { NextResponse } from 'next/server'; -import { revalidatePath, revalidateTag } from 'next/cache'; -import { AVAILABLE_LOCALES } from '../../../i18n/routing'; import { nonEmpty } from '../../utils/config'; +import { + revalidateFullSite, + revalidateAllFeeds, + revalidateAllGbfsFeeds, + revalidateAllGtfsFeeds, + revalidateAllGtfsRtFeeds, + revalidateSpecificFeeds, +} from '../../utils/revalidate-feeds'; type RevalidateTypes = | 'full' @@ -46,8 +52,7 @@ export async function GET(req: Request): Promise { } try { - revalidateTag('guest-feeds', 'max'); - revalidatePath('/[locale]/feeds/[feedDataType]/[feedId]', 'layout'); + revalidateAllFeeds(); console.log( '[cron] revalidate /api/revalidate: all-feeds revalidation triggered', ); @@ -104,60 +109,13 @@ export async function POST(req: Request): Promise { // 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`); - }); - }); - } + if (payload.type === 'full') revalidateFullSite(); + if (payload.type === 'all-feeds') revalidateAllFeeds(); + if (payload.type === 'all-gbfs-feeds') revalidateAllGbfsFeeds(); + if (payload.type === 'all-gtfs-feeds') revalidateAllGtfsFeeds(); + if (payload.type === 'all-gtfs-rt-feeds') revalidateAllGtfsRtFeeds(); + if (payload.type === 'specific-feeds') + revalidateSpecificFeeds(payload.feedIds); return NextResponse.json({ ok: true, diff --git a/src/app/screens/Feed/actions.ts b/src/app/screens/Feed/actions.ts new file mode 100644 index 00000000..a54dad33 --- /dev/null +++ b/src/app/screens/Feed/actions.ts @@ -0,0 +1,18 @@ +'use server'; +import { + getCurrentUserFromCookie, + isMobilityDatabaseAdmin, +} from '../../utils/auth-server'; +import { revalidateSpecificFeeds } from '../../utils/revalidate-feeds'; + +export async function revalidateFeedCache( + feedId: string, +): Promise<{ ok: boolean; message: string }> { + const user = await getCurrentUserFromCookie(); + if (!isMobilityDatabaseAdmin(user?.email)) { + return { ok: false, message: 'Unauthorized: admin access required' }; + } + + revalidateSpecificFeeds([feedId]); + return { ok: true, message: `Cache revalidated for feed ${feedId}` }; +} diff --git a/src/app/screens/Feed/components/RevalidateCacheButton.tsx b/src/app/screens/Feed/components/RevalidateCacheButton.tsx new file mode 100644 index 00000000..7a22c070 --- /dev/null +++ b/src/app/screens/Feed/components/RevalidateCacheButton.tsx @@ -0,0 +1,51 @@ +'use client'; +import { useState, useTransition } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { revalidateFeedCache } from '../actions'; + +interface Props { + feedId: string; +} + +export default function RevalidateCacheButton({ + feedId, +}: Props): React.ReactElement { + const [isPending, startTransition] = useTransition(); + const [result, setResult] = useState<{ + ok: boolean; + message: string; + } | null>(null); + + const handleClick = (): void => { + setResult(null); + startTransition(async () => { + const res = await revalidateFeedCache(feedId); + setResult(res); + }); + }; + + return ( + + + {result != null && !isPending && ( + + {result.message} + + )} + + ); +} diff --git a/src/app/utils/revalidate-feeds.ts b/src/app/utils/revalidate-feeds.ts new file mode 100644 index 00000000..07f9a4e6 --- /dev/null +++ b/src/app/utils/revalidate-feeds.ts @@ -0,0 +1,62 @@ +import 'server-only'; +import { revalidatePath, revalidateTag } from 'next/cache'; +import { AVAILABLE_LOCALES } from '../../i18n/routing'; + +/** + * Revalidates the ISR cache for specific feed pages. + * Applies to all feed types (gtfs, gtfs_rt, gbfs) since we don't know the type from the id alone. + * Also revalidates localized paths and /map sub-routes. + */ +export function revalidateSpecificFeeds(feedIds: string[]): void { + const localPaths = AVAILABLE_LOCALES.filter((loc) => loc !== 'en'); + const pathsToRevalidate: string[] = []; + + 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`); + }); + }); +} + +/** Clears cache for all feed pages (ISR-cached layout). */ +export function revalidateAllFeeds(): void { + revalidateTag('guest-feeds', 'max'); + revalidatePath('/[locale]/feeds/[feedDataType]/[feedId]', 'layout'); +} + +/** Clears cache for all GBFS feed pages (ISR-cached layout). */ +export function revalidateAllGbfsFeeds(): void { + revalidateTag('feed-type-gbfs', 'max'); + revalidatePath('/[locale]/feeds/gbfs/[feedId]', 'layout'); +} + +/** Clears cache for all GTFS feed pages (ISR-cached layout). */ +export function revalidateAllGtfsFeeds(): void { + revalidateTag('feed-type-gtfs', 'max'); + revalidatePath('/[locale]/feeds/gtfs/[feedId]', 'layout'); +} + +/** Clears cache for all GTFS-RT feed pages (ISR-cached layout). */ +export function revalidateAllGtfsRtFeeds(): void { + revalidateTag('feed-type-gtfs_rt', 'max'); + revalidatePath('/[locale]/feeds/gtfs_rt/[feedId]', 'layout'); +} + +/** Clears cache for the entire site. */ +export function revalidateFullSite(): void { + revalidateTag('guest-feeds', 'max'); + revalidatePath('/', 'layout'); +} From 39d6908f7169110040b43d07d3167855ef16ff1f Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 25 Mar 2026 09:47:49 -0400 Subject: [PATCH 3/7] subtitle typo --- src/app/components/ContentBox.tsx | 6 +++--- src/app/screens/Feed/FeedView.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/components/ContentBox.tsx b/src/app/components/ContentBox.tsx index b8360f60..1a50846a 100644 --- a/src/app/components/ContentBox.tsx +++ b/src/app/components/ContentBox.tsx @@ -3,7 +3,7 @@ import { Box, Typography, type SxProps } from '@mui/material'; export interface ContentBoxProps { title: string; - subtile?: React.ReactNode; + subtitle?: React.ReactNode; width?: Record; outlineColor: string; padding?: Partial; @@ -46,9 +46,9 @@ export const ContentBox = ( {props.action != null && props.action} )} - {props.subtile != null && ( + {props.subtitle != null && ( - {props.subtile} + {props.subtitle} )} {props.children} diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 2b5eda3d..0cfc0414 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -425,7 +425,7 @@ export default async function FeedView({ {isMobilityDatabaseAdmin && ( This section is only visible to Mobility Data employees with an{' '} @mobilitydata.org email address. It contains tools From 7a1a1ce3f90934f344d65aae633edcad00bb5a0b Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 25 Mar 2026 09:55:36 -0400 Subject: [PATCH 4/7] revalidate error catching --- .../Feed/components/RevalidateCacheButton.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/screens/Feed/components/RevalidateCacheButton.tsx b/src/app/screens/Feed/components/RevalidateCacheButton.tsx index 7a22c070..286b1c4a 100644 --- a/src/app/screens/Feed/components/RevalidateCacheButton.tsx +++ b/src/app/screens/Feed/components/RevalidateCacheButton.tsx @@ -20,9 +20,17 @@ export default function RevalidateCacheButton({ const handleClick = (): void => { setResult(null); + startTransition(async () => { - const res = await revalidateFeedCache(feedId); - setResult(res); + try { + const res = await revalidateFeedCache(feedId); + setResult(res); + } catch { + setResult({ + ok: false, + message: 'Failed to revalidate the cache. Please try again.', + }); + } }); }; From 2eb6040b22bc0aa7785a2c364bff77ae3d1299ca Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 25 Mar 2026 09:56:12 -0400 Subject: [PATCH 5/7] contentbox optional border color --- src/app/components/ContentBox.tsx | 7 +++++-- src/app/screens/Feed/FeedView.tsx | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/components/ContentBox.tsx b/src/app/components/ContentBox.tsx index 1a50846a..bf50b457 100644 --- a/src/app/components/ContentBox.tsx +++ b/src/app/components/ContentBox.tsx @@ -5,7 +5,7 @@ export interface ContentBoxProps { title: string; subtitle?: React.ReactNode; width?: Record; - outlineColor: string; + outlineColor?: string; padding?: Partial; margin?: string | number; sx?: SxProps; @@ -22,7 +22,10 @@ export const ContentBox = ( backgroundColor: 'background.default', color: 'text.primary', borderRadius: '6px', - border: `2px solid ${props.outlineColor}`, + border: + props.outlineColor != null + ? `2px solid ${props.outlineColor}` + : 'none', p: props.padding ?? 5, m: props.margin ?? 0, fontSize: '18px', diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 0cfc0414..68ab5501 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -432,10 +432,11 @@ export default async function FeedView({ for debugging and managing feed data } - outlineColor='background.paper' sx={{ mt: 4, backgroundColor: 'background.paper' }} > - + {feed?.id != null && feed?.id !== '' && ( + + )} )} From 8dfa848a492c616e29342215060a025f2a104dbd Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 25 Mar 2026 09:58:39 -0400 Subject: [PATCH 6/7] safe guarding the admin email verification --- src/app/utils/auth-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/utils/auth-server.ts b/src/app/utils/auth-server.ts index 459d5d60..d85be85e 100644 --- a/src/app/utils/auth-server.ts +++ b/src/app/utils/auth-server.ts @@ -227,5 +227,5 @@ export async function getUserContextJwtFromCookie(): Promise< } export function isMobilityDatabaseAdmin(email: string | undefined): boolean { - return email?.endsWith('@mobilitydata.org') === true; + return email?.trim().toLocaleLowerCase().endsWith('@mobilitydata.org') === true; } From e6117227704e4bd6f7a72d1afebcd488f6f008e5 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 25 Mar 2026 10:17:37 -0400 Subject: [PATCH 7/7] lint --- src/app/utils/auth-server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/utils/auth-server.ts b/src/app/utils/auth-server.ts index d85be85e..1dc72db6 100644 --- a/src/app/utils/auth-server.ts +++ b/src/app/utils/auth-server.ts @@ -227,5 +227,7 @@ export async function getUserContextJwtFromCookie(): Promise< } export function isMobilityDatabaseAdmin(email: string | undefined): boolean { - return email?.trim().toLocaleLowerCase().endsWith('@mobilitydata.org') === true; + return ( + email?.trim().toLocaleLowerCase().endsWith('@mobilitydata.org') === true + ); }