6d935e7180
- 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
119 lines
4.3 KiB
TypeScript
119 lines
4.3 KiB
TypeScript
/**
|
|
* @module computeSubResourceCacheTTL
|
|
*
|
|
* Adaptive Cache TTL for Opportunity Sub-Resources
|
|
* =================================================
|
|
*
|
|
* Determines how long cached sub-resource data (notes, contacts) should
|
|
* live before being re-fetched from ConnectWise.
|
|
*
|
|
* Sub-resources change less frequently than the opportunity record itself
|
|
* or its activity feed, so TTLs are longer than the primary cache. The
|
|
* same activity-signal heuristics are used (expected close date, last
|
|
* updated, closed status) but with relaxed durations.
|
|
*
|
|
* ## Spec
|
|
*
|
|
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
|
|
* |---|-------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------|
|
|
* | 1 | `closedFlag` is `true` AND closed > 30 days ago | `null` | Do not cache| Old closed records are rarely accessed. |
|
|
* | 1b| `closedFlag` is `true` AND closed within 30 days | 300 000 | 5 minutes | Recently-closed records may still be viewed occasionally. |
|
|
* | 2 | `expectedCloseDate` OR `lastUpdated` within **5 days** | 60 000 | 60 seconds | Active deals — contacts/notes may still change. |
|
|
* | 3 | `expectedCloseDate` OR `lastUpdated` within **14 days** | 120 000 | 2 minutes | Moderate activity — less likely to change. |
|
|
* | 4 | Everything else (older than 14 days) | 300 000 | 5 minutes | Low activity — safe to cache longer. |
|
|
*
|
|
* ## Evaluation order
|
|
*
|
|
* Rules are evaluated top-to-bottom; the first matching rule wins.
|
|
*
|
|
* ## Inputs
|
|
*
|
|
* Uses the same {@link CacheTTLInput} interface as `computeCacheTTL`.
|
|
*
|
|
* ## Output
|
|
*
|
|
* Returns `number | null`:
|
|
* - Positive integer = TTL in **milliseconds**.
|
|
* - `null` = do **not** cache.
|
|
*/
|
|
|
|
import type { CacheTTLInput } from "./computeCacheTTL";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** 60 seconds — TTL for high-activity sub-resources (within 5 days). */
|
|
export const SUB_TTL_HIGH_ACTIVITY = 60_000;
|
|
|
|
/** 2 minutes — TTL for moderate-activity sub-resources (within 14 days). */
|
|
export const SUB_TTL_MODERATE_ACTIVITY = 120_000;
|
|
|
|
/** 5 minutes — TTL for low-activity / stale sub-resources. */
|
|
export const SUB_TTL_LOW_ACTIVITY = 300_000;
|
|
|
|
/** 30 days in milliseconds. */
|
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
/** 5 days in milliseconds. */
|
|
const FIVE_DAYS_MS = 5 * 24 * 60 * 60 * 1000;
|
|
|
|
/** 14 days in milliseconds. */
|
|
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Algorithm
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Compute the cache TTL for an opportunity sub-resource (notes, contacts).
|
|
*
|
|
* @param input - The opportunity's activity signals. See {@link CacheTTLInput}.
|
|
* @returns The TTL in milliseconds, or `null` if the data should not be cached.
|
|
*/
|
|
export function computeSubResourceCacheTTL(
|
|
input: CacheTTLInput,
|
|
): number | null {
|
|
const {
|
|
closedFlag,
|
|
closedDate,
|
|
expectedCloseDate,
|
|
lastUpdated,
|
|
now = new Date(),
|
|
} = input;
|
|
|
|
const nowMs = now.getTime();
|
|
|
|
const isWithinWindow = (date: Date | null, windowMs: number): boolean => {
|
|
if (!date) return false;
|
|
return Math.abs(nowMs - date.getTime()) <= windowMs;
|
|
};
|
|
|
|
// Rule 1 — Closed records
|
|
if (closedFlag) {
|
|
if (isWithinWindow(closedDate, THIRTY_DAYS_MS)) {
|
|
return SUB_TTL_LOW_ACTIVITY;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Rule 2 — High activity (5 days)
|
|
if (
|
|
isWithinWindow(expectedCloseDate, FIVE_DAYS_MS) ||
|
|
isWithinWindow(lastUpdated, FIVE_DAYS_MS)
|
|
) {
|
|
return SUB_TTL_HIGH_ACTIVITY;
|
|
}
|
|
|
|
// Rule 3 — Moderate activity (14 days)
|
|
if (
|
|
isWithinWindow(expectedCloseDate, FOURTEEN_DAYS_MS) ||
|
|
isWithinWindow(lastUpdated, FOURTEEN_DAYS_MS)
|
|
) {
|
|
return SUB_TTL_MODERATE_ACTIVITY;
|
|
}
|
|
|
|
// Rule 4 — Low activity / stale
|
|
return SUB_TTL_LOW_ACTIVITY;
|
|
}
|