feat: Redis opportunity cache, CW API retry/logging, adaptive TTLs
- Add Redis-backed opportunity cache with background refresh (30s interval) - Fix concurrency bug: use lazy thunks instead of eager promises for batching - Add withCwRetry utility with exponential backoff for transient CW errors - Add adaptive TTL algorithms (primary, sub-resource, products) based on opportunity activity - Add include query param on GET /sales/opportunities/:id (notes,contacts,products) - Add opt-in CW API logger (LOG_CW_API env var) with timestamped files in cw-api-logs/ - Add debug-scripts/analyze-cw-calls.py for API call analysis - Add computeSubResourceCacheTTL and computeProductsCacheTTL algorithms with tests - Increase CW API timeout from 15s to 30s - Unblock cache refresh from startup chain (remove await) - Prioritize recently updated opportunities in refresh cycle - Add CACHING.md documentation - Update API_ROUTES.md with caching details and include param - Update copilot instructions to require CACHING.md sync - Add dev:log script for CW API call logging during development
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* CW Endpoint Validator
|
||||
*
|
||||
* Validates that all ConnectWise API endpoints used by the application
|
||||
* are reachable and respond correctly. Uses the same axios instance
|
||||
* and credentials as the running app.
|
||||
*
|
||||
* Usage: bun ./utils/validateCwEndpoints.ts
|
||||
*/
|
||||
|
||||
import { connectWiseApi } from "../src/constants";
|
||||
import { prisma } from "../src/constants";
|
||||
|
||||
interface EndpointTest {
|
||||
name: string;
|
||||
method: "get" | "post" | "patch" | "delete";
|
||||
url: string;
|
||||
/** If true, a 404 is treated as "endpoint exists but resource doesn't" — still a pass. */
|
||||
allow404?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build test list — some require real IDs from the database
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function buildTestList(): Promise<EndpointTest[]> {
|
||||
// Grab a sample opportunity from the DB to test with real IDs
|
||||
const sampleOpp = await prisma.opportunity.findFirst({
|
||||
where: { closedFlag: false },
|
||||
select: { cwOpportunityId: true, companyId: true },
|
||||
orderBy: { cwLastUpdated: "desc" },
|
||||
});
|
||||
|
||||
const sampleCompany = await prisma.company.findFirst({
|
||||
select: { cw_CompanyId: true },
|
||||
});
|
||||
|
||||
const oppId = sampleOpp?.cwOpportunityId ?? 1;
|
||||
const companyId = sampleCompany?.cw_CompanyId ?? 1;
|
||||
|
||||
return [
|
||||
// ── Core counts (lightweight, always work) ──────────────────────────
|
||||
{
|
||||
name: "Opportunities count",
|
||||
method: "get",
|
||||
url: "/sales/opportunities/count",
|
||||
},
|
||||
{
|
||||
name: "Activities count",
|
||||
method: "get",
|
||||
url: "/sales/activities/count",
|
||||
},
|
||||
{
|
||||
name: "Companies count",
|
||||
method: "get",
|
||||
url: "/company/companies/count",
|
||||
},
|
||||
{
|
||||
name: "Members count",
|
||||
method: "get",
|
||||
url: "/system/members/count",
|
||||
},
|
||||
{
|
||||
name: "Catalog count",
|
||||
method: "get",
|
||||
url: "/procurement/catalog/count",
|
||||
},
|
||||
|
||||
// ── Paginated list endpoints ────────────────────────────────────────
|
||||
{
|
||||
name: "Opportunities list (page 1, size 1)",
|
||||
method: "get",
|
||||
url: "/sales/opportunities?page=1&pageSize=1",
|
||||
},
|
||||
{
|
||||
name: "Activities list (page 1, size 1)",
|
||||
method: "get",
|
||||
url: "/sales/activities?page=1&pageSize=1",
|
||||
},
|
||||
{
|
||||
name: "Companies list (page 1, size 1)",
|
||||
method: "get",
|
||||
url: "/company/companies?page=1&pageSize=1",
|
||||
},
|
||||
{
|
||||
name: "Members list (page 1, size 1)",
|
||||
method: "get",
|
||||
url: "/system/members?page=1&pageSize=1",
|
||||
},
|
||||
{
|
||||
name: "Catalog list (page 1, size 1)",
|
||||
method: "get",
|
||||
url: "/procurement/catalog?page=1&pageSize=1",
|
||||
},
|
||||
{
|
||||
name: "User-defined fields (page 1)",
|
||||
method: "get",
|
||||
url: "/system/userDefinedFields?pageSize=1",
|
||||
},
|
||||
|
||||
// ── Single-resource fetches (need real IDs) ─────────────────────────
|
||||
{
|
||||
name: `Opportunity #${oppId}`,
|
||||
method: "get",
|
||||
url: `/sales/opportunities/${oppId}`,
|
||||
allow404: true,
|
||||
},
|
||||
{
|
||||
name: `Opportunity #${oppId} forecast`,
|
||||
method: "get",
|
||||
url: `/sales/opportunities/${oppId}/forecast`,
|
||||
allow404: true,
|
||||
},
|
||||
{
|
||||
name: `Opportunity #${oppId} notes`,
|
||||
method: "get",
|
||||
url: `/sales/opportunities/${oppId}/notes`,
|
||||
allow404: true,
|
||||
},
|
||||
{
|
||||
name: `Opportunity #${oppId} contacts`,
|
||||
method: "get",
|
||||
url: `/sales/opportunities/${oppId}/contacts`,
|
||||
allow404: true,
|
||||
},
|
||||
{
|
||||
name: `Activities for opp #${oppId}`,
|
||||
method: "get",
|
||||
url: `/sales/activities/count?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}`,
|
||||
allow404: true,
|
||||
},
|
||||
{
|
||||
name: `Procurement products for opp #${oppId}`,
|
||||
method: "get",
|
||||
url: `/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag`,
|
||||
allow404: true,
|
||||
},
|
||||
{
|
||||
name: `Company #${companyId}`,
|
||||
method: "get",
|
||||
url: `/company/companies/${companyId}`,
|
||||
allow404: true,
|
||||
},
|
||||
{
|
||||
name: `Company #${companyId} sites`,
|
||||
method: "get",
|
||||
url: `/company/companies/${companyId}/sites?pageSize=1`,
|
||||
allow404: true,
|
||||
},
|
||||
{
|
||||
name: `Company #${companyId} configurations`,
|
||||
method: "get",
|
||||
url: `/company/configurations?conditions=${encodeURIComponent(`company/id=${companyId}`)}&pageSize=1`,
|
||||
allow404: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log(
|
||||
"╔══════════════════════════════════════════════════════════════╗",
|
||||
);
|
||||
console.log(
|
||||
"║ ConnectWise API Endpoint Validator ║",
|
||||
);
|
||||
console.log(
|
||||
"╚══════════════════════════════════════════════════════════════╝",
|
||||
);
|
||||
console.log();
|
||||
console.log(`Base URL: ${connectWiseApi.defaults.baseURL}`);
|
||||
console.log(`Timeout: ${connectWiseApi.defaults.timeout ?? "none"}ms`);
|
||||
console.log();
|
||||
|
||||
const tests = await buildTestList();
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let warned = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const response = await connectWiseApi.request({
|
||||
method: test.method,
|
||||
url: test.url,
|
||||
timeout: 30_000, // Use a generous timeout for validation
|
||||
});
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
const statusTag =
|
||||
elapsed > 5000 ? `⚠️ SLOW (${elapsed}ms)` : `${elapsed}ms`;
|
||||
console.log(` ✅ ${test.name} — ${response.status} [${statusTag}]`);
|
||||
if (elapsed > 5000) warned++;
|
||||
passed++;
|
||||
} catch (err: any) {
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
if (err?.isAxiosError) {
|
||||
const status = err.response?.status;
|
||||
const code = err.code;
|
||||
|
||||
if (status === 404 && test.allow404) {
|
||||
console.log(
|
||||
` ⚠️ ${test.name} — 404 (resource not found, endpoint OK) [${elapsed}ms]`,
|
||||
);
|
||||
warned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code === "ECONNABORTED") {
|
||||
console.log(` ❌ ${test.name} — TIMEOUT after ${elapsed}ms`);
|
||||
} else if (code === "ECONNREFUSED") {
|
||||
console.log(
|
||||
` ❌ ${test.name} — CONNECTION REFUSED (CW server down?)`,
|
||||
);
|
||||
} else if (status) {
|
||||
console.log(
|
||||
` ❌ ${test.name} — HTTP ${status} [${elapsed}ms]: ${err.response?.data?.message ?? err.message}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` ❌ ${test.name} — ${code ?? err.message} [${elapsed}ms]`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(` ❌ ${test.name} — ${err.message} [${elapsed}ms]`);
|
||||
}
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log("─".repeat(64));
|
||||
console.log(
|
||||
` Results: ${passed} passed, ${warned} warnings, ${failed} failed (${tests.length} total)`,
|
||||
);
|
||||
console.log("─".repeat(64));
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user