Files
optima/src/modules/cw-utils/activities/activities.ts
T
HoloPanio 6d935e7180 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
2026-03-02 23:23:24 -06:00

187 lines
5.0 KiB
TypeScript

import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
import {
CWActivity,
CWActivitySummary,
CWCreateActivity,
CWPatchOperation,
} from "./activity.types";
export const activityCw = {
/**
* Count Activities
*
* Returns the total number of activities in ConnectWise.
* Optionally accepts CW conditions string for filtered counts.
*/
countItems: async (conditions?: string): Promise<number> => {
const query = conditions
? `/sales/activities/count?conditions=${encodeURIComponent(conditions)}`
: "/sales/activities/count";
const response = await connectWiseApi.get(query);
return response.data.count;
},
/**
* Fetch All Activity Summaries
*
* Lightweight fetch returning only id and _info (for lastUpdated comparison).
* Paginates through all activities.
*/
fetchAllSummaries: async (): Promise<
Collection<number, CWActivitySummary>
> => {
const allItems = new Collection<number, CWActivitySummary>();
const pageSize = 1000;
const count = await activityCw.countItems();
const totalPages = Math.ceil(count / pageSize);
for (let page = 0; page < totalPages; page++) {
const response = await connectWiseApi.get(
`/sales/activities?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
);
const items: CWActivitySummary[] = response.data;
for (const item of items) {
allItems.set(item.id, item);
}
}
return allItems;
},
/**
* Fetch All Activities (Full)
*
* Fetches all activities with complete data. Paginates through
* the full list. Optionally accepts CW conditions string for filtering.
*/
fetchAll: async (
conditions?: string,
): Promise<Collection<number, CWActivity>> => {
const allItems = new Collection<number, CWActivity>();
const pageSize = 1000;
const count = await activityCw.countItems(conditions);
const totalPages = Math.ceil(count / pageSize);
for (let page = 0; page < totalPages; page++) {
const conditionsParam = conditions
? `&conditions=${encodeURIComponent(conditions)}`
: "";
const response = await connectWiseApi.get(
`/sales/activities?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
);
const items: CWActivity[] = response.data;
for (const item of items) {
allItems.set(item.id, item);
}
}
return allItems;
},
/**
* Fetch Single Activity
*
* Fetches a single activity by its ConnectWise ID.
*/
fetch: async (id: number): Promise<CWActivity> => {
const response = await connectWiseApi.get(`/sales/activities/${id}`);
return response.data;
},
/**
* Fetch Activities by Company
*
* Fetches all activities associated with a specific ConnectWise company ID.
*/
fetchByCompany: async (
cwCompanyId: number,
): Promise<Collection<number, CWActivity>> => {
return activityCw.fetchAll(`company/id=${cwCompanyId}`);
},
/**
* Fetch Activities by Opportunity
*
* Fetches all activities associated with a specific opportunity ID.
*/
fetchByOpportunity: async (
opportunityId: number,
): Promise<Collection<number, CWActivity>> => {
return activityCw.fetchAll(`opportunity/id=${opportunityId}`);
},
/**
* Fetch Activities by Opportunity (Direct)
*
* Lightweight single-call variant that skips the count request.
* Fetches up to 1000 activities in a single GET — sufficient for
* virtually all opportunities. Used by the background cache refresh
* to avoid doubling CW API calls.
*/
fetchByOpportunityDirect: async (
opportunityId: number,
): Promise<CWActivity[]> => {
const conditions = encodeURIComponent(`opportunity/id=${opportunityId}`);
const response = await connectWiseApi.get(
`/sales/activities?pageSize=1000&conditions=${conditions}`,
);
return response.data;
},
/**
* Create Activity
*
* Creates a new activity in ConnectWise.
*/
create: async (activity: CWCreateActivity): Promise<CWActivity> => {
const response = await connectWiseApi.post("/sales/activities", activity);
return response.data;
},
/**
* Update Activity (PATCH)
*
* Updates an existing activity using JSON Patch operations.
*/
update: async (
id: number,
operations: CWPatchOperation[],
): Promise<CWActivity> => {
const response = await connectWiseApi.patch(
`/sales/activities/${id}`,
operations,
);
return response.data;
},
/**
* Replace Activity (PUT)
*
* Replaces an entire activity record in ConnectWise.
*/
replace: async (
id: number,
activity: CWCreateActivity,
): Promise<CWActivity> => {
const response = await connectWiseApi.put(
`/sales/activities/${id}`,
activity,
);
return response.data;
},
/**
* Delete Activity
*
* Deletes an activity by its ConnectWise ID.
*/
delete: async (id: number): Promise<void> => {
await connectWiseApi.delete(`/sales/activities/${id}`);
},
};