From e0369e00aedaaddab02ffe9d262640990d928751 Mon Sep 17 00:00:00 2001 From: Abhishekfm Date: Fri, 6 Nov 2026 11:46:03 +0530 Subject: [PATCH] Refactor AI model and dataset components to utilize public URLs and slugs for improved routing and metadata generation. Update GraphQL queries to support publicId and urlSlug, enhancing link handling across various pages. Implement canonical redirects for better SEO and user experience. --- .../aimodels/[modelId]/AIModelDetailsPage.tsx | 10 +-- .../(user)/aimodels/[modelId]/page.tsx | 61 ++++++++++++----- .../CollaborativeDetailsClient.tsx | 6 +- app/[locale]/(user)/components/Datasets.tsx | 4 +- .../(user)/components/ListingComponent.tsx | 10 ++- app/[locale]/(user)/components/UseCases.tsx | 4 +- .../DatasetDetailsPage.tsx | 30 +++++---- .../DatasetConnected/DatasetConnected.tsx | 5 +- .../components/SimilarDatasets/index.tsx | 4 +- .../[datasetIdentifier]/dataset-cover.ts | 7 +- .../datasets/[datasetIdentifier]/page.tsx | 39 +++++++++-- .../(user)/events/[eventSlug]/page.tsx | 13 +++- .../(user)/publishers/components/Datasets.tsx | 5 +- .../(user)/publishers/components/UseCases.tsx | 5 +- .../components/UnifiedListingComponent.tsx | 8 ++- .../[useCaseSlug]/UsecaseDetailsClient.tsx | 43 ++++++------ .../(user)/usecases/[useCaseSlug]/page.tsx | 66 +++++++++++++++++-- .../usecases/components/DatasetStories.tsx | 4 +- .../usecases/components/usecase-cover.ts | 7 +- .../aimodels/edit/[id]/publish/page.tsx | 4 +- .../usecases/edit/[id]/publish/page.tsx | 8 ++- app/sitemap/[entityPage]/route.ts | 10 +-- lib/canonical-redirect.ts | 28 ++++++++ lib/public-link.ts | 47 +++++++++++++ lib/utils.ts | 2 + 25 files changed, 339 insertions(+), 91 deletions(-) create mode 100644 lib/canonical-redirect.ts create mode 100644 lib/public-link.ts diff --git a/app/[locale]/(user)/aimodels/[modelId]/AIModelDetailsPage.tsx b/app/[locale]/(user)/aimodels/[modelId]/AIModelDetailsPage.tsx index 2af6bb93..4269055c 100644 --- a/app/[locale]/(user)/aimodels/[modelId]/AIModelDetailsPage.tsx +++ b/app/[locale]/(user)/aimodels/[modelId]/AIModelDetailsPage.tsx @@ -7,15 +7,17 @@ import { Spinner, Text } from 'opub-ui'; import BreadCrumbs from '@/components/BreadCrumbs'; import JsonLd from '@/components/JsonLd'; import { GraphQL } from '@/lib/api'; +import { aiModelLookup, publicPageUrl } from '@/lib/public-link'; import { generateJsonLd } from '@/lib/utils'; import Metadata from './components/Metadata'; import PrimaryData from './components/PrimaryData'; import Versions from './components/Versions'; const aiModelQuery = graphql(` - query getAIModel($modelId: Int!) { - getAiModel(modelId: $modelId) { + query getAIModel($modelId: Int, $publicId: String) { + getAiModel(modelId: $modelId, publicId: $publicId) { id + urlSlug name displayName description @@ -93,7 +95,7 @@ export default function AIModelDetailsPage({ }) { const { data, isLoading, error } = useQuery( [`aimodel_details_${modelId}`], - () => GraphQL(aiModelQuery, {}, { modelId: parseInt(modelId) }), + () => GraphQL(aiModelQuery, {}, aiModelLookup(modelId)), { retry: false, onError: (err) => { @@ -108,7 +110,7 @@ export default function AIModelDetailsPage({ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: modelData?.displayName || modelData?.name, - url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/aimodels/${modelId}`, + url: publicPageUrl('aimodels', modelData?.urlSlug || modelId), description: modelData?.description, applicationCategory: 'AI Model', publisher: { diff --git a/app/[locale]/(user)/aimodels/[modelId]/page.tsx b/app/[locale]/(user)/aimodels/[modelId]/page.tsx index da694b35..19a06915 100644 --- a/app/[locale]/(user)/aimodels/[modelId]/page.tsx +++ b/app/[locale]/(user)/aimodels/[modelId]/page.tsx @@ -1,45 +1,76 @@ +import { graphql } from '@/gql'; + +import { GraphQL } from '@/lib/api'; +import { + isCanonicalRedirect, + redirectToCanonical, +} from '@/lib/canonical-redirect'; +import { aiModelLookup, publicPageUrl } from '@/lib/public-link'; import { generatePageMetadata } from '@/lib/utils'; -import { use } from 'react'; import AIModelDetailsPage from './AIModelDetailsPage'; -// For now, we'll use a simple metadata generation -// You can add GraphQL query later if needed +const aiModelMetaQuery = graphql(` + query getAIModelInfo($modelId: Int, $publicId: String) { + getAiModel(modelId: $modelId, publicId: $publicId) { + name + displayName + description + urlSlug + } + } +`); + export async function generateMetadata({ params, }: { params: Promise<{ modelId: string }>; }) { + const { modelId } = await params; try { - const { modelId } = await params; - // TODO: Fetch AI model data from backend API - // const res = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/aimodels/${modelId}`); - // const model = await res.json(); - + const res = await GraphQL(aiModelMetaQuery, {}, aiModelLookup(modelId)); + const model = res?.getAiModel; + await redirectToCanonical('aimodels', modelId, model?.urlSlug); + const title = model?.displayName || model?.name || 'AI Model Details'; + const description = + model?.description || + 'Explore AI model details, capabilities, and specifications'; + const segment = model?.urlSlug || modelId; + return generatePageMetadata({ - title: `AI Model Details | CivicDataSpace`, - description: 'Explore AI model details, capabilities, and specifications', + title: `${title} | CivicDataSpace`, + description, keywords: ['AI Model', 'Machine Learning', 'API', 'Model Registry'], openGraph: { type: 'website', locale: 'en_US', - url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/aimodels/${modelId}`, - title: 'AI Model Details', - description: 'Explore AI model details and capabilities', + url: publicPageUrl('aimodels', segment), + title, + description, siteName: 'CivicDataSpace', image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, }, }); } catch (e) { + if (isCanonicalRedirect(e)) throw e; console.error('Metadata fetch error', e); return generatePageMetadata({ title: 'AI Model Details' }); } } -export default function Page({ +export default async function Page({ params, }: { params: Promise<{ modelId: string }>; }) { - const { modelId } = use(params); + const { modelId } = await params; + + try { + const res = await GraphQL(aiModelMetaQuery, {}, aiModelLookup(modelId)); + await redirectToCanonical('aimodels', modelId, res?.getAiModel?.urlSlug); + } catch (error) { + if (isCanonicalRedirect(error)) throw error; + console.error('AI model lookup error', error); + } + return ; } diff --git a/app/[locale]/(user)/collaboratives/[collaborativeSlug]/CollaborativeDetailsClient.tsx b/app/[locale]/(user)/collaboratives/[collaborativeSlug]/CollaborativeDetailsClient.tsx index fc73cd3f..042db2f5 100644 --- a/app/[locale]/(user)/collaboratives/[collaborativeSlug]/CollaborativeDetailsClient.tsx +++ b/app/[locale]/(user)/collaboratives/[collaborativeSlug]/CollaborativeDetailsClient.tsx @@ -95,6 +95,7 @@ const CollaborativeDetails = graphql(` datasets { title id + urlSlug isIndividualDataset user { fullName @@ -133,6 +134,7 @@ const CollaborativeDetails = graphql(` } useCases { id + urlSlug title summary slug @@ -539,7 +541,7 @@ const CollaborativeDetailClient = () => { iconColor="success" href={getPlatformEntityUrl( 'usecases', - useCase.id, + useCase.urlSlug || useCase.id, locale )} /> @@ -597,7 +599,7 @@ const CollaborativeDetailClient = () => { ]} href={getPlatformEntityUrl( 'datasets', - dataset.id, + dataset.urlSlug || dataset.id, locale )} leftFooterChips={[ diff --git a/app/[locale]/(user)/components/Datasets.tsx b/app/[locale]/(user)/components/Datasets.tsx index c0d473e0..f9fb7cec 100644 --- a/app/[locale]/(user)/components/Datasets.tsx +++ b/app/[locale]/(user)/components/Datasets.tsx @@ -14,6 +14,7 @@ import { Text, } from 'opub-ui'; +import { publicHref } from '@/lib/public-link'; import { cn, mediaUrl } from '@/lib/utils'; import { Icons } from '@/components/icons'; import { DatasetListingSkeleton } from '@/components/loading'; @@ -40,6 +41,7 @@ interface DatasetPublisher { interface DatasetResult { id: string | number; + url_slug?: string | null; title: string; description?: string; modified: string; @@ -188,7 +190,7 @@ const Datasets = () => { ]} variation={'collapsed'} iconColor="metadata" - href={`/datasets/${item.id}`} + href={publicHref('datasets', item.url_slug, item.id)} withViewButton={false} // type={[ // { diff --git a/app/[locale]/(user)/components/ListingComponent.tsx b/app/[locale]/(user)/components/ListingComponent.tsx index 0ac70fa0..4dc5595d 100644 --- a/app/[locale]/(user)/components/ListingComponent.tsx +++ b/app/[locale]/(user)/components/ListingComponent.tsx @@ -18,6 +18,7 @@ import { Tray, } from 'opub-ui'; +import { publicHref } from '@/lib/public-link'; import { cn, formatDate, mediaUrl } from '@/lib/utils'; import BreadCrumbs from '@/components/BreadCrumbs'; import { Icons } from '@/components/icons'; @@ -58,6 +59,7 @@ interface ListingSdg { interface ListingResult { id: string | number; + url_slug?: string | null; title: string; description?: string; modified: string; @@ -722,7 +724,13 @@ const ListingComponent: React.FC = ({ view === 'expanded' ? 'expanded' : 'collapsed' } iconColor="warning" - href={`${redirectionURL}/${item.id}`} + href={publicHref( + redirectionURL === '/usecases' || type === 'usecase' + ? 'usecases' + : 'datasets', + item.url_slug, + item.id + )} data-tour={ index === 0 && type === 'dataset' ? 'dataset-card' diff --git a/app/[locale]/(user)/components/UseCases.tsx b/app/[locale]/(user)/components/UseCases.tsx index 970d3ad8..3f9548bb 100644 --- a/app/[locale]/(user)/components/UseCases.tsx +++ b/app/[locale]/(user)/components/UseCases.tsx @@ -16,6 +16,7 @@ import { } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { publicHref } from '@/lib/public-link'; import { cn, formatDate, mediaUrl } from '@/lib/utils'; import { Icons } from '@/components/icons'; import { UseCaseListingSkeleton } from '@/components/loading'; @@ -29,6 +30,7 @@ const useCasesListDoc = graphql(` ) { publishedUseCases(filters: $filters, pagination: $pagination) { id + urlSlug title summary slug @@ -143,7 +145,7 @@ const UseCasesListingPage = () => { + GraphQL(datasetQuery, {}, datasetLookup(datasetId)) + ); + const recordId = datasetQueryResult.data?.getDataset?.id; + // Record one view per dataset, whichever tab the visitor opens. useEffect(() => { - trackDataset(datasetId); - }, [datasetId, trackDataset]); + if (recordId) trackDataset(recordId); + }, [recordId, trackDataset]); - // Load this dataset. - const datasetQueryResult = useQuery(['dataset-cover', datasetId], () => - GraphQL(datasetQuery, {}, { datasetId }) - ); - // Count the charts that belong to this dataset. - const chartQueryResult = useQuery(['dataset-charts', datasetId], () => - GraphQL(chartCountQuery, {}, { datasetId }) + // Count the charts that belong to this dataset. Charts are keyed by the UUID. + const chartQueryResult = useQuery( + ['dataset-charts', recordId], + () => GraphQL(chartCountQuery, {}, { datasetId: recordId as string }), + { enabled: Boolean(recordId) } ); // Collect published use cases so the summary can count the ones that include this dataset. const useCaseQueryResult = useQuery(['dataset-use-case-ids'], () => @@ -157,7 +163,7 @@ export default function DatasetDetailsPage({ const dataset = datasetQueryResult.data?.getDataset; // Public address the share actions use. - const pageUrl = buildDatasetPageUrl(datasetId); + const pageUrl = buildDatasetPageUrl(dataset?.urlSlug || datasetId); const jsonLd = generateJsonLd({ '@context': 'https://schema.org', '@type': 'Dataset', diff --git a/app/[locale]/(user)/datasets/[datasetIdentifier]/components/DatasetConnected/DatasetConnected.tsx b/app/[locale]/(user)/datasets/[datasetIdentifier]/components/DatasetConnected/DatasetConnected.tsx index 6a213aee..ef2877bf 100644 --- a/app/[locale]/(user)/datasets/[datasetIdentifier]/components/DatasetConnected/DatasetConnected.tsx +++ b/app/[locale]/(user)/datasets/[datasetIdentifier]/components/DatasetConnected/DatasetConnected.tsx @@ -7,6 +7,7 @@ import { Card, Spinner } from 'opub-ui'; import { Icons } from '@/components/icons'; import { GraphQLPublic } from '@/lib/api'; +import { publicHref } from '@/lib/public-link'; import { backendFileUrl, joinNames } from '@/lib/public-entity'; import { formatDate } from '@/lib/utils'; import { stripMarkdown } from '@/app/[locale]/(user)/search/components/UnifiedListingComponent'; @@ -17,6 +18,7 @@ const connectedUseCaseQuery = graphql(` query connectedUseCaseCard($pk: ID!) { useCase(pk: $pk) { id + urlSlug title summary slug @@ -91,6 +93,7 @@ export type ConnectedUseCase = { id: string; title?: string | null; summary?: string | null; + urlSlug?: string | null; slug?: string | null; modified?: string | number | null; isIndividualUsecase?: boolean | null; @@ -393,7 +396,7 @@ function UseCaseCard({ item }: { item: ConnectedUseCase }) { metadataContent={metadataContent} variation="collapsed" iconColor="success" - href={`/usecases/${item.id}`} + href={publicHref('usecases', item.urlSlug, item.id)} imageUrl={backendFileUrl(item.logo?.url) || ''} leftFooterChips={[ { diff --git a/app/[locale]/(user)/datasets/[datasetIdentifier]/components/SimilarDatasets/index.tsx b/app/[locale]/(user)/datasets/[datasetIdentifier]/components/SimilarDatasets/index.tsx index f464423e..da24ce47 100644 --- a/app/[locale]/(user)/datasets/[datasetIdentifier]/components/SimilarDatasets/index.tsx +++ b/app/[locale]/(user)/datasets/[datasetIdentifier]/components/SimilarDatasets/index.tsx @@ -13,6 +13,7 @@ import { } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { publicHref } from '@/lib/public-link'; import { cn, mediaUrl } from '@/lib/utils'; import { Icons } from '@/components/icons'; import Styles from './similarDatasets.module.scss'; @@ -25,6 +26,7 @@ const similarDatasetQuery = graphql(` similarDatasets { downloadCount id + urlSlug title tags { id @@ -184,7 +186,7 @@ const SimilarDatasets: React.FC = () => { ]} variation={'collapsed'} iconColor="warning" - href={`/datasets/${item.id}`} + href={publicHref('datasets', item.urlSlug, item.id)} withViewButton={false} /> diff --git a/app/[locale]/(user)/datasets/[datasetIdentifier]/dataset-cover.ts b/app/[locale]/(user)/datasets/[datasetIdentifier]/dataset-cover.ts index 6afe8642..5fed8737 100644 --- a/app/[locale]/(user)/datasets/[datasetIdentifier]/dataset-cover.ts +++ b/app/[locale]/(user)/datasets/[datasetIdentifier]/dataset-cover.ts @@ -4,7 +4,7 @@ * Also builds the public page address used by the share actions. */ -import { env } from '@/env'; +import { publicPageUrl } from '@/lib/public-link'; import { backendFileUrl, formatShortDate, @@ -128,9 +128,8 @@ const CHARTS_ANCHOR = '#dataset-visualisations'; const CONNECTED_USE_CASES = '#dataset-connected-usecases'; const CONNECTED_COLLABORATIVES = '#dataset-connected-collaboratives'; -export function buildDatasetPageUrl(datasetId: string): string { - const base = env.NEXT_PUBLIC_PLATFORM_URL.replace(/\/$/, ''); - return `${base}/datasets/${encodeURIComponent(datasetId)}`; +export function buildDatasetPageUrl(segment: string): string { + return publicPageUrl('datasets', segment); } export function buildDatasetCover( diff --git a/app/[locale]/(user)/datasets/[datasetIdentifier]/page.tsx b/app/[locale]/(user)/datasets/[datasetIdentifier]/page.tsx index 556f14eb..dcb794d9 100644 --- a/app/[locale]/(user)/datasets/[datasetIdentifier]/page.tsx +++ b/app/[locale]/(user)/datasets/[datasetIdentifier]/page.tsx @@ -2,15 +2,21 @@ import { graphql } from '@/gql'; import { ClientError } from 'graphql-request'; import { GraphQL } from '@/lib/api'; +import { + isCanonicalRedirect, + redirectToCanonical, +} from '@/lib/canonical-redirect'; +import { datasetLookup, publicPageUrl } from '@/lib/public-link'; import { generatePageMetadata } from '@/lib/utils'; import DatasetDetailsPage from './DatasetDetailsPage'; const datasetMetaQuery = graphql(` - query getDatasetInfo($datasetId: UUID!) { - getDataset(datasetId: $datasetId) { + query getDatasetInfo($datasetId: UUID, $publicId: String) { + getDataset(datasetId: $datasetId, publicId: $publicId) { title description id + urlSlug tags { id value @@ -42,10 +48,16 @@ export async function generateMetadata({ const res = await GraphQL( datasetMetaQuery, {}, - { datasetId: datasetIdentifier } + datasetLookup(datasetIdentifier) ); const dataset = res?.getDataset; + await redirectToCanonical( + 'datasets', + datasetIdentifier, + dataset?.urlSlug + ); + const segment = dataset?.urlSlug || datasetIdentifier; return generatePageMetadata({ title: `${dataset?.title ?? ''} | Dataset | CivicDataSpace`, description: dataset?.description ?? undefined, @@ -53,7 +65,7 @@ export async function generateMetadata({ openGraph: { type: 'dataset', locale: 'en_US', - url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/datasets/${datasetIdentifier}`, + url: publicPageUrl('datasets', segment), title: dataset?.title ?? '', description: dataset?.description ?? '', siteName: 'CivicDataSpace', @@ -61,6 +73,7 @@ export async function generateMetadata({ }, }); } catch (e) { + if (isCanonicalRedirect(e)) throw e; if (!isUnpublishedDatasetError(e)) { console.error('Metadata fetch error', e); } @@ -75,5 +88,23 @@ export default async function Page({ }) { const { datasetIdentifier } = await params; + try { + const res = await GraphQL( + datasetMetaQuery, + {}, + datasetLookup(datasetIdentifier) + ); + await redirectToCanonical( + 'datasets', + datasetIdentifier, + res?.getDataset?.urlSlug + ); + } catch (error) { + if (isCanonicalRedirect(error)) throw error; + if (!isUnpublishedDatasetError(error)) { + console.error('Dataset lookup error', error); + } + } + return ; } diff --git a/app/[locale]/(user)/events/[eventSlug]/page.tsx b/app/[locale]/(user)/events/[eventSlug]/page.tsx index 63a98061..07156fe9 100644 --- a/app/[locale]/(user)/events/[eventSlug]/page.tsx +++ b/app/[locale]/(user)/events/[eventSlug]/page.tsx @@ -3,6 +3,11 @@ import { Text } from 'opub-ui'; import { GraphQLPublic } from '@/lib/api'; import { lumaCover } from '@/lib/luma-cover'; +import { + isCanonicalRedirect, + redirectToCanonical, +} from '@/lib/canonical-redirect'; +import { publicPageUrl } from '@/lib/public-link'; import { generatePageMetadata } from '@/lib/utils'; import BreadCrumbs from '@/components/BreadCrumbs'; import { @@ -26,7 +31,9 @@ export async function generateMetadata({ { publicId: eventSlug } ); const event = result.eventBySlug; + await redirectToCanonical('events', eventSlug, event?.urlSlug); const title = event?.name?.trim() || 'Event'; + const segment = event?.urlSlug || eventSlug; return generatePageMetadata({ title: `${title} | Events | CivicDataSpace`, description: event?.location?.trim() || title, @@ -34,14 +41,15 @@ export async function generateMetadata({ openGraph: { type: 'article', locale: 'en_US', - url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/events/${eventSlug}`, + url: publicPageUrl('events', segment), title, description: event?.location?.trim() || title, siteName: 'CivicDataSpace', image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, }, }); - } catch { + } catch (error) { + if (isCanonicalRedirect(error)) throw error; return generatePageMetadata({ title: 'Event | CivicDataSpace', description: 'An event on CivicDataSpace.', @@ -72,6 +80,7 @@ export default async function EventDetailPage({ ).catch(() => null); const event = result?.eventBySlug; if (!event) notFound(); + await redirectToCanonical('events', eventSlug, event.urlSlug); const name = event.name?.trim() || 'Untitled event'; const cover = await lumaCover(event.lumaEmbed?.src); diff --git a/app/[locale]/(user)/publishers/components/Datasets.tsx b/app/[locale]/(user)/publishers/components/Datasets.tsx index 20dcc567..54a4ae2d 100644 --- a/app/[locale]/(user)/publishers/components/Datasets.tsx +++ b/app/[locale]/(user)/publishers/components/Datasets.tsx @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query'; import { Card, Icon, Spinner, Text } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { publicHref } from '@/lib/public-link'; import { cn, extractPublisherId, mediaUrl } from '@/lib/utils'; import { Icons } from '@/components/icons'; import { stripMarkdown } from '../../search/components/UnifiedListingComponent'; @@ -16,6 +17,7 @@ const userPublishedDatasetsDoc = graphql(` query userPublishedDatasetsList($userId: ID!) { userPublishedDatasets(userId: $userId) { id + urlSlug title downloadCount id @@ -63,6 +65,7 @@ const organizationPublishedDatasetsDoc = graphql(` query organizationPublishedDatasetsList($organizationId: ID!) { organizationPublishedDatasets(organizationId: $organizationId) { id + urlSlug title downloadCount id @@ -212,7 +215,7 @@ const Datasets = ({ type }: { type: 'organization' | 'Publisher' }) => { ]} variation={'collapsed'} iconColor="warning" - href={`/datasets/${item.id}`} + href={publicHref('datasets', item.urlSlug, item.id)} withViewButton={false} /> )) diff --git a/app/[locale]/(user)/publishers/components/UseCases.tsx b/app/[locale]/(user)/publishers/components/UseCases.tsx index 6cb452ad..8140cc0a 100644 --- a/app/[locale]/(user)/publishers/components/UseCases.tsx +++ b/app/[locale]/(user)/publishers/components/UseCases.tsx @@ -9,6 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { Card, Icon, Spinner, Text } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { publicHref } from '@/lib/public-link'; import { cn, extractPublisherId, formatDate, mediaUrl } from '@/lib/utils'; import { Icons } from '@/components/icons'; import { getUseCaseSummaryExcerpt } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/content-document'; @@ -17,6 +18,7 @@ const userPublishedUseCasesDoc = graphql(` query userPublishedUseCasesList($userId: ID!) { userPublishedUseCases(userId: $userId) { id + urlSlug title summary slug @@ -62,6 +64,7 @@ const orgPublishedUseCasesDoc = graphql(` query orgPublishedUseCasesList($organizationId: ID!) { organizationPublishedUseCases(organizationId: $organizationId) { id + urlSlug title summary slug @@ -171,7 +174,7 @@ const UseCases = ({ type }: { type: 'organization' | 'Publisher' }) => { // ]} title={item.title ?? ''} key={index} - href={`/usecases/${item.id}`} + href={publicHref('usecases', item.urlSlug, item.id)} metadataContent={[ { icon: Icons.calendarEvent, diff --git a/app/[locale]/(user)/search/components/UnifiedListingComponent.tsx b/app/[locale]/(user)/search/components/UnifiedListingComponent.tsx index 55c58d91..0487e4c9 100644 --- a/app/[locale]/(user)/search/components/UnifiedListingComponent.tsx +++ b/app/[locale]/(user)/search/components/UnifiedListingComponent.tsx @@ -15,6 +15,7 @@ import { Tray, } from 'opub-ui'; +import { publicHref } from '@/lib/public-link'; import { cn, formatDate, mediaUrl } from '@/lib/utils'; import { getCollaborativeDetailUrl } from '@/lib/collaborativesRouting'; import BreadCrumbs from '@/components/BreadCrumbs'; @@ -113,6 +114,7 @@ interface SearchSector { interface SearchResult { type?: string; id: string | number; + url_slug?: string | null; slug?: string; publisher_type?: string; is_individual_dataset?: boolean; @@ -567,11 +569,11 @@ const UnifiedListingComponent: React.FC = ({ const getRedirectUrl = (item: SearchResult) => { switch (item.type) { case 'dataset': - return `/datasets/${item.id}`; + return publicHref('datasets', item.url_slug, item.id); case 'usecase': - return `/usecases/${item.id}`; + return publicHref('usecases', item.url_slug, item.id); case 'aimodel': - return `/aimodels/${item.id}`; + return publicHref('aimodels', item.url_slug, item.id); case 'collaborative': return getCollaborativeDetailUrl(item.slug); case 'publisher': diff --git a/app/[locale]/(user)/usecases/[useCaseSlug]/UsecaseDetailsClient.tsx b/app/[locale]/(user)/usecases/[useCaseSlug]/UsecaseDetailsClient.tsx index 6b55f1d0..401ab23c 100644 --- a/app/[locale]/(user)/usecases/[useCaseSlug]/UsecaseDetailsClient.tsx +++ b/app/[locale]/(user)/usecases/[useCaseSlug]/UsecaseDetailsClient.tsx @@ -39,9 +39,10 @@ import { UseCaseStory } from '../components/UseCaseStory'; import Dashboards from './Dashboards'; void graphql(` - query UseCasedetails($pk: ID!) { - useCase(pk: $pk) { + query UseCasedetails($publicId: String!) { + useCaseBySlug(publicId: $publicId) { id + urlSlug title summary created @@ -83,6 +84,7 @@ void graphql(` datasets { title id + urlSlug isIndividualDataset user { fullName @@ -144,12 +146,12 @@ const UseCaseDetailClient = () => { } = useQuery( [`fetch_UsecaseDetails_${params.useCaseSlug}`], async () => { + const segment = + typeof params.useCaseSlug === 'string' ? params.useCaseSlug : ''; const result = await GraphQLPublic( UseCasedetailsDocument, {}, - { - pk: typeof params.useCaseSlug === 'string' ? params.useCaseSlug : '', - } + { publicId: segment } ); return result; }, @@ -164,26 +166,31 @@ const UseCaseDetailClient = () => { // Track usecase view when data is loaded useEffect(() => { - if (UseCaseDetails?.useCase) { + if (UseCaseDetails?.useCaseBySlug) { trackUsecase( - UseCaseDetails.useCase.id, - UseCaseDetails.useCase.title || undefined + UseCaseDetails.useCaseBySlug.id, + UseCaseDetails.useCaseBySlug.title || undefined ); } - }, [UseCaseDetails?.useCase, trackUsecase]); + }, [UseCaseDetails?.useCaseBySlug, trackUsecase]); + + const pageSegment = + UseCaseDetails?.useCaseBySlug?.urlSlug || + (typeof params.useCaseSlug === 'string' ? params.useCaseSlug : ''); + const pageUrl = buildUseCasePageUrl(pageSegment); const jsonLd = generateJsonLd({ '@context': 'https://schema.org', '@type': 'WebPage', name: 'CivicDataLab', - url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases/${params.useCaseSlug}`, + url: pageUrl, description: - getUseCaseSummaryExcerpt(UseCaseDetails?.useCase?.summary) || - `Explore open data and curated datasets in the ${UseCaseDetails?.useCase?.title} sector.`, + getUseCaseSummaryExcerpt(UseCaseDetails?.useCaseBySlug?.summary) || + `Explore open data and curated datasets in the ${UseCaseDetails?.useCaseBySlug?.title} sector.`, publisher: { '@type': 'Organization', name: 'CivicDataSpace', - url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases/${params.useCaseSlug}`, + url: pageUrl, }, }); @@ -209,7 +216,7 @@ const UseCaseDetailClient = () => { - ) : !UseCaseDetails?.useCase ? ( + ) : !UseCaseDetails?.useCaseBySlug ? (
Use Case Not Found @@ -220,10 +227,8 @@ const UseCaseDetailClient = () => {
) : ( )}
@@ -235,7 +240,7 @@ function PublishedUseCase({ useCase, pageSlug, }: { - useCase: NonNullable; + useCase: NonNullable; pageSlug: string; }) { // Shape the cover from the published record. diff --git a/app/[locale]/(user)/usecases/[useCaseSlug]/page.tsx b/app/[locale]/(user)/usecases/[useCaseSlug]/page.tsx index 6c54f13a..77292bf5 100644 --- a/app/[locale]/(user)/usecases/[useCaseSlug]/page.tsx +++ b/app/[locale]/(user)/usecases/[useCaseSlug]/page.tsx @@ -2,17 +2,40 @@ import { Metadata } from 'next'; import { graphql } from '@/gql'; import { GraphQLPublic } from '@/lib/api'; +import { + isCanonicalRedirect, + redirectToCanonical, +} from '@/lib/canonical-redirect'; +import { isIntegerId, publicPageUrl } from '@/lib/public-link'; import { generatePageMetadata } from '@/lib/utils'; import { getUseCaseSummaryExcerpt } from '@/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/content-document'; import UseCaseDetailClient from './UsecaseDetailsClient'; const UseCaseInfoQuery = graphql(` - query UseCaseInfo($pk: ID!) { + query UseCaseInfo($publicId: String!) { + useCaseBySlug(publicId: $publicId) { + id + title + summary + urlSlug + logo { + url + } + tags { + id + value + } + } + } +`); + +const UseCaseLegacyInfoQuery = graphql(` + query UseCaseLegacyInfo($pk: ID!) { useCase(pk: $pk) { id title summary - slug + urlSlug logo { url } @@ -24,6 +47,20 @@ const UseCaseInfoQuery = graphql(` } `); +async function loadUseCase(segment: string) { + if (isIntegerId(segment)) { + const data = await GraphQLPublic( + UseCaseLegacyInfoQuery, + {}, + { pk: segment } + ); + return data.useCase; + } + + const data = await GraphQLPublic(UseCaseInfoQuery, {}, { publicId: segment }); + return data.useCaseBySlug; +} + export async function generateMetadata({ params, }: { @@ -31,8 +68,9 @@ export async function generateMetadata({ }): Promise { const { useCaseSlug } = await params; try { - const data = await GraphQLPublic(UseCaseInfoQuery, {}, { pk: useCaseSlug }); - const UseCase = data?.useCase; + const UseCase = await loadUseCase(useCaseSlug); + await redirectToCanonical('usecases', useCaseSlug, UseCase?.urlSlug); + const segment = UseCase?.urlSlug || useCaseSlug; return generatePageMetadata({ title: `${UseCase?.title} | Sector Data | CivicDataSpace`, @@ -43,7 +81,7 @@ export async function generateMetadata({ openGraph: { type: 'article', locale: 'en_US', - url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases/${useCaseSlug}`, + url: publicPageUrl('usecases', segment), title: `${UseCase?.title} | Sector Data | CivicDataSpace`, description: getUseCaseSummaryExcerpt(UseCase?.summary) || @@ -53,7 +91,7 @@ export async function generateMetadata({ }, }); } catch (error) { - // Fallback to generic metadata if the API call fails + if (isCanonicalRedirect(error)) throw error; console.error('Error fetching use case info:', error); return generatePageMetadata({ title: `Use Case Details | CivicDataSpace`, @@ -72,6 +110,20 @@ export async function generateMetadata({ } } -export default function Page() { +export default async function Page({ + params, +}: { + params: Promise<{ useCaseSlug: string }>; +}) { + const { useCaseSlug } = await params; + + try { + const useCase = await loadUseCase(useCaseSlug); + await redirectToCanonical('usecases', useCaseSlug, useCase?.urlSlug); + } catch (error) { + if (isCanonicalRedirect(error)) throw error; + console.error('Use case lookup error', error); + } + return ; } diff --git a/app/[locale]/(user)/usecases/components/DatasetStories.tsx b/app/[locale]/(user)/usecases/components/DatasetStories.tsx index f6cab9d3..29f9bf9d 100644 --- a/app/[locale]/(user)/usecases/components/DatasetStories.tsx +++ b/app/[locale]/(user)/usecases/components/DatasetStories.tsx @@ -2,6 +2,7 @@ import { Card } from 'opub-ui'; +import { publicHref } from '@/lib/public-link'; import { backendFileUrl } from '@/lib/public-entity'; import { formatDate } from '@/lib/utils'; import { Icons } from '@/components/icons'; @@ -9,6 +10,7 @@ import { stripMarkdown } from '../../search/components/UnifiedListingComponent'; type StoryDataset = { id: string; + urlSlug?: string | null; title?: string | null; description?: string | null; modified?: string | null; @@ -132,7 +134,7 @@ function listingCardProps(dataset: StoryDataset) { ], variation: 'collapsed' as const, iconColor: 'warning' as const, - href: `/datasets/${dataset.id}`, + href: publicHref('datasets', dataset.urlSlug, dataset.id), withViewButton: false, }; } diff --git a/app/[locale]/(user)/usecases/components/usecase-cover.ts b/app/[locale]/(user)/usecases/components/usecase-cover.ts index 7ad8220d..242f4885 100644 --- a/app/[locale]/(user)/usecases/components/usecase-cover.ts +++ b/app/[locale]/(user)/usecases/components/usecase-cover.ts @@ -9,7 +9,7 @@ import type { EntityCoverMeta, EntityCoverPublisher, } from '@/components/entity-cover/EntityCover'; -import { env } from '@/env'; +import { publicPageUrl } from '@/lib/public-link'; import { backendFileUrl, formatShortDate, @@ -47,9 +47,8 @@ export type UseCaseCover = { meta: EntityCoverMeta[]; }; -export function buildUseCasePageUrl(slug: string): string { - const base = env.NEXT_PUBLIC_PLATFORM_URL.replace(/\/$/, ''); - return `${base}/usecases/${encodeURIComponent(slug)}`; +export function buildUseCasePageUrl(segment: string): string { + return publicPageUrl('usecases', segment); } export function buildUseCaseCover(useCase: UseCaseCoverSource): UseCaseCover { diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/publish/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/publish/page.tsx index d6113558..03c74f9c 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/publish/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/publish/page.tsx @@ -17,6 +17,7 @@ import { TriangleAlert } from 'lucide-react'; import { Button, SectionCard, Spinner, Tag, Text, toast } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { publicHref } from '@/lib/public-link'; import { RichTextRenderer } from '@/components/RichTextRenderer'; import { isAccessMethodComplete, @@ -31,6 +32,7 @@ export const FetchAIModelForPublish = graphql(` query AIModelForPublish($filters: AIModelFilter) { aiModels(filters: $filters) { id + urlSlug name displayName description @@ -333,7 +335,7 @@ export default function PublishPage() { const ready = issues.length === 0; const metadata = model?.metadata; - const previewHref = `/aimodels/${params.id}`; + const previewHref = publicHref('aimodels', model?.urlSlug, model?.id || params.id); const summaryRows: Array<{ label: string; value: ReactNode }> = [ { label: 'MODEL NAME', value: model?.displayName || model?.name || '' }, { diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/publish/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/publish/page.tsx index dfb2cfc9..f86a40bd 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/publish/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/publish/page.tsx @@ -23,6 +23,7 @@ import { import { Button, SectionCard, Spinner, Text, toast } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { publicHref } from '@/lib/public-link'; import { mediaUrl } from '@/lib/utils'; import MediaImage from '@/components/MediaImage'; import { @@ -42,6 +43,7 @@ const FetchUseCaseReview = graphql(` query UseCaseReviewData($filters: UseCaseFilter) { useCases(filters: $filters) { id + urlSlug title summary slug @@ -205,7 +207,11 @@ export default function PublishPage() { const connectComplete = isUseCaseConnectComplete(useCase ?? {}); const hasContent = getUseCaseHasContent(useCase?.summary); const ready = builderComplete && connectComplete && hasContent; - const previewHref = `/usecases/${useCase?.slug || useCase?.id || params.id}`; + const previewHref = publicHref( + 'usecases', + useCase?.urlSlug, + useCase?.id || params.id + ); const builderIssues: string[] = []; if (!useCase?.title?.trim()) builderIssues.push('Enter a use case title.'); diff --git a/app/sitemap/[entityPage]/route.ts b/app/sitemap/[entityPage]/route.ts index df6ece46..9d39690d 100644 --- a/app/sitemap/[entityPage]/route.ts +++ b/app/sitemap/[entityPage]/route.ts @@ -12,6 +12,8 @@ import { interface EntityItem { // AIModel ids are GraphQL Int; every other entity's are strings. id: string | number; + urlSlug?: string; + url_slug?: string; slug?: string; name?: string; fullName?: string; @@ -76,13 +78,11 @@ function getEntityLoc( case 'collaboratives': return `${baseUrl}/collaboratives/${encodeURIComponent(item.slug || item.id)}`; case 'datasets': - // App routes use dataset id (not slug). - return `${baseUrl}/datasets/${item.id}`; + return `${baseUrl}/datasets/${encodeURIComponent(item.url_slug || item.urlSlug || String(item.id))}`; case 'aimodels': - return `${baseUrl}/aimodels/${item.id}`; + return `${baseUrl}/aimodels/${encodeURIComponent(item.urlSlug || String(item.id))}`; case 'usecases': - // App routes use usecase id (not slug). - return `${baseUrl}/usecases/${item.id}`; + return `${baseUrl}/usecases/${encodeURIComponent(item.urlSlug || String(item.id))}`; case 'sectors': return `${baseUrl}/sectors/${encodeURIComponent(item.slug || item.id)}`; default: diff --git a/lib/canonical-redirect.ts b/lib/canonical-redirect.ts new file mode 100644 index 00000000..8e23e4b5 --- /dev/null +++ b/lib/canonical-redirect.ts @@ -0,0 +1,28 @@ +import { getLocale } from 'next-intl/server'; +import { permanentRedirect } from 'next/navigation'; + +import { publicHref, type PublicSection } from '@/lib/public-link'; + +/** Next.js implements redirects by throwing. Callers must rethrow this error. */ +export function isCanonicalRedirect(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'digest' in error && + String((error as { digest?: unknown }).digest).startsWith('NEXT_REDIRECT') + ); +} + +/** 308 to the current urlSlug when the address is an old id or a renamed link. */ +export async function redirectToCanonical( + section: PublicSection, + requested: string, + urlSlug: string | null | undefined +): Promise { + const canonical = urlSlug?.trim(); + if (!canonical || canonical === requested) return; + + const locale = await getLocale(); + const prefix = locale === 'en' ? '' : `/${locale}`; + permanentRedirect(`${prefix}${publicHref(section, canonical, canonical)}`); +} diff --git a/lib/public-link.ts b/lib/public-link.ts new file mode 100644 index 00000000..84707a0c --- /dev/null +++ b/lib/public-link.ts @@ -0,0 +1,47 @@ +import { env } from '@/env'; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export type PublicSection = 'datasets' | 'usecases' | 'aimodels' | 'events'; + +/** Dataset primary keys are UUIDs. A UUID is an old link, so it is not a publicId. */ +export function isUuid(value: string): boolean { + return UUID_RE.test(value); +} + +/** Use case and AI model primary keys are integers. */ +export function isIntegerId(value: string): boolean { + return /^\d+$/.test(value); +} + +export function publicHref( + section: PublicSection, + urlSlug: string | null | undefined, + fallbackId: string | number +): string { + const segment = urlSlug?.trim() || String(fallbackId); + return `/${section}/${segment}`; +} + +export function publicPageUrl(section: PublicSection, segment: string): string { + const base = env.NEXT_PUBLIC_PLATFORM_URL.replace(/\/$/, ''); + return `${base}${publicHref(section, segment, segment)}`; +} + +export function datasetLookup(segment: string): { + datasetId?: string; + publicId?: string; +} { + return isUuid(segment) ? { datasetId: segment } : { publicId: segment }; +} + +export function aiModelLookup(segment: string): { + modelId?: number; + publicId?: string; +} { + return isIntegerId(segment) + ? { modelId: Number.parseInt(segment, 10) } + : { publicId: segment }; +} + diff --git a/lib/utils.ts b/lib/utils.ts index 9c7e4060..b829aa4c 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -232,6 +232,7 @@ export const ENTITY_CONFIG: ENTITY_CONFIG_TYPE = { graphqlQuery: `query SitemapAIModels { aiModels(filters: { isPublic: true, status: ACTIVE }) { id + urlSlug updatedAt } }`, @@ -243,6 +244,7 @@ export const ENTITY_CONFIG: ENTITY_CONFIG_TYPE = { graphqlQuery: `query PublishedUseCasesList { publishedUseCases { id + urlSlug slug } }`,