diff --git a/bin/cli.js b/bin/cli.js index 79858a1..5fadcfa 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -4,6 +4,7 @@ const toolkit = require("../index"); const liquidTestGenerator = require("../lib/liquidTestGenerator"); const liquidTestRunner = require("../lib/liquidTestRunner"); const { ExportFileInstanceGenerator } = require("../lib/exportFileInstanceGenerator"); +const { LiquidSamplerRunner } = require("../lib/liquidSamplerRunner"); const stats = require("../lib/cli/stats"); const { Command, Option } = require("commander"); const pkg = require("../package.json"); @@ -514,6 +515,44 @@ program ); }); +// Run Liquid Sampler +program + .command("run-sampler") + .description("Run Liquid Sampler for partner templates (reconciliation texts, account detail templates, and/or shared parts)") + .requiredOption("-p, --partner ", "Specify the partner to be used") + .option("-h, --handle ", "Specify reconciliation text handle(s) - can specify multiple") + .option("-at, --account-template ", "Specify account detail template name(s) - can specify multiple") + .option("-s, --shared-part ", "Specify shared part name(s) - can specify multiple") + .option("--firm-ids ", "Specify firm ID(s) to run the sampler against - can specify multiple (optional)") + .option("--id ", "Specify an existing sampler ID to fetch results for (optional)") + .action(async (options) => { + // If an existing sampler ID is provided, fetch and display results + if (options.id) { + await new LiquidSamplerRunner(options.partner).checkStatus(options.id); + return; + } + + // Validate: at least one template specified + const handles = options.handle || []; + const accountTemplates = options.accountTemplate || []; + const sharedParts = options.sharedPart || []; + + if (handles.length === 0 && accountTemplates.length === 0 && sharedParts.length === 0) { + consola.error("You need to specify at least one template using -h, -at, or -s"); + process.exit(1); + } + + const templateHandles = { + reconciliationTexts: handles, + accountTemplates: accountTemplates, + sharedParts: sharedParts, + }; + + const firmIds = options.firmIds || []; + + await new LiquidSamplerRunner(options.partner).run(templateHandles, firmIds); + }); + // Create Liquid Test program .command("create-test") diff --git a/lib/api/sfApi.js b/lib/api/sfApi.js index 6e54717..56e2697 100644 --- a/lib/api/sfApi.js +++ b/lib/api/sfApi.js @@ -717,6 +717,30 @@ async function getExportFileInstance(firmId, companyId, periodId, exportFileInst } } +async function createSamplerRun(partnerId, attributes) { + const instance = AxiosFactory.createInstance("partner", partnerId); + try { + const response = await instance.post("liquid_sampler/run", attributes); + apiUtils.responseSuccessHandler(response); + return response; + } catch (error) { + const response = await apiUtils.responseErrorHandler(error); + return response; + } +} + +async function readSamplerRun(partnerId, samplerId) { + const instance = AxiosFactory.createInstance("partner", partnerId); + try { + const response = await instance.get(`liquid_sampler/${samplerId}`); + apiUtils.responseSuccessHandler(response); + return response; + } catch (error) { + const response = await apiUtils.responseErrorHandler(error); + return response; + } +} + module.exports = { authorizeFirm, refreshFirmTokens, @@ -771,4 +795,6 @@ module.exports = { getFirmDetails, createExportFileInstance, getExportFileInstance, + createSamplerRun, + readSamplerRun, }; diff --git a/lib/liquidSamplerRunner.js b/lib/liquidSamplerRunner.js new file mode 100644 index 0000000..e43517b --- /dev/null +++ b/lib/liquidSamplerRunner.js @@ -0,0 +1,259 @@ +const { UrlHandler } = require("./utils/urlHandler"); +const errorUtils = require("./utils/errorUtils"); +const { spinner } = require("./cli/spinner"); +const SF = require("./api/sfApi"); +const fsUtils = require("./utils/fsUtils"); +const { consola } = require("consola"); + +const { ReconciliationText } = require("./templates/reconciliationText"); +const { AccountTemplate } = require("./templates/accountTemplate"); +const { SharedPart } = require("./templates/sharedPart"); + +/** + * Class to run liquid samplers for partner templates + */ +class LiquidSamplerRunner { + constructor(partnerId) { + this.partnerId = partnerId; + } + + /** + * Run liquid sampler for partner templates + * @param {Object} templateHandles - Object containing arrays of template identifiers + * @param {Array} templateHandles.reconciliationTexts - Array of reconciliation text handles + * @param {Array} templateHandles.accountTemplates - Array of account template names + * @param {Array} templateHandles.sharedParts - Array of shared part names + * @param {Array} firmIds - Array of firm IDs to use in the sampler + * @returns {Promise} + */ + async run(templateHandles = {}, firmIds = []) { + try { + // Validate at least one template specified + const { reconciliationTexts = [], accountTemplates = [], sharedParts = [] } = templateHandles; + if (reconciliationTexts.length === 0 && accountTemplates.length === 0 && sharedParts.length === 0) { + consola.error("You need to specify at least one template using -h, -at, or -s"); + process.exit(1); + } + + // Build payload + const samplerParams = await this.#buildSamplerParams(templateHandles, firmIds); + + consola.info(`Starting sampler run with ${samplerParams.templates.length} template(s)...`); + + // Start sampler run + const samplerResponse = await SF.createSamplerRun(this.partnerId, samplerParams); + const samplerId = samplerResponse.data.id || samplerResponse.data; + + if (!samplerId) { + consola.error("Failed to start sampler run - no ID returned"); + process.exit(1); + } + + consola.info(`Sampler run started with ID: ${samplerId}`); + + // Poll for completion + const samplerRun = await this.#fetchAndWaitSamplerResult(samplerId); + + // Process results + await this.#handleSamplerResponse(samplerRun); + } catch (error) { + errorUtils.errorHandler(error); + } + } + + /** + * Fetch the status of an existing sampler run + * @param {string} samplerId - The sampler run ID + * @returns {Promise} + */ + async checkStatus(samplerId) { + try { + consola.info(`Fetching status for sampler run ID: ${samplerId}`); + + const response = await SF.readSamplerRun(this.partnerId, samplerId); + + if (!response || !response.data) { + consola.error("Failed to fetch sampler run status. Is staging running?"); + process.exit(1); + } + + await this.#handleSamplerResponse(response.data); + } catch (error) { + errorUtils.errorHandler(error); + } + } + + /** + * Build sampler parameters from local template files + * @param {Object} templateHandles - Object containing arrays of template identifiers + * @param {Array} templateHandles.reconciliationTexts - Array of reconciliation text handles + * @param {Array} templateHandles.accountTemplates - Array of account template names + * @param {Array} templateHandles.sharedParts - Array of shared part names + * @param {Array} firmIds - Array of firm IDs to use in the sampler + * @returns {Object} Sampler payload with templates array + */ + async #buildSamplerParams(templateHandles = {}, firmIds = []) { + const templates = []; + const { reconciliationTexts = [], accountTemplates = [], sharedParts = [] } = templateHandles; + + // Process reconciliation texts + for (const handle of reconciliationTexts) { + const templateType = "reconciliationText"; + const configPresent = fsUtils.configExists(templateType, handle); + + if (!configPresent) { + consola.error(`Config file for reconciliation text "${handle}" not found`); + process.exit(1); + } + + const config = fsUtils.readConfig(templateType, handle); + + // Validate partner_id exists in config + if (!config.partner_id || !config.partner_id[this.partnerId]) { + consola.error(`Template '${handle}' has no partner_id entry for partner ${this.partnerId}. Import the template to this partner first.`); + process.exit(1); + } + + const templateId = config.partner_id[this.partnerId]; + const templateContent = ReconciliationText.read(handle); + + templates.push({ + type: "Global::Partner::ReconciliationText", + id: String(templateId), + text: templateContent.text, + text_parts: templateContent.text_parts, + }); + } + + // Process account templates + for (const name of accountTemplates) { + const templateType = "accountTemplate"; + const configPresent = fsUtils.configExists(templateType, name); + + if (!configPresent) { + consola.error(`Config file for account template "${name}" not found`); + process.exit(1); + } + + const config = fsUtils.readConfig(templateType, name); + + // Validate partner_id exists in config + if (!config.partner_id || !config.partner_id[this.partnerId]) { + consola.error(`Template '${name}' has no partner_id entry for partner ${this.partnerId}. Import the template to this partner first.`); + process.exit(1); + } + + const templateId = config.partner_id[this.partnerId]; + const templateContent = AccountTemplate.read(name); + + templates.push({ + type: "Global::Partner::AccountDetailTemplate", + id: String(templateId), + text: templateContent.text, + text_parts: templateContent.text_parts, + }); + } + + // Process shared parts + for (const name of sharedParts) { + const templateType = "sharedPart"; + const configPresent = fsUtils.configExists(templateType, name); + + if (!configPresent) { + consola.error(`Config file for shared part "${name}" not found`); + process.exit(1); + } + + const config = fsUtils.readConfig(templateType, name); + + // Validate partner_id exists in config + if (!config.partner_id || !config.partner_id[this.partnerId]) { + consola.error(`Shared part '${name}' has no partner_id entry for partner ${this.partnerId}. Import the shared part to this partner first.`); + process.exit(1); + } + + const templateId = config.partner_id[this.partnerId]; + const templateContent = await SharedPart.read(name); + + templates.push({ + type: "Global::Partner::SharedPart", + id: String(templateId), + text: templateContent.text, + }); + } + + return { templates, firm_ids: firmIds }; + } + + /** + * Poll for sampler run completion + * @param {number} partnerId - The partner ID + * @param {string} samplerId - The sampler run ID + * @returns {Promise} The completed sampler run + */ + async #fetchAndWaitSamplerResult(samplerId) { + let samplerRun = { status: "pending" }; + const pollingDelay = 15000; // 15 seconds + const waitingLimit = 3600000; // 1 hour + + spinner.spin("Running sampler..."); + let waitingTime = 0; + + while (samplerRun.status === "pending" || samplerRun.status === "running") { + await new Promise((resolve) => setTimeout(resolve, pollingDelay)); + + const response = await SF.readSamplerRun(this.partnerId, samplerId); + samplerRun = response.data; + + waitingTime += pollingDelay; + // pollingDelay *= 1.05; + + if (waitingTime >= waitingLimit) { + spinner.stop(); + consola.error("Timeout. Try to fetch the status by using the --id flag, if not run your sampler again"); + process.exit(1); + } + } + + spinner.stop(); + return samplerRun; + } + + /** + * Process and display sampler run results + * @param {Object} response - The sampler run response + */ + async #handleSamplerResponse(response) { + if (!response || !response.status) { + consola.error("Invalid sampler response"); + process.exit(1); + } + + switch (response.status) { + case "failed": + consola.error(`Sampler run failed: ${response.error_message || "Unknown error"}`); + break; + + case "completed": + consola.success("Sampler run completed successfully"); + + if (response && response.result_url) { + await new UrlHandler(response.result_url).openFile(); + } else { + consola.warn("No URL returned"); + } + break; + + case "pending": + case "running": + consola.info(`Sampler run is still in progress. Current status: "${response.status}". Please check again later.`); + break; + + default: + consola.error(`Unexpected sampler status: ${response.status}`); + process.exit(1); + } + } +} + +module.exports = { LiquidSamplerRunner };