Major clean up and alpha.1 release.

This commit is contained in:
Patrick Fic
2026-02-26 12:31:24 -08:00
parent 4915e05ac9
commit 1c44e92fb0
20 changed files with 285 additions and 546 deletions

View File

@@ -3,4 +3,7 @@
# Outstanding Todos
* Update certificates and signing.
* Create S3 upload buckets
* Create S3 EMS upload bucket.
* Create S3 EMS upload bucket.
testing api key
c0be5714-ff60-420e-a0af-aff440afdb61

View File

@@ -1,6 +1,7 @@
{
"name": "esdp",
"version": "0.0.1",
"productName": "EMS Uploader",
"version": "0.0.1-alpha.1",
"description": "EMS Uploader",
"main": "./out/main/index.js",
"author": "ImEX Systems Inc.",

View File

@@ -12,6 +12,7 @@ stages:
domain: es.imex.online
es_user: Imex2
es_password: Patrick
hasura_url: https://es.db.imex.online/v1/graphql
beta:
# Enables observability in the prod stage
observability: false
@@ -22,6 +23,7 @@ stages:
domain: beta.es.imex.online
es_user: Imex2
es_password: Patrick
hasura_url: https://es.db.imex.online/v1/graphql
alpha:
# Enables observability in the prod stage
observability: false
@@ -31,6 +33,7 @@ stages:
domain: alpha.es.imex.online
es_user: Imex2
es_password: Patrick
hasura_url: https://es.db.imex.online/v1/graphql
dev:
# Enables observability in the prod stage
observability: false
@@ -40,6 +43,7 @@ stages:
domain: dev.es.imex.online
es_user: Imex2
es_password: Patrick
hasura_url: https://es.db.imex.online/v1/graphql
# params:
# dev:
@@ -82,6 +86,10 @@ functions:
ES_ENDPOINT: ${param:es_endpoint}
ES_USER: ${param:es_user}
ES_PASSWORD: ${param:es_password}
HASURA_URL: ${param:hasura_url}
# Resolve at deploy-time from AWS Secrets Manager (value is not stored in this file).
# This secret is expected to be JSON; we pull the `admin_secret` field.
HASURA_SECRET: '{{resolve:secretsmanager:arn:aws:secretsmanager:ca-central-1:714144183158:secret:esdp-hasura-credentials-s81i1u-BDFgPi:SecretString:admin_secret::}}'
events:
- httpApi:
path: /scrub

View File

@@ -10,8 +10,8 @@ import { UUID } from 'node:crypto';
const ES_USER = process.env.ES_USER || '';
const ES_PASSWORD = process.env.ES_PASSWORD || '';
const ES_ENDPOINT = process.env.ES_ENDPOINT || '';
const HASURA_URL = process.env.HASURA_URL || '';
const HASURA_URL = process.env.HASURA_URL || 'https://db.es.imex.online/v1/graphql';
const HASURA_SECRET = process.env.HASURA_SECRET || '';
interface ScrubRequest {
esApiKey: string;
rawJob: RawJobDataObject;
@@ -29,7 +29,19 @@ export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayPr
rawJob,
} = JSON.parse(event.body || '{}') as ScrubRequest;
const esApiKey = event.headers['x-api-key'] || '';
//await uploadJobToHasura(rawJob, esApiKey);
if (!esApiKey) {
return {
statusCode: 401,
body: JSON.stringify({
message: 'Access invalid.',
}),
};
}
uploadJobToHasura(rawJob, esApiKey).catch((error) => {
console.error('Failed to upload job to Hasura:', error);
});
// Transform the raw job object to ES format
const estimate: ESJobObject = await transformJobForEstimateScrubber(rawJob);
@@ -56,14 +68,14 @@ export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayPr
},
});
const resultPDFUrl = result?.data?.report_link;
const reportIssueUrl = `https://insurtechtoolkit.com/pcontactUs.aspx?apiKey=${esApiKey}&file=${fileName}.json`;
const pdf_url = result?.data?.report_link;
const report_issue_url = `https://insurtechtoolkit.com/pcontactUs.aspx?apiKey=${esApiKey}&file=${fileName}.json`;
return {
statusCode: 200,
body: JSON.stringify({
resultPDFUrl,
reportIssueUrl,
pdf_url,
report_issue_url,
identified_item: result.data?.identified_item,
}),
};
@@ -83,7 +95,7 @@ export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayPr
const uploadJobToHasura = async (rawJob: RawJobDataObject, esApiKey: string): Promise<void> => {
const graphQLClient = new GraphQLClient(HASURA_URL, {
headers: {
'x-hasura-admin-secret': 'UXWqeUlNMc2dd2SD7DTOKgjEQlVkZkaW',
'x-hasura-admin-secret': HASURA_SECRET,
},
});

View File

@@ -12,6 +12,7 @@ export type ScrubHistoryJobRow = {
vehicle: string;
claim_number: string;
pdf_url: string | null;
report_issue_url?: string | null;
};
export type ScrubHistoryScrubResultRow = {
@@ -99,7 +100,8 @@ function getDb(): Database.Database {
ownr_name TEXT NOT NULL,
vehicle TEXT NOT NULL,
claim_number TEXT NOT NULL,
pdf_url TEXT
pdf_url TEXT,
report_issue_url TEXT
);
CREATE TABLE IF NOT EXISTS scrub_results (
@@ -141,6 +143,7 @@ function buildJobRow(
job: RawJobDataObject,
createdAt: number,
pdfUrl: string | null,
reportIssueUrl: string | null = null,
): ScrubHistoryJobRow {
const ownrName = `${job.ownr_fn ?? ""} ${job.ownr_ln ?? ""}`.trim();
const vehicle =
@@ -153,17 +156,24 @@ function buildJobRow(
vehicle: vehicle || "Unknown",
claim_number: claimNumber || "Unknown",
pdf_url: pdfUrl,
report_issue_url: reportIssueUrl,
};
}
export function insertScrubRun(params: {
job: RawJobDataObject;
identifiedItems: unknown;
pdfUrl: string | null;
pdf_url: string | null;
report_issue_url?: string | null;
}): { jobId: string; createdAt: number; insertedResultsCount: number } {
const database = getDb();
const createdAt = Date.now();
const jobRow = buildJobRow(params.job, createdAt, params.pdfUrl);
const jobRow = buildJobRow(
params.job,
createdAt,
params.pdf_url,
params.report_issue_url,
);
const items: IdentifiedItem[] = Array.isArray(params.identifiedItems)
? (params.identifiedItems as IdentifiedItem[])
@@ -174,7 +184,7 @@ export function insertScrubRun(params: {
const insertTx = database.transaction(() => {
database
.prepare(
"INSERT INTO jobs (id, created_at, ownr_name, vehicle, claim_number, pdf_url) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO jobs (id, created_at, ownr_name, vehicle, claim_number, pdf_url, report_issue_url) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
jobRow.id,
@@ -183,6 +193,7 @@ export function insertScrubRun(params: {
jobRow.vehicle,
jobRow.claim_number,
jobRow.pdf_url,
jobRow.report_issue_url,
);
const stmt = database.prepare(
@@ -212,7 +223,7 @@ export function getScrubHistory(): ScrubHistoryItem[] {
const jobs = database
.prepare(
"SELECT id, created_at, ownr_name, vehicle, claim_number, pdf_url FROM jobs ORDER BY created_at DESC",
"SELECT id, created_at, ownr_name, vehicle, claim_number, pdf_url, report_issue_url FROM jobs ORDER BY created_at DESC",
)
.all() as ScrubHistoryJobRow[];
@@ -246,6 +257,7 @@ export function getScrubHistory(): ScrubHistoryItem[] {
vehicle: j.vehicle,
claimNumber: j.claim_number,
pdfUrl: j.pdf_url ?? null,
reportIssueUrl: j.report_issue_url ?? null,
results: resultsByJobId.get(j.id) ?? [],
}));
}
@@ -301,7 +313,7 @@ export function getScrubHistoryPage(params: {
const jobs = database
.prepare(
"SELECT id, created_at, ownr_name, vehicle, claim_number, pdf_url FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?",
"SELECT id, created_at, ownr_name, vehicle, claim_number, pdf_url, report_issue_url FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?",
)
.all(pageSize, offset) as ScrubHistoryJobRow[];
@@ -348,6 +360,7 @@ export function getScrubHistoryPage(params: {
vehicle: j.vehicle,
claimNumber: j.claim_number,
pdfUrl: j.pdf_url ?? null,
reportIssueUrl: j.report_issue_url ?? null,
results: resultsByJobId.get(j.id) ?? [],
})),
totalJobs,

View File

@@ -36,6 +36,8 @@ import { DecodedTtl } from "./decode-ttl.interface";
import DecodeVeh from "./decode-veh";
import { DecodedVeh } from "./decode-veh.interface";
import UploadEmsToS3 from "./emsbackup";
import getMainWindow from "../../util/getMainWindow";
import newWindow from "../../util/newWindow";
async function ImportJob(filepath: string): Promise<void> {
const parsedFilePath = path.parse(filepath);
@@ -136,7 +138,7 @@ async function ImportJob(filepath: string): Promise<void> {
console.log("Available Job record to upload;", newAvailableJob);
//Scrub the estimate
const scrubResults = await ScrubEstimate({ job: jobObject });
const scrubPdfURL = await ScrubEstimate({ job: jobObject });
setAppProgressbar(0.95);
const esApiKey = store.get("settings.esApiKey") as string;
@@ -149,17 +151,42 @@ async function ImportJob(filepath: string): Promise<void> {
});
setAppProgressbar(-1);
const items = ["One", "Two", "Three"];
const n = new Notification({
title: "Choose an Action!",
actions: [
{ type: "button", text: "Action 1" },
{ type: "button", text: "Action 2" },
{ type: "selection", text: "Apply", items },
],
});
const uploadNotification = new Notification({
title: "Job Scrubbed",
//subtitle: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}`,
body: `${newAvailableJob.ownr_name} - ${newAvailableJob.vehicle_info}. Click to view.`,
actions: [{ text: "View Job", type: "button" }],
actions: [
{ text: "View in App", type: "button" as const },
...(scrubPdfURL ? [{ text: "View PDF", type: "button" as const }] : []),
],
});
uploadNotification.on("click", () => {
if (scrubResults?.data?.resultPDFUrl) {
shell.openExternal(scrubResults.data.resultPDFUrl);
uploadNotification.on("action", (e) => {
// e.actionIndex
if (e.actionIndex === 0) {
const mainWindow = getMainWindow();
mainWindow?.show();
} else if (e.actionIndex === 1) {
if (scrubPdfURL) {
newWindow(scrubPdfURL);
}
}
});
// uploadNotification.on("click", () => {
// if (scrubPdfURL) {
// shell.openExternal(scrubPdfURL);
// }
// });
uploadNotification.show();
} catch (error) {
log.error("Error encountered while decoding job. ", errorTypeCheck(error));

View File

@@ -8,6 +8,7 @@ import { RawJobDataObject } from "../decoder/decoder";
import store from "../store/store";
import ipcTypes from "../../util/ipcTypes.json";
import { insertScrubRun } from "../db/scrub-history-db";
import { Notification } from "electron/main";
// Function to write job object to logs subfolder
async function writeJobToLogsFolder(job, fileName): Promise<string> {
@@ -47,21 +48,24 @@ async function ScrubEstimate({
// No transformation here - send raw job to Lambda
const currentChannel = autoUpdater.channel;
let estimateScrubberUrl: string;
switch (currentChannel) {
case null:
case "dev":
estimateScrubberUrl = "https://dev.es.imex.online/scrub"; //dev specific URL.
break;
case "alpha":
estimateScrubberUrl = "https://alpha.es.imex.online/scrub"; //dev specific URL.
break;
case "beta":
estimateScrubberUrl = "https://beta.es.imex.online/scrub"; //Beta specific URL.
break;
case "latest":
default:
estimateScrubberUrl = "https://es.imex.online/scrub"; //Production route.
break;
if (process.env.NODE_ENV !== "production") {
estimateScrubberUrl = "https://dev.es.imex.online/scrub"; //dev specific URL.
} else {
switch (currentChannel) {
case "dev":
estimateScrubberUrl = "https://dev.es.imex.online/scrub"; //dev specific URL.
break;
case "alpha":
estimateScrubberUrl = "https://alpha.es.imex.online/scrub"; //dev specific URL.
break;
case "beta":
estimateScrubberUrl = "https://beta.es.imex.online/scrub"; //Beta specific URL.
break;
case "latest":
default:
estimateScrubberUrl = "https://es.imex.online/scrub"; //Production route.
break;
}
}
log.log(`Estimate Scrubber URL: [${currentChannel} |`, estimateScrubberUrl);
@@ -98,13 +102,15 @@ async function ScrubEstimate({
},
);
const { resultPDFUrl, identified_item } = result?.data ?? {};
const { report_issue_url, identified_item, pdf_url } = result?.data ?? {};
try {
insertScrubRun({
job,
identifiedItems: identified_item.slice(1, identified_item.length), // Remove first item which is the result metadata
pdfUrl: typeof resultPDFUrl === "string" ? resultPDFUrl : null,
pdf_url: typeof pdf_url === "string" ? pdf_url : null,
report_issue_url:
typeof report_issue_url === "string" ? report_issue_url : null,
});
const mainWindow = BrowserWindow.getAllWindows()[0];
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -119,19 +125,11 @@ async function ScrubEstimate({
// b.webContents.send(ipcTypes.app.toRenderer.scrubResults, {
// jobid: job.id,
// items: result.data?.identified_item,
// pdfUrl: resultPDFUrl,
// reportIssueUrl,
// pdf_url: resultPDFUrl,
// report_issue_url,
// });
const pdfWindow = new BrowserWindow({
webPreferences: {
plugins: true, // Enable PDF viewing
},
});
pdfWindow.loadURL(resultPDFUrl);
pdfWindow.focus();
return resultPDFUrl;
return pdf_url;
} catch (error) {
const err = error as AxiosError;
log.error("Error while scrubbing estimate:", err.message, err.stack);
@@ -150,13 +148,23 @@ async function ScrubEstimate({
if (status === 400) {
mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
message:
responseMessage ||
JSON.parse(responseMessage).message ||
"Error encountered sending estimate to Estimate Scrubber.",
});
} else if (status === 401) {
const notificationError = new Notification({
title: "Error scrubbing estimate",
body:
JSON.parse(responseMessage).message ||
"Authentication with Estimate Scrubber failed.",
});
notificationError.show();
mainWindow?.webContents.send(ipcTypes.toRenderer.scrub.scrubError, {
message:
responseMessage || "Authentication with Estimate Scrubber failed.",
JSON.parse(responseMessage).message ||
"Authentication with Estimate Scrubber failed.",
});
}
return "Error: Unable to scrub estimate.";

View File

@@ -1,4 +1,3 @@
import "source-map-support/register";
import { is, optimizer, platform } from "@electron-toolkit/utils";
import Sentry from "@sentry/electron/main";
import {
@@ -14,6 +13,7 @@ import {
import log from "electron-log/main";
import { autoUpdater } from "electron-updater";
import path, { join } from "path";
import "source-map-support/register";
import imexAppIcon from "../../resources/icon.png?asset";
import {
@@ -24,19 +24,11 @@ import ipcTypes from "../util/ipcTypes.json";
import ImportJob from "./decoder/decoder";
import { dumpMemoryStatsToFile } from "../util/memUsage";
import {
isKeepAliveAgentInstalled,
setupKeepAliveAgent,
} from "./setup-keep-alive-agent";
import {
isKeepAliveTaskInstalled,
setupKeepAliveTask,
} from "./setup-keep-alive-task";
import store from "./store/store";
import { checkForAppUpdates } from "./util/checkForAppUpdates";
import ensureWindowOnScreen from "./util/ensureWindowOnScreen";
import { getMainWindow } from "./util/toRenderer";
import { GetAllEnvFiles } from "./watcher/watcher";
import { GetAllEnvFiles, GetLatestEnvFile } from "./watcher/watcher";
const appIconToUse = imexAppIcon;
@@ -93,7 +85,7 @@ function createWindow(): void {
icon: appIconToUse,
}
: {}),
title: "EMS Uploader",
title: app.name,
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
@@ -109,8 +101,6 @@ function createWindow(): void {
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
@@ -126,8 +116,6 @@ function createWindow(): void {
{
label: "File",
submenu: [
// @ts-ignore
...(!isMac ? [{ role: "about" }] : []),
// @ts-ignore
isMac ? { role: "close" } : { role: "quit" },
],
@@ -193,36 +181,72 @@ function createWindow(): void {
}
},
},
{
label: "Rescrub Last Estimate",
click: (): void => {
const latestFile = GetLatestEnvFile();
if (latestFile) {
ImportJob(latestFile);
}
},
},
{
label: `Check for Updates (${app.getVersion()})`,
click: (): void => {
checkForAppUpdates();
},
},
{
label: "Development",
id: "development",
visible: import.meta.env.DEV,
submenu: [
// {
// label: "Connect to Test",
// checked: store.get("app.isTest") as boolean,
// type: "checkbox",
// id: "toggleTest",
// click: (): void => {
// const currentSetting = store.get("app.isTest") as boolean;
// store.set("app.isTest", !currentSetting);
// log.info("Setting isTest to: ", !currentSetting);
// app.relaunch(); // Relaunch the app
// preQuitMethods(); //Quitting handlers aren't called. Manually execute to clean up the app.
// app.exit(0); // Exit the current instance
// },
// },
// {
// label: "Check for updates",
// click: (): void => {
// checkForAppUpdates();
// },
// },
{
label: "Connect to Test",
checked: store.get("app.isTest") as boolean,
type: "checkbox",
id: "toggleTest",
click: (): void => {
const currentSetting = store.get("app.isTest") as boolean;
store.set("app.isTest", !currentSetting);
log.info("Setting isTest to: ", !currentSetting);
app.relaunch(); // Relaunch the app
preQuitMethods(); //Quitting handlers aren't called. Manually execute to clean up the app.
app.exit(0); // Exit the current instance
},
},
{
label: "Check for updates",
click: (): void => {
checkForAppUpdates();
},
label: `Release Path`,
submenu: [
{
label: "Alpha",
checked: store.get("app.channel") === "alpha",
onClick: (): void => {
checkForAppUpdates("alpha");
},
},
{
label: "Beta",
checked: store.get("app.channel") === "beta",
onClick: (): void => {
checkForAppUpdates("beta");
},
},
{
label: "Latest",
checked: store.get("app.channel") === "latest",
onClick: (): void => {
checkForAppUpdates("latest");
},
},
],
},
{
label: "Open Log File",
@@ -293,34 +317,35 @@ function createWindow(): void {
// ImportJob(`C:\\EMS\\CCC\\9ee762f4.ENV`);
// },
// },
// {
// label: "Install Keep Alive",
// enabled: true, // Default to enabled, update dynamically
// click: async (): Promise<void> => {
// try {
// if (platform.isWindows) {
// log.debug("Creating Windows keep-alive task");
// await setupKeepAliveTask();
// log.info("Successfully installed Windows keep-alive task");
// } else if (platform.isMacOS) {
// log.debug("Creating macOS keep-alive agent");
// await setupKeepAliveAgent();
// log.info("Successfully installed macOS keep-alive agent");
// }
// // Wait to ensure task/agent is registered
// await new Promise((resolve) => setTimeout(resolve, 1500));
// // Rebuild menu and update enabled state
// await updateKeepAliveMenuItem();
// } catch (error) {
// log.error(
// `Failed to install keep-alive: ${error instanceof Error ? error.message : String(error)}`,
// );
// // Optionally notify user (e.g., via dialog or log)
// }
// },
// },
{
label: "Install Keep Alive",
enabled: true, // Default to enabled, update dynamically
click: async (): Promise<void> => {
try {
if (platform.isWindows) {
log.debug("Creating Windows keep-alive task");
await setupKeepAliveTask();
log.info("Successfully installed Windows keep-alive task");
} else if (platform.isMacOS) {
log.debug("Creating macOS keep-alive agent");
await setupKeepAliveAgent();
log.info("Successfully installed macOS keep-alive agent");
}
// Wait to ensure task/agent is registered
await new Promise((resolve) => setTimeout(resolve, 1500));
// Rebuild menu and update enabled state
await updateKeepAliveMenuItem();
} catch (error) {
log.error(
`Failed to install keep-alive: ${error instanceof Error ? error.message : String(error)}`,
);
// Optionally notify user (e.g., via dialog or log)
}
},
},
{
label: "Add All Estimates in watched directories",
label: "Scrub all estimates in watched directories",
click: (): void => {
GetAllEnvFiles().forEach((file) => ImportJob(file));
},
@@ -348,45 +373,8 @@ function createWindow(): void {
},
];
// Dynamically update Install Keep Alive enabled state
const updateKeepAliveMenuItem = async (): Promise<void> => {
try {
const isInstalled = platform.isWindows
? await isKeepAliveTaskInstalled()
: platform.isMacOS
? await isKeepAliveAgentInstalled()
: false;
const developmentMenu = template
.find((item) => item.label === "Application")
// @ts-ignore
?.submenu?.find((item: { id: string }) => item.id === "development")
?.submenu as Electron.MenuItemConstructorOptions[];
const keepAliveItem = developmentMenu?.find(
(item) => item.label === "Install Keep Alive",
);
if (keepAliveItem) {
keepAliveItem.enabled = !isInstalled; // Enable if not installed, disable if installed
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
log.debug(
`Updated Install Keep Alive menu item: enabled=${keepAliveItem.enabled}`,
);
}
} catch (error) {
log.error(
`Error updating Keep Alive menu item: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const menu: Electron.Menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
// Update menu item enabled state on app start
updateKeepAliveMenuItem().catch((error) => {
log.error(
`Error updating Keep Alive menu item: ${error instanceof Error ? error.message : String(error)}`,
);
});
// Register a global shortcut to show the hidden item
globalShortcut.register("CommandOrControl+Shift+T", () => {
@@ -535,6 +523,7 @@ app.whenReady().then(async () => {
//Check for app updates.
autoUpdater.logger = log;
autoUpdater.allowDowngrade = true;
autoUpdater.channel = store.get("app.channel") as string; // Set the channel from the persisted store value
// if (import.meta.env.DEV) {
// // Useful for some dev/debugging tasks, but download can
// // not be validated because dev app is not signed

View File

@@ -6,6 +6,11 @@ import ipcTypes from "../../util/ipcTypes.json";
import ImportJob from "../decoder/decoder";
import store from "../store/store";
import { StartWatcher, StopWatcher } from "../watcher/watcher";
import {
ScrubHistoryClearAll,
ScrubHistoryDeleteJob,
ScrubHistoryGetAll,
} from "./ipcMainHandler.scrubHistory";
import {
getSetting,
setSetting,
@@ -19,15 +24,6 @@ import {
SettingsWatcherPollingGet,
SettingsWatcherPollingSet,
} from "./ipcMainHandler.settings";
import {
ipcMainHandleAuthStateChanged,
ipMainHandleResetPassword,
} from "./ipcMainHandler.user";
import {
ScrubHistoryClearAll,
ScrubHistoryDeleteJob,
ScrubHistoryGetAll,
} from "./ipcMainHandler.scrubHistory";
// Log all IPC messages and their payloads
const logIpcMessages = (): void => {
@@ -57,34 +53,11 @@ ipcMain.on(ipcTypes.toMain.test, () =>
console.log("** Verify that ipcMain is loaded and working."),
);
// Auth handler
ipcMain.on(ipcTypes.toMain.authStateChanged, ipcMainHandleAuthStateChanged);
ipcMain.on(ipcTypes.toMain.user.resetPassword, ipMainHandleResetPassword);
// Scrub History Handlers
ipcMain.handle(ipcTypes.toMain.scrubHistory.getAll, ScrubHistoryGetAll);
ipcMain.handle(ipcTypes.toMain.scrubHistory.deleteJob, ScrubHistoryDeleteJob);
ipcMain.handle(ipcTypes.toMain.scrubHistory.clearAll, ScrubHistoryClearAll);
// Add debug handlers if in development
if (import.meta.env.DEV) {
log.debug("[IPC Debug Functions] Adding Debug Handlers");
ipcMain.on(ipcTypes.toMain.debug.decodeEstimate, async (): Promise<void> => {
const relativeEmsFilepath = `_reference/ems/MPI_1/3698420.ENV`;
const rootDir = app.getAppPath();
const absoluteFilepath = path.join(rootDir, relativeEmsFilepath);
log.debug("[IPC Debug Function] Decode test Estimate", absoluteFilepath);
await ImportJob(absoluteFilepath);
const job2 = `/Users/pfic/Downloads/12285264/2285264.ENV`;
const job3 = `/Users/pfic/Downloads/14033376/4033376.ENV`;
await ImportJob(job2);
await ImportJob(job3);
});
}
// Settings Handlers
ipcMain.handle(
ipcTypes.toMain.settings.filepaths.get,
@@ -109,21 +82,6 @@ ipcMain.handle(
SettingsWatcherPollingSet,
);
ipcMain.handle(ipcTypes.toMain.settings.getPpcFilePath, SettingsPpcFilePathGet);
ipcMain.handle(ipcTypes.toMain.settings.setPpcFilePath, SettingsPpcFilePathSet);
ipcMain.handle(
ipcTypes.toMain.settings.getEmsOutFilePath,
SettingEmsOutFilePathGet,
);
ipcMain.handle(
ipcTypes.toMain.settings.setEmsOutFilePath,
SettingEmsOutFilePathSet,
);
ipcMain.handle(ipcTypes.toMain.user.getActiveShop, () => {
return store.get("app.bodyshop.shopname");
});
// Watcher Handlers
ipcMain.on(ipcTypes.toMain.watcher.start, () => {
StartWatcher().catch((error) => {

View File

@@ -93,110 +93,12 @@ const SettingsWatcherPollingSet = async (
return { enabled, interval };
};
const SettingsPpcFilePathGet = async (): Promise<string> => {
return Store.get("settings.ppcFilePath");
};
const SettingsPpcFilePathSet = async (): Promise<string> => {
const mainWindow = getMainWindow();
if (!mainWindow) {
log.error("No main window found when trying to open dialog");
return "";
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory"],
});
if (!result.canceled) {
Store.set("settings.ppcFilePath", result.filePaths[0]);
}
return (Store.get("settings.ppcFilePath") as string) || "";
};
const SettingEmsOutFilePathGet = async (): Promise<string> => {
return Store.get("settings.emsOutFilePath");
};
const SettingEmsOutFilePathSet = async (): Promise<string> => {
const mainWindow = getMainWindow();
if (!mainWindow) {
log.error("No main window found when trying to open dialog");
return "";
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory"],
});
if (!result.canceled) {
Store.set("settings.emsOutFilePath", result.filePaths[0]);
}
return (Store.get("settings.emsOutFilePath") as string) || "";
};
const SettingsPaintScaleInputPathSet = async (
_event: IpcMainInvokeEvent,
): Promise<string | null> => {
try {
const mainWindow = getMainWindow();
if (!mainWindow) {
log.error("No main window found when trying to open dialog");
return null;
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory"],
});
if (result.canceled) {
log.debug("Paint scale input path selection canceled");
return null;
}
const path = result.filePaths[0];
log.debug("Selected paint scale input path:", path);
return path;
} catch (error) {
log.error("Error setting paint scale input path:", error);
throw error;
}
};
const SettingsPaintScaleOutputPathSet = async (
_event: IpcMainInvokeEvent,
): Promise<string | null> => {
try {
const mainWindow = getMainWindow();
if (!mainWindow) {
log.error("No main window found when trying to open dialog");
return null;
}
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory"],
});
if (result.canceled) {
log.debug("Paint scale output path selection canceled");
return null;
}
const path = result.filePaths[0];
log.debug("Selected paint scale output path:", path);
return path;
} catch (error) {
log.error("Error setting paint scale output path:", error);
throw error;
}
};
export {
SettingsPpcFilePathGet,
SettingsPpcFilePathSet,
getSetting,
setSetting,
SettingsWatchedFilePathsAdd,
SettingsWatchedFilePathsGet,
SettingsWatchedFilePathsRemove,
SettingsWatcherPollingGet,
SettingsWatcherPollingSet,
SettingEmsOutFilePathGet,
SettingEmsOutFilePathSet,
SettingsPaintScaleInputPathSet,
SettingsPaintScaleOutputPathSet,
getSetting,
setSetting,
};

View File

@@ -1,102 +0,0 @@
import { IpcMainEvent, shell } from "electron";
import log from "electron-log/main";
import { autoUpdater } from "electron-updater";
import { User } from "firebase/auth";
import errorTypeCheck from "../../util/errorTypeCheck";
import ipcTypes from "../../util/ipcTypes.json";
import client from "../graphql/graphql-client";
import {
ActiveBodyshopQueryResult,
MasterdataQueryResult,
QUERY_ACTIVE_BODYSHOP_TYPED,
QUERY_MASTERDATA_TYPED,
} from "../graphql/queries";
import { default as Store, default as store } from "../store/store";
import { checkForAppUpdatesContinuously } from "../util/checkForAppUpdates";
import { getMainWindow, sendIpcToRenderer } from "../util/toRenderer";
const ipcMainHandleAuthStateChanged = async (
_event: IpcMainEvent,
user: User | null,
): Promise<void> => {
Store.set("user", user);
log.debug("Received authentication state change from Renderer.", user);
await setReleaseChannel();
checkForAppUpdatesContinuously();
};
async function setReleaseChannel(): Promise<void> {
try {
//Need to query the currently active shop, and store the metadata as well.
//Also need to query the OP Codes for decoding reference.
await handleShopMetaDataFetch();
//Check for updates
const bodyshop = Store.get("app.bodyshop");
if (bodyshop?.convenient_company?.toLowerCase() === "alpha") {
autoUpdater.channel = "alpha";
log.debug("Setting update channel to ALPHA channel.");
} else if (bodyshop?.convenient_company?.toLowerCase() === "beta") {
autoUpdater.channel = "beta";
log.debug("Setting update channel to BETA channel.");
} else {
log.debug("Setting update channel to LATEST channel.");
}
} catch (error) {
log.error(
"Error while querying active bodyshop or master data",
errorTypeCheck(error),
);
sendIpcToRenderer(
ipcTypes.toRenderer.general.showErrorMessage,
"Error connecting to ImEX Online servers to get shop data. Please try again.",
);
}
}
const handleShopMetaDataFetch = async (
reloadWindow?: boolean,
): Promise<void> => {
try {
log.debug("Requery shop information & master data.");
const activeBodyshop: ActiveBodyshopQueryResult = await client.request(
QUERY_ACTIVE_BODYSHOP_TYPED,
);
Store.set("app.bodyshop", activeBodyshop.bodyshops[0]);
const OpCodes: MasterdataQueryResult = await client.request(
QUERY_MASTERDATA_TYPED,
{
key: `${activeBodyshop.bodyshops[0].region_config}_ciecaopcodes`,
},
);
Store.set(
"app.masterdata.opcodes",
JSON.parse(OpCodes.masterdata[0]?.value),
);
if (reloadWindow) {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.reload();
}
}
} catch (error) {
log.error("Error while fetching shop metadata", errorTypeCheck(error));
throw error;
}
};
const ipMainHandleResetPassword = async (): Promise<void> => {
shell.openExternal(
store.get("app.isTest")
? `${import.meta.env.VITE_FE_URL_TEST}/resetpassword`
: `${import.meta.env.VITE_FE_URL}/resetpassword`,
);
};
export {
handleShopMetaDataFetch,
ipcMainHandleAuthStateChanged,
ipMainHandleResetPassword,
setReleaseChannel,
};

View File

@@ -1,77 +0,0 @@
import { promises as fs } from "fs";
import { join } from "path";
import { homedir } from "os";
import { exec } from "child_process";
import { promisify } from "util";
import log from "electron-log/main";
const execPromise = promisify(exec);
// Define the interval as a variable (in seconds)
const KEEP_ALIVE_INTERVAL_SECONDS = 15 * 60; // 15 minutes
export async function setupKeepAliveAgent(): Promise<void> {
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.imex.esdp.keepalive</string>
<key>ProgramArguments</key>
<array>
<string>Shop Partner Keep Alive</string>
<string>imexmedia://keep-alive</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>${KEEP_ALIVE_INTERVAL_SECONDS}</integer>
</dict>
</plist>`;
const plistPath = join(
homedir(),
"/Library/LaunchAgents/com.imex.esdp.keepalive.plist",
);
try {
await fs.writeFile(plistPath, plistContent);
const { stdout, stderr } = await execPromise(`launchctl load ${plistPath}`);
log.info(`Launch agent created and loaded: ${stdout}`);
if (stderr) log.warn(`Launch agent stderr: ${stderr}`);
} catch (error) {
log.error(
`Error setting up launch agent: ${error instanceof Error ? error.message : String(error)}`,
);
throw error; // Rethrow to allow caller to handle
}
}
export async function isKeepAliveAgentInstalled(): Promise<boolean> {
const plistPath = join(
homedir(),
"/Library/LaunchAgents/com.imex.esdp.keepalive.plist",
);
const maxRetries = 3;
const retryDelay = 500; // 500ms delay between retries
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await fs.access(plistPath, fs.constants.F_OK);
const { stdout } = await execPromise(
`launchctl list | grep com.imex.esdp.keepalive`,
);
return !!stdout; // Return true if plist exists and agent is loaded
} catch (error) {
log.debug(
`Launch agent not found (attempt ${attempt}/${maxRetries}): ${error instanceof Error ? error.message : String(error)}`,
);
if (attempt === maxRetries) {
return false; // Return false after all retries fail
}
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
}
return false; // Fallback return
}

View File

@@ -1,52 +0,0 @@
import { exec } from "child_process";
import { promisify } from "util";
import log from "electron-log/main";
const execPromise = promisify(exec);
// Define the interval as a variable (in minutes)
const KEEP_ALIVE_INTERVAL_MINUTES = 15;
const taskName = "ShopPartnerKeepAlive";
export async function setupKeepAliveTask(): Promise<void> {
const protocolUrl = "imexmedia://keep-alive";
// Use rundll32.exe to silently open the URL as a protocol
const command = `rundll32.exe url.dll,OpenURL "${protocolUrl}"`;
// Escape quotes for schtasks /tr parameter
const escapedCommand = command.replace(/"/g, '\\"');
const schtasksCommand = `schtasks /create /tn "${taskName}" /tr "${escapedCommand}" /sc minute /mo ${KEEP_ALIVE_INTERVAL_MINUTES} /f`;
try {
const { stdout, stderr } = await execPromise(schtasksCommand);
log.info(`Scheduled task created: ${stdout}`);
if (stderr) log.warn(`Scheduled task stderr: ${stderr}`);
} catch (error) {
log.error(
`Error creating scheduled task: ${error instanceof Error ? error.message : String(error)}`,
);
throw error; // Rethrow to allow caller to handle
}
}
export async function isKeepAliveTaskInstalled(): Promise<boolean> {
const maxRetries = 3;
const retryDelay = 500; // 500ms delay between retries
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const { stdout } = await execPromise(`schtasks /query /tn "${taskName}"`);
return !!stdout; // Return true if task exists
} catch (error) {
log.debug(
`Scheduled task ${taskName} not found (attempt ${attempt}/${maxRetries}): ${error instanceof Error ? error.message : String(error)}`,
);
if (attempt === maxRetries) {
return false; // Return false after all retries fail
}
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
}
return false; // Fallback return
}

View File

@@ -19,6 +19,7 @@ const store = new Store({
x: undefined,
y: undefined,
},
channel: "latest",
user: null,
isTest: false,
bodyshop: {},

View File

@@ -1,5 +1,5 @@
import { autoUpdater } from "electron-updater";
import { setReleaseChannel } from "../ipc/ipcMainHandler.user";
import store from "../store/store";
let continuousUpdatesTriggered = false;
@@ -15,8 +15,13 @@ async function checkForAppUpdatesContinuously(): Promise<void> {
);
}
}
async function checkForAppUpdates(): Promise<void> {
await setReleaseChannel();
async function checkForAppUpdates(channel?: string | null): Promise<void> {
if (channel) {
autoUpdater.channel = channel;
//Persist to store
store.set("app.channel", channel);
}
autoUpdater.checkForUpdates();
}

View File

@@ -159,10 +159,35 @@ function GetAllEnvFiles(): string[] {
});
return files;
}
function GetLatestEnvFile(): string | null {
const directories = store.get("settings.filepaths") as string[];
let latestFile: string | null = null;
let latestMTime = 0;
directories.forEach((directory) => {
try {
const envFiles = fs
.readdirSync(directory)
.filter((file: string) => file.toLowerCase().endsWith(".env"));
envFiles.forEach((file) => {
const fullPath = path.join(directory, file);
const stats = fs.statSync(fullPath);
if (stats.mtimeMs > latestMTime) {
latestMTime = stats.mtimeMs;
latestFile = fullPath;
}
});
} catch (error) {
log.error(`Failed to read directory ${directory}:`, error);
throw error;
}
});
return latestFile;
}
export {
addWatcherPath,
GetAllEnvFiles,
GetLatestEnvFile,
removeWatcherPath,
StartWatcher,
StopWatcher,

View File

@@ -1,7 +1,8 @@
import { BrowserWindow, contextBridge, shell } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import { contextBridge } from "electron";
import "electron-log/preload";
import store from "../main/store/store";
import newWindow from "../util/newWindow";
// Custom APIs for renderer
interface Api {
@@ -12,14 +13,7 @@ interface Api {
const api: Api = {
isTest: (): boolean => store.get("app.isTest") || false,
openExternal: async (url: string): Promise<void> => {
const pdfWindow = new BrowserWindow({
webPreferences: {
plugins: true, // Enable PDF viewing
},
});
pdfWindow.loadURL(url);
pdfWindow.focus();
newWindow(url);
},
};

View File

@@ -322,7 +322,7 @@ const Home: FC = () => {
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={8}>
<Card
bordered={false}
variant="borderless"
style={{
background: `linear-gradient(135deg, ${token.colorPrimary} 0%, ${token.colorPrimaryHover} 100%)`,
}}
@@ -335,13 +335,13 @@ const Home: FC = () => {
}
value={totalJobs}
prefix={<FileTextOutlined style={{ color: "#fff" }} />}
valueStyle={{ color: "#fff", fontWeight: 600 }}
styles={{ content: { color: "#fff", fontWeight: 600 } }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
bordered={false}
variant="borderless"
style={{
background: `linear-gradient(135deg, #52c41a 0%, #73d13d 100%)`,
}}
@@ -354,13 +354,13 @@ const Home: FC = () => {
}
value={totalItemsScrubbed}
prefix={<CheckCircleOutlined style={{ color: "#fff" }} />}
valueStyle={{ color: "#fff", fontWeight: 600 }}
styles={{ content: { color: "#fff", fontWeight: 600 } }}
/>
</Card>
</Col>
<Col xs={24} sm={24} lg={8}>
<Card
bordered={false}
variant="borderless"
style={{
background: `linear-gradient(135deg, #722ed1 0%, #9254de 100%)`,
}}
@@ -377,7 +377,7 @@ const Home: FC = () => {
: "—"
}
prefix={<ClockCircleOutlined style={{ color: "#fff" }} />}
valueStyle={{ color: "#fff", fontWeight: 600 }}
styles={{ content: { color: "#fff", fontWeight: 600 } }}
/>
</Card>
</Col>
@@ -385,7 +385,7 @@ const Home: FC = () => {
{/* Recent Estimates Card */}
<Card
bordered={false}
variant="borderless"
title={
<Flex align="center" gap="small">
<DatabaseOutlined style={{ fontSize: "20px" }} />

View File

@@ -95,3 +95,12 @@ ipcRenderer.on(
});
},
);
ipcRenderer.on(
ipcTypes.toRenderer.scrub.scrubError,
(_event: Electron.IpcRendererEvent, { message }) => {
notification.error({
message: i18n.t("errors.notificationtitle"),
description: message,
});
},
);

15
src/util/newWindow.ts Normal file
View File

@@ -0,0 +1,15 @@
import { app, BrowserWindow } from "electron";
async function newWindow(url: string): Promise<void> {
const pdfWindow = new BrowserWindow({
title: app.name,
webPreferences: {
plugins: true, // Enable PDF viewing
},
});
pdfWindow.loadURL(url);
pdfWindow.focus();
}
export default newWindow;