From 3745d7a414b6306f44702481f2b9679f789524d5 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 10 Feb 2026 12:48:48 -0500 Subject: [PATCH 1/3] feature/IO-3556-Chattr-Integration --- .ebignore | 3 +- docker-compose-cluster.yml | 51 ++------------- docker-compose.yml | 50 ++------------ hasura/metadata/tables.yaml | 2 + .../down.sql | 4 ++ .../up.sql | 2 + localstack/init/10-bootstrap.sh | 65 +++++++++++++++++++ server.js | 1 + server/chatter/createLocation.js | 22 +++++++ server/graphql-client/queries.js | 11 +++- server/routes/chatterRoutes.js | 12 ++++ 11 files changed, 131 insertions(+), 92 deletions(-) create mode 100644 hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/down.sql create mode 100644 hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/up.sql create mode 100644 localstack/init/10-bootstrap.sh create mode 100644 server/chatter/createLocation.js create mode 100644 server/routes/chatterRoutes.js diff --git a/.ebignore b/.ebignore index f407b0596..4043a0f46 100644 --- a/.ebignore +++ b/.ebignore @@ -13,4 +13,5 @@ .env.development.local .env.test.local .env.production.local -bodyshop_translations.babel \ No newline at end of file +.env.localstack.docker +bodyshop_translations.babel diff --git a/docker-compose-cluster.yml b/docker-compose-cluster.yml index bbce31dd4..6988d4d35 100644 --- a/docker-compose-cluster.yml +++ b/docker-compose-cluster.yml @@ -38,8 +38,6 @@ services: condition: service_healthy localstack: condition: service_healthy - aws-cli: - condition: service_completed_successfully ports: - "4001:4000" # Different external port for local access volumes: @@ -65,8 +63,6 @@ services: condition: service_healthy localstack: condition: service_healthy - aws-cli: - condition: service_completed_successfully ports: - "4002:4000" # Different external port for local access volumes: @@ -92,8 +88,6 @@ services: condition: service_healthy localstack: condition: service_healthy - aws-cli: - condition: service_completed_successfully ports: - "4003:4000" # Different external port for local access volumes: @@ -156,23 +150,18 @@ services: # LocalStack localstack: - image: localstack/localstack + image: localstack/localstack:4.13.1 container_name: localstack hostname: localstack networks: - redis-cluster-net restart: unless-stopped volumes: + - ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/... + - ./localstack/init:/etc/localstack/init/ready.d:ro - /var/run/docker.sock:/var/run/docker.sock - environment: - - SERVICES=s3,ses,secretsmanager,cloudwatch,logs - - DEBUG=0 - - AWS_ACCESS_KEY_ID=test - - AWS_SECRET_ACCESS_KEY=test - - AWS_DEFAULT_REGION=ca-central-1 - - EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type - - EXTRA_CORS_ALLOWED_ORIGINS=* - - EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type + env_file: + - .env.localstack.docker ports: - "4566:4566" healthcheck: @@ -182,36 +171,6 @@ services: retries: 5 start_period: 20s - # AWS-CLI - aws-cli: - image: amazon/aws-cli - container_name: aws-cli - hostname: aws-cli - networks: - - redis-cluster-net - depends_on: - localstack: - condition: service_healthy - volumes: - - './localstack:/tmp/localstack' - - './certs:/tmp/certs' - environment: - - AWS_ACCESS_KEY_ID=test - - AWS_SECRET_ACCESS_KEY=test - - AWS_DEFAULT_REGION=ca-central-1 - entrypoint: /bin/sh -c - command: > - " - aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1 - aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1 - aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key - aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1 - " - networks: redis-cluster-net: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 0662dd9bd..f2a0f160c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,23 +68,18 @@ services: # LocalStack: Used to emulate AWS services locally, currently setup for SES # Notes: Set the ENV Debug to 1 for additional logging localstack: - image: localstack/localstack + image: localstack/localstack:4.13.1 container_name: localstack hostname: localstack networks: - redis-cluster-net restart: unless-stopped volumes: + - ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/... + - ./localstack/init:/etc/localstack/init/ready.d:ro - /var/run/docker.sock:/var/run/docker.sock - environment: - - SERVICES=s3,ses,secretsmanager,cloudwatch,logs - - DEBUG=0 - - AWS_ACCESS_KEY_ID=test - - AWS_SECRET_ACCESS_KEY=test - - AWS_DEFAULT_REGION=ca-central-1 - - EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type - - EXTRA_CORS_ALLOWED_ORIGINS=* - - EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type + env_file: + - .env.localstack.docker ports: - "4566:4566" healthcheck: @@ -94,38 +89,6 @@ services: retries: 5 start_period: 20s - # AWS-CLI - Used in conjunction with LocalStack to set required permission to send emails - aws-cli: - image: amazon/aws-cli - container_name: aws-cli - hostname: aws-cli - networks: - - redis-cluster-net - depends_on: - localstack: - condition: service_healthy - volumes: - - './localstack:/tmp/localstack' - - './certs:/tmp/certs' - - environment: - - AWS_ACCESS_KEY_ID=test - - AWS_SECRET_ACCESS_KEY=test - - AWS_DEFAULT_REGION=ca-central-1 - entrypoint: /bin/sh -c - command: > - " - aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1 - aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1 - aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key - aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket parts-estimates --create-bucket-configuration LocationConstraint=ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1 - aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1 - " # Node App: The Main IMEX API node-app: build: @@ -145,8 +108,7 @@ services: condition: service_healthy localstack: condition: service_healthy - aws-cli: - condition: service_completed_successfully + ports: - "4000:4000" - "9229:9229" diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 7cf11c8de..2f29ac26c 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -947,6 +947,7 @@ - carfax_exclude - cdk_configuration - cdk_dealerid + - chatter_company_id - chatterid - city - claimscorpid @@ -1063,6 +1064,7 @@ - bill_allow_post_to_closed - bill_tax_rates - cdk_configuration + - chatter_company_id - city - country - created_at diff --git a/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/down.sql b/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/down.sql new file mode 100644 index 000000000..291344aa9 --- /dev/null +++ b/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."bodyshops" add column "chatter_company_id" text +-- null; diff --git a/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/up.sql b/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/up.sql new file mode 100644 index 000000000..4a6e1e8d4 --- /dev/null +++ b/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/up.sql @@ -0,0 +1,2 @@ +alter table "public"."bodyshops" add column "chatter_company_id" text + null; diff --git a/localstack/init/10-bootstrap.sh b/localstack/init/10-bootstrap.sh new file mode 100644 index 000000000..ee8183d1e --- /dev/null +++ b/localstack/init/10-bootstrap.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +echo "Running LocalStack bootstrap script: 10-bootstrap.sh" + +set -euo pipefail + +REGION="${AWS_DEFAULT_REGION:-ca-central-1}" + +# awslocal is the LocalStack wrapper so you don't need --endpoint-url +# (it targets the LocalStack gateway automatically) +# Docs: https://docs.localstack.cloud/.../aws-cli/ +ensure_bucket() { + local b="$1" + if ! awslocal s3api head-bucket --bucket "$b" >/dev/null 2>&1; then + awslocal s3api create-bucket \ + --bucket "$b" \ + --create-bucket-configuration LocationConstraint="$REGION" \ + --region "$REGION" >/dev/null + fi +} + +ensure_log_group() { + local lg="$1" + awslocal logs create-log-group --log-group-name "$lg" --region "$REGION" >/dev/null 2>&1 || true +} + +ensure_secret_string() { + local name="$1" + local value="$2" + + if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then + awslocal secretsmanager update-secret --secret-id "$name" --secret-string "$value" >/dev/null + else + awslocal secretsmanager create-secret --name "$name" --secret-string "$value" >/dev/null + fi +} + +ensure_secret_file() { + local name="$1" + local filepath="$2" + + if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then + awslocal secretsmanager update-secret --secret-id "$name" --secret-string "file://$filepath" >/dev/null + else + awslocal secretsmanager create-secret --name "$name" --secret-string "file://$filepath" >/dev/null + fi +} + +# SES identities (idempotent-ish; ignoring if it already exists) +awslocal ses verify-domain-identity --domain imex.online --region "$REGION" >/dev/null || true +awslocal ses verify-email-identity --email-address noreply@imex.online --region "$REGION" >/dev/null || true + +# Secrets +ensure_secret_file "CHATTER_PRIVATE_KEY" "/tmp/certs/io-ftp-test.key" +ensure_secret_string "CHATTER_COMPANY_KEY_6713" "${CHATTER_COMPANY_KEY_6713:-REPLACE_ME}" + +# Logs +ensure_log_group "development" + +# Buckets +ensure_bucket "imex-job-totals" +ensure_bucket "parts-estimate" +ensure_bucket "imex-large-log" +ensure_bucket "imex-carfax-uploads" +ensure_bucket "rome-carfax-uploads" +ensure_bucket "rps-carfax-uploads" diff --git a/server.js b/server.js index 099ae3562..07901ab6e 100644 --- a/server.js +++ b/server.js @@ -125,6 +125,7 @@ const applyRoutes = ({ app }) => { app.use("/payroll", require("./server/routes/payrollRoutes")); app.use("/sso", require("./server/routes/ssoRoutes")); app.use("/integrations", require("./server/routes/intergrationRoutes")); + app.use("/chatter", require("./server/routes/chatterRoutes")); // Default route for forbidden access app.get("/", (req, res) => { diff --git a/server/chatter/createLocation.js b/server/chatter/createLocation.js new file mode 100644 index 000000000..2bd85f95f --- /dev/null +++ b/server/chatter/createLocation.js @@ -0,0 +1,22 @@ +const DEFAULT_COMPANY_ID = process.env.CHATTER_DEFAULT_COMPANY_ID; + +const createLocation = (req, res) => { + const { logger } = req; + const { bodyshopID } = req.body; + + // No Default company + if (!DEFAULT_COMPANY_ID) { + logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID }); + return res.json({ success: false }); + } + + // No Bodyshop data available + if (!bodyshopID) { + logger.log("chatter-create-location-invalid-bodyshop", "warn", null, null, { bodyshopID }); + return res.json({ success: false }); + } + + return res.json({ success: true }); +}; + +module.exports = createLocation; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 54714f723..e6576b080 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -1911,10 +1911,19 @@ exports.GET_AUTOHOUSE_SHOPS = `query GET_AUTOHOUSE_SHOPS { }`; exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS { - bodyshops(where: {chatterid: {_is_null: false}, _or: {chatterid: {_neq: ""}}}){ + bodyshops( + where: { + chatterid: { _is_null: false, _neq: "" } + _or: [ + { chatter_company_id: { _is_null: true } } + { chatter_company_id: { _eq: "" } } + ] + } + ) { id shopname chatterid + chatter_company_id imexshopid timezone } diff --git a/server/routes/chatterRoutes.js b/server/routes/chatterRoutes.js new file mode 100644 index 000000000..52675bc9b --- /dev/null +++ b/server/routes/chatterRoutes.js @@ -0,0 +1,12 @@ +const express = require("express"); +const createLocation = require("../chatter/createLocation"); +const router = express.Router(); +const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); +const validateAdminMiddleware = require("../middleware/validateAdminMiddleware"); + +router.use(validateFirebaseIdTokenMiddleware); +router.use(validateAdminMiddleware); + +router.post("/create-location", createLocation); + +module.exports = router; From 1b2fc8b11477032443a7f4abf8b0511346b57d66 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 10 Feb 2026 17:17:44 -0500 Subject: [PATCH 2/3] feature/IO-3556-Chattr-Integration --- server/chatter/chatter-client.js | 126 +++++++++++ server/chatter/createLocation.js | 115 +++++++++- server/data/chatter-api.js | 350 +++++++++++++++++++++++++++++++ server/data/chatter.js | 15 +- server/data/data.js | 3 +- server/graphql-client/queries.js | 16 ++ server/routes/dataRoutes.js | 13 +- 7 files changed, 628 insertions(+), 10 deletions(-) create mode 100644 server/chatter/chatter-client.js create mode 100644 server/data/chatter-api.js diff --git a/server/chatter/chatter-client.js b/server/chatter/chatter-client.js new file mode 100644 index 000000000..a25f0c812 --- /dev/null +++ b/server/chatter/chatter-client.js @@ -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_ + * + * @param {string|number} companyId - The company ID + * @returns {Promise} 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} 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 +}; diff --git a/server/chatter/createLocation.js b/server/chatter/createLocation.js index 2bd85f95f..46766dcf8 100644 --- a/server/chatter/createLocation.js +++ b/server/chatter/createLocation.js @@ -1,22 +1,123 @@ 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 { bodyshopID } = req.body; + const { bodyshopID, googlePlaceID } = req.body; + + console.dir({ body: req.body }); - // No Default company if (!DEFAULT_COMPANY_ID) { 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) { 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; diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js new file mode 100644 index 000000000..e49e35e42 --- /dev/null +++ b/server/data/chatter-api.js @@ -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 +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_ + * + * 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); + } +} diff --git a/server/data/chatter.js b/server/data/chatter.js index 45402a67e..86182fbf9 100644 --- a/server/data/chatter.js +++ b/server/data/chatter.js @@ -4,6 +4,8 @@ const converter = require("json-2-csv"); const logger = require("../utils/logger"); const fs = require("fs"); 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"); const client = require("../graphql-client/graphql-client").client; @@ -144,7 +146,18 @@ async function processBatch(shopsToProcess, start, end, allChatterObjects, allEr async function getPrivateKey() { // 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" }); logger.log("chatter-get-private-key", "DEBUG", "api", null, null); diff --git a/server/data/data.js b/server/data/data.js index 1706d78af..52e72f6b8 100644 --- a/server/data/data.js +++ b/server/data/data.js @@ -9,4 +9,5 @@ exports.emsUpload = require("./emsUpload").default; exports.carfax = require("./carfax").default; exports.carfaxRps = require("./carfax-rps").default; exports.vehicletype = require("./vehicletype/vehicletype").default; -exports.documentAnalytics = require("./analytics/documents").default; \ No newline at end of file +exports.documentAnalytics = require("./analytics/documents").default; +exports.chatterApi = require("chatter-api").default(); diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index e6576b080..73d132a93 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -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 { bodyshops(where: {external_shop_id: {_is_null: true}, carfax_exclude: {_neq: "true"}}){ id diff --git a/server/routes/dataRoutes.js b/server/routes/dataRoutes.js index c72a2a502..9024f13fc 100644 --- a/server/routes/dataRoutes.js +++ b/server/routes/dataRoutes.js @@ -1,10 +1,21 @@ const express = require("express"); 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("/cc", claimscorp); router.post("/chatter", chatter); +router.post("/chatter-api", chatterApi); router.post("/kaizen", kaizen); router.post("/usagereport", usageReport); router.post("/podium", podium); From 0340ca5fccd11d9e22231d5c5040738d53847aa6 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 10 Feb 2026 17:25:59 -0500 Subject: [PATCH 3/3] feature/IO-3556-Chattr-Integration - Add in Redis caching for Chatter --- server/data/chatter-api.js | 59 +++++++++++++++++----------------- server/utils/redisHelpers.js | 61 +++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js index e49e35e42..89f29b8ae 100644 --- a/server/data/chatter-api.js +++ b/server/data/chatter-api.js @@ -2,32 +2,16 @@ 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 +// Client caching (in-memory) - tokens are now cached in Redis const clientCache = new Map(); // companyId -> ChatterApiClient +const tokenInFlight = new Map(); // companyId -> Promise (for in-flight deduplication) exports.default = async (req, res) => { if (process.env.NODE_ENV !== "production") return res.sendStatus(403); @@ -67,7 +51,8 @@ exports.default = async (req, res) => { end, skipUpload, allShopSummaries, - allErrors + allErrors, + sessionUtils: req.sessionUtils }); const totals = allShopSummaries.reduce( @@ -96,7 +81,7 @@ exports.default = async (req, res) => { } }; -async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors }) { +async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors, sessionUtils }) { for (const bodyshop of shopsToProcess) { const summary = { bodyshopid: bodyshop.id, @@ -127,7 +112,7 @@ async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShop continue; } - const chatterApi = await getChatterApiClient(companyId); + const chatterApi = await getChatterApiClient(companyId, sessionUtils); const { jobs } = await client.request(queries.CHATTER_QUERY, { bodyshopid: bodyshop.id, @@ -299,13 +284,13 @@ function createConcurrencyLimit(max) { /** * Returns a per-company Chatter API client, caching both the token and the client. */ -async function getChatterApiClient(companyId) { +async function getChatterApiClient(companyId, sessionUtils) { const key = String(companyId); const existing = clientCache.get(key); if (existing) return existing; - const apiToken = await getChatterApiTokenCached(companyId); + const apiToken = await getChatterApiTokenCached(companyId, sessionUtils); const chatterApi = new ChatterApiClient({ baseUrl: CHATTER_BASE_URL, apiToken }); clientCache.set(key, chatterApi); @@ -313,30 +298,42 @@ async function getChatterApiClient(companyId) { } /** - * Fetches the per-company token from AWS Secrets Manager with caching + * Fetches the per-company token from AWS Secrets Manager with Redis caching * SecretId: CHATTER_COMPANY_KEY_ * - * Uses caching + in-flight dedupe to avoid hammering Secrets Manager. + * Uses Redis caching + in-flight dedupe to avoid hammering Secrets Manager. */ -async function getChatterApiTokenCached(companyId) { +async function getChatterApiTokenCached(companyId, sessionUtils) { 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; + // Check Redis cache if sessionUtils is available + if (sessionUtils?.getChatterToken) { + const cachedToken = await sessionUtils.getChatterToken(key); + if (cachedToken) { + logger.log("chatter-api-get-token-cache-hit", "DEBUG", "api", null, { companyId: key }); + return cachedToken; + } + } + // Check for in-flight requests const inflight = tokenInFlight.get(key); if (inflight) return inflight; const p = (async () => { - logger.log("chatter-api-get-token", "DEBUG", "api", null, { companyId: key }); + logger.log("chatter-api-get-token-cache-miss", "DEBUG", "api", null, { companyId: key }); - // Use the shared function from chatter-client + // Fetch token from Secrets Manager using shared function const token = await getChatterApiToken(companyId); - tokenCache.set(key, token); + + // Store in Redis cache if sessionUtils is available + if (sessionUtils?.setChatterToken) { + await sessionUtils.setChatterToken(key, token); + } + return token; })(); diff --git a/server/utils/redisHelpers.js b/server/utils/redisHelpers.js index c8336b607..a317e7e7a 100644 --- a/server/utils/redisHelpers.js +++ b/server/utils/redisHelpers.js @@ -8,6 +8,12 @@ const client = require("../graphql-client/graphql-client").client; */ const BODYSHOP_CACHE_TTL = 3600; // 1 hour +/** + * Chatter API token cache TTL in seconds + * @type {number} + */ +const CHATTER_TOKEN_CACHE_TTL = 3600; // 1 hour + /** * Generate a cache key for a bodyshop * @param bodyshopId @@ -15,6 +21,13 @@ const BODYSHOP_CACHE_TTL = 3600; // 1 hour */ const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`; +/** + * Generate a cache key for a Chatter API token + * @param companyId + * @returns {`chatter-token:${string}`} + */ +const getChatterTokenCacheKey = (companyId) => `chatter-token:${companyId}`; + /** * Generate a cache key for a user socket mapping * @param email @@ -373,9 +386,53 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => { */ const getProviderCache = (ns, field) => getSessionData(`${ns}:provider`, field); + /** + * Get Chatter API token from Redis cache + * @param companyId + * @returns {Promise} + */ + const getChatterToken = async (companyId) => { + const key = getChatterTokenCacheKey(companyId); + try { + const token = await pubClient.get(key); + return token; + } catch (error) { + logger.log("get-chatter-token-from-redis", "ERROR", "redis", null, { + companyId, + error: error.message + }); + return null; + } + }; + + /** + * Set Chatter API token in Redis cache + * @param companyId + * @param token + * @returns {Promise} + */ + const setChatterToken = async (companyId, token) => { + const key = getChatterTokenCacheKey(companyId); + try { + await pubClient.set(key, token); + await pubClient.expire(key, CHATTER_TOKEN_CACHE_TTL); + devDebugLogger("chatter-token-cache-set", { + companyId, + action: "Token cached" + }); + } catch (error) { + logger.log("set-chatter-token-in-redis", "ERROR", "redis", null, { + companyId, + error: error.message + }); + throw error; + } + }; + const api = { getUserSocketMappingKey, getBodyshopCacheKey, + getChatterTokenCacheKey, setSessionData, getSessionData, clearSessionData, @@ -390,7 +447,9 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => { getSessionTransactionData, clearSessionTransactionData, setProviderCache, - getProviderCache + getProviderCache, + getChatterToken, + setChatterToken }; Object.assign(module.exports, api);