diff --git a/__tests__/client/components/OnboardingChecklist.client.test.tsx b/__tests__/client/components/OnboardingChecklist.client.test.tsx index 2fca2a29..772f1d24 100644 --- a/__tests__/client/components/OnboardingChecklist.client.test.tsx +++ b/__tests__/client/components/OnboardingChecklist.client.test.tsx @@ -39,7 +39,8 @@ jest.mock('../../../src/collections/Tenants/components/onboardingActions', () => })) const buildStatus = (overrides: Partial = {}): ProvisioningStatus => ({ - builtInPages: { count: 0, expected: 7 }, + forecastPages: { count: 0, expected: 2 }, + defaultBuiltInPages: { count: 0, expected: 5 }, pages: { created: 0, expected: 5, missing: [] }, homePage: false, navigation: false, @@ -49,7 +50,8 @@ const buildStatus = (overrides: Partial = {}): ProvisioningS }) const fullyProvisioned = buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, navigation: true, @@ -83,7 +85,7 @@ describe('OnboardingChecklist', () => { render() await flushAsync() - expect(screen.queryByText('(0/7)')).not.toBeInTheDocument() + expect(screen.queryByText('(0/2)')).not.toBeInTheDocument() expect(screen.queryByText('(0/5)')).not.toBeInTheDocument() expect(screen.queryByText('colors.css')).not.toBeInTheDocument() expect(screen.queryByText('centerColorMap')).not.toBeInTheDocument() @@ -93,7 +95,8 @@ describe('OnboardingChecklist', () => { // Automated items complete but pages incomplete — needsProvisioning returns false, // so auto-provision doesn't run but the button shows const incompleteStatus = buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] }, homePage: true, navigation: true, @@ -119,10 +122,37 @@ describe('OnboardingChecklist', () => { }) describe('loaded', () => { + it('shows forecast and default built-in page labels', async () => { + render() + await flushAsync() + + expect(screen.getByText('Forecast Built-In pages')).toBeInTheDocument() + expect(screen.getByText('Default Built-In pages')).toBeInTheDocument() + }) + + it('shows forecast and default built-in page counts', async () => { + render() + await flushAsync() + + expect(screen.getByText('(2/2)')).toBeInTheDocument() + // (5/5) appears for both defaultBuiltInPages and pages + expect(screen.getAllByText('(5/5)')).toHaveLength(2) + }) + + it('shows NAC zones description when fully provisioned', async () => { + render() + await flushAsync() + + expect( + screen.getByText('Forecast built-ins are automatically based on NAC zones.'), + ).toBeInTheDocument() + }) + it('shows missing pages', async () => { mockCheckStatus.mockResolvedValue({ status: buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] }, homePage: true, navigation: true, diff --git a/__tests__/client/components/needsProvisioning.client.test.ts b/__tests__/client/components/needsProvisioning.client.test.ts index 79e82e7d..696f3cec 100644 --- a/__tests__/client/components/needsProvisioning.client.test.ts +++ b/__tests__/client/components/needsProvisioning.client.test.ts @@ -2,7 +2,8 @@ import { needsProvisioning } from '@/collections/Tenants/components/needsProvisi import type { ProvisioningStatus } from '@/collections/Tenants/components/onboardingActions' const buildStatus = (overrides: Partial = {}): ProvisioningStatus => ({ - builtInPages: { count: 0, expected: 7 }, + forecastPages: { count: 0, expected: 2 }, + defaultBuiltInPages: { count: 0, expected: 5 }, pages: { created: 0, expected: 5, missing: [] }, homePage: false, navigation: false, @@ -20,7 +21,8 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, navigation: true, @@ -34,7 +36,8 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - builtInPages: { count: 3, expected: 7 }, + forecastPages: { count: 1, expected: 2 }, + defaultBuiltInPages: { count: 2, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, navigation: true, @@ -48,7 +51,8 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] }, homePage: true, navigation: true, @@ -62,7 +66,8 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: false, navigation: true, @@ -76,7 +81,8 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, navigation: false, @@ -90,7 +96,8 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, navigation: true, @@ -104,7 +111,8 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, homePage: true, navigation: true, settings: { exists: true }, diff --git a/__tests__/server/OnboardingStatusCell.server.test.tsx b/__tests__/server/OnboardingStatusCell.server.test.tsx index 66bccc4a..95639bfd 100644 --- a/__tests__/server/OnboardingStatusCell.server.test.tsx +++ b/__tests__/server/OnboardingStatusCell.server.test.tsx @@ -30,7 +30,8 @@ function isReactElement(value: unknown): value is React.ReactElement<{ } const buildStatus = (overrides: Partial = {}): ProvisioningStatus => ({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, navigation: true, diff --git a/__tests__/server/resolveBuiltInPages.server.test.ts b/__tests__/server/resolveBuiltInPages.server.test.ts new file mode 100644 index 00000000..774c8662 --- /dev/null +++ b/__tests__/server/resolveBuiltInPages.server.test.ts @@ -0,0 +1,154 @@ +// TODO: Migrate to MSW for HTTP-level mocking. MSW v2 ships ESM which requires +// transformIgnorePatterns changes in jest.config.mjs (next/jest overrides them). +// See PR #969 (getAvalancheCenterPlatforms test) which uses this same jest.mock +// pattern. Unlike that test which re-implements the function logic with mock data, +// this test mocks at the function boundary since resolveBuiltInPages orchestrates +// multiple NAC calls — MSW would let us test the full call chain end-to-end. +const mockGetActiveForecastZones = jest.fn() +const mockGetAvalancheCenterPlatforms = jest.fn() + +jest.mock('../../src/services/nac/nac', () => ({ + getActiveForecastZones: (...args: unknown[]) => mockGetActiveForecastZones(...args), + getAvalancheCenterPlatforms: (...args: unknown[]) => mockGetAvalancheCenterPlatforms(...args), +})) + +import { resolveBuiltInPages } from '@/collections/Tenants/endpoints/provisionTenant' + +// @ts-expect-error - partial mock of pino Logger; only methods used in tests are provided +const mockLog: import('pino').Logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +} + +const navBuiltInPages = [ + { title: 'All Forecasts', url: '/forecasts/avalanche' }, + { title: 'Mountain Weather', url: '/weather/forecast' }, + { title: 'Weather Stations', url: '/weather/stations/map' }, + { title: 'Recent Observations', url: '/observations' }, + { title: 'Blog', url: '/blog' }, +] + +function makeZone(name: string, slug: string, rank: number) { + return { + slug, + zone: { + id: Math.floor(Math.random() * 1000), + name, + url: `/forecasts/${slug}`, + zone_id: slug, + config: { + elevation_band_names: { + lower: 'Below Treeline', + middle: 'Near Treeline', + upper: 'Above Treeline', + }, + }, + status: 'active' as const, + rank, + }, + } +} + +describe('resolveBuiltInPages', () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetAvalancheCenterPlatforms.mockResolvedValue({ weather: false }) + }) + + describe('forecast pages', () => { + it('creates single forecast page for single-zone center', async () => { + mockGetActiveForecastZones.mockResolvedValue([makeZone('Olympic', 'olympic', 1)]) + + const { forecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog) + + expect(forecastPages).toEqual([ + { title: 'Avalanche Forecast', url: '/forecasts/avalanche/olympic' }, + ]) + }) + + it('creates All Forecasts + per-zone pages for multi-zone center sorted by rank', async () => { + mockGetActiveForecastZones.mockResolvedValue([ + makeZone('Zone B', 'zone-b', 2), + makeZone('Zone A', 'zone-a', 1), + ]) + + const { forecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog) + + expect(forecastPages).toEqual([ + { title: 'All Forecasts', url: '/forecasts/avalanche' }, + { title: 'Zone A', url: '/forecasts/avalanche/zone-a' }, + { title: 'Zone B', url: '/forecasts/avalanche/zone-b' }, + ]) + }) + + it('creates default All Forecasts page when AFP returns no zones', async () => { + mockGetActiveForecastZones.mockResolvedValue([]) + + const { forecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog) + + expect(forecastPages).toEqual([{ title: 'All Forecasts', url: '/forecasts/avalanche' }]) + }) + + it('falls back to default All Forecasts page when AFP fails', async () => { + mockGetActiveForecastZones.mockRejectedValue(new Error('network error')) + + const { forecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog) + + expect(forecastPages).toEqual([{ title: 'All Forecasts', url: '/forecasts/avalanche' }]) + }) + }) + + describe('non-forecast pages', () => { + beforeEach(() => { + mockGetActiveForecastZones.mockResolvedValue([]) + }) + + it('excludes forecast pages from non-forecast list', async () => { + const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog) + + expect(nonForecastPages.find((p) => p.url.startsWith('/forecasts/avalanche'))).toBeUndefined() + }) + + it('excludes Mountain Weather when center has no weather platform', async () => { + mockGetAvalancheCenterPlatforms.mockResolvedValue({ weather: false }) + + const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog) + + expect(nonForecastPages.find((p) => p.url === '/weather/forecast')).toBeUndefined() + }) + + it('includes Mountain Weather when center has weather platform', async () => { + mockGetAvalancheCenterPlatforms.mockResolvedValue({ weather: true }) + + const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog) + + expect(nonForecastPages).toContainEqual({ + title: 'Mountain Weather', + url: '/weather/forecast', + }) + }) + + it('excludes Mountain Weather when NAC platforms query fails', async () => { + mockGetAvalancheCenterPlatforms.mockRejectedValue(new Error('network error')) + + const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog) + + expect(nonForecastPages.find((p) => p.url === '/weather/forecast')).toBeUndefined() + }) + + it('includes other DVAC nav pages unchanged', async () => { + const { nonForecastPages } = await resolveBuiltInPages('test', navBuiltInPages, mockLog) + + expect(nonForecastPages).toContainEqual({ + title: 'Weather Stations', + url: '/weather/stations/map', + }) + expect(nonForecastPages).toContainEqual({ + title: 'Recent Observations', + url: '/observations', + }) + expect(nonForecastPages).toContainEqual({ title: 'Blog', url: '/blog' }) + }) + }) +}) diff --git a/docs/onboarding.md b/docs/onboarding.md index 8cc9a419..df7a30cc 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -11,29 +11,26 @@ Provisioning is idempotent and can be rerun safely. | Step | Details | |------|---------| | Website Settings | Created with placeholder brand assets (logo, icon, banner). Replace with real assets via the checklist link. | -| Built-in pages | Creates standard pages per the table below. | +| Forecast pages | Queries AFP via `getActiveForecastZones()` to auto-detect single vs multi-zone. Creates zone-specific built-in pages (see table below). Falls back to a default "All Forecasts" page if AFP is unavailable. | +| Default built-in pages | Creates non-forecast built-in pages sourced from the template (DVAC) navigation (see table below). Mountain Weather is only included if the center has a weather forecast configured in NAC (`platforms.weather`). | | Template pages | Copies all published pages from the template tenant (DVAC). Pages whose blocks all reference tenant-scoped data (teams, sponsors, events, forms) are copied as empty drafts. Demo pages (`blocks`, `lexical-blocks`) are skipped. Static blog/event list blocks are converted to dynamic mode. | | Home page | Creates a home page with welcome content and quick links to About Us and Donate. | -| Navigation | Creates navigation menus linked to all copied pages and built-in pages. | +| Navigation | Creates navigation menus linked to all copied pages and built-in pages. Forecasts tab is zone-aware (single zone: direct link; multi-zone: "All Forecasts" + per-zone items). | | Edge Config | The `updateEdgeConfigAfterChange` hook automatically adds the tenant to Vercel Edge Config. | #### Built-In Pages -\* If a center has a single forecast zone, it gets an "Avalanche Forecast" page pointing to that zone. Multi-zone centers get an "All Forecasts" page plus individual zone pages. - -| Title | URL | For AC with single or multi zone* | -|-------|-----|-----------------------------------| -| All Forecasts | `/forecasts/avalanche` | multi | -| _ZONE NAME_ | `/forecasts/avalanche/ZONE` | multi | -| Avalanche Forecast | `/forecasts/avalanche/ZONE` | single | -| Mountain Weather** | `/weather/forecast` | both | -| Weather Stations | `/weather/stations/map` | both | -| Recent Observations | `/observations` | both | -| Submit Observations | `/observations/submit` | both | -| Blog | `/blog` | both | -| Events | `/events` | both | - -\*\* Mountain Weather is only available for centers that have a weather forecast configured. +Forecast pages are determined by AFP zone data\*. Non-forecast pages are sourced from the template tenant's (DVAC) navigation — adding or removing a built-in page in DVAC's nav automatically changes what new tenants get. + +\* If a center has a single forecast zone, it gets an "Avalanche Forecast" page pointing to that zone. Multi-zone centers get an "All Forecasts" page plus individual zone pages. If AFP is unavailable, a default "All Forecasts" page is created. + +| Title | URL | Source | +|-------|-----|--------| +| All Forecasts | `/forecasts/avalanche` | AFP (multi-zone) | +| _ZONE NAME_ | `/forecasts/avalanche/ZONE` | AFP (multi-zone) | +| Avalanche Forecast | `/forecasts/avalanche/ZONE` | AFP (single-zone) | +| Mountain Weather | `/weather/forecast` | NAC `platforms.weather` | +| _Non-forecast pages_ | _varies_ | DVAC navigation | ## Manual steps diff --git a/src/collections/Tenants/components/OnboardingChecklist.tsx b/src/collections/Tenants/components/OnboardingChecklist.tsx index e86136fd..d9aa7440 100644 --- a/src/collections/Tenants/components/OnboardingChecklist.tsx +++ b/src/collections/Tenants/components/OnboardingChecklist.tsx @@ -13,7 +13,8 @@ import { } from './onboardingActions' const DEFAULT_STATUS: ProvisioningStatus = { - builtInPages: { count: 0, expected: 0 }, + forecastPages: { count: 0, expected: 0 }, + defaultBuiltInPages: { count: 0, expected: 0 }, pages: { created: 0, expected: 0, missing: [] }, homePage: false, navigation: false, @@ -145,10 +146,12 @@ export function OnboardingChecklist() { }) }, [tenantId]) // eslint-disable-line react-hooks/exhaustive-deps - const { builtInPages, pages, homePage, navigation, settings, theme } = status + const { forecastPages, defaultBuiltInPages, pages, homePage, navigation, settings, theme } = + status const automatedComplete = - builtInPages.count >= builtInPages.expected && + forecastPages.count >= forecastPages.expected && + defaultBuiltInPages.count >= defaultBuiltInPages.expected && pages.created >= pages.expected && pages.expected > 0 && homePage && @@ -168,24 +171,35 @@ export function OnboardingChecklist() { )} - {automatedComplete && ( -

- Blank pages are created using DVACs navigation structure -

- )} - = builtInPages.expected} - label="Built-in pages" - details={loaded && `(${builtInPages.count}/${builtInPages.expected})`} - /> + done={loaded && forecastPages.count >= forecastPages.expected} + label="Forecast Built-In pages" + details={loaded && `(${forecastPages.count}/${forecastPages.expected})`} + > + {automatedComplete && ( +

+ Forecast built-ins are automatically based on NAC zones. +

+ )} +
+ = defaultBuiltInPages.expected} + label="Default Built-In pages" + details={loaded && `(${defaultBuiltInPages.count}/${defaultBuiltInPages.expected})`} + > = pages.expected && pages.expected > 0} label="Pages" details={loaded && `(${pages.created}/${pages.expected})`} > + {automatedComplete && ( +

+ Blank pages are created using DVACs navigation structure. +

+ )} {pages.missing.length > 0 &&
Missing: {pages.missing.join(', ')}
}
@@ -202,7 +216,11 @@ export function OnboardingChecklist() { ) } - /> + > + {automatedComplete && ( +

Placeholder logo, icon and banner added.

+ )} +

