diff --git a/scripts/yarn/setup.js b/scripts/yarn/setup.js index 2bb165c89..8c1790475 100644 --- a/scripts/yarn/setup.js +++ b/scripts/yarn/setup.js @@ -13,7 +13,6 @@ const target = path.resolve( 'generated' ) const symlinkPath = path.resolve(__dirname, '..', '..', 'public', 'generated') -const cmsDataPath = path.resolve(__dirname, '..', '..', 'public', 'data', 'cms') ;(async () => { try { @@ -31,26 +30,4 @@ const cmsDataPath = path.resolve(__dirname, '..', '..', 'public', 'data', 'cms') } catch (error) { console.error('Error creating symlink:', error) } - - try { - const vamcEhrExists = await fs.pathExists(cmsDataPath) - - if (!vamcEhrExists) { - // Grab data file populated by the cms - const response = await fetch('https://va.gov/data/cms/vamc-ehr.json') - const data = await response.json() - - await fs.mkdirp(cmsDataPath) - - await fs.writeJson(`${cmsDataPath}/vamc-ehr.json`, data) - - // eslint-disable-next-line no-console - console.log('vamc-ehr data fetched successfully!') - } else { - // eslint-disable-next-line no-console - console.log('vamc-ehr data already exists.') - } - } catch (error) { - console.error('Error fetching vamc-ehr data file:', error) - } })() diff --git a/src/data/queries/index.ts b/src/data/queries/index.ts index d7e18c848..b1ccb22e5 100644 --- a/src/data/queries/index.ts +++ b/src/data/queries/index.ts @@ -18,7 +18,8 @@ import * as StaticPathResources from './staticPathResources' import * as HeaderFooter from './headerFooter' import * as PromoBlock from './promoBlock' import * as Event from './event' -import { RESOURCE_TYPES } from '@/lib/constants/resourceTypes' +import * as VamcEhr from './vamcEhr' +import { ResourceType } from '@/lib/constants/resourceTypes' export const QUERIES_MAP = { // standard Drupal entity data queries @@ -46,17 +47,59 @@ export const QUERIES_MAP = { // Static Path Generation 'static-path-resources': StaticPathResources, + + // Static JSON files + 'vamc-ehr': VamcEhr, } // Type representing all possible object shapes returned from querying and formatting Drupal data. // E.g. StoryListingType | NewsStoryType | (other future resoource type) // Type constructed by: -// 1. Consider all keys of QUERIES_MAP above -// 2. Take subset of those keys that appear in RESOURCE_TYPES (imported) +// 1. Consider all ResourceType types +// 2. Take subset of those types that have a key in QUERIES_MAP // 3. Map that subset of keys to their respective values, which are modules for querying data // 4. Within each of those modules, grab the return type of the `formatter` function export type FormattedResource = ReturnType< - (typeof QUERIES_MAP)[(typeof RESOURCE_TYPES)[keyof typeof RESOURCE_TYPES]]['formatter'] + (typeof QUERIES_MAP)[ResourceType & keyof typeof QUERIES_MAP]['formatter'] +> + +// Type representing all keys from QUERIES_MAP whose values have a 'data' function. +// This type is used, for example, to type values we can pass to queries.getData() +// E.g. `node--news_story` is included because src/data/queries/newsStory.ts has a function `data` +// E.g. `block--alert` is NOT included because src/data/queries/alert.ts does not have a function `data` (only `formatter`) +export type QueryType = { + [K in keyof typeof QUERIES_MAP]: 'data' extends keyof (typeof QUERIES_MAP)[K] + ? K + : never +}[keyof typeof QUERIES_MAP] + +// Type mapping keys from QUERIES_MAP to the types of opts passable to the respective `data` function +// of the key's value. +// This type is used, for example, to ensure that StaticJsonFile configurations define an acceptable +// value for `queryOpts`. +// E.g. `node--news_story` => NewsStoryDataOpts because the `data` function in src/data/queries/newsStory.ts +// is typed QueryData (note first parameter) +/*eslint-disable @typescript-eslint/no-explicit-any*/ +type AllQueryDataOptsMap = { + [K in keyof typeof QUERIES_MAP]: 'data' extends keyof (typeof QUERIES_MAP)[K] + ? (typeof QUERIES_MAP)[K] extends { data: (...args: infer U) => any } + ? U[0] + : never + : never +} +/*eslint-enable @typescript-eslint/no-explicit-any*/ +type NonNeverKeys = { + [K in keyof T]: T[K] extends never ? never : K +}[keyof T] +export type QueryDataOptsMap = Pick< + AllQueryDataOptsMap, + NonNeverKeys > +// Type representing resource types that have queries defined +// E.g. `node--news_story` is included because it's a resource type and has an entry in QUERIES_MAP +// E.g. `node--health_care_local_facility` is not included because it does not have an entry in QUERIES_MAP +// E.g. `vamc-ehr` is not included because it's not a resource type +export type QueryResourceType = QueryType & ResourceType + export const queries = createQueries(QUERIES_MAP) diff --git a/src/data/queries/staticPathResources.ts b/src/data/queries/staticPathResources.ts index 491ed2427..07361b9dc 100644 --- a/src/data/queries/staticPathResources.ts +++ b/src/data/queries/staticPathResources.ts @@ -5,17 +5,14 @@ import { QueryParams, } from 'next-drupal-query' import { JsonApiResourceWithPath } from 'next-drupal' -import { - ADDITIONAL_RESOURCE_TYPES, - ResourceType, -} from '@/lib/constants/resourceTypes' +import { ResourceType } from '@/lib/constants/resourceTypes' import { StaticPathResource } from '@/types/formatted/staticPathResource' import { FieldAdministration } from '@/types/drupal/field_type' import { PAGE_SIZES } from '@/lib/constants/pageSizes' import { queries } from '.' import { fetchAndConcatAllResourceCollectionPages } from '@/lib/drupal/query' -const PAGE_SIZE = PAGE_SIZES[ADDITIONAL_RESOURCE_TYPES.STATIC_PATHS] +const PAGE_SIZE = PAGE_SIZES.MAX // Define the query params for fetching static paths. export const params: QueryParams = ( diff --git a/src/data/queries/vamcEhr.ts b/src/data/queries/vamcEhr.ts new file mode 100644 index 000000000..2ce3f64de --- /dev/null +++ b/src/data/queries/vamcEhr.ts @@ -0,0 +1,64 @@ +import { QueryData, QueryFormatter, QueryParams } from 'next-drupal-query' +import { queries } from '.' +import { RESOURCE_TYPES } from '@/lib/constants/resourceTypes' +import { fetchAndConcatAllResourceCollectionPages } from '@/lib/drupal/query' +import { VamcEhr as DrupalVamcEhr } from '@/types/drupal/vamcEhr' +import { VamcEhrGraphQLMimic } from '@/types/formatted/vamcEhr' +import { PAGE_SIZES } from '@/lib/constants/pageSizes' + +const PAGE_SIZE = PAGE_SIZES.MAX + +// Define the query params for fetching node--health_care_local_facility. +export const params: QueryParams = () => { + return queries + .getParams() + .addFields(RESOURCE_TYPES.VAMC_FACILITY, [ + 'title', + 'field_facility_locator_api_id', + 'field_region_page', + ]) + .addFilter('field_main_location', '1') + .addInclude(['field_region_page']) + .addFields(RESOURCE_TYPES.VAMC_SYSTEM, ['title', 'field_vamc_ehr_system']) +} + +// Implement the data loader. +export const data: QueryData = async (): Promise< + DrupalVamcEhr[] +> => { + const { data } = + await fetchAndConcatAllResourceCollectionPages( + RESOURCE_TYPES.VAMC_FACILITY, + params(), + PAGE_SIZE + ) + return data +} + +export const formatter: QueryFormatter = ( + entities: DrupalVamcEhr[] +) => { + // For now, return data formatted as it is in content-build (mimic GraphQL output). + // In future, we should move the formatting from the preProcess in vets-website + // into this formatter and, while it exists, into postProcess in content-build. + // This change will require a coordinated effort so as to not break things with regard + // to what vets-website is expecting and what is present in the generated file. + return { + data: { + nodeQuery: { + count: entities.length, + entities: entities.map((entity) => ({ + title: entity.title, + fieldFacilityLocatorApiId: entity.field_facility_locator_api_id, + fieldRegionPage: { + entity: { + title: entity.field_region_page.title, + fieldVamcEhrSystem: + entity.field_region_page.field_vamc_ehr_system, + }, + }, + })), + }, + }, + } +} diff --git a/src/lib/constants/pageSizes.ts b/src/lib/constants/pageSizes.ts index 83adb36ef..9345abea8 100644 --- a/src/lib/constants/pageSizes.ts +++ b/src/lib/constants/pageSizes.ts @@ -1,9 +1,6 @@ -import { - RESOURCE_TYPES, - ADDITIONAL_RESOURCE_TYPES, -} from '@/lib/constants/resourceTypes' +import { RESOURCE_TYPES } from '@/lib/constants/resourceTypes' export const PAGE_SIZES = { [RESOURCE_TYPES.STORY_LISTING]: 10, - [ADDITIONAL_RESOURCE_TYPES.STATIC_PATHS]: 50, //must be <= 50 due to JSON:API limit + MAX: 50, //50 is JSON:API limit. Use this for fetching as many as possible at a time. } as const diff --git a/src/lib/constants/resourceTypes.ts b/src/lib/constants/resourceTypes.ts index 3688f3fbc..2eecf8198 100644 --- a/src/lib/constants/resourceTypes.ts +++ b/src/lib/constants/resourceTypes.ts @@ -2,11 +2,9 @@ export const RESOURCE_TYPES = { STORY_LISTING: 'node--story_listing', STORY: 'node--news_story', EVENT: 'node--event', + VAMC_FACILITY: 'node--health_care_local_facility', + VAMC_SYSTEM: 'node--health_care_region_page', // QA: 'node--q_a', } as const export type ResourceType = (typeof RESOURCE_TYPES)[keyof typeof RESOURCE_TYPES] - -export const ADDITIONAL_RESOURCE_TYPES = { - STATIC_PATHS: 'static-path-resources', -} diff --git a/src/lib/drupal/staticProps.ts b/src/lib/drupal/staticProps.ts index 47f1731ea..95df4fd91 100644 --- a/src/lib/drupal/staticProps.ts +++ b/src/lib/drupal/staticProps.ts @@ -16,7 +16,11 @@ import { LovellStaticPropsResource, LovellFormattedResource, } from './lovell/types' -import { FormattedResource } from '@/data/queries' +import { + FormattedResource, + QueryResourceType, + QUERIES_MAP, +} from '@/data/queries' import { RESOURCE_TYPES, ResourceType } from '@/lib/constants/resourceTypes' import { ListingPageDataOpts } from '@/lib/drupal/listingPages' import { NewsStoryDataOpts } from '@/data/queries/newsStory' @@ -100,7 +104,9 @@ export async function fetchSingleStaticPropsResource( queryOpts: StaticPropsQueryOpts ): Promise { // Request resource based on type - const resource = await queries.getData(resourceType, queryOpts) + const resource = Object.keys(QUERIES_MAP).includes(resourceType) + ? await queries.getData(resourceType as QueryResourceType, queryOpts) + : null if (!resource) { throw new Error(`Failed to fetch resource: ${pathInfo.jsonapi.individual}`) } diff --git a/src/pages/data/cms/[filename].tsx b/src/pages/data/cms/[filename].tsx new file mode 100644 index 000000000..9278bbdc2 --- /dev/null +++ b/src/pages/data/cms/[filename].tsx @@ -0,0 +1,81 @@ +import { + GetStaticPathsContext, + GetStaticPathsResult, + GetStaticPropsContext, +} from 'next' +import fs from 'fs' +import path from 'path' +import { QueryDataOptsMap, queries } from '@/data/queries' + +type StaticJsonFile = { + filename: string + query: T + queryOpts?: QueryDataOptsMap[T] +} + +const STATIC_JSON_FILES: Array> = [ + { + filename: 'vamc-ehr', + query: 'vamc-ehr', + } as StaticJsonFile<'vamc-ehr'>, + + // Another example: + // { + // filename: 'hypothetical-banner-data-static-json-file', + // query: 'banner-data', //must be defined in QUERIES_MAP in src/data/queries/index.ts + // queryOpts: { + // itemPath: 'path/to/item', + // }, + // } as StaticJsonFile<'banner-data'>, +] + +/* This component never generates a page, but this default export must be present */ +export default function StaticJsonPage() { + return null +} + +export async function getStaticPaths( + context: GetStaticPathsContext +): Promise { + if (process.env.SSG === 'false') { + return { + paths: [], + fallback: 'blocking', + } + } + + return { + paths: STATIC_JSON_FILES.map(({ filename }) => ({ + params: { + filename, + }, + })), + fallback: 'blocking', + } +} + +export async function getStaticProps(context: GetStaticPropsContext) { + const staticJsonFilename = context.params?.filename + const staticJsonFile = STATIC_JSON_FILES.find( + ({ filename }) => filename === staticJsonFilename + ) + if (staticJsonFile) { + const { filename, query, queryOpts = {} } = staticJsonFile + + // fetch data + const data = await queries.getData(query, queryOpts) + + // Write to /public/data/cms + const filePath = path.resolve(`public/data/cms/${filename}.json`) + const directoryPath = path.dirname(filePath) + if (!fs.existsSync(directoryPath)) { + fs.mkdirSync(directoryPath, { + recursive: true, + }) + } + fs.writeFileSync(filePath, JSON.stringify(data)) + } + return { + notFound: true, + } +} diff --git a/src/types/drupal/vamcEhr.ts b/src/types/drupal/vamcEhr.ts new file mode 100644 index 000000000..e3cd791d4 --- /dev/null +++ b/src/types/drupal/vamcEhr.ts @@ -0,0 +1,8 @@ +export type VamcEhr = { + title: string + field_facility_locator_api_id: string + field_region_page: { + title: string + field_vamc_ehr_system: 'vista' | 'cerner' | 'cerner_staged' + } +} diff --git a/src/types/formatted/vamcEhr.ts b/src/types/formatted/vamcEhr.ts new file mode 100644 index 000000000..4661d9c27 --- /dev/null +++ b/src/types/formatted/vamcEhr.ts @@ -0,0 +1,39 @@ +// // FUTURE USE +// // vamc-ehr.json should eventually include formatted data. +// // At the least, it should contain formatted facilities. +// export type VamcEhrFacility = { +// title: string +// vhaId: string +// vamcFacilityName: string +// vamcSystemName: string +// ehr: 'vista' | 'cerner' | 'cerner_staged' +// } +// // Maybe addtionally, it should categorize the +// // facilities and offload this piece from vets-website code as well. +// export type VamcEhr = { +// ehrDataByVhaId: { +// vhaId: VamcEhrFacility, +// }, +// cernerFacilities: VamcEhrFacility[], +// vistaFacilities: VamcEhrFacility[], +// } + +export type VamcEhrGraphQLEntity = { + title: string + fieldFacilityLocatorApiId: string + fieldRegionPage: { + entity: { + title: string + fieldVamcEhrSystem: 'vista' | 'cerner' | 'cerner_staged' + } + } +} + +export type VamcEhrGraphQLMimic = { + data: { + nodeQuery: { + count: number + entities: VamcEhrGraphQLEntity[] + } + } +}