index.ts
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { URL } from 'node:url'
import puppeteerLaunch, { type puppeteer } from '@cityssm/puppeteer-launch'
import Debug from 'debug'
import {
minimumRecommendedTimeoutSeconds,
reportExportTypes
} from './lookups.js'
import { applyReportFilters } from './puppeteerHelpers.js'
import type {
ReportExportType,
ReportParameters,
ReportTimeZone
} from './types.js'
import { defaultDelayMillis, delay, longDelayMillis } from './utilities.js'
const debug = Debug('faster-report-exporter:index')
export interface FasterReportExporterOptions {
downloadFolderPath: string
timeoutMillis: number
showBrowserWindow: boolean
timeZone: ReportTimeZone
}
export class FasterReportExporter {
readonly #fasterBaseUrl: `https://${string}.fasterwebcloud.com/FASTER`
readonly #fasterUserName: string
readonly #fasterPassword: string
#downloadFolderPath = os.tmpdir()
#useHeadlessBrowser = true
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
#timeoutMillis = Math.max(90_000, minimumRecommendedTimeoutSeconds)
#timeZone: ReportTimeZone = 'Eastern'
/**
* Initializes the FasterReportExporter.
* @param fasterTenant - The subdomain of the FASTER Web URL before ".fasterwebcloud.com"
* @param fasterUserName - The user name
* @param fasterPassword - The password
* @param options - Options
*/
constructor(
fasterTenant: string,
fasterUserName: string,
fasterPassword: string,
options: Partial<FasterReportExporterOptions> = {}
) {
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
}
}
/**
* Sets the folder where downloaded reports are saved.
* @param downloadFolderPath - The folder where downloaded reports are saved.
*/
setDownloadFolderPath(downloadFolderPath: string): void {
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!fs.existsSync(downloadFolderPath)) {
throw new Error(
`Download folder path does not exist: ${downloadFolderPath}`
)
}
this.#downloadFolderPath = downloadFolderPath
}
/**
* Changes the timeout for loading the browser and navigating between pages.
* @param timeoutMillis - Number of milliseconds.
*/
setTimeoutMillis(timeoutMillis: number): void {
this.#timeoutMillis = timeoutMillis
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
if (timeoutMillis < minimumRecommendedTimeoutSeconds * 1000) {
debug(
`Warning: Timeouts less than ${minimumRecommendedTimeoutSeconds}s are not recommended.`
)
}
}
/**
* Switches off headless mode, making the browser window visible.
* Useful for debugging.
*/
showBrowserWindow(): void {
this.#useHeadlessBrowser = false
}
/**
* Changes the time zone parameter used in reports.
* @param timezone - The preferred report time zone.
*/
setTimeZone(timezone: ReportTimeZone): void {
this.#timeZone = timezone
}
async #getLoggedInFasterPage(): Promise<{
browser: puppeteer.Browser
page: puppeteer.Page
}> {
// eslint-disable-next-line @typescript-eslint/init-declarations
let browser: puppeteer.Browser | undefined
try {
browser = await puppeteerLaunch({
browser: 'chrome',
protocol: 'cdp',
headless: this.#useHeadlessBrowser,
timeout: this.#timeoutMillis
})
/*
* Load Faster
*/
debug('Logging into FASTER...')
const page = await browser.newPage()
await page.goto(this.#fasterBaseUrl, {
timeout: this.#timeoutMillis
})
await page.waitForNetworkIdle({
timeout: this.#timeoutMillis
})
/*
* Log in if need be
*/
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 {}
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw error
}
}
// eslint-disable-next-line @typescript-eslint/max-params
async #navigateToFasterReportPage(
browser: puppeteer.Browser,
page: puppeteer.Page,
reportKey: `/${string}`,
reportParameters: ReportParameters,
reportFilters?: Record<string, string>
): Promise<{ browser: puppeteer.Browser; page: puppeteer.Page }> {
try {
/*
* Navigate to report
*/
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 {}
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw error
}
}
/**
* Exports a FASTER report to a file.
* @param browser - Puppeteer browser
* @param page - Puppeteer page on a report page
* @param exportType - Output file type
* @returns - Path to the exported file.
*/
async #exportFasterReport(
browser: puppeteer.Browser,
page: puppeteer.Page,
exportType: ReportExportType = 'PDF'
): Promise<string> {
await page.bringToFront()
await page.waitForNetworkIdle({
timeout: this.#timeoutMillis
})
debug(`Report Page Title: ${await page.title()}`)
// eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor, sonarjs/no-misused-promises
const downloadPromise = new Promise<string>(async (resolve) => {
let downloadStarted = false
try {
/*
* Catch the download
*/
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
)
// eslint-disable-next-line security/detect-object-injection
const newFilePath = `${downloadedFilePath}.${reportExportTypes[exportType]}`
// eslint-disable-next-line security/detect-non-literal-fs-filename
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.')
}
})
/*
* Print to PDF
*/
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
// eslint-disable-next-line sonarjs/no-infinite-loop, no-unmodified-loop-condition, @typescript-eslint/no-unnecessary-condition
while (downloadStarted && retries > 0) {
await delay()
retries--
}
} finally {
try {
await browser.close()
} catch {}
}
})
return await Promise.resolve(downloadPromise)
}
/**
* Exports a Part Order Print (W299) report for a given order number.
* @param orderNumber - The order number.
* @param exportType - The export type.
* @returns The path to the exported report.
*/
async exportPartOrderPrint(
orderNumber: number,
exportType?: ReportExportType
): Promise<string> {
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)
}
/**
* Exports an Inventory Report (W200).
* @param exportType - The export type.
* @returns The path to the exported report.
*/
async exportInventory(exportType?: ReportExportType): Promise<string> {
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)
}
/**
* Export an Asset Master List (W114) report.
* @param exportType - The export type.
* @returns The path to the exported report.
*/
async exportAssetList(exportType?: ReportExportType): Promise<string> {
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: number,
maxWorkOrderNumber?: number,
exportType?: ReportExportType
): Promise<string> {
const minWorkOrderNumberString = minWorkOrderNumber.toString()
const maxWorkOrderNumberString = (
maxWorkOrderNumber ?? minWorkOrderNumber
).toString()
const { browser, page } = await this.#getLoggedInFasterPage()
await this.#navigateToFasterReportPage(
browser,
page,
// eslint-disable-next-line no-secrets/no-secrets
'/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: number,
exportType: ReportExportType,
printButtonSelector: string
): Promise<string> {
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 {}
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw error
}
}
/**
* Exports the Customer Print (W398) for a given work order.
* @param workOrderNumber - The work order number.
* @param exportType - The export type.
* @returns The path to the exported report.
*/
async exportWorkOrderCustomerPrint(
workOrderNumber: number,
exportType: ReportExportType = 'PDF'
): Promise<string> {
return await this.#exportWorkOrderPrint(
workOrderNumber,
exportType,
// eslint-disable-next-line no-secrets/no-secrets
'#ctl00_ContentPlaceHolder_Content_MasterWorkOrderDetailMenu_CustomerPrintLinkButton'
)
}
/**
* Exports the Technician Print (W399) for a given work order.
* @param workOrderNumber - The work order number.
* @param exportType - The export type.
* @returns The path to the exported report.
*/
async exportWorkOrderTechnicianPrint(
workOrderNumber: number,
exportType: ReportExportType = 'PDF'
): Promise<string> {
return await this.#exportWorkOrderPrint(
workOrderNumber,
exportType,
// eslint-disable-next-line no-secrets/no-secrets
'#ctl00_ContentPlaceHolder_Content_MasterWorkOrderDetailMenu_WorkOrderPrintLinkButton'
)
}
}