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 } }`,