const queries = require("../graphql-client/queries"); const Dinero = require("dinero.js"); const moment = require("moment-timezone"); const fs = require("fs"); const storage = require("node-persist"); const _ = require("lodash"); const logger = require("../utils/logger"); const soap = require("soap"); const { sendServerEmail } = require("../email/sendemail"); const entegralEndpoint = process.env.NODE_ENV === "production" ? "https://ws.entegral.com/RepairOrderFolderService/RepairOrderFolderService.asmx?op=RepairOrderFolderAddRq" : "https://uat-ws.armsbusinesssolutions.net/RepairOrderFolderService/RepairOrderFolderService.asmx?WSDL"; const client = require("../graphql-client/graphql-client").client; const { v4 } = require("uuid"); const momentFormat = "yyyy-MM-DDTHH:mm:ss.SSS"; function pollFunc(fn, timeout, interval) { var startTime = new Date().getTime(); ((interval = interval || 1000), (canPoll = true)); (function p() { canPoll = timeout === 0 ? true : new Date().getTime() - startTime <= timeout; if (fn() && canPoll) { // ensures the function exucutes setTimeout(p, interval); } })(); } pollFunc(getEntegralShopData, 0, 5 * 60 * 1000); //Set the metadata to refresh every 5 minutes. async function getEntegralShopData() { // await storage.init({ logging: true }); // const { bodyshops } = await client.request(queries.GET_ENTEGRAL_SHOPS); // logger.log("set-entegral-shops-local-storage", "DEBUG", "API", null, null); // await storage.setItem("entegralShops", bodyshops); // return true; //Continue execution. } exports.default = async (req, res) => { res.sendStatus(401); return; //Query for the List of Bodyshop Clients. const job = req.body.event.data.new; logger.log("arms-job-update", "DEBUG", "api", job.id, null); let allEntegralShops = await storage.getItem("entegralShops"); if (!allEntegralShops) { await getEntegralShopData(); allEntegralShops = await storage.getItem("entegralShops"); } //Is this job part of an entegral shop? const bodyshop = allEntegralShops.find((b) => b.id === job.shopid); if (!bodyshop) { //This job is not for entegral based shops. res.sendStatus(200); return; } if (process.env.NODE_ENV === "production") { res.sendStatus(200); return; } //TODO: Check if an update should even be sent. if (false) { res.sendStatus(200); return; } try { const transId = v4(); // Can this actually be the job id? let obj = { RqUID: transId, DocumentInfo: { BMSVer: "4.0.0", DocumentType: "RO", DocumentVerCode: "EM", DocumentVerNum: GetSupplementNumber(job.joblines), //TODO Get Supplement Number DocumentStatus: GetDocumentstatus(job, bodyshop), CreateDateTime: moment().format(momentFormat), TransmitDateTime: moment().format(momentFormat) // Omitted from ARMS docs }, EventInfo: { AssignmentEvent: { CreateDateTime: job.asgn_date && moment(job.asgn_date).format(momentFormat) }, // EstimateEvent: { // UploadDateTime: moment().format(momentFormat), // }, RepairEvent: { CreatedDateTime: (job.date_open ? moment(job.date_open).tz(bodyshop.timezone) : moment()).format( momentFormat ), ArrivalDateTime: job.actual_in && moment(job.actual_in).tz(bodyshop.timezone).format(momentFormat), ArrivalOdometerReading: job.kmin, TargetCompletionDateTime: job.scheduled_completion && moment(job.scheduled_completion).tz(bodyshop.timezone).format(momentFormat), ActualCompletionDateTime: job.actual_completion && moment(job.actual_completion).tz(bodyshop.timezone).format(momentFormat), ActualPickUpDateTime: job.actual_delivery && moment(job.actual_delivery).tz(bodyshop.timezone).format(momentFormat), CloseDateTime: job.date_exported && moment(job.date_exported).tz(bodyshop.timezone).format(momentFormat) } }, RepairOrderHeader: { AdminInfo: { InsuranceCompany: { Party: { OrgInfo: { CompanyName: job.ins_co_nm, IDInfo: { IDQualifierCode: "US" //IDNum: 44, // ** Not sure where to get this entegral ID from? } // Communications: [ // { // CommQualifier: "WA", // Address: { // Address1: job.ins_addr1, // Address2: job.ins_addr2, // City: job.ins_city, // StateProvince: job.ins_st, // PostalCode: job.ins_zip, // CountryCode: job.ins_ctry, // }, // }, // { // CommQualifier: "WP", // CommPhone: job.ins_ph1, // }, // { // CommQualifier: "WF", // CommPhone: job.ins_ph2, // }, // ], } // ContactInfo: { // ContactJobTitle: "Adjuster", // ContactName: { // FirstName: job.est_ct_fn, // LastName: job.est_ct_ln, // }, // }, } }, // InsuranceAgent: { // Party: { // OrgInfo: { // CompanyName: "Nationwide Insurance", // Communications: { // CommQualifier: "WP", // CommPhone: "714-5551212", // }, // }, // ContactInfo: { // ContactJobTitle: "Insurance Agent", // ContactName: { // FirstName: "Paul", // LastName: "White", // }, // }, // }, // }, // Insured: { // Party: { // PersonInfo: { // PersonName: { // FirstName: job.insd_fn, // LastName: job.insd_ln, // }, // }, // }, // }, Owner: { Party: { PersonInfo: { PersonName: { FirstName: job.ownr_co_nm ? "N/A" : job.ownr_fn, LastName: job.ownr_co_nm ? job.ownr_co_nm : job.ownr_ln } // Communications: [ // { // CommQualifier: "HA", // Address: { // Address1: job.ownr_addr1, // City: job.ownr_city, // StateProvince: job.ownr_st, // PostalCode: job.ownr_zip, // CountryCode: job.ownr_ctry, // }, // }, // { // CommQualifier: "HP", // CommPhone: job.ownr_ph1, // }, // { // CommQualifier: "WP", // CommPhone: job.ownr_ph2, // }, // { // CommQualifier: "CP", // CommPhone: job.ownr_ph1, // }, // { // CommQualifier: "EM", // CommEmail: job.ownr_ea, // }, // ], } } }, // Claimant: { // Party: { // PersonInfo: { // PersonName: { // FirstName: job.clm_ct_fn, // LastName: job.clm_ct_ln, // }, // }, // }, // OwnerInd: true, // }, // Estimator: { // Party: { // PersonInfo: { // PersonName: { // FirstName: job.est_ct_fn, // LastName: job.est_ct_ln, // }, // // IDInfo: { // // IDQualifierCode: "US", // // IDNum: 2941, // // }, // }, // }, // }, RepairFacility: { Party: { OrgInfo: { CompanyName: process.env.NODE_ENV === "production" ? bodyshop.shopname : "IMEX Test Canadian Shop", IDInfo: { IDQualifierCode: "US", IDNum: bodyshop.entegral_id } } } } }, RepairOrderIDs: { RepairOrderNum: job.ro_number // VendorCode: "C", // EstimateDocumentID: "1223HJ76", }, RepairOrderType: "DirectRepairProgram", //Need to get from Entegral //ReferralSourceType: "Yellow Pages", VehicleInfo: { VINInfo: { VIN: { VINNum: job.v_vin } }, License: { LicensePlateNum: job.plate_no }, VehicleDesc: { //ProductionDate: "2009-10", ModelYear: parseInt(job.v_model_yr) < 1900 ? parseInt(job.v_model_yr) < moment().tz(bodyshop.timezone).format("YY") ? `20${job.v_model_yr}` : `19${job.v_model_yr}` : job.v_model_yr, MakeDesc: job.v_make_desc, ModelName: job.v_model_desc } // Paint: { // Exterior: { // Color: { // ColorName: job.v_color, // // OEMColorCode: "1M3", // }, // }, // }, // Body: { // BodyStyle: "2 Door Convertible", // Trim: { // TrimCode: "1B3", // }, // }, // Condition: { // DrivableInd: job.driveable ? "Y" : "N", // }, }, ClaimInfo: { ClaimNum: job.clm_no, PolicyInfo: { PolicyNum: job.policy_no }, LossInfo: { Facts: { LossDateTime: job.loss_date && moment(job.loss_date) //.tz(bodyshop.timezone) .format(momentFormat), LossDescCode: "Collision", PrimaryPOI: { POICode: job.area_of_damage && job.area_of_damage.impact1 }, SecondaryPOI: { POICode: job.area_of_damage && job.area_of_damage.impact2 } }, TotalLossInd: job.tlos_ind } } }, ProfileInfo: { ProfileName: "ImEX", RateInfo: [ { RateType: "PA", RateDesc: "Parts Tax", TaxInfo: { TaxType: "LS", TaxableInd: true, TaxTierInfo: { TierNum: 1, Percentage: job.parts_tax_rates.PAN.prt_tax_rt * 100 //TODO Find the best place to take the tax rates for parts. } } }, { RateType: "LA", RateDesc: "Labor Tax", TaxInfo: { TaxType: "LS", TaxableInd: true, TaxTierInfo: { TierNum: 1, Percentage: job.parts_tax_rates.PAN.prt_tax_rt * 100 //TODO Find the best place to take the tax rates for labor. } } }, { RateType: "LAB", RateDesc: "Body Labor", RateTierInfo: { TierNum: 1, Rate: job.rate_lab } }, { RateType: "LAS", RateDesc: "Structural Labor", RateTierInfo: { TierNum: 1, Rate: job.rate_las } }, { RateType: "LAR", RateDesc: "Refinish Labor", RateTierInfo: { TierNum: 1, Rate: job.rate_lar } }, { RateType: "LAG", RateDesc: "Glass Labor", RateTierInfo: { TierNum: 1, Rate: job.rate_lag } }, { RateType: "LAF", RateDesc: "Frame Labor", RateTierInfo: { TierNum: 1, Rate: job.rate_laf } }, { RateType: "LAM", RateDesc: "Mechancial Labor", RateTierInfo: { TierNum: 1, Rate: job.rate_lam } }, { RateType: "LAU", RateDesc: "User Defined Labor", RateTierInfo: { TierNum: 1, Rate: job.rate_lau } }, { RateType: "MAPA", RateDesc: "Paint Materials", RateTierInfo: { TierNum: 1, Rate: job.rate_mapa, ThresholdAmt: 0 }, MaterialCalcSettings: { CalcMethodCode: 2, CalcMaxAmt: 9999.99 //TODO Find threshold amts. } }, { RateType: "MASH", RateDesc: "Shop Materials", RateTierInfo: { TierNum: 1, Rate: job.rate_mash }, MaterialCalcSettings: { CalcMethodCode: 4, CalcMaxAmt: 9999.99 //TODO Find threshold amounts. } }, { RateType: "MAHW", RateDesc: "Hazardous Wastes Removal", RateTierInfo: { TierNum: 1, Rate: job.rate_mahw }, MaterialCalcSettings: { //Todo Capture Calc Settings CalcMethodCode: 2, CalcMaxAmt: 10 } }, { RateType: "MA2S", RateDesc: "Two Stage Paint", RateTierInfo: { TierNum: 1, Rate: job.rate_ma2s }, MaterialCalcSettings: { CalcMethodCode: 1, CalcMaxAmt: 999999.99 } }, { RateType: "MA2T", RateDesc: "Two Tone Paint", RateTierInfo: { TierNum: 1, Rate: job.rate_ma2t }, MaterialCalcSettings: { CalcMethodCode: 1, CalcMaxAmt: 999999.99 } }, { RateType: "MA3S", RateDesc: "Three Stage Paint", RateTierInfo: { TierNum: 1, Rate: job.rate_ma3s }, MaterialCalcSettings: { CalcMethodCode: 1, CalcMaxAmt: 999999.99 } } ] }, //StorageDuration: 17, RepairTotalsInfo: { LaborTotalsInfo: [ { TotalType: "LAB", TotalTypeDesc: "Body Labor", TotalHours: job.job_totals.rates.lab.hours, TotalAmt: Dinero(job.job_totals.rates.lab.total).toFormat("0.00") }, { TotalType: "LAF", TotalTypeDesc: "Frame Labor", TotalHours: job.job_totals.rates.laf.hours, TotalAmt: Dinero(job.job_totals.rates.laf.total).toFormat("0.00") }, { TotalType: "LAM", TotalTypeDesc: "Mechanical Labor", TotalHours: job.job_totals.rates.lam.hours, TotalAmt: Dinero(job.job_totals.rates.lam.total).toFormat("0.00") }, { TotalType: "LAR", TotalTypeDesc: "Refinish Labor", TotalHours: job.job_totals.rates.lar.hours, TotalAmt: Dinero(job.job_totals.rates.lar.total).toFormat("0.00") } ], PartsTotalsInfo: [ { TotalType: "PAA", TotalTypeDesc: "Aftermarket Parts", TotalAmt: Dinero(job.job_totals.parts.parts.list.PAA && job.job_totals.parts.parts.list.PAA.total).toFormat( "0.00" ) }, { TotalType: "PAC", TotalTypeDesc: "Re-Chromed Parts", TotalAmt: Dinero(job.job_totals.parts.parts.list.PAC && job.job_totals.parts.parts.list.PAC.total).toFormat( "0.00" ) }, { TotalType: "PAG", TotalTypeDesc: "Glass Parts", TotalAmt: Dinero(job.job_totals.parts.parts.list.PAG && job.job_totals.parts.parts.list.PAG.total).toFormat( "0.00" ) }, { TotalType: "PAL", TotalTypeDesc: "LKQ/Used Parts", TotalAmt: Dinero(job.job_totals.parts.parts.list.PAL && job.job_totals.parts.parts.list.PAL.total).toFormat( "0.00" ) }, { TotalType: "PAM", TotalTypeDesc: "Remanufactured Parts", TotalAmt: Dinero(job.job_totals.parts.parts.list.PAM && job.job_totals.parts.parts.list.PAM.total).toFormat( "0.00" ) }, { TotalType: "PAN", TotalTypeDesc: "New Parts", TotalAmt: Dinero(job.job_totals.parts.parts.list.PAN && job.job_totals.parts.parts.list.PAN.total).toFormat( "0.00" ) }, { TotalType: "PAR", TotalTypeDesc: "Recored Parts", TotalAmt: Dinero(job.job_totals.parts.parts.list.PAR && job.job_totals.parts.parts.list.PAR.total).toFormat( "0.00" ) } ], OtherChargesTotalsInfo: [ { TotalType: "OTSL", TotalTypeDesc: "Sublet", TotalAmt: Dinero(job.job_totals.parts.sublets.total).toFormat("0.00") }, { TotalType: "MAPA", TotalTypeDesc: "Paint Materials", TotalAmt: Dinero(job.job_totals.rates.mapa.total).toFormat("0.00") }, { TotalType: "MASH", TotalTypeDesc: "Shop Materials", TotalAmt: Dinero(job.job_totals.rates.mash.total).toFormat("0.00") }, // { // TotalType: "MAHW", // TotalTypeDesc: "Hazardous Wastes Removal", // TotalAmt: Dinero(job.job_totals.rates.mahw.total).toFormat( // 0.0 // ), // }, { TotalType: "OTST", TotalTypeDesc: "Storage", TotalAmt: Dinero(job.job_totals.additional.storage).toFormat("0.00") }, { TotalType: "OTTW", TotalTypeDesc: "Towing", TotalAmt: Dinero(job.job_totals.additional.towing).toFormat("0.00") }, { TotalType: "OTAC", TotalTypeDesc: "Additional Charges", TotalAmt: Dinero(job.job_totals.additional.additionalCosts) .add(Dinero(job.job_totals.additional.pvrt)) .toFormat("0.00") } ], SummaryTotalsInfo: [ { TotalType: "TOT", TotalSubType: "TT", TotalTypeDesc: "Gross Total", TotalAmt: Dinero(job.job_totals.totals.total_repairs).toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "T2", TotalTypeDesc: "Net Total", TotalAmt: Dinero(job.job_totals.totals.subtotal).toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "SM", TotalTypeDesc: "Supplement Total", TotalAmt: job.cieca_ttl ? job.cieca_ttl.data.supp_amt : Dinero().toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "F7", TotalTypeDesc: "Sales Tax", TotalAmt: Dinero(job.job_totals.totals.state_tax).toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "GST", TotalTypeDesc: "GST Tax", TotalAmt: Dinero(job.job_totals.totals.federal_tax).toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "D8", TotalTypeDesc: "Bottom Line Discount", TotalAmt: Dinero(job.job_totals.additional.adjustments).toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "D2", TotalTypeDesc: "Deductible", TotalAmt: Dinero({ amount: Math.round((job.ded_amt || 0) * 100) }).toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "BTR", TotalTypeDesc: "Betterment", TotalAmt: Dinero(job.job_totals.totals.custPayable.dep_taxes).toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "AA", TotalTypeDesc: "Appearance Allowance", TotalAmt: Dinero().toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "DEPOSIT", TotalTypeDesc: "Deposit", TotalAmt: Dinero().toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "INS", TotalTypeDesc: "Insurance Pay", TotalAmt: Dinero(job.job_totals.totals.total_repairs) .subtract(Dinero(job.job_totals.totals.custPayable.total)) .toFormat("0.00") }, { TotalType: "TOT", TotalSubType: "CUST", TotalTypeDesc: "Customer Pay", TotalAmt: Dinero(job.job_totals.totals.custPayable.total).toFormat("0.00") } ] // RepairTotalsType: 1, }, // RepairLabor: { // LaborAllocations: { // LaborAllocation: [ // { // LaborAllocationUUID: // "426cce3a-efa7-44d9-b76e-50b9102c4198", // LaborType: "LAB", // Technician: { // Employee: { // PersonInfo: { // PersonName: { // FirstName: "Jose", // LastName: "Gonzalez", // }, // IDInfo: { // IDQualifierCode: "US", // IDNum: 2987, // }, // }, // }, // }, // AllocatedHours: 3.5, // }, // { // LaborAllocationUUID: // "426cce3a-efa7-44d9-b76e-50b9102c4199", // LaborType: "LAR", // Technician: { // Employee: { // PersonInfo: { // PersonName: { // FirstName: "Rcardo", // LastName: "Himenez", // }, // IDInfo: { // IDQualifierCode: "US", // IDNum: 2989, // }, // }, // }, // }, // AllocatedHours: 5.5, // }, // ], // }, // }, ProductionStatus: { ProductionStage: { ProductionStageCode: GetProductionStageCode(job, bodyshop), ProductionStageDateTime: moment().tz(bodyshop.timezone).format(momentFormat) // ProductionStageStatusComment: // "Going to be painted this afternoon", }, RepairStatus: { RepairStatusCode: GetRepairStatusCode(job), RepairStatusDateTime: moment().tz(bodyshop.timezone).format(momentFormat) // RepairStatusMemo: "Waiting on back ordered parts", } } // RepairOrderNotes: { // RepairOrderNote: { // LineSequenceNum: 1, // Note: "Revision Requested : approved--but needs est separated.8/22/2008 11:58:53 AM", // CreateDateTime: "2008-08-22T11:58:53", // AuthoredBy: { // FirstName: { // "#text": "Elizabeth/FirstName>", // LastName: "Unis", // }, // }, // RepairOrderNote: { // LineSequenceNum: 2, // Note: "Approved : 8/26/2008 12:21:08 PM", // CreateDateTime: "2008-08-26T12:21:08", // AuthoredBy: { // FirstName: { // "#text": "Elizabeth/FirstName>", // LastName: "Unis", // }, // }, // }, // }, // }, }; deleteNullKeys(obj); try { const entegralSoapClient = await soap.createClientAsync(entegralEndpoint, { ignoredNamespaces: true, wsdl_options: { // useEmptyTag: true, }, wsdl_headers: { Authorization: `Basic ${new Buffer.from( `${process.env.ENTEGRAL_USER}:${process.env.ENTEGRAL_PASSWORD}` ).toString("base64")}` } }); entegralSoapClient.setSecurity( new soap.BasicAuthSecurity(process.env.ENTEGRAL_USER, process.env.ENTEGRAL_PASSWORD) ); const entegralResponse = await entegralSoapClient.RepairOrderFolderAddRqAsync( obj, function (err, result, rawResponse, soapHeader, rawRequest) { fs.writeFileSync(`./logs/arms-request.xml`, rawRequest); fs.writeFileSync(`./logs/arms-response.xml`, rawResponse); logger.log("arms-job-xml-request", "DEBUG", "api", job.id, { xml: rawRequest }); logger.log("arms-job-xml-response", "DEBUG", "api", job.id, { xml: rawResponse }); if (err) { sendServerEmail({ subject: `ARMS Update Failed: ${bodyshop.shopname} - ${job.ro_number}`, text: `Error: ${JSON.stringify(error)}` }); } res.status(200).json(err || result); } ); const [result, rawResponse, , rawRequest] = entegralResponse; } catch (error) { logger.log("arms-failed-job-upload", "ERROR", "api", job.shopid, { job: JSON.stringify({ id: job.id, ro_number: job.ro_number }), error: error.message || JSON.stringify(error) }); //console.log(error); } } catch (error) { logger.log("arms-failed-job", "ERROR", "api", job.shopid, { job: JSON.stringify({ id: job.id, ro_number: job.ro_number }), error: error.message || JSON.stringify(error) }); } res.sendStatus(200); return; const allErrors = []; try { for (const bodyshop of bodyshops) { logger.log("arms-start-shop-extract", "DEBUG", "api", bodyshop.id, { shopname: bodyshop.shopname }); const erroredJobs = []; try { const { jobs } = await client.request(queries.ENTEGRAL_EXPORT, { bodyshopid: bodyshop.id }); const jobsToPush = []; if (erroredJobs.length > 0) { logger.log("arms-failed-jobs", "ERROR", "api", bodyshop.id, { count: erroredJobs.length, jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number)) }); allErrors = [...allErrors, ...erroredJobs]; } logger.log("arms-end-shop-extract", "DEBUG", "api", bodyshop.id, { shopname: bodyshop.shopname }); try { const entegralSoapClient = await soap.createClientAsync(entegralEndpoint, { ignoredNamespaces: true, wsdl_options: { // useEmptyTag: true, }, wsdl_headers: { Authorization: `Basic ${new Buffer.from( `${process.env.ENTEGRAL_USER}:${process.env.ENTEGRAL_PASSWORD}` ).toString("base64")}` } }); entegralSoapClient.setSecurity( new soap.BasicAuthSecurity(process.env.ENTEGRAL_USER, process.env.ENTEGRAL_PASSWORD) ); const entegralResponse = await entegralSoapClient.RepairOrderFolderAddRqAsync( [jobsToPush[0]], function (err, result, rawResponse, soapHeader, rawRequest) { fs.writeFileSync(`./logs/arms-request.xml`, rawRequest); fs.writeFileSync(`./logs/arms-response.xml`, rawResponse); res.json(err || result); } ); const [result, rawResponse, , rawRequest] = entegralResponse; } catch (error) { fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml); //console.log(error); logger.log("arms-error-shop", "ERROR", "api", bodyshop.id, { error }); } } catch (error) { //Error at the shop level. logger.log("arms-error-shop", "ERROR", "api", bodyshop.id, { error }); allErrors.push({ bodyshopid: bodyshop.id, imexshopid: bodyshop.imexshopid, fatal: true, errors: [error.toString()] }); } finally { allErrors.push({ bodyshopid: bodyshop.id, imexshopid: bodyshop.imexshopid, errors: erroredJobs }); } } res.sendStatus(200); } catch (error) { res.status(200).json(error); } }; function GetSupplementNumber(joblines) { if (!joblines) return 0; const max = _.max(joblines.map((jl) => parseInt((jl.line_ind || "0").replace(/[^\d.-]/g, "")))); return max || 0; } function GetDocumentstatus(job, bodyshop) { switch (job.status) { case bodyshop.md_ro_statuses.default_void: return "V"; case bodyshop.md_ro_statuses.default_invoiced: case bodyshop.md_ro_statuses.default_exported: return "Z"; default: return "O"; } } function GetRepairStatusCode(job) { return "25"; } function GetProductionStageCode(job, bodyshop) { const result = (bodyshop.features?.entegral).find((k) => k.status === job.status); return result?.code || "33"; } function isEmpty(obj) { for (var key in obj) return false; return true; } function deleteNullKeys(app) { for (var key in app) { if (app[key] !== null && typeof app[key] === "object") { deleteNullKeys(app[key]); if (isEmpty(app[key])) { delete app[key]; } } if (app[key] === null) { delete app[key]; } } }