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/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/components/ContentBox.tsx b/src/app/components/ContentBox.tsx index d151cfef..bf50b457 100644 --- a/src/app/components/ContentBox.tsx +++ b/src/app/components/ContentBox.tsx @@ -3,8 +3,9 @@ import { Box, Typography, type SxProps } from '@mui/material'; export interface ContentBoxProps { title: string; + subtitle?: React.ReactNode; width?: Record; - outlineColor: string; + outlineColor?: string; padding?: Partial; margin?: string | number; sx?: SxProps; @@ -21,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', @@ -45,6 +49,11 @@ export const ContentBox = ( {props.action != null && props.action} )} + {props.subtitle != null && ( + + {props.subtitle} + + )} {props.children} ); diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 3c22a099..68ab5501 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,23 @@ 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 + + } + sx={{ mt: 4, backgroundColor: 'background.paper' }} + > + {feed?.id != null && feed?.id !== '' && ( + + )} + + )} ); } 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..286b1c4a --- /dev/null +++ b/src/app/screens/Feed/components/RevalidateCacheButton.tsx @@ -0,0 +1,59 @@ +'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 () => { + try { + const res = await revalidateFeedCache(feedId); + setResult(res); + } catch { + setResult({ + ok: false, + message: 'Failed to revalidate the cache. Please try again.', + }); + } + }); + }; + + return ( + + + {result != null && !isPending && ( + + {result.message} + + )} + + ); +} diff --git a/src/app/utils/auth-server.ts b/src/app/utils/auth-server.ts index 595dbc18..1dc72db6 100644 --- a/src/app/utils/auth-server.ts +++ b/src/app/utils/auth-server.ts @@ -225,3 +225,9 @@ export async function getUserContextJwtFromCookie(): Promise< return undefined; } } + +export function isMobilityDatabaseAdmin(email: string | undefined): boolean { + return ( + email?.trim().toLocaleLowerCase().endsWith('@mobilitydata.org') === true + ); +} 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'); +}