diff --git a/migrations/20260720_000000_check_political_entity_slug_uniqueness.ts b/migrations/20260720_000000_check_political_entity_slug_uniqueness.ts new file mode 100644 index 00000000..15e687ca --- /dev/null +++ b/migrations/20260720_000000_check_political_entity_slug_uniqueness.ts @@ -0,0 +1,59 @@ +import type { MigrateUpArgs } from "@payloadcms/db-mongodb"; + +/** + * Political-entity slugs must be unique per tenant (enforced going forward by + * the `ensureUniqueSlug` beforeValidate hook). This migration verifies no + * pre-existing duplicates violate that invariant; it fails loudly so an + * operator resolves the conflicting slugs before deploying, since duplicate + * slugs make public entity resolution ambiguous. + */ +export async function up({ payload }: MigrateUpArgs): Promise { + const { docs } = await payload.find({ + collection: "political-entities", + pagination: false, + depth: 0, + overrideAccess: true, + }); + + const entitiesByKey = new Map(); + + for (const doc of docs) { + if (!doc.slug) { + continue; + } + const tenantId = + typeof doc.tenant === "string" + ? doc.tenant + : (doc.tenant?.id ?? "no-tenant"); + const key = `${tenantId}:${doc.slug}`; + const entries = entitiesByKey.get(key) ?? []; + entries.push(`${doc.name ?? "unnamed"} (${doc.id})`); + entitiesByKey.set(key, entries); + } + + const duplicates = [...entitiesByKey.entries()].filter( + ([, entries]) => entries.length > 1, + ); + + if (duplicates.length > 0) { + const details = duplicates + .map( + ([key, entries]) => + ` ${key}: ${entries.join(", ")}`, + ) + .join("\n"); + + throw new Error( + `Duplicate political-entity slugs detected (tenant:slug). ` + + `Resolve these before migrating:\n${details}`, + ); + } + + payload.logger.info( + "check_political_entity_slug_uniqueness:: no duplicate slugs found", + ); +} + +export async function down(): Promise { + // Verification-only migration; nothing to revert. +} diff --git a/src/app/(frontend)/[...slugs]/page.tsx b/src/app/(frontend)/[...slugs]/page.tsx index 997f6b03..5d74a065 100644 --- a/src/app/(frontend)/[...slugs]/page.tsx +++ b/src/app/(frontend)/[...slugs]/page.tsx @@ -20,6 +20,10 @@ import { getPageSeo, resolveTenantSeoContext, } from "@/lib/seo"; +import { + resolveGlobalRoute, + resolvePublicRoute, +} from "@/lib/routes/publicRoutes"; import { resolveTenantLocale } from "@/utils/locales"; import { resolveBrowserLocale } from "@/lib/locale"; import type { EntityPage } from "@/payload-types"; @@ -222,8 +226,16 @@ export default async function Page(params: Args) { const slugs = paramsValue?.slugs ?? []; if (!tenant) { - const pageSlug = slugs[0] ?? "index"; - const globalPage = await queryGlobalPageBySlug({ slug: pageSlug, locale }); + const globalRoute = resolveGlobalRoute(slugs); + + if (globalRoute.type === "not-found") { + return notFound(); + } + + const globalPage = await queryGlobalPageBySlug({ + slug: globalRoute.pageSlug, + locale, + }); if (!globalPage) { return notFound(); @@ -245,77 +257,71 @@ export default async function Page(params: Args) { ); } - const [maybePoliticalEntitySlug, pageSlugCandidate] = slugs; - const politicalEntities = await getPoliticalEntitiesByTenant(tenant, locale); + const resolution = resolvePublicRoute(slugs, politicalEntities); + + if (resolution.type === "not-found") { + return notFound(); + } - const politicalEntity = politicalEntities.find( - (entity) => entity.slug === maybePoliticalEntitySlug - ); const payload = await getGlobalPayload(); - if (!politicalEntity) { - const hasExplicitPageSlug = - Boolean(pageSlugCandidate) || Boolean(maybePoliticalEntitySlug); + if (resolution.type === "entity-selector") { + const entityPageSettings = await payload.findGlobal({ + slug: "entity-page", + locale, + }); + const entityBlocks = entityPageSettings?.entitySelector?.blocks ?? []; - if (!hasExplicitPageSlug) { - const entityPageSettings = await payload.findGlobal({ - slug: "entity-page", - locale, - }); - const entityBlocks = entityPageSettings?.entitySelector?.blocks ?? []; - - const entityNavMenus = - (entityPageSettings?.primaryNavigation?.menus || []) - .map((m) => toMenuLink(m?.link ?? m)) - .filter((x: MenuLink | null): x is MenuLink => Boolean(x)) || []; - - const entitySecondaryNavColumns = - (entityPageSettings?.secondaryNavigationList || []) - .map((entry) => { - const menus = entry?.secondaryNavigation?.menus || []; - const links = menus - .map((m) => toMenuLink(m?.link ?? m)) - .filter((x: MenuLink | null): x is MenuLink => Boolean(x)); - if (!entry?.secondaryNavigation?.titles && links.length === 0) { - return null; - } - return { - title: entry?.secondaryNavigation?.titles || null, - links, - }; - }) - .filter((col): col is { title: string | null; links: MenuLink[] } => - Boolean(col) - ) || []; - - const navigationForEntity = { ...navigation, menus: entityNavMenus }; - - const footerForTenant = - entitySecondaryNavColumns.length > 0 - ? { ...footer, secondaryNavColumns: entitySecondaryNavColumns } - : footer; + const entityNavMenus = + (entityPageSettings?.primaryNavigation?.menus || []) + .map((m) => toMenuLink(m?.link ?? m)) + .filter((x: MenuLink | null): x is MenuLink => Boolean(x)) || []; - return ( - <> - - - - -