feat: add CW callback route and optimize cache refresh workflows
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
import axios from "axios";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
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<unknown[]> => {
|
||||
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<string, SnapshotItem>();
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
|
||||
const port = 3001;
|
||||
const logDir = "cw-api-logs";
|
||||
const logFilePath = `${logDir}/test-webserver-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`;
|
||||
|
||||
const jsonBodyMethods = ["POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
type ParsedJson = Record<string, unknown> | unknown[];
|
||||
type EventSummary = ReturnType<typeof buildSummary>;
|
||||
|
||||
const color = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
cyan: "\x1b[36m",
|
||||
blue: "\x1b[34m",
|
||||
yellow: "\x1b[33m",
|
||||
magenta: "\x1b[35m",
|
||||
green: "\x1b[32m",
|
||||
gray: "\x1b[90m",
|
||||
};
|
||||
|
||||
const paint = (value: string, tone: keyof typeof color) =>
|
||||
`${color[tone]}${value}${color.reset}`;
|
||||
|
||||
const safeParseJson = (value: string): ParsedJson | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const isObject = typeof parsed === "object" && parsed !== null;
|
||||
|
||||
return isObject ? (parsed as ParsedJson) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseEntity = (value: unknown): ParsedJson | null => {
|
||||
if (typeof value === "string") return safeParseJson(value);
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
|
||||
return value as ParsedJson;
|
||||
};
|
||||
|
||||
const asObject = (value: ParsedJson | null): Record<string, unknown> | null => {
|
||||
if (!value) return null;
|
||||
if (Array.isArray(value)) return null;
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseJsonStringFields = (
|
||||
value: Record<string, unknown> | null,
|
||||
): Record<string, unknown> | null => {
|
||||
if (!value) return null;
|
||||
|
||||
return Object.entries(value).reduce<Record<string, unknown>>(
|
||||
(acc, [key, current]) => {
|
||||
if (typeof current !== "string") {
|
||||
acc[key] = current;
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const looksLikeJson = current.startsWith("{") || current.startsWith("[");
|
||||
if (!looksLikeJson) {
|
||||
acc[key] = current;
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const parsed = safeParseJson(current);
|
||||
acc[key] = parsed ?? current;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
const parseQuery = (url: URL) => {
|
||||
const entries = [...url.searchParams.entries()];
|
||||
const params = entries.reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
const rawQuery = url.search.startsWith("?")
|
||||
? url.search.slice(1)
|
||||
: url.search;
|
||||
const firstSegment = rawQuery.split("&")[0] ?? "";
|
||||
const hasEquals = firstSegment.includes("=");
|
||||
const inferredId = !hasEquals && firstSegment ? firstSegment : null;
|
||||
|
||||
return {
|
||||
params,
|
||||
inferredId,
|
||||
};
|
||||
};
|
||||
|
||||
const buildSummary = (
|
||||
parsedBody: Record<string, unknown> | null,
|
||||
parsedEntity: Record<string, unknown> | null,
|
||||
) => {
|
||||
if (!parsedBody) return null;
|
||||
|
||||
return {
|
||||
messageId: parsedBody.MessageId ?? null,
|
||||
action: parsedBody.Action ?? null,
|
||||
type: parsedBody.Type ?? null,
|
||||
id: parsedBody.ID ?? null,
|
||||
memberId: parsedBody.MemberId ?? null,
|
||||
entityStatus:
|
||||
parsedEntity?.StatusName ??
|
||||
parsedEntity?.TicketStatus ??
|
||||
parsedEntity?.Status ??
|
||||
null,
|
||||
entitySummary: parsedEntity?.Summary ?? parsedEntity?.CompanyName ?? null,
|
||||
entityUpdatedBy: parsedEntity?.UpdatedBy ?? null,
|
||||
entityLastUpdated:
|
||||
parsedEntity?.LastUpdatedUTC ?? parsedEntity?.LastUpdated ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const displayTerminalEvent = (
|
||||
method: string,
|
||||
routePath: string,
|
||||
query: { params: Record<string, string>; inferredId: string | null },
|
||||
summary: EventSummary,
|
||||
timestamp: string,
|
||||
) => {
|
||||
const id = String(summary?.id ?? query.inferredId ?? "-");
|
||||
const action = String(summary?.action ?? query.params.action ?? "-");
|
||||
const eventType = String(summary?.type ?? routePath.split("/")[1] ?? "-");
|
||||
const actor = String(
|
||||
summary?.entityUpdatedBy ??
|
||||
query.params.memberId ??
|
||||
summary?.memberId ??
|
||||
"-",
|
||||
);
|
||||
const status = String(summary?.entityStatus ?? "-");
|
||||
const title = String(summary?.entitySummary ?? "-");
|
||||
|
||||
const methodTone = method === "GET" ? "green" : "yellow";
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
`${paint("●", "cyan")} ${paint(method, methodTone)} ${paint(routePath, "blue")} ${paint(timestamp, "gray")}`,
|
||||
);
|
||||
console.log(
|
||||
`${paint("type", "magenta")}: ${paint(eventType, "bold")} ${paint("action", "magenta")}: ${action} ${paint("id", "magenta")}: ${id}`,
|
||||
);
|
||||
console.log(
|
||||
`${paint("actor", "magenta")}: ${paint(actor, "cyan")} ${paint("status", "magenta")}: ${status}`,
|
||||
);
|
||||
console.log(`${paint("title", "magenta")}: ${title}`);
|
||||
};
|
||||
|
||||
const writeLogRecord = async (record: Record<string, unknown>) => {
|
||||
await appendFile(logFilePath, `${JSON.stringify(record)}\n`, "utf8");
|
||||
};
|
||||
|
||||
await mkdir(logDir, { recursive: true });
|
||||
|
||||
Bun.serve({
|
||||
port,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
const routePath = `${url.pathname}${url.search}`;
|
||||
const method = request.method;
|
||||
const query = parseQuery(url);
|
||||
const startedAt = new Date().toISOString();
|
||||
const rawBody = jsonBodyMethods.includes(method)
|
||||
? await request.text()
|
||||
: "";
|
||||
const parsedJson = safeParseJson(rawBody);
|
||||
const parsedBody = asObject(parsedJson);
|
||||
const parsedBodyExpanded = parseJsonStringFields(parsedBody);
|
||||
const parsedEntity = asObject(parseEntity(parsedBodyExpanded?.Entity));
|
||||
const summary = buildSummary(parsedBodyExpanded, parsedEntity);
|
||||
|
||||
const responseBody = {
|
||||
success: true,
|
||||
method,
|
||||
path: routePath,
|
||||
timestamp: startedAt,
|
||||
summary,
|
||||
};
|
||||
|
||||
const responseStatus = 200;
|
||||
|
||||
displayTerminalEvent(method, routePath, query, summary, startedAt);
|
||||
|
||||
await writeLogRecord({
|
||||
timestamp: startedAt,
|
||||
request: {
|
||||
method,
|
||||
path: routePath,
|
||||
query,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
bodyRaw: rawBody || null,
|
||||
bodyParsed: parsedBodyExpanded,
|
||||
entityParsed: parsedEntity,
|
||||
summary,
|
||||
},
|
||||
response: {
|
||||
status: responseStatus,
|
||||
body: responseBody,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(responseBody, { status: responseStatus });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Test webserver listening on http://localhost:${port}`);
|
||||
console.log(`Response/request log file: ${logFilePath}`);
|
||||
Reference in New Issue
Block a user