Instantly share code, notes, and snippets.
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save Kingo4luv/733ac8b1f855d89ea887f5e85cd4492c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import sql from "mssql"; | |
import states from "./states.js"; | |
import path from "node:path"; | |
import { fileURLToPath } from "node:url"; | |
import { Eta } from "eta"; | |
const DEFAULT_DATE_FORMAT = { | |
timeZone: "US/Eastern", | |
dateStyle: "short", | |
}; | |
const DATE_FORMAT = new Intl.DateTimeFormat("en-US", DEFAULT_DATE_FORMAT); | |
const DATETIME_FORMAT = new Intl.DateTimeFormat("en-US", { | |
...DEFAULT_DATE_FORMAT, | |
timeStyle: "short", | |
}); | |
try { | |
// Load .env file | |
let dotenv = await import("dotenv"); | |
dotenv.config({ | |
path: "../.env", | |
}); | |
} catch (e) { | |
// Do nothing if dotenv package isn't available. | |
// This will catch on a serverless function because the dependency needs to be present in the functions | |
// package.json as well. But it's only needed for local dev, so it's not installed there. | |
} | |
const CRM_URL = parseCrmUrl(process.env.CRM_URL); | |
export async function invalidate_all_stores() { | |
try { | |
await dbConnect(); | |
// Update existing records so that they are invalidated. | |
await sql.query` | |
update CouponLookupsT | |
set invalidated = 1 | |
from CouponLookupsT | |
join StoresT on CouponLookupsT.Store = StoresT.Store | |
where | |
CouponCode in (select CouponCode from ActiveCouponsT where Active = 1) | |
and StoresT.Status = 'Open' | |
`; | |
const queryResult = await sql.query` | |
select | |
StoresT.Store, | |
ActiveCouponsT.CouponCode | |
from StoresT | |
cross join ActiveCouponsT | |
left join CouponLookupsT | |
on StoresT.Store = CouponLookupsT.Store | |
and ActiveCouponsT.CouponCode = CouponLookupsT.CouponCode | |
where ActiveCouponsT.Active = 1 | |
and CouponLookupsT.Store is null | |
and StoresT.Status = 'Open' | |
`; | |
if ((queryResult.recordset ?? []).length > 0) { | |
const lookupTable = new sql.Table("CouponLookupsT"); | |
lookupTable.create = false; | |
lookupTable.columns.add("Store", sql.Int, { nullable: false }); | |
lookupTable.columns.add("CouponCode", sql.NVarChar(255), { | |
nullable: true, | |
}); | |
for (const record of queryResult.recordset) { | |
lookupTable.rows.add(record.Store, record.CouponCode); | |
} | |
const request = new sql.Request(); | |
// Insert new records. This only happens when an active coupon code changes. | |
await request.bulk(lookupTable); | |
} | |
} catch (err) { | |
console.error(err); | |
} | |
} | |
export async function invalidate_store(storeId, couponCodes) { | |
try { | |
await dbConnect(); | |
const couponCodesFilter = `'${couponCodes.join(`', '`)}'`; | |
// Update existing records so that they are invalidated. | |
let request = new sql.Request(); | |
const res = await request.query(` | |
update CouponLookupsT | |
set invalidated = 1 | |
where | |
Store = ${storeId} | |
and CouponCode in (${couponCodesFilter}) | |
`); | |
} catch (err) { | |
console.error(err); | |
} | |
} | |
export async function lookup_invalidated_stores() { | |
await dbConnect(); | |
const lookupsQueryResult = await sql.query` | |
update top (1000) CouponLookupsT | |
set Invalidated = 0 | |
output Inserted.Store, Inserted.CouponCode, Inserted.Fulfilled, Inserted.LastFulfilledDate | |
from CouponLookupsT | |
where Invalidated = 1 | |
`; | |
const statement = await prepareUpdateCouponStatement(); // Prepare just once, and reuse. | |
const insertStoreNoteStatement = await prepareInsertStoreNoteStatement(); | |
let chunks = arrayChunks(lookupsQueryResult.recordset ?? [], 25); | |
for (const chunk of chunks) { | |
const promises = []; | |
for (const record of chunk) { | |
promises.push( | |
lookupCouponStore( | |
record.Store, | |
record.CouponCode, | |
record.Fulfilled, | |
record.LastFulfilledDate | |
) | |
); | |
} | |
let results = await Promise.allSettled(promises); | |
for (const { status, value } of results) { | |
if (status === "fulfilled") { | |
await statement.execute({ | |
Store: value.storeId, | |
CouponCode: value.couponCode, | |
Fulfilled: value.couponApplied, | |
PreviousFulfilled: value.previousFulfilled, | |
Menu: value.menu, | |
Adjustment: value.adjustment, | |
Net: value.net, | |
DateUpdated: value.dateUpdated, | |
LastFulfilledDate: value.lastFulfilledDate, | |
}); | |
if (value.couponApplied !== value.previousFulfilled) { | |
try { | |
let storeNoteMessage = ""; | |
if (value.couponApplied) { | |
storeNoteMessage = `Code ${value.couponCode} accepted`; | |
} else { | |
storeNoteMessage = `Code ${value.couponCode} was not accepted`; | |
} | |
await insertStoreNoteStatement.execute({ | |
dateEntered: formatDate(new Date(), true), | |
note: storeNoteMessage, | |
storeId: value.storeId, | |
}); | |
} catch (e) { | |
console.error(e); | |
} | |
} | |
} else { | |
console.error("rejected", value); | |
} | |
} | |
} | |
await statement.unprepare(); | |
await insertStoreNoteStatement.unprepare(); | |
if (chunks.length > 0) { | |
// If there was no data returned from the lookup query then do not process batch end. Otherwise, we'll send an | |
// email out every time process-batch is run. | |
await processBatchesEnded(); | |
} else { | |
console.log("No invalidated stores to process"); | |
} | |
} | |
export async function lookForStoreChanges() { | |
// list of "new" stores to ignore | |
const IGNORED_NEW_STORE_IDS = [ | |
"259033467753805017", | |
"test-location", | |
"test123", | |
]; | |
const reportDate = formatDate(new Date()); | |
await dbConnect(); | |
// fetch all of the stores in the db using a prepared statement | |
const storeDataPromise = prepareStoreValidationStatement().then( | |
async (statement) => { | |
// get all of the stores | |
const stores = await statement.execute(); | |
// start un-preparing the statement to release the connection from the DB, but don't block on it until all other prep logic is done | |
// this lets us build the map and unprepare in parallel | |
const unpreparePromise = statement.unprepare(); | |
// convert the array of stores to a map for faster lookup | |
/**@typedef {import("./shared").StoreValidationDBStore} StoreValidationDBStore | |
* @type {Map<number, StoreValidationDBStore>} | |
*/ | |
const storesMap = new Map( | |
stores.recordset?.map((store) => [store.store, store]) | |
); | |
await unpreparePromise; | |
return storesMap; | |
} | |
); | |
const [apiStores, dbStores, franchises] = await Promise.all([ | |
fetchStoresFromDominosApi(), | |
storeDataPromise, | |
getAllFranchises(), | |
]); | |
const visitedStores = new Map(); | |
/** | |
* @type {FranchiseChangeData[]} | |
*/ | |
const storesWithFranchiseChanges = []; | |
/** | |
* @type {DominosApiStore[]} | |
*/ | |
const newStores = []; | |
for (const store of apiStores) { | |
// if the API store doesn't have a franchiseId | |
// assign it to a corporate franchise if applicable. | |
if (!store.franchiseId) { | |
const corporateFranchiseId = getCorporateFranchise(store); | |
if (corporateFranchiseId) { | |
store.franchiseId = corporateFranchiseId; | |
store.assignedToCorporateFranchise = true; | |
} | |
} | |
const matchedStore = dbStores.get(Number(store.storeId)); | |
if (!matchedStore) { | |
if ( | |
!store.storeId.toLowerCase().includes("test") && | |
!IGNORED_NEW_STORE_IDS.includes(store.storeId.toLowerCase()) && | |
// only include stores with numeric store #'s because we need them to be integers to insert them into the DB. | |
Number.parseInt(store.storeId).toString() === store.storeId | |
) { | |
store.county = await lookupCountyByAddress(store); | |
const newStoreAddress = { | |
address: store.line1, | |
city: store.city, | |
region: store.region, | |
zip: store.postalCode, | |
}; | |
const verificationResult = await verifyAddress(newStoreAddress); | |
// Ensure verified value is added to the store before insertion | |
store.AddressVerified = verificationResult.valid ? 1 : 0; | |
newStores.push(store); | |
// After adding to the database, process the verification | |
await processStoreAddress(store.storeId, newStoreAddress, "new", verificationResult); | |
} | |
} else { | |
visitedStores.set(matchedStore.store); | |
if ( | |
store.line1 !== matchedStore.line1 || | |
store.city !== matchedStore.city || | |
store.region !== matchedStore.region || | |
store.postalCode !== matchedStore.postalCode | |
) { | |
const updatedStoreAddress = { | |
address: store.line1, | |
city: store.city, | |
region: store.region, | |
zip: store.postalCode, | |
}; | |
const verificationResult = await verifyAddress(updatedStoreAddress); | |
await processStoreAddress( | |
store.storeId, | |
updatedStoreAddress, | |
"update", | |
verificationResult | |
); | |
} | |
if (store.franchiseId && matchedStore.franchiseId !== store.franchiseId) { | |
const newFranchiseForStore = franchises.get(store.franchiseId); | |
const oldFranchiseForStore = franchises.get(matchedStore.franchiseId); | |
storesWithFranchiseChanges.push({ | |
store: matchedStore, | |
oldFranchiseId: matchedStore.franchiseId, | |
oldFranchiseAgreed: oldFranchiseForStore?.agreed ?? false, | |
newFranchiseId: store.franchiseId, | |
newFranchiseAssignedToCorporateId: store.assignedToCorporateFranchise, | |
newFranchiseName: newFranchiseForStore?.fullName, | |
newFranchiseState: newFranchiseForStore?.state, | |
newFranchiseAgreed: newFranchiseForStore?.agreed ?? false, | |
}); | |
} | |
} | |
} | |
const closedStores = Array.from(dbStores.entries()).flatMap( | |
([id, dbStore]) => { | |
// if the store is open in the DB and it wasn't visited while looping through API data, the store is closed | |
if (dbStore.status?.toLowerCase() === "open" && !visitedStores.has(id)) { | |
return dbStore; | |
} else { | |
// if the above conditions are false, return a [] so it gets filtered from the map | |
return []; | |
} | |
} | |
); | |
/** @type {Array<{franchiseCode: string, franchiseName: string, stores: DominosApiStore[]}>} */ | |
const insertedFranchises = []; | |
/** | |
* This set keeps track of any inserted franchises so there are no repeats | |
* @type {Set<string>} */ | |
const insertedFranchiseIds = new Set(); | |
/** @type {Array<{franchiseCode: string, franchiseName: string, error: Error}>} */ | |
const insertedFranchiseErrors = []; | |
/** | |
* @type {Array<{store: DominosApiStore, error: Error}>} | |
*/ | |
let newStoreErrors = []; | |
if (newStores.length) { | |
newStoreErrors = await saveNewStores(newStores); | |
// check if the new store's franchise exists. If not, we need to insert it into the db. | |
const newStoresWithNewFranchises = newStores.filter( | |
(s) => s.franchiseId && !franchises.has(s.franchiseId) | |
); | |
if (newStoresWithNewFranchises.length) { | |
const insertFranchiseStatement = | |
await prepareInsertNewFranchiseStatement(); | |
const insertFranchiseNoteStatement = | |
await prepareInsertFranchiseNoteStatement(); | |
// if the store has a franchise code that's not in the DB, insert it with a placeholder name | |
for (const storeInfo of newStoresWithNewFranchises) { | |
if (insertedFranchiseIds.has(storeInfo.franchiseId)) { | |
const matchingFranchise = insertedFranchises.find( | |
(f) => f.franchiseCode === storeInfo.franchiseId | |
); | |
// add this stores to the matchingFranchise's stores list | |
if (matchingFranchise) { | |
matchingFranchise.stores ??= []; | |
matchingFranchise.stores.push(storeInfo); | |
} | |
continue; | |
} | |
let newFranchiseeName = `Franchisee ${storeInfo.franchiseId}`; | |
try { | |
await insertFranchiseStatement.execute({ | |
franchiseCode: storeInfo.franchiseId, | |
fullName: newFranchiseeName, | |
dateUpdated: reportDate, | |
}); | |
insertedFranchises.push({ | |
franchiseCode: storeInfo.franchiseId, | |
franchiseName: newFranchiseeName, | |
stores: [storeInfo], | |
}); | |
insertedFranchiseIds.add(storeInfo.franchiseId); | |
} catch (e) { | |
insertedFranchiseErrors.push({ | |
franchiseCode: storeInfo.franchiseId, | |
franchiseName: newFranchiseeName, | |
error: e, | |
}); | |
continue; | |
} | |
try { | |
await insertFranchiseNoteStatement.execute({ | |
note: `Franchise ${newFranchiseeName} created with Franchise Code ${storeInfo.franchiseId} for Store ${storeInfo.storeId}`, | |
dateUpdated: formatDate(new Date(), true), | |
franchiseCode: storeInfo.franchiseId, | |
}); | |
} catch (e) { | |
console.error("error inserting franchise note for new store", e); | |
} | |
} | |
await insertFranchiseStatement.unprepare(); | |
await insertFranchiseNoteStatement.unprepare(); | |
} | |
} | |
/** | |
* | |
* @type {Array<{franchiseChangeInfo: FranchiseChangeData, error: Error}>} | |
* */ | |
let updatedStoreErrors = []; | |
if (storesWithFranchiseChanges.length) { | |
const franchiseUpdateResults = await updateFranchiseDataForStores( | |
storesWithFranchiseChanges, | |
insertedFranchiseIds | |
); | |
updatedStoreErrors = franchiseUpdateResults.updatedStoreErrors; | |
for (const insertedFranchise of franchiseUpdateResults.insertedFranchises) { | |
// check if we've already included this franchise before | |
const matchedFranchise = insertedFranchises.find( | |
(f) => f.franchiseCode === insertedFranchise.franchiseCode | |
); | |
// if we've handled this franchise before had add the stores for this new franchise to the franchise list | |
if (matchedFranchise) { | |
matchedFranchise.stores.push(...insertedFranchise.stores); | |
} else { | |
// if we've not inserted this franchise somewhere else, add it to the list of inserted franchises | |
insertedFranchises.push(insertedFranchise); | |
} | |
} | |
insertedFranchiseErrors.push( | |
...franchiseUpdateResults.insertedFranchiseErrors | |
); | |
} | |
const eta = new Eta({ | |
views: path.join(path.dirname(fileURLToPath(import.meta.url)), "templates"), | |
}); | |
/** | |
* get only the stores that were successfully saved to send in the report. | |
*/ | |
const insertedNewStores = newStores.filter( | |
(s) => | |
!newStoreErrors.some( | |
(erroredStores) => erroredStores.store.storeId === s.storeId | |
) | |
); | |
// record only the stores that successfully had their franchises changed to include in the report. | |
const updatedStores = storesWithFranchiseChanges.filter( | |
(s) => | |
!updatedStoreErrors.some( | |
(erroredStore) => | |
erroredStore.franchiseChangeInfo.store.store === s.store.store | |
) | |
); | |
const storeStatusEmails = process.env.EMAIL_TO_STORE_LOOKUPS.split(",").map( | |
(email) => ({ | |
email, | |
}) | |
); | |
const [ | |
newStoresReportEmailResult, | |
closedStoresReportEmailResult, | |
newFranchiseEmailStatus, | |
storeFranchiseChangesEmail, | |
] = await Promise.allSettled([ | |
sendNewStoresEmail({ | |
eta, | |
newStores: insertedNewStores, | |
emails: storeStatusEmails, | |
runDate: reportDate, | |
}), | |
sendClosedStoresEmail({ | |
eta, | |
closedStores, | |
emails: storeStatusEmails, | |
runDate: reportDate, | |
}), | |
sendNewFranchiseEmail({ | |
eta, | |
newFranchises: insertedFranchises, | |
emails: storeStatusEmails, | |
runDate: reportDate, | |
}), | |
sendStoreFranchiseChangesEmail({ | |
eta, | |
storesWithFranchiseChanges: updatedStores, | |
emails: storeStatusEmails, | |
runDate: reportDate, | |
}), | |
]); | |
// if we couldn't save any stores, send an email to engineering@fostercommerce so they can be reviewed and handled separate from the client. | |
if (newStoreErrors.length) { | |
const [newStoreErrorReport, newStoreErrorsCsv] = await Promise.all([ | |
eta.renderAsync("./new-store-errors", { | |
storeErrors: newStoreErrors, | |
runDate: reportDate, | |
formatPhoneNumber, | |
}), | |
eta.renderAsync("./csv", { | |
data: newStoreErrors.map((storeError) => ({ | |
"Store #": storeError.store.storeId, | |
"Franchise Code": storeError.store.franchiseId, | |
Phone: formatPhoneNumber(storeError.store.phoneNumber), | |
"Address Line 1": `"${storeError.store.line1}"`, | |
"Address Line 2": `"${storeError.store.line2}"`, | |
City: storeError.store.city, | |
State: storeError.store.region, | |
Zip: storeError.store.postalCode, | |
Latitude: storeError.store?.coordinates?.latitude, | |
Longitude: storeError.store?.coordinates?.longitude, | |
Timezone: storeError.store?.timezone, | |
"Store URL": storeError.store.storeUrl, | |
Error: `"${storeError.error.message?.replace(`"`, `'`)}"`, | |
})), | |
}), | |
]); | |
await sendEmail( | |
[{ email: "[email protected]" }], | |
"STP CRM Error: There was an error saving new Domino's stores to the STP DB", | |
newStoreErrorReport, | |
[{ name: "new-store-errors.csv", data: newStoreErrorsCsv }] | |
); | |
} | |
if (updatedStoreErrors.length) { | |
const updatedStoreFranchiseErrorsCsv = await eta.renderAsync("./csv", { | |
data: updatedStoreErrors.map((storeError) => ({ | |
"Store #": storeError.franchiseChangeInfo.store.store, | |
"New Franchise Code": storeError.franchiseChangeInfo.newFranchiseId, | |
"New Franchise Name": | |
storeError.franchiseChangeInfo.newFranchiseName ?? | |
`Franchisee ${storeError.franchiseChangeInfo.newFranchiseId}`, | |
"Old Franchise Id": storeError.franchiseChangeInfo.oldFranchiseId, | |
Error: `"${storeError.error.message?.replace(`"`, `'`)}"`, | |
})), | |
}); | |
await sendEmail( | |
[{ email: "[email protected]" }], | |
"STP Status Report Error: There was an error updating stores with new franchise codes", | |
`<body><h1>We could not update some stores with new franchise codes. Please see attachment</h1></body>`, | |
[ | |
{ | |
name: "franchise-change-errors.csv", | |
data: updatedStoreFranchiseErrorsCsv, | |
}, | |
] | |
); | |
} | |
if (insertedFranchiseErrors.length) { | |
const insertedFranchiseErrorsCsv = await eta.renderAsync("./csv", { | |
data: insertedFranchiseErrors.map((franchiseError) => ({ | |
"New Franchise Code": franchiseError.franchiseCode, | |
"New Franchise Name": franchiseError.franchiseName, | |
Error: `"${franchiseError.error.message?.replace(`"`, `'`)}"`, | |
})), | |
}); | |
await sendEmail( | |
[{ email: "[email protected]" }], | |
"STP Status Report Error: There was an error inserting new franchises", | |
`<body><h1>We could not update some stores with new franchise codes. Please see attachment</h1></body>`, | |
[{ name: "new-franchise-errors.csv", data: insertedFranchiseErrorsCsv }] | |
); | |
} | |
// if any of our report email promises are rejected, send an email to engineering@fostercommerce so we can be made aware of it | |
if (newStoresReportEmailResult.status === "rejected") { | |
console.error( | |
"Could not send the new stores report email", | |
newStoresReportEmailResult.reason | |
); | |
await sendEmail( | |
"[email protected]", | |
`STP Reports: Unable to send 'New Stores' report`, | |
String.raw` | |
<html> | |
<head> | |
<title>Error Sending the 'New Stores' report</title> | |
</head> | |
<body> | |
<h1>We were unable to send the 'New Stores' report for ${reportDate}</h1> | |
<p>Error:</p> | |
<code> | |
<pre> | |
${newStoresReportEmailResult.reason} | |
</pre> | |
</code> | |
</body> | |
</html>`, | |
[] | |
); | |
} | |
if (closedStoresReportEmailResult.status === "rejected") { | |
console.error( | |
"Could not send the closed stores report email", | |
closedStoresReportEmailResult.reason | |
); | |
await sendEmail( | |
"[email protected]", | |
`STP Reports: Unable to send 'Closed Stores' report`, | |
String.raw` | |
<html> | |
<head> | |
<title>Error Sending the 'Closed Stores' report</title> | |
</head> | |
<body> | |
<h1>We were unable to send the 'Closed Stores' report for ${reportDate}</h1> | |
<p>Error:</p> | |
<code> | |
<pre> | |
${closedStoresReportEmailResult.reason} | |
</pre> | |
</code> | |
</body> | |
</html> | |
` | |
); | |
} | |
if (newFranchiseEmailStatus.status === "rejected") { | |
console.error( | |
"Could not send the new franchises report email", | |
newFranchiseEmailStatus.reason | |
); | |
await sendEmail( | |
"[email protected]", | |
`STP Reports: Unable to send 'New Franchise' report`, | |
String.raw` | |
<html> | |
<head> | |
<title>Error Sending the 'New Franchise' report</title> | |
</head> | |
<body> | |
<h1>We were unable to send the 'New Franchise' report for ${reportDate}</h1> | |
<p>Error:</p> | |
<code> | |
<pre> | |
${newFranchiseEmailStatus.reason} | |
</pre> | |
</code | |
</body> | |
</html> | |
`, | |
[] | |
); | |
} | |
if (storeFranchiseChangesEmail.status === "rejected") { | |
console.error( | |
"Could not send the store franchise change report email", | |
storeFranchiseChangesEmail.reason | |
); | |
await sendEmail( | |
"[email protected]", | |
`STP Reports: Unable to send 'Store Franchise Changes' report`, | |
String.raw` | |
<html> | |
<head> | |
<title>Error sending the 'Store Franchise Changes' report</title> | |
</head> | |
<body> | |
<h1>We were unable to send the 'Store Franchise Changes' report</h1> | |
<p>Error:</p> | |
<code> | |
<pre>${storeFranchiseChangesEmail.reason}</pre> | |
</code> | |
</body> | |
</html> | |
`, | |
[] | |
); | |
} | |
} | |
/** | |
* Send the "new" stores discovered via the Domino's Location API | |
* @param {{eta: Eta, newStores: DominosApiStore[], emails: string[], runDate: string}} newStoresEmailArgs | |
*/ | |
async function sendNewStoresEmail({ eta, newStores, emails, runDate }) { | |
if (!newStores.length) { | |
return; | |
} | |
const [newStoresReportEmail, newStoresReportCsv] = await Promise.all([ | |
await eta.renderAsync("./location-api-new-stores", { | |
newStores, | |
runDate, | |
formatPhoneNumber, | |
generateFranchiseCrmUrl, | |
generateStoreCrmUrl, | |
generateCountyCrmUrl, | |
}), | |
newStores.length | |
? eta.renderAsync("./csv", { | |
data: newStores.map((s) => ({ | |
"Store #": s.storeId, | |
"Franchise Code": s.franchiseId, | |
"Phone Number": formatPhoneNumber(s.phoneNumber), | |
City: s.city, | |
State: s.region, | |
Address1: s.line1?.replace(",", ""), | |
Address2: s.line2?.replace(",", ""), | |
Zip: s.postalCode, | |
"Store URL": s.storeUrl, | |
})), | |
}) | |
: Promise.resolve(), | |
]); | |
const emailSubject = newStores.length | |
? `New Stores Report ${runDate}: ${newStores.length} New Domino's ${ | |
newStores.length === 1 ? "Stores" : "Store" | |
} Found` | |
: `New Stores Report ${runDate}: No New Stores Found`; | |
await sendEmail( | |
emails, | |
emailSubject, | |
newStoresReportEmail, | |
newStoresReportCsv | |
? [ | |
{ | |
name: "new-stores.csv", | |
data: newStoresReportCsv, | |
}, | |
] | |
: [] | |
); | |
} | |
/** | |
* | |
* @param {{eta: Eta, newStores: DominosApiStore[], emails: string[], runDate: string}} closedStoresEmailParam | |
*/ | |
async function sendClosedStoresEmail({ eta, closedStores, emails, runDate }) { | |
if (!closedStores.length) { | |
return; | |
} | |
const [closedStoresEmailTemplate, closedStoresCsv] = await Promise.all([ | |
eta.renderAsync("./location-api-closed-stores", { | |
closedStores, | |
runDate, | |
formatPhoneNumber, | |
generateFranchiseCrmUrl, | |
generateStoreCrmUrl, | |
}), | |
closedStores.length | |
? eta.renderAsync("./csv", { | |
data: closedStores.map((store) => ({ | |
"Store #": store.store, | |
City: store.city, | |
State: store.state, | |
Zip: store.zip, | |
Phone: formatPhoneNumber(store.phoneNumber), | |
"Franchise Owner": `"${store.franchiseFullName}"`, | |
})), | |
}) | |
: Promise.resolve(), | |
]); | |
const emailSubject = closedStores.length | |
? `Closed Stores Report ${runDate}: ${ | |
closedStores.length | |
} Potentially Closed ${ | |
closedStores.length === 1 ? "Store" : "Stores" | |
} Found` | |
: `Closed Stores Report ${runDate}: No Stores Closed`; | |
await sendEmail( | |
emails, | |
emailSubject, | |
closedStoresEmailTemplate, | |
closedStoresCsv | |
? [{ name: "closed-stores.csv", data: closedStoresCsv }] | |
: [] | |
); | |
} | |
async function sendNewFranchiseEmail({ eta, newFranchises, emails, runDate }) { | |
if (!newFranchises?.length) { | |
return; | |
} | |
const [newFranchisesEmailBody, newFranchisesCsv] = await Promise.all([ | |
eta.renderAsync("./location-api-new-franchises", { | |
newFranchises, | |
runDate, | |
generateFranchiseCrmUrl, | |
generateCountyCrmUrl, | |
generateStoreCrmUrl, | |
}), | |
eta.renderAsync("./csv", { | |
data: newFranchises.map((franchiseData) => ({ | |
"New Franchise Name": franchiseData.franchiseName, | |
"New Franchise Code": franchiseData.franchiseCode, | |
})), | |
}), | |
]); | |
const emailSubject = `New Franchise Report ${runDate}: ${ | |
newFranchises.length | |
} New ${newFranchises.length === 1 ? "Franchise" : "Franchises"} Created`; | |
await sendEmail(emails, emailSubject, newFranchisesEmailBody, [ | |
{ name: "new-franchises.csv", data: newFranchisesCsv }, | |
]); | |
} | |
/** | |
* | |
* @param {{eta: any, emails: string[], runDate: string, storesWithFranchiseChanges: FranchiseChangeData[]}} | |
*/ | |
async function sendStoreFranchiseChangesEmail({ | |
eta, | |
storesWithFranchiseChanges, | |
emails, | |
runDate, | |
}) { | |
if (!storesWithFranchiseChanges.length) { | |
return; | |
} | |
const [franchiseChangeEmailBody, franchiseChangeCsv] = await Promise.all([ | |
eta.renderAsync("./location-api-store-franchise-changes", { | |
storesWithFranchiseChanges, | |
runDate, | |
generateStoreCrmUrl, | |
generateFranchiseCrmUrl, | |
generateCountyCrmUrl, | |
}), | |
storesWithFranchiseChanges.length | |
? eta.renderAsync("./csv", { | |
data: storesWithFranchiseChanges.map((storeData) => ({ | |
"Store #": storeData.store.store, | |
"Old Franchise Name": storeData.store.franchiseFullName, | |
"Old Franchise Code": storeData.oldFranchiseId, | |
"New Franchise Name": | |
storeData.newFranchiseName ?? | |
`Franchisee ${storeData.newFranchiseId}`, | |
"New Franchise Code": storeData.newFranchiseId, | |
})), | |
}) | |
: Promise.resolve(), | |
]); | |
const hasAgreementStatusChange = storesWithFranchiseChanges.some( | |
(s) => s.newFranchiseAgreed !== s.oldFranchiseAgreed | |
); | |
const emailSubject = `${ | |
hasAgreementStatusChange ? "Urgent! " : "" | |
}Store Franchise Change Report ${runDate}: ${ | |
storesWithFranchiseChanges.length | |
? `${storesWithFranchiseChanges.length} ${ | |
storesWithFranchiseChanges.length === 1 ? "Store" : "Stores" | |
} with Franchise Changes` | |
: `No Stores with Franchise Changes` | |
}`; | |
await sendEmail( | |
emails, | |
emailSubject, | |
franchiseChangeEmailBody, | |
franchiseChangeCsv | |
? [ | |
{ | |
name: "stores-with-franchise-changes.csv", | |
data: franchiseChangeCsv, | |
}, | |
] | |
: [] | |
); | |
} | |
/** | |
* Save new stores to the db, and return any failures | |
* @param {Array<DominosApiStore & {assignedToCorporateFranchise?: true}>} storesList | |
* @returns {Promise<Array<{store: DominosApiStore, error: Error}>>} - list of stores not inserted with the associated errors | |
*/ | |
async function saveNewStores(storesList) { | |
const insertStatement = await prepareInsertNewStoresStatement(); | |
const insertStoreNoteStatement = await prepareInsertStoreNoteStatement(); | |
const storesNotInserted = []; | |
const date = formatDate(new Date(), true); | |
for (const store of storesList) { | |
try { | |
const storeState = states[store.region] ?? null; | |
const storeCounty = store.county; | |
const storePhoneNumber = trimPhoneNumber(store.phoneNumber); | |
await insertStatement.execute({ | |
storeId: store.storeId, | |
address1: store.line1, | |
address2: store.line2, | |
city: store.city, | |
state: storeState, | |
zip: store.postalCode, | |
phone: storePhoneNumber, | |
latitude: store.coordinates?.latitude, | |
longitude: store.coordinates?.longitude, | |
timezone: store.timezone, | |
storeUrl: store.storeUrl, | |
franchiseCode: store.franchiseId, | |
county: storeCounty, | |
}); | |
} catch (err) { | |
console.error(err); | |
console.error( | |
`[lookForStoreChanges].[saveNewStores] - Skipping inserting new store with id ${store.storeId}` | |
); | |
storesNotInserted.push({ store, error: err }); | |
} | |
try { | |
let note = `Store ${store.storeId} added with no Franchise Code`; | |
if (store.assignedToCorporateFranchise && store.franchiseId) { | |
note = `Store ${store.storeId} added. Since Domino's API did not have a franchise, it was assigned to ${store.franchiseId}.`; | |
} else if (store.franchiseId) { | |
note = `Store ${store.storeId} added with Franchise Code ${store.franchiseId}.`; | |
} | |
await insertStoreNoteStatement.execute({ | |
dateEntered: date, | |
note, | |
storeId: store.storeId, | |
franchiseCode: store.franchiseId, | |
}); | |
} catch (e) { | |
console.error(e); | |
} | |
} | |
// unprepare the statement to make sure the DB connection gets dropped | |
await insertStatement.unprepare(); | |
await insertStoreNoteStatement.unprepare(); | |
return storesNotInserted; | |
} | |
async function processBatchesEnded() { | |
const countQueryResult = await sql.query` | |
select count(*) as InvalidatedCount | |
from CouponLookupsT | |
where | |
Invalidated = 1 | |
and CouponCode in (select CouponCode from ActiveCouponsT where Active = 1) | |
`; | |
let count = countQueryResult.recordset[0].InvalidatedCount; | |
if (count === 0) { | |
console.log("All batches done"); | |
const fulfillmentEmailAddresses = | |
process.env.EMAIL_TO_ADDRESSES_FULFILLMENT.split(",").map((email) => ({ | |
email, | |
})); | |
const reportDate = formatDate(new Date()); | |
const couponCodes = | |
( | |
await sql.query`select CouponCode from ActiveCouponsT where Active = 1` | |
)?.recordset?.map((c) => c.CouponCode) ?? []; | |
const eta = new Eta({ | |
views: path.join( | |
path.dirname(fileURLToPath(import.meta.url)), | |
"templates" | |
), | |
}); | |
await fulfillmentChangesReport({ | |
emails: fulfillmentEmailAddresses, | |
reportDate, | |
eta, | |
couponCodes, | |
}); | |
await nonfulfillmentReport({ | |
emails: fulfillmentEmailAddresses, | |
reportDate, | |
eta, | |
couponCodes, | |
}); | |
await agreedFranchiseeReport({ | |
eta, | |
emails: fulfillmentEmailAddresses, | |
reportDate, | |
}); | |
await notAgreedFranchiseeReport({ | |
eta, | |
emails: fulfillmentEmailAddresses, | |
reportDate, | |
}); | |
await storesToActivateReport({ | |
eta, | |
emails: fulfillmentEmailAddresses, | |
reportDate, | |
}); | |
await triggerDBSync(); | |
} | |
} | |
async function fulfillmentChangesReport({ | |
eta, | |
reportDate, | |
emails, | |
couponCodes, | |
}) { | |
const couponCodeDiffs = {}; | |
for (const code of couponCodes) { | |
couponCodeDiffs[code] = []; | |
} | |
const diffQueryResult = await sql.query` | |
select | |
StoresT.Store, | |
FullName, | |
FranchiseT.FranchiseID, | |
CouponCode, | |
Fulfilled, | |
PreviousFulfilled, | |
CouponLookupsT.DateUpdated | |
from CouponLookupsT | |
join StoresT on CouponLookupsT.Store = StoresT.Store | |
left join FranchiseT on StoresT.FranchiseFK = FranchiseT.FranchiseID | |
where | |
Fulfilled != PreviousFulfilled | |
and StoresT.Status = 'Open' | |
and CouponCode in (select CouponCode from ActiveCouponsT where Active = 1) | |
`; | |
const diffs = diffQueryResult.recordset ?? []; | |
for (const record of diffs) { | |
// if the coupon code diff value is nullish, initialize it as an array. | |
couponCodeDiffs[record.CouponCode] ??= []; | |
couponCodeDiffs[record.CouponCode].push(record); | |
const fulfilled = record.Fulfilled ? "Yes" : "No"; | |
const previousFulfilled = record.PreviousFulfilled ? "Yes" : "No"; | |
} | |
const emailPromises = Object.entries(couponCodeDiffs).map( | |
async ([couponCode, fulfillmentChanges]) => { | |
if (!fulfillmentChanges.length) { | |
return; | |
} | |
const template = await eta.renderAsync( | |
"./coupon-fulfillment-changes-report.eta", | |
{ | |
couponCode, | |
fulfillmentChanges, | |
runDate: reportDate, | |
generateStoreCrmUrl, | |
generateFranchiseCrmUrl, | |
formatDate, | |
} | |
); | |
const csv = fulfillmentChanges.length | |
? await eta.renderAsync("./csv", { | |
data: fulfillmentChanges.map((c) => ({ | |
Store: c.Store, | |
"Full Name": `"${c.FullName}"`, | |
CouponCode: c.CouponCode, | |
Fulfilled: c.Fulfilled ? "Yes" : "No", | |
"Previously Fulfilled": c.PreviousFulfilled ? "Yes" : "No", | |
"Date Updated": c.DateUpdated, | |
})), | |
}) | |
: undefined; | |
const emailSubject = `Coupon ${couponCode} Fulfillment Report ${reportDate}: ${ | |
fulfillmentChanges.length > 1 | |
? `${fulfillmentChanges.length} changes` | |
: fulfillmentChanges.length | |
? "1 change" | |
: "No changes" | |
}`; | |
return sendEmail( | |
emails, | |
emailSubject, | |
template, | |
csv ? [{ name: `coupon-${couponCode}-changes.csv`, data: csv }] : [] | |
); | |
} | |
); | |
await Promise.all(emailPromises); | |
} | |
async function nonfulfillmentReport({ eta, emails, reportDate, couponCodes }) { | |
const nonfulfillmentQueryResult = await sql.query` | |
SELECT | |
CouponLookupsT.CouponCode, FranchiseT.FullName, FranchiseT.FranchiseID, StoresT.Store, StoresT.Address1, StoresT.City, StoresT.State, StoresT.Zip, StoresT.County | |
FROM CouponLookupsT | |
LEFT JOIN StoresT ON CouponLookupsT.Store = StoresT.Store | |
LEFT JOIN FranchiseT ON StoresT.FranchiseFK = FranchiseT.FranchiseID | |
INNER JOIN ActiveCouponsT ON ActiveCouponsT.CouponCode = CouponLookupsT.CouponCode | |
WHERE StoresT.Active=1 | |
AND CouponLookupsT.DateUpdated Is Not Null | |
AND CouponLookupsT.Fulfilled = 0 | |
AND ActiveCouponsT.Active = 1 | |
AND StoresT.Status = 'Open' | |
ORDER BY CouponLookupsT.CouponCode, FranchiseT.FullName | |
`; | |
const nonFulfillmentsByCoupon = {}; | |
for (const code of couponCodes) { | |
nonFulfillmentsByCoupon[code] = []; | |
} | |
const nonfulfillments = nonfulfillmentQueryResult.recordset ?? []; | |
for (const record of nonfulfillments) { | |
nonFulfillmentsByCoupon[record.CouponCode] ??= []; | |
nonFulfillmentsByCoupon[record.CouponCode].push(record); | |
} | |
const emailPromises = Object.entries(nonFulfillmentsByCoupon).map( | |
async ([couponCode, couponNonfulfillments]) => { | |
if (!couponNonfulfillments.length) { | |
return; | |
} | |
const [emailBody, csv] = await Promise.all([ | |
eta.renderAsync("./coupon-nonfulfillment-report", { | |
couponCode, | |
nonfulfillments: couponNonfulfillments, | |
runDate: reportDate, | |
generateStoreCrmUrl, | |
generateFranchiseCrmUrl, | |
generateCountyCrmUrl, | |
formatDate, | |
}), | |
couponNonfulfillments.length | |
? eta.renderAsync("./csv", { | |
data: couponNonfulfillments.map((f) => ({ | |
"Coupon Code": f.CouponCode, | |
"Full Name": f.FullName, | |
Store: f.Store, | |
"Address 1": f.Address1, | |
City: f.City, | |
County: f.County, | |
State: f.State, | |
Zip: f.Zip, | |
})), | |
}) | |
: Promise.resolve(), | |
]); | |
const emailSubject = `Coupon ${couponCode} Nonfulfillment Report ${reportDate}: ${ | |
couponNonfulfillments.length === 1 | |
? `1 store did not accept the coupon` | |
: `${couponNonfulfillments.length} stores did not accept the coupon` | |
}`; | |
return sendEmail( | |
emails, | |
emailSubject, | |
emailBody, | |
csv | |
? [{ name: `coupon-${couponCode}-nonfulfillments.csv`, data: csv }] | |
: [] | |
); | |
} | |
); | |
await Promise.all(emailPromises); | |
} | |
function groupFranchisees(recordset) { | |
return (recordset ?? []).reduce((acc, record) => { | |
let franchises = acc[record.CouponCode] || {}; | |
let franchise = franchises[record.FranchiseID] || []; | |
franchise.push(record); | |
return { | |
...acc, | |
[record.CouponCode]: { | |
...franchises, | |
[record.FranchiseID]: franchise, | |
}, | |
}; | |
}, {}); | |
} | |
async function agreedFranchiseeReport({ eta, reportDate, emails }) { | |
const franchiseeAgreedStoresResult = await sql.query` | |
select CouponLookupsT.CouponCode, | |
FranchiseT.FranchiseID, | |
FranchiseT.FullName, | |
FranchiseT.Agreed, | |
CouponLookupsT.Fulfilled, | |
StoresT.Store, | |
StoresT.Address1, StoresT.City, StoresT.State, StoresT.Zip, StoresT.County | |
from FranchiseT | |
join StoresT on StoresT.FranchiseFK = FranchiseT.FranchiseID | |
left join CouponLookupsT on StoresT.Store = CouponLookupsT.Store | |
join ActiveCouponsT on CouponLookupsT.CouponCode = ActiveCouponsT.CouponCode | |
where | |
FranchiseT.Agreed = 1 | |
and StoresT.Active = 1 | |
and ActiveCouponsT.Active = 1 | |
and StoresT.Status = 'Open' | |
order by ActiveCouponsT.CouponCode, | |
FranchiseT.FranchiseID | |
`; | |
const couponCodes = groupFranchisees(franchiseeAgreedStoresResult.recordset); | |
const emailPromises = Object.entries(couponCodes).map( | |
async ([couponCode, agreedFranchises]) => { | |
const franchisesWithUnfulfilledCoupons = []; | |
for (const [franchise, stores] of Object.entries(agreedFranchises)) { | |
let notFulfilledCount = 0; | |
const totalStoreCount = stores.length; | |
const fullName = stores.at(0)?.FullName; | |
const notFulfilledStores = []; | |
for (const store of stores) { | |
if (!store.Fulfilled) { | |
notFulfilledCount++; | |
notFulfilledStores.push(store); | |
} | |
} | |
if (notFulfilledCount) { | |
franchisesWithUnfulfilledCoupons.push({ | |
notFulfilledCount, | |
fullName, | |
franchiseId: franchise, | |
totalStoreCount, | |
notFulfilledStores, | |
stores, | |
}); | |
} | |
} | |
if (!franchisesWithUnfulfilledCoupons.length) { | |
return; | |
} | |
const [emailMessage, csv] = await Promise.all([ | |
eta.renderAsync("./agreed-franchises-fulfillment-report", { | |
couponCode, | |
runDate: reportDate, | |
agreedFranchises: franchisesWithUnfulfilledCoupons, | |
generateStoreCrmUrl, | |
generateFranchiseCrmUrl, | |
generateCountyCrmUrl, | |
}), | |
eta.renderAsync("./csv", { | |
data: Object.values(agreedFranchises).map((store) => ({ | |
"Coupon Code": store.CouponCode, | |
"Franchise ID": store.FranchiseID, | |
"Full Name": store.FullName, | |
Fulfilled: store.fulfilled ? "Yes" : "No", | |
Store: store.Store, | |
Address1: store.Address1, | |
City: store.City, | |
County: store.County, | |
State: store.State, | |
Zip: store.Zip, | |
})), | |
}), | |
]); | |
const emailSubject = `Coupon ${couponCode} Agreed Franchisees Report: ${reportDate}`; | |
return sendEmail(emails, emailSubject, emailMessage, [ | |
{ name: `coupon-${couponCode}-agreed-franchises.csv`, data: csv }, | |
]); | |
} | |
); | |
await Promise.all(emailPromises); | |
} | |
/** | |
* Sends a report of inactive stores of agreed franchises that had fulfillments | |
* These are stores that might be good candidates to mark as active | |
* @param {{eta: Eta, reportDate: string, emails: Array<{email: string}>}} args | |
* @returns {Promise<void>} | |
*/ | |
async function storesToActivateReport({ eta, reportDate, emails }) { | |
const storesToActivateResult = await sql.query` | |
select CouponLookupsT.CouponCode, | |
FranchiseT.FranchiseID, | |
FranchiseT.FullName, | |
FranchiseT.Agreed, | |
CouponLookupsT.Fulfilled, | |
StoresT.Store, | |
StoresT.Address1, | |
StoresT.City, | |
StoresT.State, | |
StoresT.Zip, | |
StoresT.County, | |
StoresT.Active | |
from FranchiseT | |
JOIN StoresT on StoresT.FranchiseFK = FranchiseT.FranchiseID | |
LEFT JOIN CouponLookupsT on StoresT.Store = CouponLookupsT.Store | |
JOIN ActiveCouponsT on CouponLookupsT.CouponCode = ActiveCouponsT.CouponCode | |
where | |
FranchiseT.Agreed = 1 | |
AND StoresT.Active = 0 | |
AND ActiveCouponsT.Active = 1 | |
AND StoresT.Status = 'Open' | |
AND EXISTS (SELECT 1 | |
FROM CouponLookupsT | |
INNER JOIN ActiveCouponsT ON ActiveCouponsT.CouponCode = CouponLookupsT.CouponCode | |
WHERE CouponLookupsT.Fulfilled = 1 AND CouponLookupsT.Store = StoresT.Store) | |
ORDER BY FranchiseT.FullName, | |
ActiveCouponsT.CouponCode | |
`; | |
if (storesToActivateResult.recordset.length === 0) { | |
return; | |
} | |
const storesToActivate = {}; | |
for (const store of storesToActivateResult.recordset) { | |
if (!storesToActivate[store.Store]) { | |
storesToActivate[store.Store] = { | |
...store, | |
activeCouponCodes: [store.CouponCode], | |
}; | |
delete storesToActivate[store.Store].CouponCode; | |
} else if (store.Fulfilled) { | |
storesToActivate[store.Store].activeCouponCodes.push(store.CouponCode); | |
} | |
} | |
const sortedStoresToActivate = Object.values(storesToActivate).sort( | |
(a, b) => { | |
const franchiseNameA = a.FullName; | |
const franchiseNameB = b.FullName; | |
if (franchiseNameA < franchiseNameB) { | |
return -1; | |
} | |
if (franchiseNameA > franchiseNameB) { | |
return 1; | |
} | |
return a.Store - b.Store; | |
} | |
); | |
const [activatedStoresTemplate, activatedStoresCsv] = await Promise.all([ | |
eta.renderAsync("./coupon-fulfillment-stores-to-activate-report", { | |
stores: sortedStoresToActivate, | |
runDate: reportDate, | |
generateStoreCrmUrl, | |
generateFranchiseCrmUrl, | |
generateCountyCrmUrl, | |
}), | |
eta.renderAsync("/csv", { | |
data: storesToActivateResult.recordset.map((s) => ({ | |
Store: s.Store, | |
"Franchise ID": s.FranchiseID, | |
"Franchisee Name": `"${s.FullName}"`, | |
"Franchisee Agreed": s.Agreed ? "Yes" : "No", | |
"Coupon Code": s.CouponCode, | |
Active: s.Active ? "Yes" : "No", | |
"Coupon Fulfilled": s.Fulfilled ? "Yes" : "No", | |
"Address 1": s.Address1, | |
City: s.City, | |
State: s.State, | |
Zip: s.Zip, | |
County: s.County, | |
})), | |
}), | |
]); | |
await sendEmail( | |
emails, | |
`Inactive stores with coupon fulfillments report - ${reportDate}`, | |
activatedStoresTemplate, | |
[{ name: "activated-stores-report.csv", data: activatedStoresCsv }] | |
); | |
} | |
/** | |
* Sends a report of inactive stores of agreed franchises that had fulfillments | |
* These are stores that might be good candidates to mark as active | |
* @param {{eta: Eta, reportDate: string, emails: Array<{email: string}>}} args | |
* @returns {Promise<void>} | |
*/ | |
async function closedStoresWithFulfillmentsReport({ eta, reportDate, emails }) { | |
const closedStoresWithFulfillmentsResult = await sql.query` | |
select CouponLookupsT.CouponCode, | |
FranchiseT.FranchiseID, | |
FranchiseT.FullName, | |
FranchiseT.Agreed, | |
CouponLookupsT.Fulfilled, | |
StoresT.Store, | |
StoresT.Address1, | |
StoresT.City, | |
StoresT.State, | |
StoresT.Zip, | |
StoresT.County, | |
StoresT.Active, | |
StoresT.Status | |
from FranchiseT | |
JOIN StoresT on StoresT.FranchiseFK = FranchiseT.FranchiseID | |
LEFT JOIN CouponLookupsT on StoresT.Store = CouponLookupsT.Store | |
JOIN ActiveCouponsT on CouponLookupsT.CouponCode = ActiveCouponsT.CouponCode | |
where | |
ActiveCouponsT.Active = 1 | |
AND StoresT.Status like 'Closed' | |
AND EXISTS (SELECT 1 | |
FROM CouponLookupsT | |
INNER JOIN ActiveCouponsT ON ActiveCouponsT.CouponCode = CouponLookupsT.CouponCode | |
WHERE CouponLookupsT.Fulfilled = 1 AND CouponLookupsT.Store = StoresT.Store) | |
ORDER BY FranchiseT.FullName, | |
ActiveCouponsT.CouponCode | |
`; | |
if (!closedStoresWithFulfillmentsResult.recordset?.length) { | |
return; | |
} | |
const closedStoresWithFulfillments = {}; | |
for (const store of closedStoresWithFulfillmentsResult.recordset) { | |
if (!closedStoresWithFulfillments[store.Store]) { | |
closedStoresWithFulfillments[store.Store] = { | |
...store, | |
activeCouponCodes: [store.CouponCode], | |
}; | |
delete closedStoresWithFulfillments[store.Store].CouponCode; | |
} else if (store.Fulfilled) { | |
closedStoresWithFulfillments[store.Store].activeCouponCodes.push( | |
store.CouponCode | |
); | |
} | |
} | |
const sortedClosedStoresWithFulfillments = Object.values( | |
closedStoresWithFulfillments | |
).sort((a, b) => { | |
const franchiseNameA = a.FullName; | |
const franchiseNameB = b.FullName; | |
if (franchiseNameA < franchiseNameB) { | |
return -1; | |
} | |
if (franchiseNameA > franchiseNameB) { | |
return 1; | |
} | |
return a.Store - b.Store; | |
}); | |
const [closedStoresTemplate, closedStoresCsv] = await Promise.all([ | |
eta.renderAsync("./coupon-fulfillment-closed-stores-report", { | |
stores: sortedClosedStoresWithFulfillments, | |
runDate: reportDate, | |
generateStoreCrmUrl, | |
generateFranchiseCrmUrl, | |
generateCountyCrmUrl, | |
}), | |
eta.renderAsync("/csv", { | |
data: closedStoresWithFulfillmentsResult.recordset.map((s) => ({ | |
Store: s.Store, | |
"Franchise ID": s.FranchiseID, | |
"Franchisee Name": `"${s.FullName}"`, | |
"Franchisee Agreed": s.Agreed ? "Yes" : "No", | |
"Coupon Code": s.CouponCode, | |
Active: s.Active ? "Yes" : "No", | |
Status: s.Status, | |
"Coupon Fulfilled": s.Fulfilled ? "Yes" : "No", | |
"Address 1": s.Address1, | |
City: s.City, | |
State: s.State, | |
Zip: s.Zip, | |
County: s.County, | |
})), | |
}), | |
]); | |
await sendEmail( | |
emails, | |
`Closed stores with coupon fulfillments report - ${reportDate}`, | |
closedStoresTemplate, | |
[{ name: "fulfilled-closed-stores-report.csv", data: closedStoresCsv }] | |
); | |
} | |
async function prepareNearbyStoresZipStatement() { | |
const statement = new sql.PreparedStatement(); | |
statement.input("Store", sql.Int); | |
statement.input("Zip", sql.NVarChar(255)); | |
return statement.prepare(` | |
select DISTINCT top(5) FranchiseT.FranchiseID, | |
FranchiseT.FullName, | |
FranchiseT.Agreed, | |
StoresT.Store, | |
StoresT.Address1, StoresT.City, StoresT.State, StoresT.Zip, StoresT.County | |
from FranchiseT | |
join StoresT on StoresT.FranchiseFK = FranchiseT.FranchiseID | |
left join CouponLookupsT on StoresT.Store = CouponLookupsT.Store | |
join ActiveCouponsT on CouponLookupsT.CouponCode = ActiveCouponsT.CouponCode | |
where | |
FranchiseT.Agreed = 1 | |
and StoresT.Active = 1 | |
and StoresT.Zip = @Zip | |
and StoresT.Store != @Store | |
and StoresT.Status = 'Open' | |
`); | |
} | |
async function prepareNearbyStoresCountyStatement() { | |
const statement = new sql.PreparedStatement(); | |
statement.input("Store", sql.Int); | |
statement.input("County", sql.NVarChar(255)); | |
statement.input("State", sql.NVarChar(255)); | |
await statement.prepare(` | |
select DISTINCT top(5) FranchiseT.FranchiseID, | |
FranchiseT.FullName, | |
FranchiseT.Agreed, | |
StoresT.Store, | |
StoresT.Address1, StoresT.City, StoresT.State, StoresT.Zip, StoresT.County | |
from FranchiseT | |
join StoresT on StoresT.FranchiseFK = FranchiseT.FranchiseID | |
left join CouponLookupsT on StoresT.Store = CouponLookupsT.Store | |
join ActiveCouponsT on CouponLookupsT.CouponCode = ActiveCouponsT.CouponCode | |
where | |
FranchiseT.Agreed = 1 | |
and StoresT.Active = 1 | |
and StoresT.County = @County | |
and StoresT.State = @State | |
and StoresT.Store != @Store | |
and StoresT.Status = 'Open' | |
`); | |
return statement; | |
} | |
/** | |
* Prepared statement for fetching all of the store data we need to look for new/closed/sold stores. | |
* This is compared to the Domino's API data. | |
* @returns {Promise<sql.PreparedStatement>} A Promise with a prepared statement | |
*/ | |
function prepareStoreValidationStatement() { | |
const statement = new sql.PreparedStatement(); | |
return statement.prepare( | |
`SELECT StoresT."Store" as store, | |
StoresT."Status" as "status", | |
StoresT.Address1 as "address1", | |
StoresT.Address2 as "address2", | |
StoresT.Zip as "zip", | |
StoresT.City as "city", | |
StoresT."State" as "state", | |
StoresT.County as "county", | |
StoresT.Phone_Number as phoneNumber, | |
StoresT.FranchiseFK as "franchiseId", | |
FranchiseT.FullName as "franchiseFullName", | |
FranchiseT.Agreed as "franchiseAgreed" | |
FROM StoresT | |
LEFT JOIN FranchiseT ON FranchiseT.FranchiseID = StoresT.FranchiseFK | |
WHERE StoresT.State not like 'Puerto%Rico' | |
` | |
); | |
} | |
/** | |
* | |
* @returns {Promise<sql.PreparedStatement>} | |
*/ | |
async function prepareInsertNewStoresStatement() { | |
const statement = new sql.PreparedStatement(); | |
statement.input("storeId", sql.Int); | |
statement.input("address1", sql.NVarChar(255)); | |
statement.input("address2", sql.NVarChar(255)); | |
statement.input("city", sql.NVarChar(255)); | |
statement.input("state", sql.NVarChar(255)); | |
statement.input("zip", sql.NVarChar(255)); | |
statement.input("phone", sql.Char(10)); | |
statement.input("latitude", sql.Float); | |
statement.input("longitude", sql.Float); | |
statement.input("timezone", sql.NVarChar(255)); | |
statement.input("storeUrl", sql.NVarChar(255)); | |
statement.input("franchiseCode", sql.NVarChar(10)); | |
statement.input("county", sql.NVarChar(255)); | |
return statement.prepare( | |
` | |
INSERT INTO [StoresT] ( | |
[Store], | |
[Address1], | |
[Address2], | |
[City], | |
[State], | |
[Zip], | |
[County], | |
[Phone_Number], | |
[Active], | |
[StoreLatitude], | |
[StoreLongitude], | |
[StoreTimezone], | |
[StoreURL], | |
[FranchiseFK], | |
[DateUpdated], | |
[Country], | |
[Status] | |
) | |
VALUES ( | |
@storeId, | |
@address1, | |
@address2, | |
@city, | |
@state, | |
@zip, | |
@county, | |
@phone, | |
0, | |
@latitude, | |
@longitude, | |
@timezone, | |
@storeUrl, | |
@franchiseCode, | |
GETDATE(), | |
'USA', | |
'Open' | |
) | |
` | |
); | |
} | |
/** | |
* Gets a list of all of the franchises and converts them to a map. | |
* @returns {Promise<Map<string, {franchiseCode: string, fullName: string, state: string, agreed: boolean}>>} | |
*/ | |
async function getAllFranchises() { | |
const allFranchisees = await sql.query` | |
SELECT | |
[FranchiseT].FranchiseID as franchiseCode, | |
[FranchiseT].FullName as fullName, | |
[FranchiseT].CompanyState as "state", | |
[FranchiseT].Agreed as 'agreed' | |
FROM [FranchiseT] | |
`; | |
return new Map(allFranchisees.recordset.map((f) => [f.franchiseCode, f])); | |
} | |
/** | |
* | |
* Updates the franchise info for stores, | |
* inserting any new franchises as necessary and inserting relevant notes into the notes tables. | |
* | |
* @param {FranchiseChangeData[]} storesWithChangedFranchises | |
* @param {Set<string>} insertedFranchiseIds | |
* @returns {Promise<FranchiseUpdateResults>} franchiseUpdateResult - contains a list of stores updated, | |
* any new franchises, and any errors with this process. | |
*/ | |
async function updateFranchiseDataForStores( | |
storesWithChangedFranchises, | |
insertedFranchiseIds | |
) { | |
// wait for each prepared statement sequentially because they all share a connection to the DB | |
const updateStoreFranchiseStatement = | |
await prepareUpdateStoreFranchiseStatement(); | |
const insertNewFranchiseStatement = | |
await prepareInsertNewFranchiseStatement(); | |
const insertFranchiseNoteStatement = | |
await prepareInsertFranchiseNoteStatement(); | |
const insertStoreNoteStatement = await prepareInsertStoreNoteStatement(); | |
let updatedStoreErrors = []; | |
let insertedFranchises = []; | |
let insertedFranchiseErrors = []; | |
// get the current date as a formatted string with the time | |
const updateCheckedDateWithTime = formatDate(new Date(), true); | |
// get the current date as a formatted string without the time. | |
const updateCheckedDateWithoutTime = formatDate(new Date(), false); | |
for (const storeInfo of storesWithChangedFranchises) { | |
try { | |
await updateStoreFranchiseStatement.execute({ | |
storeId: storeInfo.store.store, | |
franchiseCode: storeInfo.newFranchiseId, | |
dateUpdated: updateCheckedDateWithoutTime, | |
}); | |
} catch (e) { | |
updatedStoreErrors.push({ franchiseChangeInfo: storeInfo, error: e }); | |
continue; | |
} | |
// if the new franchise name doesn't exist, use a filler name of `Franchisee ${franchiseCode}` | |
let newFranchiseName = | |
storeInfo.newFranchiseName ?? `Franchisee ${storeInfo.newFranchiseId}`; | |
//insert new franchises for any franchises without a name. | |
if (!storeInfo.newFranchiseName) { | |
if (insertedFranchiseIds.has(storeInfo.newFranchiseId)) { | |
const insertedFranchise = insertedFranchises.find( | |
(f) => f.franchiseCode === storeInfo.newFranchiseId | |
); | |
if (insertedFranchise) { | |
insertedFranchise.stores.push(storeInfo.store); | |
} else { | |
insertedFranchises.push({ | |
franchiseCode: storeInfo.newFranchiseId, | |
franchiseName: newFranchiseName, | |
stores: [storeInfo.store], | |
}); | |
} | |
} else { | |
try { | |
await insertNewFranchiseStatement.execute({ | |
franchiseCode: storeInfo.newFranchiseId, | |
fullName: newFranchiseName, | |
dateUpdated: updateCheckedDateWithTime, | |
}); | |
insertedFranchises.push({ | |
franchiseCode: storeInfo.newFranchiseId, | |
franchiseName: newFranchiseName, | |
stores: [storeInfo.store], | |
}); | |
await insertFranchiseNoteStatement.execute({ | |
dateEntered: updateCheckedDateWithTime, | |
note: `Franchise ${newFranchiseName} with Franchise Code ${storeInfo.newFranchiseId} added for store ${storeInfo.store.store}`, | |
franchiseCode: storeInfo.newFranchiseId, | |
}); | |
insertedFranchiseIds.add(storeInfo.newFranchiseId); | |
} catch (e) { | |
insertedFranchiseErrors.push({ | |
franchiseCode: storeInfo.newFranchiseId, | |
franchiseName: newFranchiseName, | |
error: e, | |
}); | |
} | |
} | |
} | |
try { | |
// insert a note for the store that the franchise ownership changed. | |
let note = `Updated franchisee from ${storeInfo.store.franchiseFullName} to ${newFranchiseName}`; | |
if (storeInfo.newFranchiseAssignedToCorporateId) { | |
note = `Store ${storeInfo.store} no longer has a franchise in the Domino's Location API. Updated it from ${storeInfo.store.franchiseFullName} to ${newFranchiseName}`; | |
} | |
await insertStoreNoteStatement.execute({ | |
dateEntered: updateCheckedDateWithTime, | |
note, | |
storeId: storeInfo.store.store, | |
franchiseCode: storeInfo.newFranchiseId, | |
}); | |
} catch (e) { | |
console.error(e); | |
} | |
try { | |
// insert notes for each franchise about the transfer of ownership. | |
let note = `${storeInfo.store.franchiseFullName} sold store ${storeInfo.store.store} to ${newFranchiseName}`; | |
if (storeInfo.newFranchiseAssignedToCorporateId) { | |
note = `${storeInfo.store.franchiseFullName} sold store ${storeInfo.store.store}. The new franchise is unknown, so it was assigned to ${newFranchiseName} based on its location.`; | |
} | |
await insertFranchiseNoteStatement | |
.execute({ | |
dateEntered: updateCheckedDateWithTime, | |
note: `${storeInfo.store.franchiseFullName} sold store ${storeInfo.store.store} to ${newFranchiseName}`, | |
franchiseCode: storeInfo.oldFranchiseId, | |
}) | |
.then(() => { | |
let note = `${newFranchiseName} bought store ${storeInfo.store.store} from ${storeInfo.store.franchiseFullName}`; | |
if (storeInfo.newFranchiseAssignedToCorporateId) { | |
note = `Store ${storeInfo.store.store} was sold, but the Domino's Location API had no new franchise to assign it to. Due to the store's location, it has been assigned to ${newFranchiseName}`; | |
} | |
return insertFranchiseNoteStatement.execute({ | |
dateEntered: updateCheckedDateWithTime, | |
note, | |
franchiseCode: storeInfo.newFranchiseId, | |
}); | |
}); | |
} catch (e) { | |
console.error(e); | |
} | |
} | |
// await for each unprepare statement sequentially because they need exclusive DB access to function. | |
await updateStoreFranchiseStatement.unprepare(); | |
await insertNewFranchiseStatement.unprepare(); | |
await insertFranchiseNoteStatement.unprepare(); | |
await insertStoreNoteStatement.unprepare(); | |
return { updatedStoreErrors, insertedFranchises, insertedFranchiseErrors }; | |
} | |
/** | |
* | |
* @returns {Promise<sql.PreparedStatement>} | |
*/ | |
async function prepareUpdateStoreFranchiseStatement() { | |
const statement = new sql.PreparedStatement(); | |
statement.input("storeId", sql.Int); | |
statement.input("franchiseCode", sql.NVarChar(10)); | |
statement.input("dateUpdated", sql.DateTime); | |
await statement.prepare(` | |
UPDATE StoresT | |
SET | |
FranchiseFK = @franchiseCode, | |
DateUpdated = @dateUpdated | |
WHERE Store = @storeId | |
`); | |
return statement; | |
} | |
/** | |
* Prepares a statement for entering new franchisees. | |
* @returns {Promise<sql.PreparedStatement>} preparedStatement | |
*/ | |
async function prepareInsertNewFranchiseStatement() { | |
const statement = new sql.PreparedStatement(); | |
statement.input("franchiseCode", sql.NVarChar(10)); | |
statement.input("fullName", sql.NVarChar(255)); | |
await statement.prepare(` | |
INSERT INTO [FranchiseT] ( | |
[FranchiseID], | |
[FullName] | |
) | |
VALUES ( | |
@franchiseCode, | |
@fullName | |
) | |
`); | |
return statement; | |
} | |
/** | |
* Prepares a statement for inserting store notes | |
* Used by PF staff to review changes to stores. | |
* @returns {Promise<sql.PreparedStatement>} preparedStatement | |
*/ | |
async function prepareInsertStoreNoteStatement() { | |
const statement = new sql.PreparedStatement(); | |
statement.input("dateEntered", sql.DateTime2(7)); | |
statement.input("note", sql.NVarChar(sql.MAX)); | |
statement.input("storeId", sql.Int); | |
statement.input("franchiseCode", sql.NChar(10)); | |
// inserts the notes as AutoBot per Tony's request in a ping | |
await statement.prepare(` | |
INSERT INTO [StoreNotesT] ( | |
NotedBy, | |
DateEntered, | |
Note, | |
StoreIDFK, | |
NewFranID, | |
Source | |
) | |
VALUES ( | |
'AutoBot', | |
@dateEntered, | |
@note, | |
@storeId, | |
@franchiseCode, | |
'sls-function' | |
) | |
`); | |
return statement; | |
} | |
/** | |
* Prepares a statement for inserting franchise note | |
* Used by PF staff to review changes to franchises. | |
* @returns {Promise<sql.PreparedStatement>} preparedStatement | |
*/ | |
async function prepareInsertFranchiseNoteStatement() { | |
const statement = new sql.PreparedStatement(); | |
statement.input("dateEntered", sql.DateTime2(7)); | |
statement.input("note", sql.NVarChar(sql.MAX)); | |
statement.input("franchiseCode", sql.NChar(10)); | |
// inserts the notes as AutoBot per Tony's request in a ping | |
await statement.prepare(` | |
INSERT INTO [FranNotesT] ( | |
NotedBy, | |
DateEntered, | |
Note, | |
FranchiseIDFK | |
Source | |
) | |
VALUES ( | |
'AutoBot', | |
@dateEntered, | |
@note, | |
@franchiseCode | |
'sls-function' | |
) | |
`); | |
return statement; | |
} | |
async function notAgreedFranchiseeReport({ emails, eta, reportDate }) { | |
const franchiseeStoresResult = await sql.query` | |
select CouponLookupsT.CouponCode, | |
FranchiseT.FranchiseID, | |
FranchiseT.FullName, | |
FranchiseT.Agreed, | |
StoresT.Store, | |
StoresT.Address1, StoresT.City, StoresT.State, StoresT.Zip, StoresT.County | |
from FranchiseT | |
join StoresT on StoresT.FranchiseFK = FranchiseT.FranchiseID | |
left join CouponLookupsT on StoresT.Store = CouponLookupsT.Store | |
join ActiveCouponsT on CouponLookupsT.CouponCode = ActiveCouponsT.CouponCode | |
where | |
FranchiseT.Agreed = 0 | |
and StoresT.Active = 1 | |
and ActiveCouponsT.Active = 1 | |
and CouponLookupsT.Fulfilled = 1 | |
and StoresT.Status = 'Open' | |
order by ActiveCouponsT.CouponCode, | |
FranchiseT.FranchiseID | |
`; | |
const couponCodes = groupFranchisees(franchiseeStoresResult.recordset); | |
const nearbyStoresZipStatement = await prepareNearbyStoresZipStatement(); | |
const nearbyStoresCountyStatement = | |
await prepareNearbyStoresCountyStatement(); | |
const fetchNearbyStores = async (store) => { | |
const nearbyStoresCountyResponse = | |
await nearbyStoresCountyStatement.execute({ | |
Store: store.Store, | |
State: store.State, | |
County: store.County, | |
}); | |
const nearbyStoresByCounty = nearbyStoresCountyResponse?.recordset ?? []; | |
if (nearbyStoresByCounty.length) { | |
return { | |
nearbyStores: nearbyStoresByCounty, | |
label: `${store.County}, ${store.State}`, | |
}; | |
} | |
// if we don't have any in the county, try by the zip code. | |
const nearbyStoresByZipResponse = await nearbyStoresZipStatement.execute({ | |
Store: store.Store, | |
Zip: store.Zip, | |
}); | |
const nearbyStoresByZip = nearbyStoresByZipResponse?.recordset ?? []; | |
return { | |
nearbyStores: nearbyStoresByZip, | |
label: store.Zip, | |
}; | |
}; | |
// Do the not-agreed franchise report synchronously so that there's no collisions with the DB connections while searching for nearby stores | |
// the db lib needs each prepared statement to run fully before running another one otherwise it errors | |
for (const [couponCode, storesByFranchise] of Object.entries(couponCodes)) { | |
const franchises = []; | |
for (const [franchiseId, stores] of Object.entries(storesByFranchise)) { | |
let storesWithNearbyStores = []; | |
for (const store of stores) { | |
const { label, nearbyStores } = await fetchNearbyStores(store); | |
storesWithNearbyStores.push({ | |
...store, | |
nearbyStores, | |
nearbyStoresLabel: label, | |
}); | |
} | |
franchises.push({ | |
franchiseId, | |
fullName: stores.at(0)?.FullName, | |
stores: storesWithNearbyStores, | |
}); | |
} | |
// if there are no fulfilled franchises for this couponCode, go to the next couponCode | |
if (!franchises.length) { | |
continue; | |
} | |
const emailBody = await eta.renderAsync( | |
"./not-agreed-franchises-fulfillment-report", | |
{ | |
couponCode, | |
franchises, | |
runDate: reportDate, | |
generateStoreCrmUrl, | |
generateFranchiseCrmUrl, | |
generateCountyCrmUrl, | |
} | |
); | |
const emailSubject = `Not Agreed Franchises Fulfillments for Coupon ${couponCode} - ${reportDate}`; | |
await sendEmail(emails, emailSubject, emailBody, []); | |
} | |
await nearbyStoresZipStatement.unprepare(); | |
await nearbyStoresCountyStatement.unprepare(); | |
} | |
async function dbConnect() { | |
try { | |
const sqlConfig = { | |
user: process.env.DB_USER_ID, | |
password: process.env.DB_PASSWORD, | |
database: process.env.DB_DATABASE, | |
server: process.env.DB_SERVER, | |
pool: { | |
max: 50, | |
min: 0, | |
}, | |
options: { | |
encrypt: true, // for azure | |
trustServerCertificate: true, // change to true for local dev / self-signed certs | |
}, | |
}; | |
await sql.connect(sqlConfig); | |
} catch (err) { | |
console.log(`DB Connect error: ${err}`); | |
throw err; | |
} | |
} | |
function sleep(ms) { | |
return new Promise((resolve) => setTimeout(resolve, ms)); | |
} | |
function random(min, max) { | |
return Math.floor(Math.random() * (max - min + 1) + min); | |
} | |
async function prepareUpdateCouponStatement() { | |
const statement = new sql.PreparedStatement(); | |
statement.input("Store", sql.Int); | |
statement.input("CouponCode", sql.NVarChar(255)); | |
statement.input("Fulfilled", sql.Bit); | |
statement.input("PreviousFulfilled", sql.Bit); | |
statement.input("Menu", sql.Float); | |
statement.input("Adjustment", sql.Float); | |
statement.input("Net", sql.Float); | |
statement.input("DateUpdated", sql.DateTime); | |
statement.input("LastFulfilledDate", sql.DateTime); | |
return statement.prepare(` | |
UPDATE CouponLookupsT | |
SET | |
Fulfilled = @Fulfilled, | |
PreviousFulfilled = @PreviousFulfilled, | |
Menu = @Menu, | |
Adjustment = @Adjustment, | |
Net = @Net, | |
DateUpdated = @DateUpdated, | |
LastFulfilledDate = @LastFulfilledDate | |
WHERE Store = @Store AND CouponCode = @CouponCode | |
`); | |
} | |
async function lookupCouponStore( | |
storeId, | |
couponCode, | |
previousFulfilled, | |
lastFulfilledDate | |
) { | |
const options = { | |
method: "POST", | |
headers: { | |
Accept: "application/json", | |
"Content-Type": "application/json", | |
}, | |
body: JSON.stringify({ | |
Order: { | |
Coupons: [ | |
{ | |
Code: couponCode, | |
}, | |
], | |
Products: [ | |
{ | |
Code: "14SCREEN", | |
Qty: 2, | |
Options: { | |
X: { | |
"1/1": "1", | |
}, | |
C: { | |
"1/1": "1", | |
}, | |
}, | |
}, | |
], | |
ServiceMethod: "Carryout", | |
SourceOrganizationURI: "order.dominos.com", | |
StoreID: storeId, | |
Version: "1.0", | |
}, | |
}), | |
}; | |
const res = await fetch( | |
"https://order.dominos.com/power/price-order", | |
options | |
); | |
const data = await res.json(); | |
const couponApplied = data?.Order?.Coupons[0]?.Status === 0; | |
const { | |
Menu: menu, | |
Adjustment: adjustment, | |
Net: net, | |
} = couponApplied ? data?.Order?.Amounts ?? {} : {}; | |
const dateUpdated = new Date(); | |
return { | |
storeId, | |
couponCode, | |
couponApplied, | |
previousFulfilled, | |
menu, | |
adjustment, | |
net, | |
dateUpdated, | |
// if we applied the coupon successfully, set the lastFulfilledDate to now | |
// otherwise keep it at the previous value | |
lastFulfilledDate: couponApplied ? dateUpdated : lastFulfilledDate, | |
}; | |
} | |
export async function update_store_details() { | |
await dbConnect(); | |
const statement = await prepareUpdateStoreStatement(); // Only prepare once. | |
const insertStoreNoteStatement = await prepareInsertStoreNoteStatement(); | |
let unchangedStoresCount = 0; | |
let invalidDataCount = 0; | |
let records = arrayChunks(await getStoreRecords(), 5); | |
const missingStores = []; | |
const changedStores = []; | |
for (const chunk of records) { | |
const promises = []; | |
for (const r of chunk) { | |
promises.push(compareAndUpdateStore(r)); | |
} | |
const results = await Promise.allSettled(promises); | |
for (const { status, value } of results) { | |
if (status === "fulfilled") { | |
if (value?.missing) { | |
missingStores.push(value.store); | |
} else if (value?.invalid) { | |
// invalid stores are stores with bad data from the Domino's API, we don't do anything with them for now. | |
invalidDataCount += 1; | |
} else if (value?.changed) { | |
changedStores.push(value); | |
await statement.execute(value.data); | |
try { | |
await insertStoreNoteStatement.execute({ | |
dateEntered: value.data.DateUpdated, | |
note: `Address data updated from the Domino's Order API`, | |
storeId: value.data.Store, | |
}); | |
} catch (e) { | |
console.error("could not save store note", e); | |
} | |
} else if (value?.unchanged) { | |
unchangedStoresCount += 1; | |
} else { | |
console.warn("unhandled edge case", value); | |
} | |
} else { | |
console.error("rejected", value); | |
} | |
} | |
await sleep(150); | |
} | |
const date = formatDate(new Date()); | |
const eta = new Eta({ | |
views: path.join(path.dirname(fileURLToPath(import.meta.url)), "templates"), | |
}); | |
const [ | |
missingStoresEmailBody, | |
missingStoresCSV, | |
changedStoresEmailBody, | |
changedStoresCsv, | |
] = await Promise.all([ | |
missingStores.length | |
? eta.renderAsync("./order-api-missing-stores", { | |
runDate: date, | |
missingStores, | |
generateStoreCrmUrl, | |
generateFranchiseCrmUrl, | |
}) | |
: Promise.resolve(), | |
missingStores.length | |
? eta.renderAsync("./csv", { | |
data: missingStores.map((s) => ({ | |
"Store #": s.Store, | |
City: s.City, | |
State: s.State, | |
Zip: s.Zip, | |
Phone: formatPhoneNumber(s.Phone_Number), | |
"Franchise Owner": `"${s.FullName}"`, | |
})), | |
}) | |
: Promise.resolve(), | |
changedStores.length | |
? eta.renderAsync("./order-api-address-validation", { | |
runDate: date, | |
changedStores, | |
generateStoreCrmUrl, | |
generateCountyCrmUrl, | |
formatPhoneNumber, | |
}) | |
: Promise.resolve(), | |
changedStores.length | |
? eta.renderAsync("./csv", { | |
data: changedStores.map(({ storeId, oldStore, newStore }) => ({ | |
Store: storeId, | |
"Old Address 1": oldStore.Address1, | |
"New Address 1": newStore.Address1, | |
"Old City": oldStore.City, | |
"New City": newStore.City, | |
"Old State": oldStore.State, | |
"New State": newStore.State, | |
"Old Zip": oldStore.Zip, | |
"New Zip": newStore.Zip, | |
"Old Phone Number": oldStore.Phone_Number, | |
"New Phone Number": newStore.Phone_Number, | |
"Old Store URL": oldStore.StoreUrl, | |
"New Store URL": newStore.StoreUrl, | |
})), | |
}) | |
: Promise.resolve(), | |
]); | |
const emailAddresses = process.env.EMAIL_TO_ADDRESSES_STORE_DETAILS.split( | |
"," | |
).map((email) => ({ | |
email, | |
})); | |
// only send emails for missing or changed stores if there are values | |
await Promise.all([ | |
missingStores.length | |
? sendEmail( | |
emailAddresses, | |
`Dominos Order API Missing Stores ${date}`, | |
missingStoresEmailBody, | |
changedStoresCsv | |
? [{ name: "missing.csv", data: missingStoresCSV }] | |
: [] | |
) | |
: Promise.resolve(), | |
changedStores.length | |
? sendEmail( | |
emailAddresses, | |
`Dominos Order API Changed Addresses ${date}`, | |
changedStoresEmailBody, | |
changedStoresCsv | |
? [{ name: "changed.csv", data: changedStoresCsv }] | |
: [] | |
) | |
: Promise.resolve(), | |
]); | |
console.table({ | |
unchanged: unchangedStoresCount, | |
changed: changedStores.length, | |
invalid: invalidDataCount, | |
missing: missingStores.length, | |
}); | |
await statement.unprepare(); | |
await insertStoreNoteStatement.unprepare(); | |
} | |
async function compareAndUpdateStore(r) { | |
try { | |
let p = await lookupStoreProfile(r.Store); | |
const crmStoreUrl = generateStoreCrmUrl(r.Store); | |
if (!p) { | |
console.error(`Store with ID ${r.Store} does not exist`); | |
return { | |
missing: true, | |
store: r, | |
}; | |
} else { | |
// Check for invalid data | |
if (p.invalidAddressData || p.phone.trim().length < 10) { | |
return { | |
invalid: `<tr><td><a href="${crmStoreUrl}">${r.Store}</a></td><td>${p.streetName}</td><td>${p.city}</td><td>${p.region}</td><td>${p.postalCode}</td><td>${p.phone}</td></tr>`, | |
invalidCsv: `${r.Store},"${p.streetName}",${p.city},${p.region},${p.postalCode},${p.phone}\n`, | |
}; | |
} | |
// Check if the county is null | |
let county = r.County; | |
if (!county) { | |
county = await lookupCountyByAddress(r); | |
if (!county) { | |
console.warn(`Could not determine county for Store ${r.Store}`); | |
} | |
} | |
// Check for data changes | |
if ( | |
r.Address1 !== p.streetName || | |
r.City !== p.city || | |
r.State !== p.region || | |
r.Zip !== p.postalCode || | |
r.Phone_Number !== p.phone || | |
r.County !== county | |
) { | |
return { | |
changed: true, | |
newStore: { | |
Address1: p.streetName, | |
City: p.city, | |
State: p.region, | |
Zip: p.postalCode, | |
Phone_Number: p.phone, | |
StoreURL: p.storeUrl, | |
County: county, | |
}, | |
oldStore: r, | |
storeId: r.Store, | |
county, | |
data: { | |
Store: r.Store, | |
Address1: p.streetName, | |
City: p.city, | |
State: p.region, | |
Zip: p.postalCode, | |
PhoneNumber: p.phone, | |
StoreUrl: p.storeUrl, | |
DateUpdated: new Date(), | |
County: county, | |
}, | |
}; | |
} | |
} | |
return { | |
unchanged: true, | |
}; | |
} catch (e) { | |
console.error("error", e); | |
} | |
} | |
async function prepareUpdateStoreStatement() { | |
const statement = new sql.PreparedStatement(); | |
statement.input("Store", sql.Int); | |
statement.input("Address1", sql.NVarChar(255)); | |
statement.input("City", sql.NVarChar(255)); | |
statement.input("State", sql.NVarChar(255)); | |
statement.input("Zip", sql.NVarChar(255)); | |
statement.input("PhoneNumber", sql.Char(10)); | |
statement.input("StoreUrl", sql.NVarChar(255)); | |
statement.input("DateUpdated", sql.DateTime); | |
statement.input("County", sql.NVarChar(255)); | |
return statement.prepare(` | |
UPDATE StoresT | |
SET | |
Address1 = @Address1, | |
City = @City, | |
State = @State, | |
Zip = @Zip, | |
Phone_Number = @PhoneNumber, | |
StoreURL = @StoreUrl, | |
DateUpdated = @DateUpdated, | |
County = @County | |
WHERE Store = @Store | |
`); | |
} | |
async function getStoreRecords() { | |
const queryResult = await sql.query` | |
select | |
Store, | |
Address1, | |
City, | |
State, | |
County, | |
Zip, | |
Phone_Number, | |
StoreLatitude, | |
StoreLongitude, | |
StoreURL, | |
DirectionsURL, | |
FranchiseT.FullName, | |
FranchiseT.FranchiseID | |
from StoresT | |
JOIN FranchiseT ON FranchiseT.FranchiseID = StoresT.FranchiseFK | |
where Status = 'Open' | |
`; | |
return queryResult.recordset ?? []; | |
} | |
async function lookupStoreProfile(storeId) { | |
const options = { | |
method: "GET", | |
redirect: "manual", // Dominos returns a 302 redirect to their homepage if the storeId is invalid | |
headers: { | |
Accept: "application/json", | |
}, | |
}; | |
const res = await fetch( | |
`https://order.dominos.com/power/store/${storeId}/profile`, | |
options | |
); | |
if (res.status !== 200) { | |
console.warn(await res.text()); | |
return false; | |
} else { | |
let { | |
Phone, | |
StreetName, | |
City, | |
Region, | |
PostalCode, | |
StoreCoordinates: { StoreLatitude, StoreLongitude }, | |
} = await res.json(); | |
const regionAbbr = Region.toUpperCase().replace(/[^A-Z]/g, ""); | |
const profile = { | |
phone: Phone.replace(/[^0-9 ]/g, ""), // Remove non-digits | |
streetName: StreetName, | |
city: City, | |
regionAbbr, | |
region: states[regionAbbr], | |
postalCode: formatZipCode(PostalCode), | |
storeLatitude: StoreLatitude, | |
storeLongitude: StoreLongitude, | |
invalidAddressData: false, | |
}; | |
const storeUrl = generateStoreUrl(storeId, profile); | |
if (!storeUrl) { | |
profile.invalidAddressData = true; | |
} | |
profile.storeUrl = storeUrl; | |
return profile; | |
} | |
} | |
function getHandle(value) { | |
return value | |
.replace(/[^a-zA-Z0-9 ]/g, "") | |
.replace(/ /g, "-") | |
.toLowerCase(); | |
} | |
function generateStoreUrl(storeId, storeProfile) { | |
try { | |
return `https://pizza.dominos.com/${getHandle( | |
storeProfile.region | |
)}/${getHandle(storeProfile.city)}/${getHandle(storeProfile.streetName)}/`; | |
} catch (e) { | |
console.error(`failed to generate url for ${storeId}`); | |
return false; | |
} | |
} | |
/** | |
* | |
* @param {string[]} toAddresses | |
* @param {string} subject | |
* @param {string} message | |
* @param {Array<{name: string, data: string}>} csvFiles | |
*/ | |
async function sendEmail(toAddresses, subject, message, csvFiles = []) { | |
const attachments = csvFiles | |
.filter((f) => f !== null) | |
.map((file) => { | |
return { | |
filename: file.name, | |
type: "text/csv", | |
content: asBase64(file.data), | |
}; | |
}); | |
const body = { | |
personalizations: [ | |
{ | |
to: toAddresses, | |
}, | |
], | |
from: { | |
email: process.env.EMAIL_FROM_ADDRESS, | |
}, | |
subject, | |
content: [ | |
{ | |
type: "text/html", | |
value: message, | |
}, | |
], | |
attachments: attachments.length > 0 ? attachments : null, | |
}; | |
const options = { | |
method: "POST", | |
headers: { | |
"Content-Type": "application/json", | |
Authorization: `Bearer ${process.env.EMAIL_SERVICE_KEY}`, | |
}, | |
body: JSON.stringify(body), | |
}; | |
const res = await fetch("https://api.sendgrid.com/v3/mail/send", options); | |
if (res.status > 299) { | |
throw new Error( | |
`Failed to send email: ${JSON.stringify(await res.text())}` | |
); | |
} | |
} | |
function asBase64(value) { | |
return Buffer.from(value).toString("base64"); | |
} | |
function arrayChunks(arr, chunkSize) { | |
const chunked = { length: Math.ceil(arr.length / chunkSize) }; | |
return Array.from(chunked, (_v, i) => | |
arr.slice(i * chunkSize, i * chunkSize + chunkSize) | |
); | |
} | |
export function formatDate(date, withTime = false) { | |
const formatter = withTime ? DATETIME_FORMAT : DATE_FORMAT; | |
return formatter.format(date); | |
} | |
/** | |
* @typedef {import('./shared').DominosApiStore} DominosApiStore | |
* @returns {Promise<DominosApiStore[]>} | |
*/ | |
export async function fetchStoresFromDominosApi() { | |
let offset = 0; | |
const limit = 50; | |
const url = new URL( | |
`https://prod-cdn.us.yextapis.com/v2/accounts/me/search/vertical/query?experienceKey=locator&api_key=db579cbf33dcf239cfae2d4466f5ce59&v=20220511&version=PRODUCTION&locale=en&input=&verticalKey=locations&filters=%7B%22builtin.location%22%3A%7B%22%24near%22%3A%7B%22lat%22%3A39.833333%2C%22lng%22%3A-98.583333%2C%22radius%22%3A214649906%7D%7D%7D&retrieveFacets=false&skipSpellCheck=false&sessionTrackingEnabled=false&sortBys=%5B%5D&source=STANDARD&limit=${limit}` | |
); | |
const firstResult = await fetch(url).then((r) => r.json()); | |
// // get the total number of results the API has | |
const maxResults = firstResult.response.resultsCount; | |
// get just the data that we care about | |
let results = firstResult.response.results.map((res) => ({ | |
storeId: res.data.id, | |
franchiseId: res.data.c_franchise, | |
closed: res.data.closed, | |
phoneNumber: res.data.mainPhone, | |
coordinates: res.data.displayCoordinate, | |
timezone: res.data.timezone, | |
storeUrl: res.data.slug | |
? new URL(res.data.slug, "https://pizza.dominos.com").toString() | |
: undefined, | |
...res.data.address, | |
// truncate/format the zip code to be 5 digits | |
postalCode: formatZipCode(res.data.address?.postalCode), | |
region: | |
states[res.data.address?.region.toUpperCase().trim() ?? ""] ?? | |
res.data.address.region, | |
})); | |
offset += limit; | |
let resultPromises = []; | |
const maxConcurrentRequests = 10; | |
try { | |
while (offset <= maxResults + limit) { | |
// update the url to use the current offset | |
url.searchParams.set("offset", offset.toString()); | |
resultPromises.push( | |
fetch(url) | |
.then((r) => r.json()) | |
.then((result) => { | |
if (!result.response.results) { | |
console.error("empty result", result); | |
} | |
return result.response.results?.map((res) => ({ | |
storeId: res.data.id, | |
franchiseId: res.data.c_franchise, | |
closed: res.data.closed, | |
phoneNumber: res.data.mainPhone, | |
coordinates: res.data.displayCoordinate, | |
timezone: res.data.timezone, | |
storeUrl: res.data.slug | |
? new URL(res.data.slug, "https://pizza.dominos.com").toString() | |
: undefined, | |
...res.data.address, | |
// truncate/format the zip code to be 5 digits | |
postalCode: formatZipCode(res.data.address?.postalCode), | |
})); | |
}) | |
); | |
if (resultPromises.length >= maxConcurrentRequests) { | |
const tempResults = (await Promise.all(resultPromises)).flat(); | |
results = results.concat(tempResults); | |
resultPromises = []; | |
} | |
offset += limit; | |
} | |
} catch (e) { | |
console.error(e); | |
} | |
if (resultPromises.length) { | |
const tempResults = (await Promise.all(resultPromises)).flat(); | |
results = results.concat(tempResults); | |
resultPromises = []; | |
} | |
return results; | |
} | |
/** | |
* Use the DO REST API to trigger a DB sync between the MSSQL DB and the CRM DB | |
*/ | |
async function triggerDBSync() { | |
const restApiUrl = process.env.DB_SYNC_FUNCTION_REST_API_URL; | |
await fetch(restApiUrl, { | |
method: "POST", | |
headers: [ | |
["Content-Type", "application/json"], | |
["Authorization", `Basic ${process.env.DB_SYNC_FUNCTION_ACCESS_TOKEN}`], | |
], | |
}); | |
console.log("DB Sync triggered"); | |
} | |
function compareZipCodes(firstZip, secondZip) { | |
/** The DB uses USA as a placeholder for unknown zip codes */ | |
if (!firstZip || !secondZip || firstZip === "USA" || secondZip === "USA") { | |
return false; | |
} | |
const firstZipParts = firstZip.split("-"); | |
const secondZipParts = secondZip.split("-"); | |
if (firstZipParts.length === secondZipParts.length) { | |
return firstZip === secondZip; | |
} else { | |
return firstZipParts.at(0) === secondZipParts.at(0); | |
} | |
} | |
/** | |
* Trims a phone number to be 10 digits or less | |
* @param {string | undefined | null} phoneNumber | |
* @returns {string|null} trimmed phone number | |
*/ | |
function trimPhoneNumber(phoneNumber) { | |
if (!phoneNumber) { | |
return null; | |
} else if (phoneNumber.length <= 10) { | |
return phoneNumber; | |
} | |
// if the phone number starts with +1, remove it | |
if (phoneNumber.length == 12 && phoneNumber.startsWith("+1")) { | |
// regex matches +1 at the start of the phone number | |
return phoneNumber.replace(/^\+1/, ""); | |
} | |
// for now just return null for all other cases since it means it's a case we haven't handled which could cause issues when inserting into the DB | |
return null; | |
} | |
/** | |
* */ | |
/** | |
* | |
* @param {{[email: string]: Array<{ | |
* id: number; | |
* franchisee: string | null; | |
* franchiseCode: string | null; | |
* address1: string | null; | |
* address2: string | null; | |
* state: string | null; | |
* county: string | null; | |
* zip: string | null; | |
* phone: string | null; | |
* }>}} specialistStoreMaps | |
*/ | |
export async function sendStoreActiveChangedEmail(specialistStoreMaps) { | |
const eta = new Eta({ | |
views: path.join(path.dirname(fileURLToPath(import.meta.url)), "templates"), | |
}); | |
for (const [email, changedStores] of Object.entries(specialistStoreMaps)) { | |
if (!changedStores.length) { | |
continue; | |
} | |
const emailBody = await eta.renderAsync( | |
"./specialist-stores-marked-inactive-on-map", | |
{ | |
changedStores, | |
generateStoreCrmUrl, | |
generateCountyCrmUrl, | |
generateFranchiseCrmUrl, | |
} | |
); | |
await sendEmail( | |
[{ email }], | |
`Update: ${changedStores.length} of your Domino's stores were marked inactive`, | |
emailBody, | |
[] | |
); | |
} | |
} | |
/** | |
* Save the mapbox import so we don't have to re-import it for every new store | |
* But this way we don't have to include it in every lookup function | |
* @typedef {import('@mapbox/search-js-core').GeocodingCore} GeocodingCore | |
* @type {GeocodingCore | null} | |
* */ | |
let mapboxGeocoder = null; | |
/** | |
* | |
* @param {DominosApiStore} store | |
* @returns {string} county name | |
*/ | |
async function lookupCountyByAddress(store) { | |
// Check if the Mapbox GeocodingCore instance is already initialized | |
if (!mapboxGeocoder) { | |
const { | |
default: { GeocodingCore }, | |
} = await import("@mapbox/search-js-core"); | |
// Initialize the Mapbox GeocodingCore instance | |
// The initialization of mapboxGeocoder is now separate from the geocoding and county lookup logic, | |
// ensuring the function works correctly on subsequent calls. | |
// Previously, the geocoder instance was re-initialized on every call, leading to redundant setups | |
// and performance overhead. This refactor avoids unnecessary re-initializations and improves efficiency. | |
mapboxGeocoder = new GeocodingCore({ | |
accessToken: process.env.MAPBOX_CLIENT_TOKEN, | |
}); | |
} | |
// Construct the address string from store details | |
// Assume city, zip, and region are always available | |
const addressString = `${store.line1} ${store.city}, ${store.region} ${store.zip}`; | |
try { | |
// Use Mapbox Geocoding API to forward geocode the address | |
const resp = await mapboxGeocoder.forward(addressString); | |
// Search for a feature with a district (county) in the response | |
const resultWithCounty = resp.features.find( | |
(f) => f?.properties?.context?.district?.name | |
); | |
if (resultWithCounty) { | |
/**@type {string} */ | |
const mapboxCounty = resultWithCounty.properties.context.district.name; | |
// all of the counties in Mapbox are in the form "[NAME] County" | |
// the MSSQL DB has them inserted as "[Name]" | |
// so we need to remove the word county & trim the whitespace | |
const cleanedUpCounty = mapboxCounty.replace(/[Cc]ounty/, "").trim(); | |
return cleanedUpCounty; | |
} else { | |
// No county information was found in the response | |
console.warn(`No county found for address: ${addressString}`); | |
return null; | |
} | |
} catch (error) { | |
// Handle any errors during the Mapbox API call | |
console.error("Error during Mapbox geocoding:", error); | |
return null; | |
} | |
} | |
export async function verifyAddress(store) { | |
const missingFields = []; | |
if (!store.city) missingFields.push('city'); | |
if (!store.region) missingFields.push('region'); | |
if (!store.zip) missingFields.push('zip'); | |
if (missingFields.length > 0) { | |
const message = missingFields.length === 3 | |
? 'Missing city, region, and zip code.' | |
: `Missing ${missingFields.join(', ')}.`; | |
console.warn(`Incomplete address details for store: ${JSON.stringify(store)}`); | |
return { | |
valid: false, | |
discrepancies: { | |
city: null, | |
state: null, | |
zip: null, | |
}, | |
errors: { message }, | |
}; | |
} | |
try { | |
const addressString = `${store.address}, ${store.city}, ${store.region} ${store.zip}`; | |
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(addressString)}.json?access_token=${process.env.MAPBOX_ACCESS_TOKEN}`; | |
const response = await fetch(url).then(res => res.json()); | |
if (!response.features || response.features.length === 0) { | |
return { | |
valid: false, | |
discrepancies: { | |
city: { dominos: store.city, mapbox: null }, | |
state: { dominos: store.region, mapbox: null }, | |
zip: { dominos: store.zip, mapbox: null }, | |
}, | |
errors: { message: 'No matching results found.' }, | |
}; | |
} | |
const highestRelevanceFeature = response.features[0]; | |
const context = highestRelevanceFeature.context || []; | |
const mapboxCity = context.find(c => c.id.startsWith('place'))?.text || null; | |
const mapboxState = context.find(c => c.id.startsWith('region'))?.text || null; | |
const mapboxZip = context.find(c => c.id.startsWith('postcode'))?.text || null; | |
const cityMatches = mapboxCity?.toLowerCase() === store.city.toLowerCase(); | |
const stateMatches = mapboxState?.toLowerCase() === store.region.toLowerCase(); | |
const zipMatches = mapboxZip ? mapboxZip === store.zip : true; | |
return { | |
valid: cityMatches && stateMatches && zipMatches, | |
discrepancies: { | |
city: cityMatches ? null : { dominos: store.city, mapbox: mapboxCity }, | |
state: stateMatches ? null : { dominos: store.region, mapbox: mapboxState }, | |
zip: zipMatches ? null : { dominos: store.zip, mapbox: mapboxZip }, | |
}, | |
errors: null, | |
}; | |
} catch (error) { | |
console.error('Error during Mapbox geocoding:', error); | |
return { | |
valid: false, | |
discrepancies: { | |
city: null, | |
state: null, | |
zip: null, | |
}, | |
errors: { message: 'Error during geocoding.', details: error.message }, | |
}; | |
} | |
} | |
async function processStoreAddress( | |
storeId, | |
storeAddress, | |
context, | |
verificationResult = null | |
) { | |
if (!verificationResult) { | |
verificationResult = await verifyAddress(storeAddress); | |
} | |
// Update AddressVerified field | |
await sql.query` | |
UPDATE StoresT | |
SET AddressVerified = ${verificationResult.valid ? 1 : 0} | |
WHERE StoreId = ${storeId} | |
`; | |
// Construct a note message | |
const noteMessage = context === "new" | |
? verificationResult.valid | |
? `Address verification successful for new store: ${storeId}.` | |
: `Address verification failed for new store: ${storeId}. Discrepancies: ${JSON.stringify(verificationResult.discrepancies)}` | |
: verificationResult.valid | |
? `Address verification successful after update for store: ${storeId}.` | |
: `Address verification failed after update for store: ${storeId}. Discrepancies: ${JSON.stringify(verificationResult.discrepancies)}`; | |
// Insert a note | |
await sql.query` | |
INSERT INTO StoreNotesT (StoreIDFK, NotedBy, Source, Note, DateEntered) | |
VALUES ( | |
${storeId}, | |
'AutoBot', | |
'sls-function', | |
${noteMessage}, | |
GETDATE() | |
) | |
`; | |
} | |
/** | |
* Gets a corresponding corporate franchise for a store without a franchise | |
* But only if it's in a state with a corporate franchise. | |
* @param {DominosApiStore} store | |
* @returns {string | null} Franchise ID for corporate franchise | |
*/ | |
export function getCorporateFranchise(store) { | |
if (store.franchiseId) { | |
return store.franchiseId; | |
} | |
switch (store.region.toUpperCase()) { | |
case "MARYLAND": | |
case "MD": | |
return "Corp3"; | |
break; | |
case "TEXAS": | |
case "TX": | |
return "Corp2"; | |
break; | |
case "FLORIDA": | |
case "FL": | |
return "Corp1"; | |
break; | |
default: | |
return "Corp0"; | |
} | |
} | |
/** | |
* A very simple phone number parser. Based on the format of phone numbers in the DB. | |
* Doesn't handle country codes or numbers without area codes | |
* If more formats or international numbers start showing up, find an npm package. | |
* @param {string | null | undefined} phoneNumber - A phone Numberinclude the +1 | |
* @returns {string | null} - a pretty-printed phone number | |
*/ | |
function formatPhoneNumber(phoneNumber) { | |
let trimmedPhoneNumber = trimPhoneNumber(phoneNumber); | |
if (!trimmedPhoneNumber) { | |
return trimmedPhoneNumber; | |
} | |
// assume if the number has - in it, we've already formatted it | |
if (trimmedPhoneNumber.split("-").length > 1) { | |
return trimmedPhoneNumber; | |
} | |
if (trimmedPhoneNumber.length === 10) { | |
const areaCode = trimmedPhoneNumber.slice(0, 3); | |
const prefix = trimmedPhoneNumber.slice(3, 6); | |
const lineNumber = trimmedPhoneNumber.slice(6); | |
return `${areaCode}-${prefix}-${lineNumber}`; | |
} else { | |
return trimmedPhoneNumber; | |
} | |
} | |
/** | |
* | |
* @param {string | number} zipCode - zip code to format | |
* @param {boolean} truncate - whether the zip code should be truncated to 5 digits or not. Defaults to true | |
*/ | |
export function formatZipCode(zipCode, truncate = true) { | |
if (!zipCode || !zipCode.toString()) { | |
return null; | |
} | |
let zipCodeString = zipCode.toString().trim(); | |
const zipCodeRegex = /^(\d|-)+$/g; //matches a string that contains only digits or a "-" character | |
// if the string has any characters that are not numeric or "-" | |
// just return the string | |
// this helps handle some of the filler strings like "USA" or state names | |
// in the zip code. | |
if (!zipCodeRegex.test(zipCodeString)) { | |
return zipCodeString; | |
} | |
// if the zipCodeString is exactly 5 characters or it's > 5 characters and truncate is false, leave it | |
if (zipCodeString.length === 5 || (zipCodeString.length > 5 && !truncate)) { | |
return zipCodeString; | |
} | |
// if the string is < 5 characters, pad it with 0's. | |
if (zipCodeString.length < 5) { | |
return zipCodeString.padStart(5, "0"); | |
} else { | |
// otherwise the string needs to be truncated | |
// slice it to the first 5 characters | |
return zipCodeString.slice(0, 5); | |
} | |
} | |
/** | |
* Generate a URL for the CRM's /stores route filtered to the storeId value | |
* @param {string} storeId | |
* @returns {string} - URL to the CRM | |
*/ | |
function generateStoreCrmUrl(storeId) { | |
const crmUrl = new URL("/stores", CRM_URL); | |
crmUrl.searchParams.set("storeId", storeId); | |
return crmUrl.toString(); | |
} | |
/** | |
* Generate a URL for the CRM's /stores route filtered to the franchiseCode | |
* @param {string} franchiseCode - the Domino's Franchise Code for the store | |
* @returns {string} - A URL string | |
*/ | |
function generateFranchiseCrmUrl(franchiseCode) { | |
const crmUrl = new URL("/stores", CRM_URL); | |
crmUrl.searchParams.set("franchiseCode", franchiseCode); | |
return crmUrl.toString(); | |
} | |
/** | |
* Generate a URL for the CRM's /stores route filtered to the county + state | |
* @param {string} county - the name of the county | |
* @param {string | undefined} state - the state for the county | |
* @returns {string} | |
*/ | |
function generateCountyCrmUrl(county, state) { | |
const crmUrl = new URL("/stores", CRM_URL); | |
crmUrl.searchParams.set("county", county); | |
if (state) { | |
crmUrl.searchParams.set("state", state); | |
} | |
return crmUrl.toString(); | |
} | |
/** | |
* Parse a URL string to use as the CRM URL | |
* Or fallback to crm.stpcards.com | |
* @param {string} urlString - the full url to use for the CRM | |
* @returns {URL} - parsed URL | |
*/ | |
function parseCrmUrl(urlString) { | |
const defaultURL = "https://crm.stpcards.com/"; | |
let url = new URL(defaultURL); | |
try { | |
url = new URL(urlString); | |
} catch {} | |
return url; | |
} | |
/** Declare some file types for better intellisense on "private" types */ | |
/** | |
* @typedef {import('./shared').StoreValidationDBStore} StoreValidationDBStore | |
* @typedef {import('./shared').DominosApiStore} DominosApiStore | |
*/ | |
/** | |
* @typedef {{ | |
* store: StoreValidationDBStore; | |
* oldFranchiseId?: string | null; | |
* oldFranchiseAgreed: boolean; | |
* newFranchiseId?: string | null; | |
* newFranchiseAssignedToCorporateId?: boolean; | |
* newFranchiseName?: string | null; | |
* newFranchiseState?: string | null; | |
* newFranchiseAgreed: boolean; | |
* }} FranchiseChangeData | |
* | |
* @typedef {{ | |
* updatedStoreErrors: Array<{franchiseChangeInfo: FranchiseChangeData, error: Error}>, | |
* insertedFranchises: Array<{franchiseCode: string, franchiseName: string, stores: Array<StoreValidationDBStore | DominosApiStore>}>, | |
* insertedFranchiseErrors: Array<{franchiseCode: string, franchiseName: string, error: Error}> | |
* }} FranchiseUpdateResults | |
**/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment