Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -40,7 +44,11 @@ export default async function AuthedFeedPage({
}: Props): Promise<ReactElement> {
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 <div>Feed not found</div>;
Expand Down Expand Up @@ -69,6 +77,7 @@ export default async function AuthedFeedPage({
relatedGtfsRtFeeds={relatedGtfsRtFeeds}
totalRoutes={totalRoutes}
routeTypes={routeTypes}
isMobilityDatabaseAdmin={isAdmin}
/>
</>
);
Expand Down
74 changes: 16 additions & 58 deletions src/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -46,8 +52,7 @@ export async function GET(req: Request): Promise<NextResponse> {
}

try {
revalidateTag('guest-feeds', 'max');
revalidatePath('/[locale]/feeds/[feedDataType]/[feedId]', 'layout');
revalidateAllFeeds();
console.log(
'[cron] revalidate /api/revalidate: all-feeds revalidation triggered',
);
Expand Down Expand Up @@ -104,60 +109,13 @@ export async function POST(req: Request): Promise<NextResponse> {
// 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,
Expand Down
13 changes: 11 additions & 2 deletions src/app/components/ContentBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { Box, Typography, type SxProps } from '@mui/material';

export interface ContentBoxProps {
title: string;
subtitle?: React.ReactNode;
width?: Record<string, string>;
outlineColor: string;
outlineColor?: string;
padding?: Partial<SxProps>;
margin?: string | number;
sx?: SxProps;
Expand All @@ -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',
Expand All @@ -45,6 +49,11 @@ export const ContentBox = (
{props.action != null && props.action}
</Typography>
)}
{props.subtitle != null && (
<Typography variant='subtitle1' sx={{ mb: 2 }}>
{props.subtitle}
</Typography>
)}
{props.children}
</Box>
);
Expand Down
21 changes: 21 additions & 0 deletions src/app/screens/Feed/FeedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
Expand Down Expand Up @@ -68,6 +70,7 @@ interface Props {
relatedGtfsRtFeeds?: GTFSRTFeedType[];
totalRoutes?: number;
routeTypes?: string[];
isMobilityDatabaseAdmin?: boolean;
}

type LatestDatasetFull = components['schemas']['GtfsDataset'] | undefined;
Expand All @@ -79,6 +82,7 @@ export default async function FeedView({
relatedGtfsRtFeeds = [],
totalRoutes,
routeTypes,
isMobilityDatabaseAdmin = false,
}: Props): Promise<React.ReactElement> {
if (feed == undefined) notFound();

Expand Down Expand Up @@ -418,6 +422,23 @@ export default async function FeedView({
</Box>
</Box>
</Box>
{isMobilityDatabaseAdmin && (
<ContentBox
title={'MobilityDatabase Admin Tools'}
subtitle={
<>
This section is only visible to Mobility Data employees with an{' '}
<code>@mobilitydata.org</code> email address. It contains tools
for debugging and managing feed data
</>
Comment on lines +426 to +433
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new admin section strings are hardcoded English, but this page already uses next-intl translations (getTranslations(...)). To keep localization consistent (especially for /fr), move these new strings into the message catalogs and render them via t(...).

Copilot uses AI. Check for mistakes.
}
sx={{ mt: 4, backgroundColor: 'background.paper' }}
>
{feed?.id != null && feed?.id !== '' && (
<RevalidateCacheButton feedId={feed.id} />
)}
</ContentBox>
Comment on lines +425 to +440
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing unit tests for the Feed screen (Feed.spec.tsx), but the new admin-only section and revalidation flow are currently untested. Consider adding tests that verify the admin tools render only when isMobilityDatabaseAdmin is true and that unauthorized users don’t see/can’t trigger revalidation.

Copilot uses AI. Check for mistakes.
)}
</Container>
);
}
18 changes: 18 additions & 0 deletions src/app/screens/Feed/actions.ts
Original file line number Diff line number Diff line change
@@ -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}` };
Comment on lines +16 to +17
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This server action assumes feedId is always a meaningful non-empty string. Given the UI currently passes feed.id ?? '', consider validating feedId (e.g., trim().length > 0) and returning an error instead of calling revalidateSpecificFeeds with an empty id.

Suggested change
revalidateSpecificFeeds([feedId]);
return { ok: true, message: `Cache revalidated for feed ${feedId}` };
const normalizedFeedId = feedId.trim();
if (normalizedFeedId.length === 0) {
return { ok: false, message: 'Invalid feed id: value is empty or whitespace' };
}
revalidateSpecificFeeds([normalizedFeedId]);
return { ok: true, message: `Cache revalidated for feed ${normalizedFeedId}` };

Copilot uses AI. Check for mistakes.
}
59 changes: 59 additions & 0 deletions src/app/screens/Feed/components/RevalidateCacheButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
width: 'fit-content',
}}
>
<Button variant='contained' onClick={handleClick} disabled={isPending}>
{isPending ? 'Revalidating…' : 'Revalidate The Cache of This Page'}
</Button>
Comment on lines +46 to +48
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The button label / status text is hardcoded. Since the app is localized with next-intl, consider moving these strings into the message files and using translations so admin users on /fr don't see mixed-language UI.

Copilot uses AI. Check for mistakes.
{result != null && !isPending && (
<Typography
variant='caption'
color={result.ok ? 'success.main' : 'error.main'}
>
{result.message}
</Typography>
)}
</Box>
);
}
6 changes: 6 additions & 0 deletions src/app/utils/auth-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
62 changes: 62 additions & 0 deletions src/app/utils/revalidate-feeds.ts
Original file line number Diff line number Diff line change
@@ -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');
}
Loading