Needs action

diff --git a/src/collections/Tenants/components/OnboardingStatusCell.tsx b/src/collections/Tenants/components/OnboardingStatusCell.tsx index b3c5c0be..95e4e6d9 100644 --- a/src/collections/Tenants/components/OnboardingStatusCell.tsx +++ b/src/collections/Tenants/components/OnboardingStatusCell.tsx @@ -16,10 +16,12 @@ export async function OnboardingStatusCell({ return null } - const { builtInPages, pages, homePage, navigation, settings } = result.status + const { forecastPages, defaultBuiltInPages, pages, homePage, navigation, settings } = + result.status const allComplete = - builtInPages.count >= builtInPages.expected && + forecastPages.count >= forecastPages.expected && + defaultBuiltInPages.count >= defaultBuiltInPages.expected && pages.created >= pages.expected && pages.expected > 0 && homePage && diff --git a/src/collections/Tenants/components/needsProvisioning.ts b/src/collections/Tenants/components/needsProvisioning.ts index 17bafda4..fe6beff8 100644 --- a/src/collections/Tenants/components/needsProvisioning.ts +++ b/src/collections/Tenants/components/needsProvisioning.ts @@ -5,8 +5,13 @@ import type { ProvisioningStatus } from './onboardingActions' * Used to auto-provision on creation without auto-triggering on existing incomplete tenants. */ export function needsProvisioning(status: ProvisioningStatus): boolean { - const { builtInPages, pages, homePage, navigation, settings } = status + const { forecastPages, defaultBuiltInPages, pages, homePage, navigation, settings } = status return ( - builtInPages.count === 0 && pages.created === 0 && !homePage && !navigation && !settings.exists + forecastPages.count === 0 && + defaultBuiltInPages.count === 0 && + pages.created === 0 && + !homePage && + !navigation && + !settings.exists ) } diff --git a/src/collections/Tenants/components/onboardingActions.ts b/src/collections/Tenants/components/onboardingActions.ts index c18ed2b4..1f02e3c8 100644 --- a/src/collections/Tenants/components/onboardingActions.ts +++ b/src/collections/Tenants/components/onboardingActions.ts @@ -2,9 +2,9 @@ import { centerColorMap } from '@/app/api/[center]/og/centerColorMap' import { - BUILT_IN_PAGES, extractNavReferences, provision, + resolveBuiltInPages, } from '@/collections/Tenants/endpoints/provisionTenant' import config from '@payload-config' import fs from 'fs/promises' @@ -12,7 +12,8 @@ import path from 'path' import { getPayload } from 'payload' export type ProvisioningStatus = { - builtInPages: { count: number; expected: number } + forecastPages: { count: number; expected: number } + defaultBuiltInPages: { count: number; expected: number } pages: { created: number; expected: number; missing: string[] } homePage: boolean navigation: boolean @@ -42,7 +43,8 @@ export async function checkProvisioningStatusAction( payload.find({ collection: 'builtInPages', where: { tenant: { equals: tenantId } }, - limit: 0, + limit: 100, + select: { url: true }, }), payload.find({ collection: 'pages', @@ -88,6 +90,8 @@ export async function checkProvisioningStatusAction( const templateTenant = templateTenantResult.docs[0] let templatePageSlugs: { slug: string; title: string }[] = [] + let navPageSlugs = new Set() + let navBuiltInPages: Array<{ title: string; url: string }> = [] if (templateTenant) { // Get page slugs from template navigation (same logic as provisioning) const templateNav = await payload @@ -99,8 +103,9 @@ export async function checkProvisioningStatusAction( }) .then((res) => res.docs[0]) - // TODO: Use builtInPageUrls to filter expected built-in page count #999 - const { pageSlugs: navPageSlugs } = extractNavReferences(templateNav ?? {}) + const refs = extractNavReferences(templateNav ?? {}) + navPageSlugs = refs.pageSlugs + navBuiltInPages = refs.builtInPages const templatePages = await payload.find({ collection: 'pages', @@ -115,14 +120,30 @@ export async function checkProvisioningStatusAction( templatePageSlugs = templatePages.docs.map((p) => ({ slug: p.slug, title: p.title })) } + const { forecastPages: expectedForecastPages, nonForecastPages: expectedNonForecastPages } = + await resolveBuiltInPages(tenant.slug, navBuiltInPages, payload.logger) + + const tenantForecastPageCount = builtInPages.docs.filter((p) => + p.url.startsWith('/forecasts/avalanche'), + ).length + const tenantDefaultPageCount = builtInPages.docs.filter( + (p) => !p.url.startsWith('/forecasts/avalanche'), + ).length + const tenantPagesBySlug = new Map(pages.docs.map((p) => [p.slug, p])) const createdPages = templatePageSlugs.filter((p) => tenantPagesBySlug.has(p.slug)) const missing = templatePageSlugs.filter((p) => !tenantPagesBySlug.has(p.slug)) return { status: { - // TODO: Filter expected count to navigation-referenced built-in pages #999 - builtInPages: { count: builtInPages.totalDocs, expected: BUILT_IN_PAGES.length }, + forecastPages: { + count: tenantForecastPageCount, + expected: expectedForecastPages.length, + }, + defaultBuiltInPages: { + count: tenantDefaultPageCount, + expected: expectedNonForecastPages.length, + }, pages: { created: createdPages.length, expected: templatePageSlugs.length, diff --git a/src/collections/Tenants/endpoints/provisionTenant.ts b/src/collections/Tenants/endpoints/provisionTenant.ts index 3e8ef839..36e1e3f6 100644 --- a/src/collections/Tenants/endpoints/provisionTenant.ts +++ b/src/collections/Tenants/endpoints/provisionTenant.ts @@ -1,20 +1,83 @@ import { hasSuperAdminPermissions } from '@/access/hasSuperAdminPermissions' import { getSeedImageByFilename, simpleContent } from '@/endpoints/seed/utilities' import type { BuiltInPage, Navigation, Page, Tenant } from '@/payload-types' +import { + getActiveForecastZones, + getAvalancheCenterPlatforms, + type ActiveForecastZoneWithSlug, +} from '@/services/nac/nac' import { isValidRelationship } from '@/utilities/relationships' import type { Payload, PayloadHandler } from 'payload' +import type { Logger } from 'pino' const TEMPLATE_TENANT_SLUG = 'dvac' -export const BUILT_IN_PAGES: Array<{ title: string; url: string }> = [ - { title: 'All Forecasts', url: '/forecasts/avalanche' }, - { title: 'Mountain Weather', url: '/weather/forecast' }, - { title: 'Weather Stations', url: '/weather/stations/map' }, - { title: 'Recent Observations', url: '/observations' }, - { title: 'Submit Observations', url: '/observations/submit' }, - { title: 'Blog', url: '/blog' }, - { title: 'Events', url: '/events' }, -] +/** + * Queries AFP for forecast zones and splits template nav built-in pages into + * zone-aware forecast pages (sorted by rank) and non-forecast pages. + */ +export async function resolveBuiltInPages( + tenantSlug: string, + navBuiltInPages: Array<{ title: string; url: string }>, + log: Logger, +): Promise<{ + forecastPages: Array<{ title: string; url: string }> + nonForecastPages: Array<{ title: string; url: string }> +}> { + let forecastZones: ActiveForecastZoneWithSlug[] = [] + try { + forecastZones = await getActiveForecastZones(tenantSlug) + if (forecastZones.length === 0) { + log.warn( + `[${tenantSlug}] No forecast zones found from AFP. Creating default "All Forecasts" page.`, + ) + } else { + log.info(`[${tenantSlug}] Found ${forecastZones.length} forecast zone(s) from AFP`) + } + } catch (err) { + log.warn( + `[${tenantSlug}] Failed to query AFP for forecast zones: ${err instanceof Error ? err.message : 'Unknown error'}. Creating default "All Forecasts" page.`, + ) + } + + // Sort by rank so consumers can iterate in display order + const sorted = [...forecastZones].sort( + (a, b) => (a.zone.rank ?? Infinity) - (b.zone.rank ?? Infinity), + ) + + const forecastPages = + sorted.length === 1 + ? [ + { + title: 'Avalanche Forecast', + url: `/forecasts/avalanche/${sorted[0].slug}`, + }, + ] + : [ + { title: 'All Forecasts', url: '/forecasts/avalanche' }, + ...sorted.map(({ zone, slug }) => ({ + title: zone.name, + url: `/forecasts/avalanche/${slug}`, + })), + ] + + // Non-forecast pages from DVAC nav, excluding Mountain Weather (determined by NAC) + const nonForecastPages = navBuiltInPages.filter( + (p) => !p.url.startsWith('/forecasts/avalanche') && p.url !== '/weather/forecast', + ) + + // Add Mountain Weather only if center has weather forecasts in NAC + try { + const { weather } = await getAvalancheCenterPlatforms(tenantSlug) + if (weather) { + nonForecastPages.push({ title: 'Mountain Weather', url: '/weather/forecast' }) + } + } catch { + log.warn(`[${tenantSlug}] Failed to query NAC platforms. Excluding Mountain Weather.`) + } + + return { forecastPages, nonForecastPages } +} /** * Creates a new tenant and provisions it with all default data. @@ -80,10 +143,11 @@ export const provisionTenant: PayloadHandler = async (req) => { * Provisions a tenant with all default data: * 1. Website Settings with placeholder brand assets (logo, icon, banner) * 2. Look up template tenant (DVAC) navigation to determine which pages to create - * 3. Built-in pages (filtered to those referenced in template navigation) - * 4. Blank pages matching the template tenant's page structure - * 5. Home page with default content - * 6. Navigation linked to the new pages and built-in pages + * 3. Query AFP for forecast zones (single vs multi-zone detection) + * 4. Built-in pages (zone-aware, filtered to those referenced in template navigation) + * 5. Blank pages matching the template tenant's page structure + * 6. Home page with default content + * 7. Navigation linked to the new pages and built-in pages (zone-aware forecasts) * * Idempotent - checks for existing data before creating. */ @@ -94,22 +158,23 @@ export const provisionTenant: PayloadHandler = async (req) => { */ export type NavReferences = { pageSlugs: Set - builtInPageUrls: Set + builtInPages: Array<{ title: string; url: string }> } export function extractNavReferences(nav: Navigation): NavReferences { const pageSlugs = new Set() - const builtInPageUrls = new Set() + const builtInPages: Array<{ title: string; url: string }> = [] + const seenUrls = new Set() for (const tab of Object.values(nav)) { if (typeof tab === 'object' && tab !== null) { // Tab with items array (forecasts, weather, education, etc.) if ('items' in tab && Array.isArray(tab.items)) { for (const item of tab.items) { - buildNavReference(item.link, pageSlugs, builtInPageUrls) + buildNavReference(item.link, pageSlugs, builtInPages, seenUrls) if (Array.isArray(item.items)) { for (const subItem of item.items) { - buildNavReference(subItem.link, pageSlugs, builtInPageUrls) + buildNavReference(subItem.link, pageSlugs, builtInPages, seenUrls) } } } @@ -117,12 +182,12 @@ export function extractNavReferences(nav: Navigation): NavReferences { // Tab with a direct link (donate) if ('link' in tab) { - buildNavReference(tab.link, pageSlugs, builtInPageUrls) + buildNavReference(tab.link, pageSlugs, builtInPages, seenUrls) } } } - return { pageSlugs, builtInPageUrls } + return { pageSlugs, builtInPages } } function buildNavReference( @@ -131,15 +196,20 @@ function buildNavReference( | null | undefined, pageSlugs: Set, - builtInPageUrls: Set, + builtInPages: Array<{ title: string; url: string }>, + seenUrls: Set, ): void { const ref = link?.reference if (!ref || !isValidRelationship(ref.value)) return if (ref.relationTo === 'pages' && 'slug' in ref.value) { pageSlugs.add(String(ref.value.slug)) - } else if (ref.relationTo === 'builtInPages' && 'url' in ref.value) { - builtInPageUrls.add(String(ref.value.url)) + } else if (ref.relationTo === 'builtInPages' && 'url' in ref.value && 'title' in ref.value) { + const url = String(ref.value.url) + if (!seenUrls.has(url)) { + seenUrls.add(url) + builtInPages.push({ title: String(ref.value.title), url }) + } } } @@ -207,7 +277,7 @@ export async function provision(payload: Payload, tenant: Tenant) { .then((res) => res.docs[0]) let navPageSlugs = new Set() - let navBuiltInPageUrls = new Set() + let navBuiltInPages: Array<{ title: string; url: string }> = [] if (templateTenant) { const templateNav = await payload @@ -221,17 +291,22 @@ export async function provision(payload: Payload, tenant: Tenant) { const refs = extractNavReferences(templateNav ?? {}) navPageSlugs = refs.pageSlugs - navBuiltInPageUrls = refs.builtInPageUrls + navBuiltInPages = refs.builtInPages log.info( - `[${tenant.slug}] Found ${navPageSlugs.size} page slugs and ${navBuiltInPageUrls.size} built-in page URLs in template navigation`, + `[${tenant.slug}] Found ${navPageSlugs.size} page slugs and ${navBuiltInPages.length} built-in pages in template navigation`, ) } else { log.warn(`Template tenant "${TEMPLATE_TENANT_SLUG}" not found. Using default built-in pages.`) } - // 3. Create Built-In Pages - // TODO: Filter to only navigation-referenced built-in pages #999 - log.info(`[${tenant.slug}] Creating built-in pages...`) + // 3–4. Query AFP for forecast zones and resolve built-in pages + const { forecastPages, nonForecastPages } = await resolveBuiltInPages( + tenant.slug, + navBuiltInPages, + log, + ) + const builtInPagesToCreate = [...forecastPages, ...nonForecastPages] + log.info(`[${tenant.slug}] Creating ${builtInPagesToCreate.length} built-in pages...`) const existingBuiltInPages = await payload.find({ collection: 'builtInPages', where: { tenant: { equals: tenant.id } }, @@ -240,7 +315,7 @@ export async function provision(payload: Payload, tenant: Tenant) { const existingBuiltInPageUrls = new Set(existingBuiltInPages.docs.map((p) => p.url)) const createdBuiltInPages: BuiltInPage[] = [...existingBuiltInPages.docs] - for (const { title, url } of BUILT_IN_PAGES) { + for (const { title, url } of builtInPagesToCreate) { if (existingBuiltInPageUrls.has(url)) { log.info(`[${tenant.slug}] Built-in page "${title}" already exists, skipping`) continue @@ -262,7 +337,7 @@ export async function provision(payload: Payload, tenant: Tenant) { builtInPagesByUrl[bip.url] = bip } - // 4. Create blank pages for pages referenced in template navigation + // 5. Create blank pages for pages referenced in template navigation const createdPages: Page[] = [] const failedPages: string[] = [] const pagesBySlug: Record = {} @@ -334,7 +409,7 @@ export async function provision(payload: Payload, tenant: Tenant) { } } - // 5. Create Home Page + // 6. Create Home Page log.info(`[${tenant.slug}] Creating home page...`) const existingHomePage = await payload.find({ collection: 'homePages', @@ -410,7 +485,7 @@ export async function provision(payload: Payload, tenant: Tenant) { log.info(`[${tenant.slug}] Home page already exists, skipping`) } - // 6. Create Navigation + // 7. Create Navigation log.info(`[${tenant.slug}] Creating navigation...`) const existingNavigation = await payload.find({ collection: 'navigations', @@ -459,8 +534,26 @@ export async function provision(payload: Payload, tenant: Tenant) { collection: 'navigations', data: { tenant: tenant.id, - forecasts: { items: [] }, - observations: { items: [] }, + forecasts: + forecastPages.length === 1 + ? { + link: navBuiltInPageItem(forecastPages[0].url, forecastPages[0].title)?.link, + items: [], + } + : { + link: navBuiltInPageItem('/forecasts/avalanche', 'All Forecasts')?.link, + items: filterNulls( + forecastPages + .filter((p) => p.url !== '/forecasts/avalanche') + .map((p) => navBuiltInPageItem(p.url, p.title)), + ), + }, + observations: { + items: filterNulls([ + navBuiltInPageItem('/observations', 'Recent Observations'), + navBuiltInPageItem('/observations/submit', 'Submit Observations'), + ]), + }, weather: { items: filterNulls([ navBuiltInPageItem('/weather/stations/map', 'Weather Stations'), @@ -514,6 +607,14 @@ export async function provision(payload: Payload, tenant: Tenant) { navPageItem('avalanche-accident-map'), ]), }, + blog: { + link: navBuiltInPageItem('/blog', 'Blog')?.link, + options: { enabled: true }, + }, + events: { + link: navBuiltInPageItem('/events', 'Events')?.link, + options: { enabled: true }, + }, donate: { link: navPageItem('donate-membership', 'Donate')?.link, },