import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { cloneDeep } from 'lodash'; import { CLIAlterationHook, CLICodeFile, CLIDocumentKey, CLIProductModuleDefinition, CLIProductModuleDefinitionOutput, isCLIDocumentKey, } from '../domain/product-module-definition'; import { PDFHelper } from './pdf-helper'; import { rmAsync } from './fs-helpers'; import { RootConfigAlterationHook } from '../domain/root-config'; import { sortObjectKeysInArray, sortObjectKeysToMatchTarget } from './sort-keys-to-match-target'; import { DocsFileName, CodeDirectoryName, DocumentsDirectoryName, EmbedFileName, mapDocumentKeyToFileName, PolicyDocumentFileName, RequiredWorkflowFileName, TopLevelDirectoryName, TopLevelFileName, WorkflowsDirectoryName, } from '../domain/file-names'; import { writeFile } from './write-file'; const getConfigAlterationHooks = (params: { alterationHooks: CLIAlterationHook[] }): RootConfigAlterationHook[] => { const { alterationHooks } = params; return alterationHooks.map((alterationHook) => { return { key: alterationHook.key, name: alterationHook.name }; }); }; const writePolicyDocuments = async (params: { productModuleDefinition: CLIProductModuleDefinitionOutput; directory: string; }) => { const { productModuleDefinition, directory } = params; const documentsDirPath = path.join(directory, TopLevelDirectoryName.Documents); const { documents } = productModuleDefinition; const keys = Object.keys(documents) as (keyof typeof documents)[]; await Promise.all( keys.map(async (key) => { if (isCLIDocumentKey(key)) { const value = documents[key]; if (!value) { // The invoice template has no server-side default. An absent invoice_template_html // is the meaningful "invoicing disabled" state, so skip writing the file instead of // treating it as a missing required document. if (key === CLIDocumentKey.InvoiceTemplateHtml) { return; } // Platform returns a (possibly default) value for every other document. throw new Error(`Policy document '${key}' is missing`); } const fileName = mapDocumentKeyToFileName(key); const content = fileName === PolicyDocumentFileName.Terms ? PDFHelper.cleanDataURI(value) : value; const encoding = fileName === PolicyDocumentFileName.Terms ? 'base64' : undefined; const documentFilePath = path.join(documentsDirPath, fileName); await writeFile(documentFilePath, content, { encoding }); return; } const supplementaryTermsFiles = documents[key]; if (supplementaryTermsFiles?.length === 0) { return; } const supplementaryTermsDirPath = path.join(documentsDirPath, DocumentsDirectoryName.SupplementaryTerms); await fsp.mkdir(supplementaryTermsDirPath); await Promise.all( supplementaryTermsFiles.map(async (file) => { const documentFilePath = path.join(supplementaryTermsDirPath, `${file.type}.pdf`); await writeFile(documentFilePath, PDFHelper.cleanDataURI(file.pdfBase64), { encoding: 'base64' }); }), ); }), ); }; /** * Create all the relevant files from the full body payload, clearing out old workings. Like a force pull with Git. */ export const writeProductModuleDefinition = async (params: { productModuleDefinition: CLIProductModuleDefinitionOutput; directory: string; host: string; targetOrderDefinition?: CLIProductModuleDefinition; }) => { const { productModuleDefinition, directory, host, targetOrderDefinition } = params; const workflowsDirPath = path.join(directory, TopLevelDirectoryName.Workflows); const documentsDirPath = path.join(directory, TopLevelDirectoryName.Documents); const codeDirPath = path.join(directory, TopLevelDirectoryName.Code); const docsDirPath = path.join(directory, TopLevelDirectoryName.Docs); const buildDirPath = path.join(directory, TopLevelDirectoryName.Code, CodeDirectoryName.Build); const srcDirPath = path.join(directory, TopLevelDirectoryName.Code, CodeDirectoryName.Src); const isTypeScript = !!productModuleDefinition.rootConfig.settings.typescriptProductModuleCode; // Clear out top level folders to ensure no mixing of old and new await Promise.all( [workflowsDirPath, documentsDirPath, codeDirPath, docsDirPath].map(async (dirName) => { await rmAsync(dirName, { recursive: true, force: true }); await fsp.mkdir(dirName); }), ); // Create TypeScript sub-directories inside code/ after it has been recreated if (isTypeScript) { await Promise.all([fsp.mkdir(srcDirPath), fsp.mkdir(buildDirPath)]); } // Create sub folders const alterationHooksDirPath = path.join(workflowsDirPath, WorkflowsDirectoryName.AlterationHooks); const applicationAlterationHooksDirPath = path.join( workflowsDirPath, WorkflowsDirectoryName.ApplicationAlterationHooks, ); const memberAlterationHooksDirPath = path.join(workflowsDirPath, WorkflowsDirectoryName.MemberAlterationHooks); const embedDirPath = path.join(workflowsDirPath, WorkflowsDirectoryName.Embed); const unitTestsDirPath = path.join(codeDirPath, CodeDirectoryName.UnitTests); await Promise.all( [ alterationHooksDirPath, applicationAlterationHooksDirPath, memberAlterationHooksDirPath, embedDirPath, unitTestsDirPath, ].map((dirName) => fsp.mkdir(dirName)), ); // Write config file const alterationHooks = getConfigAlterationHooks({ alterationHooks: productModuleDefinition.workflows.alterationHooks, }); const targetAlterationHooks = targetOrderDefinition ? getConfigAlterationHooks({ alterationHooks: targetOrderDefinition.workflows.alterationHooks }) : undefined; const applicationAlterationHooks = getConfigAlterationHooks({ alterationHooks: productModuleDefinition.workflows.applicationAlterationHooks || [], }); const targetApplicationAlterationHooks = targetOrderDefinition ? getConfigAlterationHooks({ alterationHooks: targetOrderDefinition.workflows.applicationAlterationHooks || [] }) : undefined; const memberAlterationHooks = getConfigAlterationHooks({ alterationHooks: productModuleDefinition.workflows.memberAlterationHooks || [], }); const targetMemberAlterationHooks = targetOrderDefinition ? getConfigAlterationHooks({ alterationHooks: targetOrderDefinition.workflows.memberAlterationHooks || [] }) : undefined; const rootConfigSchemaPath = 'v1/cli/json-schemas/root-config'; const configOut: Record = { $schema: /^https?:\/\//i.test(host) ? `${host}/${rootConfigSchemaPath}` : `https://${host}/${rootConfigSchemaPath}`, productModuleName: productModuleDefinition.productModuleName, productModuleKey: productModuleDefinition.key, organizationId: productModuleDefinition.organizationId, host, stack: productModuleDefinition.rootConfig.stack, ...(isTypeScript ? {} : { codeFileOrder: productModuleDefinition.codeFiles.map((cf: CLICodeFile) => cf.fileName) }), settings: targetOrderDefinition ? sortObjectKeysToMatchTarget({ objectToSort: productModuleDefinition.rootConfig.settings, targetObject: targetOrderDefinition.rootConfig.settings, }) : productModuleDefinition.rootConfig.settings, alterationHooks: targetAlterationHooks ? sortObjectKeysInArray({ arrayToSort: alterationHooks, targetArray: targetAlterationHooks, }) : alterationHooks, applicationAlterationHooks: targetApplicationAlterationHooks ? sortObjectKeysInArray({ arrayToSort: applicationAlterationHooks, targetArray: targetApplicationAlterationHooks, }) : applicationAlterationHooks, memberAlterationHooks: targetMemberAlterationHooks ? sortObjectKeysInArray({ arrayToSort: memberAlterationHooks, targetArray: targetMemberAlterationHooks, }) : memberAlterationHooks, scheduledFunctions: targetOrderDefinition ? sortObjectKeysInArray({ arrayToSort: productModuleDefinition.rootConfig.scheduledFunctions, targetArray: targetOrderDefinition.rootConfig.scheduledFunctions, }) : productModuleDefinition.rootConfig.scheduledFunctions, fulfillmentTypes: targetOrderDefinition ? sortObjectKeysInArray({ arrayToSort: productModuleDefinition.rootConfig.fulfillmentTypes, targetArray: targetOrderDefinition.rootConfig.fulfillmentTypes, }) : productModuleDefinition.rootConfig.fulfillmentTypes, }; const writeConfig = writeFile(path.join(directory, TopLevelFileName.RootConfig), JSON.stringify(configOut, null, 2)); // Write the README.md file const writeReadme = writeFile(path.join(directory, TopLevelFileName.Readme), productModuleDefinition.readmeMarkdown); // Write policy documents const writeDocuments = writePolicyDocuments({ productModuleDefinition, directory }); // Write all the workflows const quoteSchemaOut = targetOrderDefinition ? sortObjectKeysInArray({ arrayToSort: productModuleDefinition.workflows.quoteSchema, targetArray: targetOrderDefinition.workflows.quoteSchema, }) : productModuleDefinition.workflows.quoteSchema; const writeQuoteSchema = writeFile( path.join(workflowsDirPath, RequiredWorkflowFileName.QuoteSchema), JSON.stringify(quoteSchemaOut, null, 2), ); const applicationSchemaOut = targetOrderDefinition ? sortObjectKeysInArray({ arrayToSort: productModuleDefinition.workflows.applicationSchema, targetArray: targetOrderDefinition.workflows.applicationSchema, }) : productModuleDefinition.workflows.applicationSchema; const writeApplicationSchema = writeFile( path.join(workflowsDirPath, RequiredWorkflowFileName.ApplicationSchema), JSON.stringify(applicationSchemaOut, null, 2), ); const claimsBlocksOut = targetOrderDefinition ? sortObjectKeysInArray({ arrayToSort: productModuleDefinition.workflows.claimsBlocks, targetArray: targetOrderDefinition.workflows.claimsBlocks, }) : productModuleDefinition.workflows.claimsBlocks; const writeClaimsBlocks = writeFile( path.join(workflowsDirPath, RequiredWorkflowFileName.ClaimsBlocks), JSON.stringify(claimsBlocksOut, null, 2), ); const writeAlterationHookSchemas = Promise.all( productModuleDefinition.workflows.alterationHooks.map(async (alterationHook: { key: string; schema: any }) => { const targetAlterationHook = targetOrderDefinition ? targetOrderDefinition.workflows.alterationHooks.find((ah: CLIAlterationHook) => ah.key === alterationHook.key) : undefined; const writeSchema = targetAlterationHook ? sortObjectKeysInArray({ arrayToSort: alterationHook.schema, targetArray: targetAlterationHook.schema, }) : alterationHook.schema; await writeFile( path.join(alterationHooksDirPath, `${alterationHook.key}.json`), JSON.stringify(writeSchema, null, 2), ); }), ); const writeApplicationAlterationHookSchemas = Promise.all( (productModuleDefinition.workflows.applicationAlterationHooks || []).map( async (alterationHook: { key: string; schema: any }) => { const targetAlterationHook = targetOrderDefinition ? (targetOrderDefinition.workflows.applicationAlterationHooks || []).find( (ah: CLIAlterationHook) => ah.key === alterationHook.key, ) : undefined; const writeSchema = targetAlterationHook ? sortObjectKeysInArray({ arrayToSort: alterationHook.schema, targetArray: targetAlterationHook.schema, }) : alterationHook.schema; await writeFile( path.join(applicationAlterationHooksDirPath, `${alterationHook.key}.json`), JSON.stringify(writeSchema, null, 2), ); }, ), ); const writeMemberAlterationHookSchemas = Promise.all( (productModuleDefinition.workflows.memberAlterationHooks || []).map( async (alterationHook: { key: string; schema: any }) => { const targetAlterationHook = targetOrderDefinition ? (targetOrderDefinition.workflows.memberAlterationHooks || []).find( (ah: CLIAlterationHook) => ah.key === alterationHook.key, ) : undefined; const writeSchema = targetAlterationHook ? sortObjectKeysInArray({ arrayToSort: alterationHook.schema, targetArray: targetAlterationHook.schema, }) : alterationHook.schema; await writeFile( path.join(memberAlterationHooksDirPath, `${alterationHook.key}.json`), JSON.stringify(writeSchema, null, 2), ); }, ), ); const { productModuleEmbedConfig: embedConfig } = productModuleDefinition.workflows; const writeEmbedFiles: Promise[] = []; if (embedConfig) { // Do not mutate the original embed config const clonedEmbedConfig = cloneDeep(embedConfig); // Separate pre personal details compliance content const prePersonalDetailsComplianceContent = clonedEmbedConfig.prePersonalDetailsCompliance?.wording?.content; if (prePersonalDetailsComplianceContent) { writeEmbedFiles.push( writeFile( path.join(embedDirPath, EmbedFileName.PrePersonalDetailsCompliance), prePersonalDetailsComplianceContent, ), ); delete clonedEmbedConfig.prePersonalDetailsCompliance.wording.content; } // Separate pre payment details compliance content const prePaymentComplianceContent = clonedEmbedConfig.prePaymentCompliance?.wording?.content; if (prePaymentComplianceContent) { writeEmbedFiles.push( writeFile(path.join(embedDirPath, EmbedFileName.PrePaymentCompliance), prePaymentComplianceContent), ); delete clonedEmbedConfig.prePaymentCompliance.wording.content; } // Separate embed inputFields const embedInputFields = clonedEmbedConfig.inputFields; if (embedInputFields) { writeEmbedFiles.push( writeFile(path.join(embedDirPath, EmbedFileName.InputFields), JSON.stringify(embedInputFields, null, 2)), ); delete clonedEmbedConfig.personalDetails.inputFields; delete clonedEmbedConfig.inputFields; } // Write the remainder of the embed config const writeEmbedConfig = targetOrderDefinition?.workflows.productModuleEmbedConfig ? sortObjectKeysToMatchTarget({ objectToSort: clonedEmbedConfig, targetObject: targetOrderDefinition.workflows.productModuleEmbedConfig, }) : clonedEmbedConfig; writeEmbedFiles.push( writeFile(path.join(embedDirPath, EmbedFileName.EmbedConfig), JSON.stringify(writeEmbedConfig, null, 2)), ); } // Write the API docs (preserve format and structure exactly as provided) const writeApiDocs = writeFile( path.join(docsDirPath, DocsFileName.ApiDocs), JSON.stringify(productModuleDefinition.apiDocsSchema, null, 2), ); const writeCodeFiles = isTypeScript ? Promise.all([ ...productModuleDefinition.codeFiles.map(async (cf: CLICodeFile) => { await writeFile(path.join(buildDirPath, cf.fileName), cf.fileContent); }), ...(productModuleDefinition.sourceCodeFiles ?? []).map(async (cf: CLICodeFile) => { const filePath = path.join(srcDirPath, cf.fileName); await fsp.mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, cf.fileContent); }), ]) : Promise.all( productModuleDefinition.codeFiles.map(async (cf: CLICodeFile) => { await writeFile(path.join(codeDirPath, cf.fileName), cf.fileContent); }), ); const writeUnitTestFiles = Promise.all( productModuleDefinition.unitTestCodeFiles.map(async (cf: CLICodeFile) => { await writeFile(path.join(unitTestsDirPath, cf.fileName), cf.fileContent); }), ); await Promise.all([ writeConfig, writeReadme, writeDocuments, writeQuoteSchema, writeApplicationSchema, writeClaimsBlocks, writeAlterationHookSchemas, writeApplicationAlterationHookSchemas, writeMemberAlterationHookSchemas, writeApiDocs, writeCodeFiles, writeUnitTestFiles, ...writeEmbedFiles, ]); };