feature/IO-3556-Chattr-Integration
This commit is contained in:
126
server/chatter/chatter-client.js
Normal file
126
server/chatter/chatter-client.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||||
|
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
|
||||||
|
const { isString, isEmpty } = require("lodash");
|
||||||
|
|
||||||
|
const CHATTER_BASE_URL = process.env.CHATTER_API_BASE_URL || "https://api.chatterresearch.com";
|
||||||
|
const AWS_REGION = process.env.AWS_REGION || "ca-central-1";
|
||||||
|
|
||||||
|
// Configure SecretsManager client with localstack support
|
||||||
|
const secretsClientOptions = {
|
||||||
|
region: AWS_REGION,
|
||||||
|
credentials: defaultProvider()
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
|
||||||
|
|
||||||
|
if (isLocal) {
|
||||||
|
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretsClient = new SecretsManagerClient(secretsClientOptions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chatter API Client for making requests to the Chatter API
|
||||||
|
*/
|
||||||
|
class ChatterApiClient {
|
||||||
|
constructor({ baseUrl, apiToken }) {
|
||||||
|
if (!apiToken) throw new Error("ChatterApiClient requires apiToken");
|
||||||
|
this.baseUrl = String(baseUrl || "").replace(/\/+$/, "");
|
||||||
|
this.apiToken = apiToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLocation(companyId, payload) {
|
||||||
|
return this.request(`/api/v1/companies/${companyId}/locations`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async postInteraction(companyId, payload) {
|
||||||
|
return this.request(`/api/v1/companies/${companyId}/solicitation/interaction`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(path, { method = "GET", body } = {}) {
|
||||||
|
const res = await fetch(this.baseUrl + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Api-Token": this.apiToken,
|
||||||
|
Accept: "application/json",
|
||||||
|
...(body ? { "Content-Type": "application/json" } : {})
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? safeJson(text) : null;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(`Chatter API error ${res.status} | ${data?.message}`);
|
||||||
|
err.status = res.status;
|
||||||
|
err.data = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse JSON, returning original text if parsing fails
|
||||||
|
*/
|
||||||
|
function safeJson(text) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches Chatter API token from AWS Secrets Manager
|
||||||
|
* SecretId format: CHATTER_COMPANY_KEY_<companyId>
|
||||||
|
*
|
||||||
|
* @param {string|number} companyId - The company ID
|
||||||
|
* @returns {Promise<string>} The API token
|
||||||
|
*/
|
||||||
|
async function getChatterApiToken(companyId) {
|
||||||
|
const key = String(companyId ?? "").trim();
|
||||||
|
if (!key) throw new Error("getChatterApiToken: companyId is required");
|
||||||
|
|
||||||
|
// Optional override for development/testing
|
||||||
|
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
|
||||||
|
|
||||||
|
const secretId = `CHATTER_COMPANY_KEY_${key}`;
|
||||||
|
const command = new GetSecretValueCommand({ SecretId: secretId });
|
||||||
|
const { SecretString, SecretBinary } = await secretsClient.send(command);
|
||||||
|
|
||||||
|
const token =
|
||||||
|
(SecretString && SecretString.trim()) ||
|
||||||
|
(SecretBinary && Buffer.from(SecretBinary, "base64").toString("ascii").trim()) ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
if (!token) throw new Error(`Chatter API token secret is empty: ${secretId}`);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Chatter API client instance
|
||||||
|
*
|
||||||
|
* @param {string|number} companyId - The company ID
|
||||||
|
* @param {string} [baseUrl] - Optional base URL override
|
||||||
|
* @returns {Promise<ChatterApiClient>} Configured API client
|
||||||
|
*/
|
||||||
|
async function createChatterClient(companyId, baseUrl = CHATTER_BASE_URL) {
|
||||||
|
const apiToken = await getChatterApiToken(companyId);
|
||||||
|
return new ChatterApiClient({ baseUrl, apiToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ChatterApiClient,
|
||||||
|
getChatterApiToken,
|
||||||
|
createChatterClient,
|
||||||
|
safeJson,
|
||||||
|
CHATTER_BASE_URL
|
||||||
|
};
|
||||||
@@ -1,22 +1,123 @@
|
|||||||
const DEFAULT_COMPANY_ID = process.env.CHATTER_DEFAULT_COMPANY_ID;
|
const DEFAULT_COMPANY_ID = process.env.CHATTER_DEFAULT_COMPANY_ID;
|
||||||
|
const client = require("../graphql-client/graphql-client").client;
|
||||||
|
const { createChatterClient } = require("./chatter-client");
|
||||||
|
const InstanceManager = require("../utils/instanceMgr").default;
|
||||||
|
|
||||||
const createLocation = (req, res) => {
|
const GET_BODYSHOP_FOR_CHATTER = `
|
||||||
|
query GET_BODYSHOP_FOR_CHATTER($id: uuid!) {
|
||||||
|
bodyshops_by_pk(id: $id) {
|
||||||
|
id
|
||||||
|
shopname
|
||||||
|
address1
|
||||||
|
city
|
||||||
|
state
|
||||||
|
zip_post
|
||||||
|
imexshopid
|
||||||
|
chatterid
|
||||||
|
chatter_company_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UPDATE_BODYSHOP_CHATTER_FIELDS = `
|
||||||
|
mutation UPDATE_BODYSHOP_CHATTER_FIELDS($id: uuid!, $chatter_company_id: String!, $chatterid: String!) {
|
||||||
|
update_bodyshops_by_pk(pk_columns: {id: $id}, _set: {chatter_company_id: $chatter_company_id, chatterid: $chatterid}) {
|
||||||
|
id
|
||||||
|
chatter_company_id
|
||||||
|
chatterid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createLocation = async (req, res) => {
|
||||||
const { logger } = req;
|
const { logger } = req;
|
||||||
const { bodyshopID } = req.body;
|
const { bodyshopID, googlePlaceID } = req.body;
|
||||||
|
|
||||||
|
console.dir({ body: req.body });
|
||||||
|
|
||||||
// No Default company
|
|
||||||
if (!DEFAULT_COMPANY_ID) {
|
if (!DEFAULT_COMPANY_ID) {
|
||||||
logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID });
|
logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID });
|
||||||
return res.json({ success: false });
|
return res.json({ success: false, message: "No default company set" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!googlePlaceID) {
|
||||||
|
logger.log("chatter-create-location-no-google-place-id", "warn", null, null, { bodyshopID });
|
||||||
|
return res.json({ success: false, message: "No google place id provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// No Bodyshop data available
|
|
||||||
if (!bodyshopID) {
|
if (!bodyshopID) {
|
||||||
logger.log("chatter-create-location-invalid-bodyshop", "warn", null, null, { bodyshopID });
|
logger.log("chatter-create-location-invalid-bodyshop", "warn", null, null, { bodyshopID });
|
||||||
return res.json({ success: false });
|
return res.json({ success: false, message: "No bodyshop id" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({ success: true });
|
try {
|
||||||
|
const { bodyshops_by_pk: bodyshop } = await client.request(GET_BODYSHOP_FOR_CHATTER, { id: bodyshopID });
|
||||||
|
|
||||||
|
if (!bodyshop) {
|
||||||
|
logger.log("chatter-create-location-bodyshop-not-found", "warn", null, null, { bodyshopID });
|
||||||
|
return res.json({ success: false, message: "Bodyshop not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodyshop.chatter_company_id && bodyshop.chatterid) {
|
||||||
|
logger.log("chatter-create-location-already-exists", "warn", null, null, {
|
||||||
|
bodyshopID
|
||||||
|
});
|
||||||
|
return res.json({ success: false, message: "This Bodyshop already has a location associated with it" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatterApi = await createChatterClient(DEFAULT_COMPANY_ID);
|
||||||
|
|
||||||
|
const locationIdentifier = `${DEFAULT_COMPANY_ID}-${bodyshop.id}`;
|
||||||
|
|
||||||
|
const locationPayload = {
|
||||||
|
name: bodyshop.shopname,
|
||||||
|
locationIdentifier: locationIdentifier,
|
||||||
|
address: bodyshop.address1,
|
||||||
|
postalCode: bodyshop.zip_post,
|
||||||
|
state: bodyshop.state,
|
||||||
|
city: bodyshop.city,
|
||||||
|
country: InstanceManager({ imex: "Canada", rome: "US" }),
|
||||||
|
googlePlaceId: googlePlaceID,
|
||||||
|
status: "active"
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.log("chatter-create-location-calling-api", "info", null, null, { bodyshopID, locationIdentifier });
|
||||||
|
|
||||||
|
const response = await chatterApi.createLocation(DEFAULT_COMPANY_ID, locationPayload);
|
||||||
|
|
||||||
|
if (!response.location?.id) {
|
||||||
|
logger.log("chatter-create-location-no-location-id", "error", null, null, { bodyshopID, response });
|
||||||
|
return res.json({ success: false, message: "No location ID in response", data: response });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.request(UPDATE_BODYSHOP_CHATTER_FIELDS, {
|
||||||
|
id: bodyshopID,
|
||||||
|
chatter_company_id: DEFAULT_COMPANY_ID,
|
||||||
|
chatterid: String(response.location.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log("chatter-create-location-success", "info", null, null, {
|
||||||
|
bodyshopID,
|
||||||
|
chatter_company_id: DEFAULT_COMPANY_ID,
|
||||||
|
chatterid: response.location.id,
|
||||||
|
locationIdentifier
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true, data: response });
|
||||||
|
} catch (error) {
|
||||||
|
logger.log("chatter-create-location-error", "error", null, null, {
|
||||||
|
bodyshopID,
|
||||||
|
error: error.message,
|
||||||
|
status: error.status,
|
||||||
|
data: error.data
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Failed to create location",
|
||||||
|
error: error.data
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = createLocation;
|
module.exports = createLocation;
|
||||||
|
|||||||
350
server/data/chatter-api.js
Normal file
350
server/data/chatter-api.js
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
const queries = require("../graphql-client/queries");
|
||||||
|
const moment = require("moment-timezone");
|
||||||
|
const logger = require("../utils/logger");
|
||||||
|
const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../chatter/chatter-client");
|
||||||
|
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
|
||||||
|
const { isString, isEmpty } = require("lodash");
|
||||||
|
|
||||||
|
const client = require("../graphql-client/graphql-client").client;
|
||||||
|
const { sendServerEmail } = require("../email/sendemail");
|
||||||
|
|
||||||
|
const CHATTER_EVENT = process.env.CHATTER_SOLICITATION_EVENT || "delivery";
|
||||||
|
const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5);
|
||||||
|
const AWS_REGION = process.env.AWS_REGION || "ca-central-1";
|
||||||
|
|
||||||
|
// Configure SecretsManager client with localstack support for caching implementation
|
||||||
|
const secretsClientOptions = {
|
||||||
|
region: AWS_REGION,
|
||||||
|
credentials: defaultProvider()
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
|
||||||
|
|
||||||
|
if (isLocal) {
|
||||||
|
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token and client caching for performance
|
||||||
|
const tokenCache = new Map(); // companyId -> token string
|
||||||
|
const tokenInFlight = new Map(); // companyId -> Promise<string>
|
||||||
|
const clientCache = new Map(); // companyId -> ChatterApiClient
|
||||||
|
|
||||||
|
exports.default = async (req, res) => {
|
||||||
|
if (process.env.NODE_ENV !== "production") return res.sendStatus(403);
|
||||||
|
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) return res.sendStatus(401);
|
||||||
|
|
||||||
|
res.status(202).json({
|
||||||
|
success: true,
|
||||||
|
message: "Processing request ...",
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.log("chatter-api-start", "DEBUG", "api", null, null);
|
||||||
|
|
||||||
|
const allErrors = [];
|
||||||
|
const allShopSummaries = [];
|
||||||
|
|
||||||
|
// Shops that DO have chatter_company_id
|
||||||
|
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS_WITH_COMPANY);
|
||||||
|
|
||||||
|
const specificShopIds = req.body.bodyshopIds;
|
||||||
|
const { start, end, skipUpload } = req.body; // keep same flag; now acts like "dry run"
|
||||||
|
|
||||||
|
const shopsToProcess =
|
||||||
|
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
|
||||||
|
|
||||||
|
logger.log("chatter-api-shopsToProcess-generated", "DEBUG", "api", null, { count: shopsToProcess.length });
|
||||||
|
|
||||||
|
if (shopsToProcess.length === 0) {
|
||||||
|
logger.log("chatter-api-shopsToProcess-empty", "DEBUG", "api", null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processBatchApi({
|
||||||
|
shopsToProcess,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
skipUpload,
|
||||||
|
allShopSummaries,
|
||||||
|
allErrors
|
||||||
|
});
|
||||||
|
|
||||||
|
const totals = allShopSummaries.reduce(
|
||||||
|
(acc, s) => {
|
||||||
|
acc.shops += 1;
|
||||||
|
acc.jobs += s.jobs || 0;
|
||||||
|
acc.sent += s.sent || 0;
|
||||||
|
acc.duplicates += s.duplicates || 0;
|
||||||
|
acc.failed += s.failed || 0;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendServerEmail({
|
||||||
|
subject: `Chatter API Report ${moment().format("MM-DD-YY")}`,
|
||||||
|
text:
|
||||||
|
`Totals:\n${JSON.stringify(totals, null, 2)}\n\n` +
|
||||||
|
`Shop summaries:\n${JSON.stringify(allShopSummaries, null, 2)}\n\n` +
|
||||||
|
`Errors:\n${JSON.stringify(allErrors, null, 2)}\n`
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log("chatter-api-end", "DEBUG", "api", null, totals);
|
||||||
|
} catch (error) {
|
||||||
|
logger.log("chatter-api-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors }) {
|
||||||
|
for (const bodyshop of shopsToProcess) {
|
||||||
|
const summary = {
|
||||||
|
bodyshopid: bodyshop.id,
|
||||||
|
imexshopid: bodyshop.imexshopid,
|
||||||
|
shopname: bodyshop.shopname,
|
||||||
|
chatter_company_id: bodyshop.chatter_company_id,
|
||||||
|
chatterid: bodyshop.chatterid,
|
||||||
|
jobs: 0,
|
||||||
|
sent: 0,
|
||||||
|
duplicates: 0,
|
||||||
|
failed: 0,
|
||||||
|
ok: true
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.log("chatter-api-start-shop", "DEBUG", "api", bodyshop.id, { shopname: bodyshop.shopname });
|
||||||
|
|
||||||
|
const companyId = parseCompanyId(bodyshop.chatter_company_id);
|
||||||
|
if (!companyId) {
|
||||||
|
summary.ok = false;
|
||||||
|
summary.failed = 0;
|
||||||
|
allErrors.push({
|
||||||
|
...pickShop(bodyshop),
|
||||||
|
fatal: true,
|
||||||
|
errors: [`Invalid chatter_company_id: "${bodyshop.chatter_company_id}"`]
|
||||||
|
});
|
||||||
|
allShopSummaries.push(summary);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatterApi = await getChatterApiClient(companyId);
|
||||||
|
|
||||||
|
const { jobs } = await client.request(queries.CHATTER_QUERY, {
|
||||||
|
bodyshopid: bodyshop.id,
|
||||||
|
start: start ? moment(start).startOf("day") : moment().subtract(1, "days").startOf("day"),
|
||||||
|
...(end && { end: moment(end).endOf("day") })
|
||||||
|
});
|
||||||
|
|
||||||
|
summary.jobs = jobs.length;
|
||||||
|
|
||||||
|
// concurrency-limited posting
|
||||||
|
const limit = createConcurrencyLimit(MAX_CONCURRENCY);
|
||||||
|
const results = await Promise.all(
|
||||||
|
jobs.map((j) =>
|
||||||
|
limit(async () => {
|
||||||
|
const payload = buildInteractionPayload(bodyshop, j);
|
||||||
|
|
||||||
|
// keep legacy flag name: skipUpload == dry-run
|
||||||
|
if (skipUpload) return { ok: true, dryRun: true };
|
||||||
|
|
||||||
|
const r = await postInteractionWithPolicy(chatterApi, companyId, payload);
|
||||||
|
return r;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
if (r?.dryRun) continue;
|
||||||
|
if (r?.ok && r?.duplicate) summary.duplicates += 1;
|
||||||
|
else if (r?.ok) summary.sent += 1;
|
||||||
|
else summary.failed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// record failures with some detail (cap to avoid huge emails)
|
||||||
|
const failures = results
|
||||||
|
.filter((r) => r && r.ok === false)
|
||||||
|
.slice(0, 25)
|
||||||
|
.map((r) => ({ status: r.status, error: r.error }));
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
summary.ok = false;
|
||||||
|
allErrors.push({
|
||||||
|
...pickShop(bodyshop),
|
||||||
|
fatal: false,
|
||||||
|
errors: failures
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log("chatter-api-end-shop", "DEBUG", "api", bodyshop.id, summary);
|
||||||
|
} catch (error) {
|
||||||
|
summary.ok = false;
|
||||||
|
|
||||||
|
logger.log("chatter-api-error-shop", "ERROR", "api", bodyshop.id, {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
});
|
||||||
|
|
||||||
|
allErrors.push({
|
||||||
|
...pickShop(bodyshop),
|
||||||
|
fatal: true,
|
||||||
|
errors: [error.toString()]
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
allShopSummaries.push(summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInteractionPayload(bodyshop, j) {
|
||||||
|
const isCompany = Boolean(j.ownr_co_nm);
|
||||||
|
|
||||||
|
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
locationIdentifier: locationIdentifier,
|
||||||
|
event: CHATTER_EVENT,
|
||||||
|
transactionId: j.ro_number != null ? String(j.ro_number) : undefined,
|
||||||
|
timestamp: j.actual_delivery ? moment(j.actual_delivery).tz(bodyshop.timezone).toISOString() : undefined,
|
||||||
|
firstName: isCompany ? null : j.ownr_fn || null,
|
||||||
|
lastName: isCompany ? j.ownr_co_nm : j.ownr_ln || null,
|
||||||
|
emailAddress: j.ownr_ea || undefined,
|
||||||
|
phoneNumber: j.ownr_ph1 || undefined,
|
||||||
|
metadata: {
|
||||||
|
imexShopId: bodyshop.imexshopid,
|
||||||
|
bodyshopId: bodyshop.id,
|
||||||
|
jobId: j.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postInteractionWithPolicy(chatterApi, companyId, payload) {
|
||||||
|
for (let attempt = 0; attempt < 6; attempt++) {
|
||||||
|
try {
|
||||||
|
await chatterApi.postInteraction(companyId, payload);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
// duplicate -> treat as successful idempotency outcome
|
||||||
|
if (e.status === 409) return { ok: true, duplicate: true, error: e.data };
|
||||||
|
|
||||||
|
// rate limited -> backoff + retry
|
||||||
|
if (e.status === 429) {
|
||||||
|
await sleep(backoffMs(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, status: e.status, error: e.data ?? e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: false, status: 429, error: "rate limit retry exhausted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCompanyId(val) {
|
||||||
|
const s = String(val ?? "").trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickShop(bodyshop) {
|
||||||
|
return {
|
||||||
|
bodyshopid: bodyshop.id,
|
||||||
|
imexshopid: bodyshop.imexshopid,
|
||||||
|
shopname: bodyshop.shopname,
|
||||||
|
chatter_company_id: bodyshop.chatter_company_id,
|
||||||
|
chatterid: bodyshop.chatterid
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function backoffMs(attempt) {
|
||||||
|
const base = Math.min(30_000, 500 * 2 ** attempt);
|
||||||
|
const jitter = Math.floor(Math.random() * 250);
|
||||||
|
return base + jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConcurrencyLimit(max) {
|
||||||
|
let active = 0;
|
||||||
|
const queue = [];
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
if (active >= max) return;
|
||||||
|
const fn = queue.shift();
|
||||||
|
if (!fn) return;
|
||||||
|
active++;
|
||||||
|
fn()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
active--;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (fn) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
queue.push(async () => {
|
||||||
|
try {
|
||||||
|
resolve(await fn());
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a per-company Chatter API client, caching both the token and the client.
|
||||||
|
*/
|
||||||
|
async function getChatterApiClient(companyId) {
|
||||||
|
const key = String(companyId);
|
||||||
|
|
||||||
|
const existing = clientCache.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const apiToken = await getChatterApiTokenCached(companyId);
|
||||||
|
const chatterApi = new ChatterApiClient({ baseUrl: CHATTER_BASE_URL, apiToken });
|
||||||
|
|
||||||
|
clientCache.set(key, chatterApi);
|
||||||
|
return chatterApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the per-company token from AWS Secrets Manager with caching
|
||||||
|
* SecretId: CHATTER_COMPANY_KEY_<companyId>
|
||||||
|
*
|
||||||
|
* Uses caching + in-flight dedupe to avoid hammering Secrets Manager.
|
||||||
|
*/
|
||||||
|
async function getChatterApiTokenCached(companyId) {
|
||||||
|
const key = String(companyId ?? "").trim();
|
||||||
|
if (!key) throw new Error("getChatterApiToken: companyId is required");
|
||||||
|
|
||||||
|
// Optional override for emergency/dev
|
||||||
|
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
|
||||||
|
|
||||||
|
const cached = tokenCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const inflight = tokenInFlight.get(key);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
|
||||||
|
const p = (async () => {
|
||||||
|
logger.log("chatter-api-get-token", "DEBUG", "api", null, { companyId: key });
|
||||||
|
|
||||||
|
// Use the shared function from chatter-client
|
||||||
|
const token = await getChatterApiToken(companyId);
|
||||||
|
tokenCache.set(key, token);
|
||||||
|
return token;
|
||||||
|
})();
|
||||||
|
|
||||||
|
tokenInFlight.set(key, p);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await p;
|
||||||
|
} finally {
|
||||||
|
tokenInFlight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ const converter = require("json-2-csv");
|
|||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||||
|
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
|
||||||
|
const { isString, isEmpty } = require("lodash");
|
||||||
let Client = require("ssh2-sftp-client");
|
let Client = require("ssh2-sftp-client");
|
||||||
|
|
||||||
const client = require("../graphql-client/graphql-client").client;
|
const client = require("../graphql-client/graphql-client").client;
|
||||||
@@ -144,7 +146,18 @@ async function processBatch(shopsToProcess, start, end, allChatterObjects, allEr
|
|||||||
|
|
||||||
async function getPrivateKey() {
|
async function getPrivateKey() {
|
||||||
// Connect to AWS Secrets Manager
|
// Connect to AWS Secrets Manager
|
||||||
const client = new SecretsManagerClient({ region: "ca-central-1" });
|
const secretsClientOptions = {
|
||||||
|
region: "ca-central-1",
|
||||||
|
credentials: defaultProvider()
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
|
||||||
|
|
||||||
|
if (isLocal) {
|
||||||
|
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new SecretsManagerClient(secretsClientOptions);
|
||||||
const command = new GetSecretValueCommand({ SecretId: "CHATTER_PRIVATE_KEY" });
|
const command = new GetSecretValueCommand({ SecretId: "CHATTER_PRIVATE_KEY" });
|
||||||
|
|
||||||
logger.log("chatter-get-private-key", "DEBUG", "api", null, null);
|
logger.log("chatter-get-private-key", "DEBUG", "api", null, null);
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ exports.carfax = require("./carfax").default;
|
|||||||
exports.carfaxRps = require("./carfax-rps").default;
|
exports.carfaxRps = require("./carfax-rps").default;
|
||||||
exports.vehicletype = require("./vehicletype/vehicletype").default;
|
exports.vehicletype = require("./vehicletype/vehicletype").default;
|
||||||
exports.documentAnalytics = require("./analytics/documents").default;
|
exports.documentAnalytics = require("./analytics/documents").default;
|
||||||
|
exports.chatterApi = require("chatter-api").default();
|
||||||
|
|||||||
@@ -1929,6 +1929,22 @@ exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS {
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
exports.GET_CHATTER_SHOPS_WITH_COMPANY = `query GET_CHATTER_SHOPS_WITH_COMPANY {
|
||||||
|
bodyshops(
|
||||||
|
where: {
|
||||||
|
chatterid: { _is_null: false, _neq: "" }
|
||||||
|
chatter_company_id: { _is_null: false, _neq: "" }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
shopname
|
||||||
|
chatterid
|
||||||
|
chatter_company_id
|
||||||
|
imexshopid
|
||||||
|
timezone
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
exports.GET_CARFAX_SHOPS = `query GET_CARFAX_SHOPS {
|
exports.GET_CARFAX_SHOPS = `query GET_CARFAX_SHOPS {
|
||||||
bodyshops(where: {external_shop_id: {_is_null: true}, carfax_exclude: {_neq: "true"}}){
|
bodyshops(where: {external_shop_id: {_is_null: true}, carfax_exclude: {_neq: "true"}}){
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { autohouse, claimscorp, chatter, kaizen, usageReport, podium, carfax, carfaxRps } = require("../data/data");
|
const {
|
||||||
|
autohouse,
|
||||||
|
claimscorp,
|
||||||
|
chatter,
|
||||||
|
kaizen,
|
||||||
|
usageReport,
|
||||||
|
podium,
|
||||||
|
carfax,
|
||||||
|
carfaxRps,
|
||||||
|
chatterApi
|
||||||
|
} = require("../data/data");
|
||||||
|
|
||||||
router.post("/ah", autohouse);
|
router.post("/ah", autohouse);
|
||||||
router.post("/cc", claimscorp);
|
router.post("/cc", claimscorp);
|
||||||
router.post("/chatter", chatter);
|
router.post("/chatter", chatter);
|
||||||
|
router.post("/chatter-api", chatterApi);
|
||||||
router.post("/kaizen", kaizen);
|
router.post("/kaizen", kaizen);
|
||||||
router.post("/usagereport", usageReport);
|
router.post("/usagereport", usageReport);
|
||||||
router.post("/podium", podium);
|
router.post("/podium", podium);
|
||||||
|
|||||||
Reference in New Issue
Block a user