import axios from "axios"; type JsonObject = Record; type SnapshotItem = { key: string; serialized: string; data: JsonObject; trackedRows: TrackedRow[]; trackedSignature: string; }; type TrackedRow = { key: string; product: string; onHand: string; inventory: string; }; const POLL_MS = 60_000; const CW_BASE_URL = process.env.CW_BASE_URL ?? "https://ttscw.totaltech.net/v4_6_release/apis/3.0"; const ENDPOINT = "/procurement/adjustments?pageSize=1000"; const CW_BASIC_TOKEN = process.env.CW_BASIC_TOKEN; const CW_CLIENT_ID = process.env.CW_CLIENT_ID; if (!CW_BASIC_TOKEN || !CW_CLIENT_ID) { console.error( "Missing required env vars: CW_BASIC_TOKEN and/or CW_CLIENT_ID", ); process.exit(1); } const cw = axios.create({ baseURL: CW_BASE_URL, headers: { Authorization: `Basic ${CW_BASIC_TOKEN}`, clientId: CW_CLIENT_ID, "Content-Type": "application/json", }, timeout: 30_000, }); const isObject = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value); const stableStringify = (value: unknown): string => { if (Array.isArray(value)) { const entries = value.map((entry) => stableStringify(entry)).sort(); return `[${entries.join(",")}]`; } if (isObject(value)) { const keys = Object.keys(value).sort(); const pairs = keys.map( (key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`, ); return `{${pairs.join(",")}}`; } return JSON.stringify(value); }; const toObject = (value: unknown): JsonObject => { if (!isObject(value)) return {}; return value; }; const readPathValue = (obj: JsonObject, path: string): unknown => { const parts = path.split("."); let current: unknown = obj; for (const part of parts) { if (!isObject(current)) return null; current = current[part]; } return current; }; const asKey = (value: unknown): string | null => { if (typeof value === "string" && value.length > 0) return value; if (typeof value === "number") return value.toString(); return null; }; const firstValue = (obj: JsonObject, paths: string[]): unknown => { for (const path of paths) { const value = readPathValue(obj, path); if (value === null || value === undefined || value === "") continue; return value; } return null; }; const itemKey = (adjustment: JsonObject): string => { const keyPaths = [ "id", "adjustmentId", "procurementAdjustmentId", "recordId", "recId", "_info.id", "_info.href", ]; for (const keyPath of keyPaths) { const keyValue = asKey(readPathValue(adjustment, keyPath)); if (keyValue) return keyValue; } return `anon:${stableStringify(adjustment)}`; }; const summarize = (adjustment: JsonObject) => { const pick = (...paths: string[]) => { for (const path of paths) { const value = readPathValue(adjustment, path); if (value !== null && value !== undefined && value !== "") return value; } return "-"; }; return { id: pick("id", "adjustmentId", "procurementAdjustmentId", "recordId"), type: pick( "type.name", "type.identifier", "type.id", "type", "adjustmentType.name", "adjustmentType", "transactionType.name", "transactionType", ), amount: pick("amount", "value", "total", "quantity"), status: pick("status.name", "status", "state"), description: pick("description", "summary", "notes"), updatedBy: pick("_info.updatedBy", "updatedBy", "lastUpdatedBy"), lastUpdated: pick("_info.lastUpdated", "lastUpdated", "dateUpdated"), }; }; const normalizeValue = (value: unknown): string => { if (value === null || value === undefined || value === "") return "-"; return toDisplayValue(value); }; const isMeaningfulQuantity = (value: string) => value !== "-"; const toTrackedRow = (detail: JsonObject): TrackedRow | null => { const productValue = firstValue(detail, [ "product.name", "product.identifier", "product.id", "item.name", "item.identifier", "item.id", "catalogItem.name", "catalogItem.identifier", "catalogItem.id", "productIdentifier", "productName", "sku", "identifier", "id", ]); const onHandValue = firstValue(detail, [ "onHand", "onHandQty", "onHandQuantity", "qtyOnHand", "quantityOnHand", "quantity.onHand", ]); const inventoryValue = firstValue(detail, [ "inventory", "inventoryQty", "inventoryLevel", "quantity", "qty", ]); const onHand = normalizeValue(onHandValue); const inventory = normalizeValue(inventoryValue); const hasMeaningfulQuantity = isMeaningfulQuantity(onHand) || isMeaningfulQuantity(inventory); if (!hasMeaningfulQuantity) return null; const product = normalizeValue(productValue); const rowKey = `${product}|${onHand}|${inventory}`; return { key: rowKey, product, onHand, inventory, }; }; const getTrackedRows = (adjustment: JsonObject): TrackedRow[] => { const detailCandidates = [ readPathValue(adjustment, "adjustmentDetails"), readPathValue(adjustment, "details"), readPathValue(adjustment, "lineItems"), ]; for (const candidate of detailCandidates) { if (!Array.isArray(candidate)) continue; const rows = candidate .map((entry) => toTrackedRow(toObject(entry))) .filter((entry): entry is TrackedRow => entry !== null) .sort((a, b) => a.key.localeCompare(b.key)); if (rows.length > 0) return rows; } const rootRow = toTrackedRow(adjustment); if (!rootRow) return []; return [rootRow]; }; const toDisplayValue = (value: unknown): string => { if (value === null || value === undefined || value === "") return "-"; if ( typeof value === "string" || typeof value === "number" || typeof value === "boolean" ) { return String(value); } if (Array.isArray(value)) { return `[${value.map((entry) => toDisplayValue(entry)).join(",")}]`; } if (!isObject(value)) return String(value); const commonObjectPaths = ["name", "identifier", "id", "value", "code"]; for (const path of commonObjectPaths) { const objectValue = readPathValue(value, path); if (objectValue === null || objectValue === undefined || objectValue === "") continue; if (typeof objectValue === "object") continue; return String(objectValue); } return stableStringify(value); }; const clean = (value: unknown): string => { if (value === null || value === undefined || value === "") return "-"; return toDisplayValue(value); }; const diffPaths = ( before: unknown, after: unknown, currentPath = "", paths: string[] = [], maxPaths = 6, ): string[] => { if (paths.length >= maxPaths) return paths; const beforeIsObject = isObject(before); const afterIsObject = isObject(after); if (!beforeIsObject || !afterIsObject) { if (stableStringify(before) === stableStringify(after)) return paths; const label = currentPath || "(root)"; paths.push(label); return paths; } const keys = [ ...new Set([...Object.keys(before), ...Object.keys(after)]), ].sort(); for (const key of keys) { if (paths.length >= maxPaths) return paths; const nextPath = currentPath ? `${currentPath}.${key}` : key; diffPaths(before[key], after[key], nextPath, paths, maxPaths); } return paths; }; const toLine = (kind: "+" | "~" | "-", adjustment: JsonObject): string => { const s = summarize(adjustment); return `${kind} id=${clean(s.id)} type=${clean(s.type)} status=${clean(s.status)} amount=${clean( s.amount, )} by=${clean(s.updatedBy)} desc=${clean(s.description)}`; }; const updatedToLine = (before: JsonObject, after: JsonObject): string => { const prev = summarize(before); const next = summarize(after); const changed: string[] = []; if (clean(prev.status) !== clean(next.status)) { changed.push(`status:${clean(prev.status)}→${clean(next.status)}`); } if (clean(prev.amount) !== clean(next.amount)) { changed.push(`amount:${clean(prev.amount)}→${clean(next.amount)}`); } if (clean(prev.updatedBy) !== clean(next.updatedBy)) { changed.push(`by:${clean(prev.updatedBy)}→${clean(next.updatedBy)}`); } if (clean(prev.description) !== clean(next.description)) { changed.push(`desc:${clean(prev.description)}→${clean(next.description)}`); } if (clean(prev.lastUpdated) !== clean(next.lastUpdated)) { changed.push( `updated:${clean(prev.lastUpdated)}→${clean(next.lastUpdated)}`, ); } const noisyFields = new Set(["_info.lastUpdated"]); const rawDiffs = diffPaths(before, after).filter( (path) => !noisyFields.has(path), ); const rawDelta = rawDiffs.length > 0 ? `fields:${rawDiffs.join(",")}` : "content changed"; const delta = changed.length > 0 ? changed.join(" | ") : rawDelta; return `~ id=${clean(next.id)} type=${clean(next.type)} ${delta}`; }; const formatTracked = (row: TrackedRow) => `product=${row.product} onHand=${row.onHand} inventory=${row.inventory}`; const trackedAddedLine = (item: SnapshotItem) => { const base = summarize(item.data); const rows = item.trackedRows.slice(0, 3).map(formatTracked).join(" ; "); const more = item.trackedRows.length > 3 ? ` ; +${item.trackedRows.length - 3} more` : ""; return `+ id=${clean(base.id)} type=${clean(base.type)} ${rows}${more}`; }; const trackedRemovedLine = (item: SnapshotItem) => { const base = summarize(item.data); const rows = item.trackedRows.slice(0, 3).map(formatTracked).join(" ; "); const more = item.trackedRows.length > 3 ? ` ; +${item.trackedRows.length - 3} more` : ""; return `- id=${clean(base.id)} type=${clean(base.type)} ${rows}${more}`; }; const trackedUpdatedLine = ( beforeItem: SnapshotItem, afterItem: SnapshotItem, ) => { const base = summarize(afterItem.data); const beforeMap = new Map( beforeItem.trackedRows.map((row) => [row.product, row]), ); const afterMap = new Map( afterItem.trackedRows.map((row) => [row.product, row]), ); const productKeys = [ ...new Set([...beforeMap.keys(), ...afterMap.keys()]), ].sort(); const deltas: string[] = []; for (const product of productKeys) { const prev = beforeMap.get(product); const next = afterMap.get(product); if (!prev && next) { deltas.push( `${product} added(onHand=${next.onHand},inventory=${next.inventory})`, ); continue; } if (prev && !next) { deltas.push(`${product} removed`); continue; } if (!prev || !next) continue; const onHandChanged = prev.onHand !== next.onHand; const inventoryChanged = prev.inventory !== next.inventory; if (!onHandChanged && !inventoryChanged) continue; const parts: string[] = []; onHandChanged ? parts.push(`onHand:${prev.onHand}→${next.onHand}`) : null; inventoryChanged ? parts.push(`inventory:${prev.inventory}→${next.inventory}`) : null; deltas.push(`${product} ${parts.join(",")}`); } const shown = deltas.slice(0, 3).join(" ; "); const more = deltas.length > 3 ? ` ; +${deltas.length - 3} more` : ""; const changes = shown || "inventory/on-hand changed"; return `~ id=${clean(base.id)} type=${clean(base.type)} ${changes}${more}`; }; const toSnapshot = (rows: unknown[]): SnapshotItem[] => rows.map((row) => { const data = toObject(row); const trackedRows = getTrackedRows(data); const trackedSignature = stableStringify(trackedRows); return { key: itemKey(data), serialized: stableStringify(data), data, trackedRows, trackedSignature, }; }); const fetchAdjustments = async (): Promise => { const response = await cw.get(ENDPOINT); const payload = response.data; if (Array.isArray(payload)) return payload; if (isObject(payload) && Array.isArray(payload.data)) return payload.data; return []; }; const now = () => new Date().toISOString(); let previous = new Map(); const run = async () => { console.log( `[${now()}] Watching ${CW_BASE_URL}${ENDPOINT} every ${POLL_MS / 1000}s`, ); while (true) { try { const rows = await fetchAdjustments(); const snapshotItems = toSnapshot(rows); const current = new Map(snapshotItems.map((item) => [item.key, item])); if (previous.size === 0) { previous = current; console.log( `[${now()}] Baseline captured (${current.size} adjustments)`, ); await Bun.sleep(POLL_MS); continue; } const added: SnapshotItem[] = []; const removed: SnapshotItem[] = []; const updated: Array<{ before: SnapshotItem; after: SnapshotItem }> = []; for (const [key, item] of current) { const previousItem = previous.get(key); if (!previousItem) { if (item.trackedRows.length === 0) continue; added.push(item); continue; } const hasTrackedRows = item.trackedRows.length > 0 || previousItem.trackedRows.length > 0; if (!hasTrackedRows) continue; if (previousItem.trackedSignature !== item.trackedSignature) { updated.push({ before: previousItem, after: item }); } } for (const [key, item] of previous) { if (!current.has(key) && item.trackedRows.length > 0) removed.push(item); } if (added.length > 0 || updated.length > 0 || removed.length > 0) { console.log(`\n[${now()}] Changes detected:`); console.log(`- added: ${added.length}`); console.log(`- updated: ${updated.length}`); console.log(`- removed: ${removed.length}`); if (added.length > 0) { console.log("\nAdded:"); for (const item of added) { console.log(trackedAddedLine(item)); } } if (updated.length > 0) { console.log("\nUpdated:"); for (const item of updated) { console.log(trackedUpdatedLine(item.before, item.after)); } } if (removed.length > 0) { console.log("\nRemoved:"); for (const item of removed) { console.log(trackedRemovedLine(item)); } } } if (added.length === 0 && updated.length === 0 && removed.length === 0) { console.log(`[${now()}] No changes (${current.size} adjustments)`); } previous = current; } catch (error) { if (axios.isAxiosError(error)) { console.error( `[${now()}] Poll failed: ${error.response?.status ?? "ERR"}`, error.message, ); } else { console.error(`[${now()}] Poll failed:`, error); } } await Bun.sleep(POLL_MS); } }; run().catch((error) => { console.error("Watcher crashed:", error); process.exit(1); });