Skip to content

Commit f44e92d

Browse files
Merge pull request #88 from MobilityData/feat/36-admin-ability-feed-detail-page
feat: admin ability feed detail page
2 parents 22c90f5 + e611722 commit f44e92d

File tree

8 files changed

+203
-61
lines changed

8 files changed

+203
-61
lines changed

src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import type { Metadata, ResolvingMetadata } from 'next';
55
import { getTranslations } from 'next-intl/server';
66
import { fetchCompleteFeedData } from '../lib/feed-data';
77
import { generateFeedMetadata } from '../lib/generate-feed-metadata';
8+
import {
9+
getCurrentUserFromCookie,
10+
isMobilityDatabaseAdmin,
11+
} from '../../../../../utils/auth-server';
812

913
interface Props {
1014
params: Promise<{ locale: string; feedDataType: string; feedId: string }>;
@@ -40,7 +44,11 @@ export default async function AuthedFeedPage({
4044
}: Props): Promise<ReactElement> {
4145
const { feedId, feedDataType } = await params;
4246

43-
const feedData = await fetchCompleteFeedData(feedDataType, feedId);
47+
const [userData, feedData] = await Promise.all([
48+
getCurrentUserFromCookie(),
49+
fetchCompleteFeedData(feedDataType, feedId),
50+
]);
51+
const isAdmin = isMobilityDatabaseAdmin(userData?.email);
4452

4553
if (feedData == null) {
4654
return <div>Feed not found</div>;
@@ -69,6 +77,7 @@ export default async function AuthedFeedPage({
6977
relatedGtfsRtFeeds={relatedGtfsRtFeeds}
7078
totalRoutes={totalRoutes}
7179
routeTypes={routeTypes}
80+
isMobilityDatabaseAdmin={isAdmin}
7281
/>
7382
</>
7483
);

src/app/api/revalidate/route.ts

Lines changed: 16 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { NextResponse } from 'next/server';
2-
import { revalidatePath, revalidateTag } from 'next/cache';
3-
import { AVAILABLE_LOCALES } from '../../../i18n/routing';
42
import { nonEmpty } from '../../utils/config';
3+
import {
4+
revalidateFullSite,
5+
revalidateAllFeeds,
6+
revalidateAllGbfsFeeds,
7+
revalidateAllGtfsFeeds,
8+
revalidateAllGtfsRtFeeds,
9+
revalidateSpecificFeeds,
10+
} from '../../utils/revalidate-feeds';
511

612
type RevalidateTypes =
713
| 'full'
@@ -46,8 +52,7 @@ export async function GET(req: Request): Promise<NextResponse> {
4652
}
4753

4854
try {
49-
revalidateTag('guest-feeds', 'max');
50-
revalidatePath('/[locale]/feeds/[feedDataType]/[feedId]', 'layout');
55+
revalidateAllFeeds();
5156
console.log(
5257
'[cron] revalidate /api/revalidate: all-feeds revalidation triggered',
5358
);
@@ -104,60 +109,13 @@ export async function POST(req: Request): Promise<NextResponse> {
104109
// revalidateTag = triggers revalidation for API calls using `unstable_cache` with matching tags (e.g., feed-123, guest-feeds)
105110

106111
try {
107-
// clears cache for entire site
108-
if (payload.type === 'full') {
109-
revalidateTag('guest-feeds', 'max');
110-
revalidatePath('/', 'layout');
111-
}
112-
113-
// clears cache for all feed pages (ISR-cached layout)
114-
if (payload.type === 'all-feeds') {
115-
revalidateTag('guest-feeds', 'max');
116-
revalidatePath('/[locale]/feeds/[feedDataType]/[feedId]', 'layout');
117-
}
118-
119-
// clears cache for all GBFS feed pages (ISR-cached layout)
120-
if (payload.type === 'all-gbfs-feeds') {
121-
revalidateTag('feed-type-gbfs', 'max');
122-
revalidatePath('/[locale]/feeds/gbfs/[feedId]', 'layout');
123-
}
124-
125-
// clears cache for all GTFS feed pages (ISR-cached layout)
126-
if (payload.type === 'all-gtfs-feeds') {
127-
revalidateTag('feed-type-gtfs', 'max');
128-
revalidatePath('/[locale]/feeds/gtfs/[feedId]', 'layout');
129-
}
130-
131-
// clears cache for all GTFS RT feed pages (ISR-cached layout)
132-
if (payload.type === 'all-gtfs-rt-feeds') {
133-
revalidateTag('feed-type-gtfs_rt', 'max');
134-
revalidatePath('/[locale]/feeds/gtfs_rt/[feedId]', 'layout');
135-
}
136-
137-
// clears cache for specific feed pages (ISR-cached page) + localized paths
138-
if (payload.type === 'specific-feeds') {
139-
const localPaths = AVAILABLE_LOCALES.filter((loc) => loc !== 'en');
140-
const pathsToRevalidate: string[] = [];
141-
142-
payload.feedIds.forEach((id) => {
143-
revalidateTag(`feed-${id}`, 'max');
144-
// 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
145-
pathsToRevalidate.push(`/feeds/gtfs/${id}`);
146-
pathsToRevalidate.push(`/feeds/gtfs_rt/${id}`);
147-
pathsToRevalidate.push(`/feeds/gbfs/${id}`);
148-
});
149-
150-
console.log('Revalidating paths:', pathsToRevalidate);
151-
152-
pathsToRevalidate.forEach((path) => {
153-
revalidatePath(path);
154-
revalidatePath(path + '/map');
155-
localPaths.forEach((loc) => {
156-
revalidatePath(`/${loc}${path}`);
157-
revalidatePath(`/${loc}${path}/map`);
158-
});
159-
});
160-
}
112+
if (payload.type === 'full') revalidateFullSite();
113+
if (payload.type === 'all-feeds') revalidateAllFeeds();
114+
if (payload.type === 'all-gbfs-feeds') revalidateAllGbfsFeeds();
115+
if (payload.type === 'all-gtfs-feeds') revalidateAllGtfsFeeds();
116+
if (payload.type === 'all-gtfs-rt-feeds') revalidateAllGtfsRtFeeds();
117+
if (payload.type === 'specific-feeds')
118+
revalidateSpecificFeeds(payload.feedIds);
161119

162120
return NextResponse.json({
163121
ok: true,

src/app/components/ContentBox.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { Box, Typography, type SxProps } from '@mui/material';
33

44
export interface ContentBoxProps {
55
title: string;
6+
subtitle?: React.ReactNode;
67
width?: Record<string, string>;
7-
outlineColor: string;
8+
outlineColor?: string;
89
padding?: Partial<SxProps>;
910
margin?: string | number;
1011
sx?: SxProps;
@@ -21,7 +22,10 @@ export const ContentBox = (
2122
backgroundColor: 'background.default',
2223
color: 'text.primary',
2324
borderRadius: '6px',
24-
border: `2px solid ${props.outlineColor}`,
25+
border:
26+
props.outlineColor != null
27+
? `2px solid ${props.outlineColor}`
28+
: 'none',
2529
p: props.padding ?? 5,
2630
m: props.margin ?? 0,
2731
fontSize: '18px',
@@ -45,6 +49,11 @@ export const ContentBox = (
4549
{props.action != null && props.action}
4650
</Typography>
4751
)}
52+
{props.subtitle != null && (
53+
<Typography variant='subtitle1' sx={{ mb: 2 }}>
54+
{props.subtitle}
55+
</Typography>
56+
)}
4857
{props.children}
4958
</Box>
5059
);

src/app/screens/Feed/FeedView.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ import {
2424
type GTFSRTFeedType,
2525
} from '../../services/feeds/utils';
2626
import ClientDownloadButton from './components/ClientDownloadButton';
27+
import RevalidateCacheButton from './components/RevalidateCacheButton';
2728
import { type components } from '../../services/feeds/types';
2829
import ClientQualityReportButton from './components/ClientQualityReportButton';
2930
import { getBoundingBox } from './Feed.functions';
3031
import dynamic from 'next/dynamic';
32+
import { ContentBox } from '../../components/ContentBox';
3133

3234
const CoveredAreaMap = dynamic(
3335
async () =>
@@ -68,6 +70,7 @@ interface Props {
6870
relatedGtfsRtFeeds?: GTFSRTFeedType[];
6971
totalRoutes?: number;
7072
routeTypes?: string[];
73+
isMobilityDatabaseAdmin?: boolean;
7174
}
7275

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

@@ -418,6 +422,23 @@ export default async function FeedView({
418422
</Box>
419423
</Box>
420424
</Box>
425+
{isMobilityDatabaseAdmin && (
426+
<ContentBox
427+
title={'MobilityDatabase Admin Tools'}
428+
subtitle={
429+
<>
430+
This section is only visible to Mobility Data employees with an{' '}
431+
<code>@mobilitydata.org</code> email address. It contains tools
432+
for debugging and managing feed data
433+
</>
434+
}
435+
sx={{ mt: 4, backgroundColor: 'background.paper' }}
436+
>
437+
{feed?.id != null && feed?.id !== '' && (
438+
<RevalidateCacheButton feedId={feed.id} />
439+
)}
440+
</ContentBox>
441+
)}
421442
</Container>
422443
);
423444
}

src/app/screens/Feed/actions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
'use server';
2+
import {
3+
getCurrentUserFromCookie,
4+
isMobilityDatabaseAdmin,
5+
} from '../../utils/auth-server';
6+
import { revalidateSpecificFeeds } from '../../utils/revalidate-feeds';
7+
8+
export async function revalidateFeedCache(
9+
feedId: string,
10+
): Promise<{ ok: boolean; message: string }> {
11+
const user = await getCurrentUserFromCookie();
12+
if (!isMobilityDatabaseAdmin(user?.email)) {
13+
return { ok: false, message: 'Unauthorized: admin access required' };
14+
}
15+
16+
revalidateSpecificFeeds([feedId]);
17+
return { ok: true, message: `Cache revalidated for feed ${feedId}` };
18+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client';
2+
import { useState, useTransition } from 'react';
3+
import Box from '@mui/material/Box';
4+
import Button from '@mui/material/Button';
5+
import Typography from '@mui/material/Typography';
6+
import { revalidateFeedCache } from '../actions';
7+
8+
interface Props {
9+
feedId: string;
10+
}
11+
12+
export default function RevalidateCacheButton({
13+
feedId,
14+
}: Props): React.ReactElement {
15+
const [isPending, startTransition] = useTransition();
16+
const [result, setResult] = useState<{
17+
ok: boolean;
18+
message: string;
19+
} | null>(null);
20+
21+
const handleClick = (): void => {
22+
setResult(null);
23+
24+
startTransition(async () => {
25+
try {
26+
const res = await revalidateFeedCache(feedId);
27+
setResult(res);
28+
} catch {
29+
setResult({
30+
ok: false,
31+
message: 'Failed to revalidate the cache. Please try again.',
32+
});
33+
}
34+
});
35+
};
36+
37+
return (
38+
<Box
39+
sx={{
40+
display: 'flex',
41+
flexDirection: 'column',
42+
gap: 1,
43+
width: 'fit-content',
44+
}}
45+
>
46+
<Button variant='contained' onClick={handleClick} disabled={isPending}>
47+
{isPending ? 'Revalidating…' : 'Revalidate The Cache of This Page'}
48+
</Button>
49+
{result != null && !isPending && (
50+
<Typography
51+
variant='caption'
52+
color={result.ok ? 'success.main' : 'error.main'}
53+
>
54+
{result.message}
55+
</Typography>
56+
)}
57+
</Box>
58+
);
59+
}

src/app/utils/auth-server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,9 @@ export async function getUserContextJwtFromCookie(): Promise<
225225
return undefined;
226226
}
227227
}
228+
229+
export function isMobilityDatabaseAdmin(email: string | undefined): boolean {
230+
return (
231+
email?.trim().toLocaleLowerCase().endsWith('@mobilitydata.org') === true
232+
);
233+
}

src/app/utils/revalidate-feeds.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import 'server-only';
2+
import { revalidatePath, revalidateTag } from 'next/cache';
3+
import { AVAILABLE_LOCALES } from '../../i18n/routing';
4+
5+
/**
6+
* Revalidates the ISR cache for specific feed pages.
7+
* Applies to all feed types (gtfs, gtfs_rt, gbfs) since we don't know the type from the id alone.
8+
* Also revalidates localized paths and /map sub-routes.
9+
*/
10+
export function revalidateSpecificFeeds(feedIds: string[]): void {
11+
const localPaths = AVAILABLE_LOCALES.filter((loc) => loc !== 'en');
12+
const pathsToRevalidate: string[] = [];
13+
14+
feedIds.forEach((id) => {
15+
revalidateTag(`feed-${id}`, 'max');
16+
// 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
17+
pathsToRevalidate.push(`/feeds/gtfs/${id}`);
18+
pathsToRevalidate.push(`/feeds/gtfs_rt/${id}`);
19+
pathsToRevalidate.push(`/feeds/gbfs/${id}`);
20+
});
21+
22+
console.log('Revalidating paths:', pathsToRevalidate);
23+
24+
pathsToRevalidate.forEach((path) => {
25+
revalidatePath(path);
26+
revalidatePath(path + '/map');
27+
localPaths.forEach((loc) => {
28+
revalidatePath(`/${loc}${path}`);
29+
revalidatePath(`/${loc}${path}/map`);
30+
});
31+
});
32+
}
33+
34+
/** Clears cache for all feed pages (ISR-cached layout). */
35+
export function revalidateAllFeeds(): void {
36+
revalidateTag('guest-feeds', 'max');
37+
revalidatePath('/[locale]/feeds/[feedDataType]/[feedId]', 'layout');
38+
}
39+
40+
/** Clears cache for all GBFS feed pages (ISR-cached layout). */
41+
export function revalidateAllGbfsFeeds(): void {
42+
revalidateTag('feed-type-gbfs', 'max');
43+
revalidatePath('/[locale]/feeds/gbfs/[feedId]', 'layout');
44+
}
45+
46+
/** Clears cache for all GTFS feed pages (ISR-cached layout). */
47+
export function revalidateAllGtfsFeeds(): void {
48+
revalidateTag('feed-type-gtfs', 'max');
49+
revalidatePath('/[locale]/feeds/gtfs/[feedId]', 'layout');
50+
}
51+
52+
/** Clears cache for all GTFS-RT feed pages (ISR-cached layout). */
53+
export function revalidateAllGtfsRtFeeds(): void {
54+
revalidateTag('feed-type-gtfs_rt', 'max');
55+
revalidatePath('/[locale]/feeds/gtfs_rt/[feedId]', 'layout');
56+
}
57+
58+
/** Clears cache for the entire site. */
59+
export function revalidateFullSite(): void {
60+
revalidateTag('guest-feeds', 'max');
61+
revalidatePath('/', 'layout');
62+
}

0 commit comments

Comments
 (0)