From f8623952476da1df0053ac94bb5efe3b40852c07 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 25 Mar 2026 15:28:54 -0500 Subject: [PATCH 1/8] Update provisioning to check zone for forecast pages --- .../Tenants/components/onboardingActions.ts | 12 +- .../Tenants/endpoints/provisionTenant.ts | 103 +++++++++++++++--- 2 files changed, 92 insertions(+), 23 deletions(-) diff --git a/src/collections/Tenants/components/onboardingActions.ts b/src/collections/Tenants/components/onboardingActions.ts index c18ed2b4..96654cdf 100644 --- a/src/collections/Tenants/components/onboardingActions.ts +++ b/src/collections/Tenants/components/onboardingActions.ts @@ -1,11 +1,7 @@ 'use server' import { centerColorMap } from '@/app/api/[center]/og/centerColorMap' -import { - BUILT_IN_PAGES, - extractNavReferences, - provision, -} from '@/collections/Tenants/endpoints/provisionTenant' +import { extractNavReferences, provision } from '@/collections/Tenants/endpoints/provisionTenant' import config from '@payload-config' import fs from 'fs/promises' import path from 'path' @@ -99,7 +95,6 @@ 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 templatePages = await payload.find({ @@ -115,14 +110,15 @@ export async function checkProvisioningStatusAction( templatePageSlugs = templatePages.docs.map((p) => ({ slug: p.slug, title: p.title })) } + // TODO: Use builtInPageUrls to filter expected built-in page count #999 + 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 }, + builtInPages: { count: builtInPages.totalDocs, expected: builtInPages.totalDocs }, 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..3d9c244e 100644 --- a/src/collections/Tenants/endpoints/provisionTenant.ts +++ b/src/collections/Tenants/endpoints/provisionTenant.ts @@ -1,13 +1,13 @@ import { hasSuperAdminPermissions } from '@/access/hasSuperAdminPermissions' import { getSeedImageByFilename, simpleContent } from '@/endpoints/seed/utilities' import type { BuiltInPage, Navigation, Page, Tenant } from '@/payload-types' +import { getActiveForecastZones, type ActiveForecastZoneWithSlug } from '@/services/nac/nac' import { isValidRelationship } from '@/utilities/relationships' import type { Payload, PayloadHandler } from 'payload' const TEMPLATE_TENANT_SLUG = 'dvac' -export const BUILT_IN_PAGES: Array<{ title: string; url: string }> = [ - { title: 'All Forecasts', url: '/forecasts/avalanche' }, +export const NON_FORECAST_BUILT_IN_PAGES: Array<{ title: string; url: string }> = [ { title: 'Mountain Weather', url: '/weather/forecast' }, { title: 'Weather Stations', url: '/weather/stations/map' }, { title: 'Recent Observations', url: '/observations' }, @@ -16,6 +16,34 @@ export const BUILT_IN_PAGES: Array<{ title: string; url: string }> = [ { title: 'Events', url: '/events' }, ] +/** + * Builds the list of built-in pages based on forecast zones from AFP data. + * Forecast pages are always included (determined by AFP, not template nav). + * Non-forecast pages are filtered to those referenced in the template navigation. + */ +export function buildBuiltInPages( + zones: ActiveForecastZoneWithSlug[], + navBuiltInPageUrls?: Set, +): Array<{ title: string; url: string }> { + const forecastPages = + zones.length === 1 + ? [{ title: 'Avalanche Forecast', url: `/forecasts/avalanche/${zones[0].slug}` }] + : [ + { title: 'All Forecasts', url: '/forecasts/avalanche' }, + ...zones.map(({ zone, slug }) => ({ + title: zone.name, + url: `/forecasts/avalanche/${slug}`, + })), + ] + + // Filter non-forecast pages to those referenced in template navigation + const nonForecastPages = navBuiltInPageUrls + ? NON_FORECAST_BUILT_IN_PAGES.filter((p) => navBuiltInPageUrls.has(p.url)) + : NON_FORECAST_BUILT_IN_PAGES + + return [...forecastPages, ...nonForecastPages] +} + /** * Creates a new tenant and provisions it with all default data. * @@ -80,10 +108,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. */ @@ -229,9 +258,30 @@ export async function provision(payload: Payload, tenant: Tenant) { 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. Query AFP for forecast zones + log.info(`[${tenant.slug}] Querying AFP for forecast zones...`) + let forecastZones: ActiveForecastZoneWithSlug[] = [] + try { + forecastZones = await getActiveForecastZones(tenant.slug) + if (forecastZones.length === 0) { + log.warn( + `[${tenant.slug}] No forecast zones found from AFP. Creating default "All Forecasts" page.`, + ) + } else { + log.info(`[${tenant.slug}] Found ${forecastZones.length} forecast zone(s) from AFP`) + } + } catch (err) { + log.warn( + `[${tenant.slug}] Failed to query AFP for forecast zones: ${err instanceof Error ? err.message : 'Unknown error'}. Creating default "All Forecasts" page.`, + ) + } + + // 4. Create Built-In Pages (zone-aware, filtered to nav-referenced) + const builtInPagesToCreate = buildBuiltInPages( + forecastZones, + navBuiltInPageUrls.size > 0 ? navBuiltInPageUrls : undefined, + ) + log.info(`[${tenant.slug}] Creating ${builtInPagesToCreate.length} built-in pages...`) const existingBuiltInPages = await payload.find({ collection: 'builtInPages', where: { tenant: { equals: tenant.id } }, @@ -240,7 +290,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 +312,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 +384,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 +460,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 +509,31 @@ export async function provision(payload: Payload, tenant: Tenant) { collection: 'navigations', data: { tenant: tenant.id, - forecasts: { items: [] }, - observations: { items: [] }, + forecasts: + forecastZones.length === 1 + ? { + link: navBuiltInPageItem( + `/forecasts/avalanche/${forecastZones[0].slug}`, + 'Avalanche Forecast', + )?.link, + items: [], + } + : { + link: navBuiltInPageItem('/forecasts/avalanche', 'All Forecasts')?.link, + items: filterNulls( + forecastZones + .sort((a, b) => (a.zone.rank ?? Infinity) - (b.zone.rank ?? Infinity)) + .map(({ zone, slug }) => + navBuiltInPageItem(`/forecasts/avalanche/${slug}`, zone.name), + ), + ), + }, + observations: { + items: filterNulls([ + navBuiltInPageItem('/observations', 'Recent Observations'), + navBuiltInPageItem('/observations/submit', 'Submit Observations'), + ]), + }, weather: { items: filterNulls([ navBuiltInPageItem('/weather/stations/map', 'Weather Stations'), From 3c57f283449544fb298ab4af75b0904d7ea6aafb Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 25 Mar 2026 15:46:30 -0500 Subject: [PATCH 2/8] Use DVAC as source of truth for built in pages creation & improve built in page function --- .../Tenants/components/onboardingActions.ts | 21 ++- .../Tenants/endpoints/provisionTenant.ts | 139 +++++++++--------- 2 files changed, 88 insertions(+), 72 deletions(-) diff --git a/src/collections/Tenants/components/onboardingActions.ts b/src/collections/Tenants/components/onboardingActions.ts index 96654cdf..81d32bba 100644 --- a/src/collections/Tenants/components/onboardingActions.ts +++ b/src/collections/Tenants/components/onboardingActions.ts @@ -1,7 +1,11 @@ 'use server' import { centerColorMap } from '@/app/api/[center]/og/centerColorMap' -import { extractNavReferences, provision } from '@/collections/Tenants/endpoints/provisionTenant' +import { + extractNavReferences, + provision, + resolveBuiltInPages, +} from '@/collections/Tenants/endpoints/provisionTenant' import config from '@payload-config' import fs from 'fs/promises' import path from 'path' @@ -84,6 +88,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 @@ -95,7 +101,9 @@ export async function checkProvisioningStatusAction( }) .then((res) => res.docs[0]) - const { pageSlugs: navPageSlugs } = extractNavReferences(templateNav ?? {}) + const refs = extractNavReferences(templateNav ?? {}) + navPageSlugs = refs.pageSlugs + navBuiltInPages = refs.builtInPages const templatePages = await payload.find({ collection: 'pages', @@ -110,7 +118,12 @@ export async function checkProvisioningStatusAction( templatePageSlugs = templatePages.docs.map((p) => ({ slug: p.slug, title: p.title })) } - // TODO: Use builtInPageUrls to filter expected built-in page count #999 + const { forecastPages, nonForecastPages } = await resolveBuiltInPages( + tenant.slug, + navBuiltInPages, + payload.logger, + ) + const expectedBuiltInPages = [...forecastPages, ...nonForecastPages] const tenantPagesBySlug = new Map(pages.docs.map((p) => [p.slug, p])) const createdPages = templatePageSlugs.filter((p) => tenantPagesBySlug.has(p.slug)) @@ -118,7 +131,7 @@ export async function checkProvisioningStatusAction( return { status: { - builtInPages: { count: builtInPages.totalDocs, expected: builtInPages.totalDocs }, + builtInPages: { count: builtInPages.totalDocs, expected: expectedBuiltInPages.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 3d9c244e..036a9ed0 100644 --- a/src/collections/Tenants/endpoints/provisionTenant.ts +++ b/src/collections/Tenants/endpoints/provisionTenant.ts @@ -4,44 +4,62 @@ import type { BuiltInPage, Navigation, Page, Tenant } from '@/payload-types' import { getActiveForecastZones, 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 NON_FORECAST_BUILT_IN_PAGES: Array<{ title: string; url: string }> = [ - { 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' }, -] - /** - * Builds the list of built-in pages based on forecast zones from AFP data. - * Forecast pages are always included (determined by AFP, not template nav). - * Non-forecast pages are filtered to those referenced in the template navigation. + * 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 function buildBuiltInPages( - zones: ActiveForecastZoneWithSlug[], - navBuiltInPageUrls?: Set, -): Array<{ title: string; url: string }> { +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 = - zones.length === 1 - ? [{ title: 'Avalanche Forecast', url: `/forecasts/avalanche/${zones[0].slug}` }] + sorted.length === 1 + ? [ + { + title: 'Avalanche Forecast', + url: `/forecasts/avalanche/${sorted[0].slug}`, + }, + ] : [ { title: 'All Forecasts', url: '/forecasts/avalanche' }, - ...zones.map(({ zone, slug }) => ({ + ...sorted.map(({ zone, slug }) => ({ title: zone.name, url: `/forecasts/avalanche/${slug}`, })), ] - // Filter non-forecast pages to those referenced in template navigation - const nonForecastPages = navBuiltInPageUrls - ? NON_FORECAST_BUILT_IN_PAGES.filter((p) => navBuiltInPageUrls.has(p.url)) - : NON_FORECAST_BUILT_IN_PAGES + const nonForecastPages = navBuiltInPages.filter((p) => !p.url.startsWith('/forecasts/avalanche')) - return [...forecastPages, ...nonForecastPages] + return { forecastPages, nonForecastPages } } /** @@ -123,22 +141,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) } } } @@ -146,12 +165,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( @@ -160,15 +179,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 }) + } } } @@ -236,7 +260,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 @@ -250,37 +274,21 @@ 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. Query AFP for forecast zones - log.info(`[${tenant.slug}] Querying AFP for forecast zones...`) - let forecastZones: ActiveForecastZoneWithSlug[] = [] - try { - forecastZones = await getActiveForecastZones(tenant.slug) - if (forecastZones.length === 0) { - log.warn( - `[${tenant.slug}] No forecast zones found from AFP. Creating default "All Forecasts" page.`, - ) - } else { - log.info(`[${tenant.slug}] Found ${forecastZones.length} forecast zone(s) from AFP`) - } - } catch (err) { - log.warn( - `[${tenant.slug}] Failed to query AFP for forecast zones: ${err instanceof Error ? err.message : 'Unknown error'}. Creating default "All Forecasts" page.`, - ) - } - - // 4. Create Built-In Pages (zone-aware, filtered to nav-referenced) - const builtInPagesToCreate = buildBuiltInPages( - forecastZones, - navBuiltInPageUrls.size > 0 ? navBuiltInPageUrls : undefined, + // 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', @@ -510,22 +518,17 @@ export async function provision(payload: Payload, tenant: Tenant) { data: { tenant: tenant.id, forecasts: - forecastZones.length === 1 + forecastPages.length === 1 ? { - link: navBuiltInPageItem( - `/forecasts/avalanche/${forecastZones[0].slug}`, - 'Avalanche Forecast', - )?.link, + link: navBuiltInPageItem(forecastPages[0].url, forecastPages[0].title)?.link, items: [], } : { link: navBuiltInPageItem('/forecasts/avalanche', 'All Forecasts')?.link, items: filterNulls( - forecastZones - .sort((a, b) => (a.zone.rank ?? Infinity) - (b.zone.rank ?? Infinity)) - .map(({ zone, slug }) => - navBuiltInPageItem(`/forecasts/avalanche/${slug}`, zone.name), - ), + forecastPages + .filter((p) => p.url !== '/forecasts/avalanche') + .map((p) => navBuiltInPageItem(p.url, p.title)), ), }, observations: { From b6c97582a8558a9b75e7829b342e57224014360c Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 25 Mar 2026 16:02:03 -0500 Subject: [PATCH 3/8] Make forecast and built in pages separate check marks --- .../OnboardingChecklist.client.test.tsx | 14 +++++--- .../needsProvisioning.client.test.ts | 24 ++++++++----- .../OnboardingStatusCell.server.test.tsx | 3 +- docs/onboarding.md | 30 +++++++--------- .../components/OnboardingChecklist.tsx | 28 +++++++++++---- .../components/OnboardingStatusCell.tsx | 6 ++-- .../Tenants/components/needsProvisioning.ts | 9 +++-- .../Tenants/components/onboardingActions.ts | 34 ++++++++++++++----- .../Tenants/endpoints/provisionTenant.ts | 3 +- 9 files changed, 99 insertions(+), 52 deletions(-) diff --git a/__tests__/client/components/OnboardingChecklist.client.test.tsx b/__tests__/client/components/OnboardingChecklist.client.test.tsx index 2fca2a29..4e5d4551 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, zoneCount: 0 }, + 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, zoneCount: 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, zoneCount: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] }, homePage: true, navigation: true, @@ -122,7 +125,8 @@ describe('OnboardingChecklist', () => { it('shows missing pages', async () => { mockCheckStatus.mockResolvedValue({ status: buildStatus({ - builtInPages: { count: 7, expected: 7 }, + forecastPages: { count: 2, expected: 2, zoneCount: 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..de1320ce 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, zoneCount: 0 }, + 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, zoneCount: 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, zoneCount: 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, zoneCount: 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, zoneCount: 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, zoneCount: 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, zoneCount: 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, zoneCount: 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..502a490f 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, zoneCount: 2 }, + defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, navigation: true, diff --git a/docs/onboarding.md b/docs/onboarding.md index 8cc9a419..0956df54 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -11,29 +11,25 @@ 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). | | 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) | +| _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..0e98d995 100644 --- a/src/collections/Tenants/components/OnboardingChecklist.tsx +++ b/src/collections/Tenants/components/OnboardingChecklist.tsx @@ -4,6 +4,7 @@ import { Button, toast, useDocumentInfo, useForm } from '@payloadcms/ui' import { CheckCircle2, Circle, Loader2 } from 'lucide-react' import Link from 'next/link' +import pluralize from 'pluralize' import { useCallback, useEffect, useState } from 'react' import { needsProvisioning } from './needsProvisioning' import { @@ -13,7 +14,8 @@ import { } from './onboardingActions' const DEFAULT_STATUS: ProvisioningStatus = { - builtInPages: { count: 0, expected: 0 }, + forecastPages: { count: 0, expected: 0, zoneCount: 0 }, + defaultBuiltInPages: { count: 0, expected: 0 }, pages: { created: 0, expected: 0, missing: [] }, homePage: false, navigation: false, @@ -145,10 +147,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 && @@ -170,15 +174,25 @@ export function OnboardingChecklist() { {automatedComplete && (

- Blank pages are created using DVACs navigation structure + Forecast pages are automated from NAC zones. 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 pages" + details={loaded && `(${forecastPages.count}/${forecastPages.expected})`} + > + {loaded && + `Center has ${forecastPages.zoneCount} ${pluralize('zone', forecastPages.zoneCount)} from NAC`} + + = defaultBuiltInPages.expected} + label="Default built-in pages" + details={loaded && `(${defaultBuiltInPages.count}/${defaultBuiltInPages.expected})`} /> = 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 81d32bba..0d186786 100644 --- a/src/collections/Tenants/components/onboardingActions.ts +++ b/src/collections/Tenants/components/onboardingActions.ts @@ -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; zoneCount: 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', @@ -118,12 +120,18 @@ export async function checkProvisioningStatusAction( templatePageSlugs = templatePages.docs.map((p) => ({ slug: p.slug, title: p.title })) } - const { forecastPages, nonForecastPages } = await resolveBuiltInPages( - tenant.slug, - navBuiltInPages, - payload.logger, - ) - const expectedBuiltInPages = [...forecastPages, ...nonForecastPages] + const { + forecastPages: expectedForecastPages, + nonForecastPages: expectedNonForecastPages, + zoneCount, + } = 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)) @@ -131,7 +139,15 @@ export async function checkProvisioningStatusAction( return { status: { - builtInPages: { count: builtInPages.totalDocs, expected: expectedBuiltInPages.length }, + forecastPages: { + count: tenantForecastPageCount, + expected: expectedForecastPages.length, + zoneCount, + }, + 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 036a9ed0..253f068d 100644 --- a/src/collections/Tenants/endpoints/provisionTenant.ts +++ b/src/collections/Tenants/endpoints/provisionTenant.ts @@ -19,6 +19,7 @@ export async function resolveBuiltInPages( ): Promise<{ forecastPages: Array<{ title: string; url: string }> nonForecastPages: Array<{ title: string; url: string }> + zoneCount: number }> { let forecastZones: ActiveForecastZoneWithSlug[] = [] try { @@ -59,7 +60,7 @@ export async function resolveBuiltInPages( const nonForecastPages = navBuiltInPages.filter((p) => !p.url.startsWith('/forecasts/avalanche')) - return { forecastPages, nonForecastPages } + return { forecastPages, nonForecastPages, zoneCount: forecastZones.length } } /** From 96656cab9dfb93ddbcf48fa2f516431173e294a2 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 25 Mar 2026 16:13:51 -0500 Subject: [PATCH 4/8] Fix navigation failing to provision --- src/collections/Tenants/endpoints/provisionTenant.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/collections/Tenants/endpoints/provisionTenant.ts b/src/collections/Tenants/endpoints/provisionTenant.ts index 253f068d..11bf0e52 100644 --- a/src/collections/Tenants/endpoints/provisionTenant.ts +++ b/src/collections/Tenants/endpoints/provisionTenant.ts @@ -591,6 +591,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, }, From 747e4f110dd11cd05edf134d2a19092056d0f271 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Wed, 25 Mar 2026 19:23:59 -0500 Subject: [PATCH 5/8] Remove zone count detail --- .../OnboardingChecklist.client.test.tsx | 8 ++++---- .../components/needsProvisioning.client.test.ts | 16 ++++++++-------- .../server/OnboardingStatusCell.server.test.tsx | 2 +- .../Tenants/components/OnboardingChecklist.tsx | 12 ++++-------- .../Tenants/components/onboardingActions.ts | 10 +++------- .../Tenants/endpoints/provisionTenant.ts | 3 +-- 6 files changed, 21 insertions(+), 30 deletions(-) diff --git a/__tests__/client/components/OnboardingChecklist.client.test.tsx b/__tests__/client/components/OnboardingChecklist.client.test.tsx index 4e5d4551..d7b33619 100644 --- a/__tests__/client/components/OnboardingChecklist.client.test.tsx +++ b/__tests__/client/components/OnboardingChecklist.client.test.tsx @@ -39,7 +39,7 @@ jest.mock('../../../src/collections/Tenants/components/onboardingActions', () => })) const buildStatus = (overrides: Partial = {}): ProvisioningStatus => ({ - forecastPages: { count: 0, expected: 2, zoneCount: 0 }, + forecastPages: { count: 0, expected: 2 }, defaultBuiltInPages: { count: 0, expected: 5 }, pages: { created: 0, expected: 5, missing: [] }, homePage: false, @@ -50,7 +50,7 @@ const buildStatus = (overrides: Partial = {}): ProvisioningS }) const fullyProvisioned = buildStatus({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, @@ -95,7 +95,7 @@ describe('OnboardingChecklist', () => { // Automated items complete but pages incomplete — needsProvisioning returns false, // so auto-provision doesn't run but the button shows const incompleteStatus = buildStatus({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] }, homePage: true, @@ -125,7 +125,7 @@ describe('OnboardingChecklist', () => { it('shows missing pages', async () => { mockCheckStatus.mockResolvedValue({ status: buildStatus({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] }, homePage: true, diff --git a/__tests__/client/components/needsProvisioning.client.test.ts b/__tests__/client/components/needsProvisioning.client.test.ts index de1320ce..696f3cec 100644 --- a/__tests__/client/components/needsProvisioning.client.test.ts +++ b/__tests__/client/components/needsProvisioning.client.test.ts @@ -2,7 +2,7 @@ import { needsProvisioning } from '@/collections/Tenants/components/needsProvisi import type { ProvisioningStatus } from '@/collections/Tenants/components/onboardingActions' const buildStatus = (overrides: Partial = {}): ProvisioningStatus => ({ - forecastPages: { count: 0, expected: 2, zoneCount: 0 }, + forecastPages: { count: 0, expected: 2 }, defaultBuiltInPages: { count: 0, expected: 5 }, pages: { created: 0, expected: 5, missing: [] }, homePage: false, @@ -21,7 +21,7 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, @@ -36,7 +36,7 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - forecastPages: { count: 1, expected: 2, zoneCount: 2 }, + forecastPages: { count: 1, expected: 2 }, defaultBuiltInPages: { count: 2, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, @@ -51,7 +51,7 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 3, expected: 5, missing: ['About Us', 'Donate'] }, homePage: true, @@ -66,7 +66,7 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: false, @@ -81,7 +81,7 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, @@ -96,7 +96,7 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, @@ -111,7 +111,7 @@ describe('needsProvisioning', () => { expect( needsProvisioning( buildStatus({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, homePage: true, navigation: true, diff --git a/__tests__/server/OnboardingStatusCell.server.test.tsx b/__tests__/server/OnboardingStatusCell.server.test.tsx index 502a490f..95639bfd 100644 --- a/__tests__/server/OnboardingStatusCell.server.test.tsx +++ b/__tests__/server/OnboardingStatusCell.server.test.tsx @@ -30,7 +30,7 @@ function isReactElement(value: unknown): value is React.ReactElement<{ } const buildStatus = (overrides: Partial = {}): ProvisioningStatus => ({ - forecastPages: { count: 2, expected: 2, zoneCount: 2 }, + forecastPages: { count: 2, expected: 2 }, defaultBuiltInPages: { count: 5, expected: 5 }, pages: { created: 5, expected: 5, missing: [] }, homePage: true, diff --git a/src/collections/Tenants/components/OnboardingChecklist.tsx b/src/collections/Tenants/components/OnboardingChecklist.tsx index 0e98d995..c6026493 100644 --- a/src/collections/Tenants/components/OnboardingChecklist.tsx +++ b/src/collections/Tenants/components/OnboardingChecklist.tsx @@ -4,7 +4,6 @@ import { Button, toast, useDocumentInfo, useForm } from '@payloadcms/ui' import { CheckCircle2, Circle, Loader2 } from 'lucide-react' import Link from 'next/link' -import pluralize from 'pluralize' import { useCallback, useEffect, useState } from 'react' import { needsProvisioning } from './needsProvisioning' import { @@ -14,7 +13,7 @@ import { } from './onboardingActions' const DEFAULT_STATUS: ProvisioningStatus = { - forecastPages: { count: 0, expected: 0, zoneCount: 0 }, + forecastPages: { count: 0, expected: 0 }, defaultBuiltInPages: { count: 0, expected: 0 }, pages: { created: 0, expected: 0, missing: [] }, homePage: false, @@ -174,8 +173,8 @@ export function OnboardingChecklist() { {automatedComplete && (

- Forecast pages are automated from NAC zones. Blank pages are created using DVACs - navigation structure. + Forecast nav is automatically based on NAC zones.
+ Blank pages are created using DVACs navigation structure.

)} @@ -184,10 +183,7 @@ export function OnboardingChecklist() { done={loaded && forecastPages.count >= forecastPages.expected} label="Forecast pages" details={loaded && `(${forecastPages.count}/${forecastPages.expected})`} - > - {loaded && - `Center has ${forecastPages.zoneCount} ${pluralize('zone', forecastPages.zoneCount)} from NAC`} -
+ /> = defaultBuiltInPages.expected} diff --git a/src/collections/Tenants/components/onboardingActions.ts b/src/collections/Tenants/components/onboardingActions.ts index 0d186786..1f02e3c8 100644 --- a/src/collections/Tenants/components/onboardingActions.ts +++ b/src/collections/Tenants/components/onboardingActions.ts @@ -12,7 +12,7 @@ import path from 'path' import { getPayload } from 'payload' export type ProvisioningStatus = { - forecastPages: { count: number; expected: number; zoneCount: number } + forecastPages: { count: number; expected: number } defaultBuiltInPages: { count: number; expected: number } pages: { created: number; expected: number; missing: string[] } homePage: boolean @@ -120,11 +120,8 @@ export async function checkProvisioningStatusAction( templatePageSlugs = templatePages.docs.map((p) => ({ slug: p.slug, title: p.title })) } - const { - forecastPages: expectedForecastPages, - nonForecastPages: expectedNonForecastPages, - zoneCount, - } = await resolveBuiltInPages(tenant.slug, navBuiltInPages, payload.logger) + const { forecastPages: expectedForecastPages, nonForecastPages: expectedNonForecastPages } = + await resolveBuiltInPages(tenant.slug, navBuiltInPages, payload.logger) const tenantForecastPageCount = builtInPages.docs.filter((p) => p.url.startsWith('/forecasts/avalanche'), @@ -142,7 +139,6 @@ export async function checkProvisioningStatusAction( forecastPages: { count: tenantForecastPageCount, expected: expectedForecastPages.length, - zoneCount, }, defaultBuiltInPages: { count: tenantDefaultPageCount, diff --git a/src/collections/Tenants/endpoints/provisionTenant.ts b/src/collections/Tenants/endpoints/provisionTenant.ts index 11bf0e52..85f28946 100644 --- a/src/collections/Tenants/endpoints/provisionTenant.ts +++ b/src/collections/Tenants/endpoints/provisionTenant.ts @@ -19,7 +19,6 @@ export async function resolveBuiltInPages( ): Promise<{ forecastPages: Array<{ title: string; url: string }> nonForecastPages: Array<{ title: string; url: string }> - zoneCount: number }> { let forecastZones: ActiveForecastZoneWithSlug[] = [] try { @@ -60,7 +59,7 @@ export async function resolveBuiltInPages( const nonForecastPages = navBuiltInPages.filter((p) => !p.url.startsWith('/forecasts/avalanche')) - return { forecastPages, nonForecastPages, zoneCount: forecastZones.length } + return { forecastPages, nonForecastPages } } /** From 146d730683399dec8b65204ab0bf8e807e97161c Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Thu, 26 Mar 2026 09:06:27 -0500 Subject: [PATCH 6/8] Improve onboarding checklist UI --- .../OnboardingChecklist.client.test.tsx | 26 +++++++++++++++ .../components/OnboardingChecklist.tsx | 32 ++++++++++++------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/__tests__/client/components/OnboardingChecklist.client.test.tsx b/__tests__/client/components/OnboardingChecklist.client.test.tsx index d7b33619..772f1d24 100644 --- a/__tests__/client/components/OnboardingChecklist.client.test.tsx +++ b/__tests__/client/components/OnboardingChecklist.client.test.tsx @@ -122,6 +122,32 @@ 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({ diff --git a/src/collections/Tenants/components/OnboardingChecklist.tsx b/src/collections/Tenants/components/OnboardingChecklist.tsx index c6026493..d9aa7440 100644 --- a/src/collections/Tenants/components/OnboardingChecklist.tsx +++ b/src/collections/Tenants/components/OnboardingChecklist.tsx @@ -171,31 +171,35 @@ export function OnboardingChecklist() { )} - {automatedComplete && ( -

- Forecast nav is automatically based on NAC zones.
- Blank pages are created using DVACs navigation structure. -

- )} - = forecastPages.expected} - label="Forecast pages" + 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" + 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(', ')}
}
@@ -212,7 +216,11 @@ export function OnboardingChecklist() { ) } - /> + > + {automatedComplete && ( +

Placeholder logo, icon and banner added.

+ )} +

Needs action

From c7a8a618a11dc28e78948e59ccbd30f4ea528f58 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Thu, 26 Mar 2026 13:22:31 -0500 Subject: [PATCH 7/8] Add mountain weather page by checking nac --- .../server/resolveBuiltInPages.server.test.ts | 148 ++++++++++++++++++ docs/onboarding.md | 3 +- .../Tenants/endpoints/provisionTenant.ts | 21 ++- 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 __tests__/server/resolveBuiltInPages.server.test.ts diff --git a/__tests__/server/resolveBuiltInPages.server.test.ts b/__tests__/server/resolveBuiltInPages.server.test.ts new file mode 100644 index 00000000..f1706015 --- /dev/null +++ b/__tests__/server/resolveBuiltInPages.server.test.ts @@ -0,0 +1,148 @@ +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 0956df54..df7a30cc 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -12,7 +12,7 @@ Provisioning is idempotent and can be rerun safely. |------|---------| | Website Settings | Created with placeholder brand assets (logo, icon, banner). Replace with real assets via the checklist link. | | 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). | +| 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. Forecasts tab is zone-aware (single zone: direct link; multi-zone: "All Forecasts" + per-zone items). | @@ -29,6 +29,7 @@ Forecast pages are determined by AFP zone data\*. Non-forecast pages are sourced | 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/endpoints/provisionTenant.ts b/src/collections/Tenants/endpoints/provisionTenant.ts index 85f28946..36e1e3f6 100644 --- a/src/collections/Tenants/endpoints/provisionTenant.ts +++ b/src/collections/Tenants/endpoints/provisionTenant.ts @@ -1,7 +1,11 @@ import { hasSuperAdminPermissions } from '@/access/hasSuperAdminPermissions' import { getSeedImageByFilename, simpleContent } from '@/endpoints/seed/utilities' import type { BuiltInPage, Navigation, Page, Tenant } from '@/payload-types' -import { getActiveForecastZones, type ActiveForecastZoneWithSlug } from '@/services/nac/nac' +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' @@ -57,7 +61,20 @@ export async function resolveBuiltInPages( })), ] - const nonForecastPages = navBuiltInPages.filter((p) => !p.url.startsWith('/forecasts/avalanche')) + // 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 } } From 665aea37ee2a087dad941f9e07abb687b4138245 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Thu, 26 Mar 2026 13:44:49 -0500 Subject: [PATCH 8/8] Add todo --- __tests__/server/resolveBuiltInPages.server.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/__tests__/server/resolveBuiltInPages.server.test.ts b/__tests__/server/resolveBuiltInPages.server.test.ts index f1706015..774c8662 100644 --- a/__tests__/server/resolveBuiltInPages.server.test.ts +++ b/__tests__/server/resolveBuiltInPages.server.test.ts @@ -1,3 +1,9 @@ +// 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()