cityssm/node-faster-report-exporter

View on GitHub
index.js

Summary

Maintainability
A
0 mins
Test Coverage
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { URL } from 'node:url';
import puppeteerLaunch from '@cityssm/puppeteer-launch';
import Debug from 'debug';
import { minimumRecommendedTimeoutSeconds, reportExportTypes } from './lookups.js';
import { applyReportFilters } from './puppeteerHelpers.js';
import { defaultDelayMillis, delay, longDelayMillis } from './utilities.js';
const debug = Debug('faster-report-exporter:index');
export class FasterReportExporter {
    #fasterBaseUrl;
    #fasterUserName;
    #fasterPassword;
    #downloadFolderPath = os.tmpdir();
    #useHeadlessBrowser = true;
    #timeoutMillis = Math.max(90_000, minimumRecommendedTimeoutSeconds);
    #timeZone = 'Eastern';
    constructor(fasterTenant, fasterUserName, fasterPassword, options = {}) {
        this.#fasterBaseUrl = `https://${fasterTenant}.fasterwebcloud.com/FASTER`;
        this.#fasterUserName = fasterUserName;
        this.#fasterPassword = fasterPassword;
        if (options.downloadFolderPath !== undefined) {
            this.setDownloadFolderPath(options.downloadFolderPath);
        }
        if (options.timeoutMillis !== undefined) {
            this.setTimeoutMillis(options.timeoutMillis);
        }
        if (options.showBrowserWindow !== undefined && options.showBrowserWindow) {
            this.showBrowserWindow();
        }
        if (options.timeZone !== undefined) {
            this.#timeZone = options.timeZone;
        }
    }
    setDownloadFolderPath(downloadFolderPath) {
        if (!fs.existsSync(downloadFolderPath)) {
            throw new Error(`Download folder path does not exist: ${downloadFolderPath}`);
        }
        this.#downloadFolderPath = downloadFolderPath;
    }
    setTimeoutMillis(timeoutMillis) {
        this.#timeoutMillis = timeoutMillis;
        if (timeoutMillis < minimumRecommendedTimeoutSeconds * 1000) {
            debug(`Warning: Timeouts less than ${minimumRecommendedTimeoutSeconds}s are not recommended.`);
        }
    }
    showBrowserWindow() {
        this.#useHeadlessBrowser = false;
    }
    setTimeZone(timezone) {
        this.#timeZone = timezone;
    }
    async #getLoggedInFasterPage() {
        let browser;
        try {
            browser = await puppeteerLaunch({
                browser: 'chrome',
                protocol: 'cdp',
                headless: this.#useHeadlessBrowser,
                timeout: this.#timeoutMillis
            });
            debug('Logging into FASTER...');
            const page = await browser.newPage();
            await page.goto(this.#fasterBaseUrl, {
                timeout: this.#timeoutMillis
            });
            await page.waitForNetworkIdle({
                timeout: this.#timeoutMillis
            });
            const loginFormElement = await page.$('#form_Signin');
            if (loginFormElement !== null) {
                debug('Filling out login form...');
                const userNameElement = await loginFormElement.$('#LoginControl_UserName');
                if (userNameElement === null) {
                    throw new Error('Unable to locate user name field.');
                }
                await userNameElement.type(this.#fasterUserName);
                const passwordElement = await loginFormElement.$('#LoginControl_Password');
                if (passwordElement === null) {
                    throw new Error('Unable to locate password field.');
                }
                await passwordElement.type(this.#fasterPassword);
                const submitButtonElement = await loginFormElement.$('#LoginControl_SignInButton_input');
                if (submitButtonElement === null) {
                    throw new Error('Unable to locate Sign In button.');
                }
                await submitButtonElement.scrollIntoView();
                await submitButtonElement.click();
                await delay();
                await page.waitForNetworkIdle({
                    timeout: this.#timeoutMillis
                });
                if (page.url().toLowerCase().includes('release/releasenotes.aspx')) {
                    debug('Release notes page, continuing...');
                    const continueButtonElement = await page.$('#OKRadButon_input');
                    if (continueButtonElement !== null) {
                        await continueButtonElement.scrollIntoView();
                        await continueButtonElement.click();
                        await delay();
                        await page.waitForNetworkIdle({
                            timeout: this.#timeoutMillis
                        });
                    }
                }
            }
            debug('Finished logging in.');
            return {
                browser,
                page
            };
        }
        catch (error) {
            try {
                await browser?.close();
            }
            catch { }
            throw error;
        }
    }
    async #navigateToFasterReportPage(browser, page, reportKey, reportParameters, reportFilters) {
        try {
            const reportUrl = new URL(`${this.#fasterBaseUrl}/Domains/Reports/ReportViewer.aspx`);
            reportUrl.searchParams.set('R', reportKey);
            for (const [parameterKey, parameterValue] of Object.entries(reportParameters)) {
                reportUrl.searchParams.set(parameterKey, parameterValue);
            }
            await page.goto(reportUrl.href, {
                timeout: this.#timeoutMillis
            });
            await delay();
            await page.waitForNetworkIdle({
                timeout: this.#timeoutMillis
            });
            if (reportFilters !== undefined) {
                await applyReportFilters(page, reportFilters, {
                    timeoutMillis: this.#timeoutMillis
                });
            }
            return {
                browser,
                page
            };
        }
        catch (error) {
            try {
                await browser.close();
            }
            catch { }
            throw error;
        }
    }
    async #exportFasterReport(browser, page, exportType = 'PDF') {
        await page.bringToFront();
        await page.waitForNetworkIdle({
            timeout: this.#timeoutMillis
        });
        debug(`Report Page Title: ${await page.title()}`);
        const downloadPromise = new Promise(async (resolve) => {
            let downloadStarted = false;
            try {
                const cdpSession = await browser.target().createCDPSession();
                await cdpSession.send('Browser.setDownloadBehavior', {
                    behavior: 'allowAndName',
                    downloadPath: this.#downloadFolderPath,
                    eventsEnabled: true
                });
                cdpSession.on('Browser.downloadProgress', (event) => {
                    if (event.state === 'completed') {
                        debug('Download complete.');
                        const downloadedFilePath = path.join(this.#downloadFolderPath, event.guid);
                        const newFilePath = `${downloadedFilePath}.${reportExportTypes[exportType]}`;
                        fs.rename(downloadedFilePath, newFilePath, (error) => {
                            if (error === null) {
                                debug(`File: ${newFilePath}`);
                                resolve(newFilePath);
                            }
                            else {
                                debug(`File: ${downloadedFilePath}`);
                                resolve(downloadedFilePath);
                            }
                        });
                        downloadStarted = false;
                    }
                    else if (event.state === 'canceled') {
                        downloadStarted = false;
                        throw new Error('Download cancelled.');
                    }
                });
                await page.waitForNetworkIdle({
                    timeout: this.#timeoutMillis
                });
                debug(`Finding the print button for "${exportType}"...`);
                const printOptionsMenuElement = await page.waitForSelector('#RvDetails_ctl05_ctl04_ctl00_ButtonLink', { timeout: this.#timeoutMillis });
                if (printOptionsMenuElement === null) {
                    throw new Error('Unable to locate print options. Consider extending the timeout millis.');
                }
                await printOptionsMenuElement.click();
                await delay(longDelayMillis);
                await page.waitForNetworkIdle({
                    timeout: this.#timeoutMillis
                });
                const printOptionElement = await page.waitForSelector(`#RvDetails_ctl05_ctl04_ctl00_Menu a[title^='${exportType}']`, { timeout: this.#timeoutMillis });
                if (printOptionElement === null) {
                    throw new Error(`Unable to locate "${exportType}" print type.`);
                }
                debug(`Print button found for "${exportType}"...`);
                await delay();
                downloadStarted = true;
                await printOptionElement.scrollIntoView();
                await printOptionElement.click();
                debug('Print selected.');
                await delay(longDelayMillis);
                await page.waitForNetworkIdle({
                    timeout: this.#timeoutMillis
                });
                let retries = this.#timeoutMillis / defaultDelayMillis;
                while (downloadStarted && retries > 0) {
                    await delay();
                    retries--;
                }
            }
            finally {
                try {
                    await browser.close();
                }
                catch { }
            }
        });
        return await Promise.resolve(downloadPromise);
    }
    async exportPartOrderPrint(orderNumber, exportType) {
        const { browser, page } = await this.#getLoggedInFasterPage();
        await this.#navigateToFasterReportPage(browser, page, '/Part Order Print/W299 - OrderPrint', {
            OrderID: orderNumber.toString(),
            ReportType: 'S',
            Domain: 'Inventory'
        }, {
            'Time Zone': this.#timeZone
        });
        return await this.#exportFasterReport(browser, page, exportType);
    }
    async exportInventory(exportType) {
        const { browser, page } = await this.#getLoggedInFasterPage();
        await this.#navigateToFasterReportPage(browser, page, '/Inventory/W200 - Inventory Report', {
            ReportType: 'S',
            Domain: 'Inventory',
            Parent: 'Reports'
        }, {
            'Time Zone': this.#timeZone,
            'Grouping within Storeroom': 'Item Category'
        });
        return await this.#exportFasterReport(browser, page, exportType);
    }
    async exportAssetList(exportType) {
        const { browser, page } = await this.#getLoggedInFasterPage();
        await this.#navigateToFasterReportPage(browser, page, '/Assets/W114 - Asset Master List', {
            ReportType: 'S',
            Domain: 'Assets',
            Parent: 'Reports'
        }, {
            'Time Zone': this.#timeZone,
            'Primary Grouping': 'Organization',
            'Secondary Grouping': 'Department'
        });
        return await this.#exportFasterReport(browser, page, exportType);
    }
    async exportWorkOrderDetails(minWorkOrderNumber, maxWorkOrderNumber, exportType) {
        const minWorkOrderNumberString = minWorkOrderNumber.toString();
        const maxWorkOrderNumberString = (maxWorkOrderNumber ?? minWorkOrderNumber).toString();
        const { browser, page } = await this.#getLoggedInFasterPage();
        await this.#navigateToFasterReportPage(browser, page, '/Maintenance/W300n - WorkOrderDetailsByWONumber', {
            ReportType: 'S',
            Domain: 'Maintenance',
            Parent: 'Reports'
        }, {
            'Time Zone': this.#timeZone,
            'Beginning Work Order Number': minWorkOrderNumberString,
            'Ending Work Order Number': maxWorkOrderNumberString
        });
        return await this.#exportFasterReport(browser, page, exportType);
    }
    async #exportWorkOrderPrint(workOrderNumber, exportType, printButtonSelector) {
        const { browser, page } = await this.#getLoggedInFasterPage();
        try {
            await page.goto(`${this.#fasterBaseUrl}/Domains/Maintenance/WorkOrder/WorkOrderMaster.aspx?workOrderID=${workOrderNumber}`, {
                timeout: this.#timeoutMillis
            });
            await delay();
            await page.waitForNetworkIdle({
                timeout: this.#timeoutMillis
            });
            const printElement = await page.waitForSelector(printButtonSelector, {
                timeout: this.#timeoutMillis
            });
            if (printElement === null) {
                throw new Error('Unable to locate print link.');
            }
            await printElement.scrollIntoView();
            await printElement.click();
            const reportViewerTarget = await browser.waitForTarget((target) => {
                return target.url().toLowerCase().includes('reportviewer.aspx');
            }, {
                timeout: this.#timeoutMillis
            });
            const newPage = await reportViewerTarget.asPage();
            await delay();
            await newPage.bringToFront();
            await delay();
            await newPage.waitForNetworkIdle({
                timeout: this.#timeoutMillis
            });
            return await this.#exportFasterReport(browser, newPage, exportType);
        }
        catch (error) {
            try {
                await browser.close();
            }
            catch { }
            throw error;
        }
    }
    async exportWorkOrderCustomerPrint(workOrderNumber, exportType = 'PDF') {
        return await this.#exportWorkOrderPrint(workOrderNumber, exportType, '#ctl00_ContentPlaceHolder_Content_MasterWorkOrderDetailMenu_CustomerPrintLinkButton');
    }
    async exportWorkOrderTechnicianPrint(workOrderNumber, exportType = 'PDF') {
        return await this.#exportWorkOrderPrint(workOrderNumber, exportType, '#ctl00_ContentPlaceHolder_Content_MasterWorkOrderDetailMenu_WorkOrderPrintLinkButton');
    }
}