fix tests
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* computeCacheTTL
|
||||
*
|
||||
* Computes the Redis TTL (in milliseconds) for an opportunity record based on
|
||||
* its activity dates and closed state.
|
||||
*
|
||||
* Rules (highest priority first):
|
||||
* 1a. Closed > 30 days ago → null (do not cache)
|
||||
* 1b. Closed ≤ 30 days ago → TTL_LOW_ACTIVITY (15 min)
|
||||
* 2. High activity (≤ 5 days) → TTL_HIGH_ACTIVITY (30 s)
|
||||
* 3. Moderate activity (6–14 days) → TTL_MODERATE_ACTIVITY (60 s)
|
||||
* 4. Everything else → TTL_LOW_ACTIVITY (15 min)
|
||||
*/
|
||||
|
||||
export const TTL_HIGH_ACTIVITY = 30_000; // 30 seconds
|
||||
export const TTL_MODERATE_ACTIVITY = 60_000; // 60 seconds
|
||||
export const TTL_LOW_ACTIVITY = 900_000; // 15 minutes
|
||||
|
||||
export interface ComputeCacheTTLInput {
|
||||
closedFlag: boolean;
|
||||
closedDate: Date | null;
|
||||
expectedCloseDate: Date | null;
|
||||
lastUpdated: Date | null;
|
||||
/** Override "now" for deterministic tests. Defaults to `new Date()`. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Returns the absolute age in milliseconds between `date` and `now`. */
|
||||
function ageMs(date: Date, now: Date): number {
|
||||
return Math.abs(now.getTime() - date.getTime());
|
||||
}
|
||||
|
||||
export function computeCacheTTL(input: ComputeCacheTTLInput): number | null {
|
||||
const now = input.now ?? new Date();
|
||||
|
||||
// Rule 1 — closed opportunities
|
||||
if (input.closedFlag) {
|
||||
if (!input.closedDate) {
|
||||
// Unknown close date → treat as stale
|
||||
return null;
|
||||
}
|
||||
const daysClosed = ageMs(input.closedDate, now) / DAY_MS;
|
||||
if (daysClosed > 30) {
|
||||
return null;
|
||||
}
|
||||
return TTL_LOW_ACTIVITY;
|
||||
}
|
||||
|
||||
// Helper: minimum absolute distance (in ms) of a date from now
|
||||
const dates = [input.lastUpdated, input.expectedCloseDate].filter(
|
||||
(d): d is Date => d !== null
|
||||
);
|
||||
|
||||
if (dates.length === 0) {
|
||||
return TTL_LOW_ACTIVITY;
|
||||
}
|
||||
|
||||
const minDeltaMs = Math.min(...dates.map((d) => ageMs(d, now)));
|
||||
const minDeltaDays = minDeltaMs / DAY_MS;
|
||||
|
||||
if (minDeltaDays <= 5) {
|
||||
return TTL_HIGH_ACTIVITY;
|
||||
}
|
||||
if (minDeltaDays <= 14) {
|
||||
return TTL_MODERATE_ACTIVITY;
|
||||
}
|
||||
return TTL_LOW_ACTIVITY;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* computeProductsCacheTTL
|
||||
*
|
||||
* Computes the Redis TTL (in milliseconds) for an opportunity's products
|
||||
* cache entry.
|
||||
*
|
||||
* Rules (highest priority first):
|
||||
* 1. Won / Lost status → null (do not cache — products are final)
|
||||
* 2. Opp not cacheable (closed > 30 days) → null
|
||||
* 3. Updated within 3 days → PRODUCTS_TTL_HOT (45 s)
|
||||
* 4. Everything else → PRODUCTS_TTL_LAZY (20 min)
|
||||
*/
|
||||
|
||||
import { computeCacheTTL } from "./computeCacheTTL";
|
||||
|
||||
export const PRODUCTS_TTL_HOT = 45_000; // 45 seconds
|
||||
export const PRODUCTS_TTL_LAZY = 1_200_000; // 20 minutes
|
||||
|
||||
/** CW Opportunity Status IDs that indicate a final won/lost state. */
|
||||
export const WON_LOST_STATUS_IDS = new Set<number>([
|
||||
29, // Won
|
||||
53, // Lost
|
||||
59, // Canceled
|
||||
]);
|
||||
|
||||
export interface ComputeProductsCacheTTLInput {
|
||||
statusCwId: number | null;
|
||||
closedFlag: boolean;
|
||||
closedDate: Date | null;
|
||||
expectedCloseDate: Date | null;
|
||||
lastUpdated: Date | null;
|
||||
/** Override "now" for deterministic tests. Defaults to `new Date()`. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function computeProductsCacheTTL(
|
||||
input: ComputeProductsCacheTTLInput
|
||||
): number | null {
|
||||
const now = input.now ?? new Date();
|
||||
|
||||
// Rule 1 — Won/Lost status means products are final
|
||||
if (input.statusCwId !== null && WON_LOST_STATUS_IDS.has(input.statusCwId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rule 2 — If the parent opportunity wouldn't be cached, skip products too
|
||||
const oppTTL = computeCacheTTL({
|
||||
closedFlag: input.closedFlag,
|
||||
closedDate: input.closedDate,
|
||||
expectedCloseDate: input.expectedCloseDate,
|
||||
lastUpdated: input.lastUpdated,
|
||||
now,
|
||||
});
|
||||
if (oppTTL === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rule 3 — Recently updated → hot cache
|
||||
if (input.lastUpdated !== null) {
|
||||
const ageDays =
|
||||
Math.abs(now.getTime() - input.lastUpdated.getTime()) / DAY_MS;
|
||||
if (ageDays <= 3) {
|
||||
return PRODUCTS_TTL_HOT;
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4 — Everything else → lazy cache
|
||||
return PRODUCTS_TTL_LAZY;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* computeSubResourceCacheTTL
|
||||
*
|
||||
* Computes the Redis TTL (in milliseconds) for opportunity sub-resources
|
||||
* (notes, contacts, activities, etc.) that share the parent opportunity's
|
||||
* activity window.
|
||||
*
|
||||
* Rules are identical to the main opportunity TTL but use different
|
||||
* exported constant names so callers can distinguish them.
|
||||
*/
|
||||
|
||||
export const SUB_TTL_HIGH_ACTIVITY = 60_000; // 60 seconds
|
||||
export const SUB_TTL_MODERATE_ACTIVITY = 120_000; // 2 minutes
|
||||
export const SUB_TTL_LOW_ACTIVITY = 300_000; // 5 minutes
|
||||
|
||||
export interface ComputeSubResourceCacheTTLInput {
|
||||
closedFlag: boolean;
|
||||
closedDate: Date | null;
|
||||
expectedCloseDate: Date | null;
|
||||
lastUpdated: Date | null;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function ageMs(date: Date, now: Date): number {
|
||||
return Math.abs(now.getTime() - date.getTime());
|
||||
}
|
||||
|
||||
export function computeSubResourceCacheTTL(
|
||||
input: ComputeSubResourceCacheTTLInput
|
||||
): number | null {
|
||||
const now = input.now ?? new Date();
|
||||
|
||||
// Rule 1a — closed > 30 days → no cache
|
||||
if (input.closedFlag) {
|
||||
if (!input.closedDate) return null;
|
||||
const daysClosed = ageMs(input.closedDate, now) / DAY_MS;
|
||||
if (daysClosed > 30) return null;
|
||||
return SUB_TTL_LOW_ACTIVITY;
|
||||
}
|
||||
|
||||
const dates = [input.lastUpdated, input.expectedCloseDate].filter(
|
||||
(d): d is Date => d !== null
|
||||
);
|
||||
|
||||
if (dates.length === 0) {
|
||||
return SUB_TTL_LOW_ACTIVITY;
|
||||
}
|
||||
|
||||
const minDeltaDays =
|
||||
Math.min(...dates.map((d) => ageMs(d, now))) / DAY_MS;
|
||||
|
||||
if (minDeltaDays <= 5) return SUB_TTL_HIGH_ACTIVITY;
|
||||
if (minDeltaDays <= 14) return SUB_TTL_MODERATE_ACTIVITY;
|
||||
return SUB_TTL_LOW_ACTIVITY;
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* opportunityCache
|
||||
*
|
||||
* Redis-backed cache helpers for opportunity sub-resources:
|
||||
* activities, notes, contacts, products, company CW data, site data.
|
||||
*/
|
||||
|
||||
import { redis } from "../../constants";
|
||||
import { activityCw } from "../cw-utils/activities/activities";
|
||||
import { opportunityCw } from "../cw-utils/opportunities/opportunities";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Key helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const activityCacheKey = (cwOppId: number) =>
|
||||
`opp:activities:${cwOppId}`;
|
||||
|
||||
export const companyCwCacheKey = (cwCompanyId: number) =>
|
||||
`opp:company-cw:${cwCompanyId}`;
|
||||
|
||||
export const notesCacheKey = (cwOppId: number) => `opp:notes:${cwOppId}`;
|
||||
|
||||
export const contactsCacheKey = (cwOppId: number) =>
|
||||
`opp:contacts:${cwOppId}`;
|
||||
|
||||
export const productsCacheKey = (cwOppId: number) =>
|
||||
`opp:products:${cwOppId}`;
|
||||
|
||||
export const siteCacheKey = (cwCompanyId: number, siteId: number) =>
|
||||
`opp:site:${cwCompanyId}:${siteId}`;
|
||||
|
||||
export const oppCwDataCacheKey = (cwOppId: number) =>
|
||||
`opp:cw-data:${cwOppId}`;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Generic helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function getJson<T>(key: string): Promise<T | null> {
|
||||
const raw = await redis.get(key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isAxios404(err: unknown): boolean {
|
||||
const e = err as any;
|
||||
return e?.isAxiosError === true && e?.response?.status === 404;
|
||||
}
|
||||
|
||||
function isTransient(err: unknown): boolean {
|
||||
const e = err as any;
|
||||
return (
|
||||
e?.isAxiosError === true &&
|
||||
(e?.code === "ECONNABORTED" ||
|
||||
e?.code === "ECONNRESET" ||
|
||||
e?.code === "ETIMEDOUT")
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Read helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function getCachedActivities(
|
||||
cwOppId: number
|
||||
): Promise<any[] | null> {
|
||||
return getJson<any[]>(activityCacheKey(cwOppId));
|
||||
}
|
||||
|
||||
export async function getCachedCompanyCwData(
|
||||
cwCompanyId: number
|
||||
): Promise<any | null> {
|
||||
return getJson<any>(companyCwCacheKey(cwCompanyId));
|
||||
}
|
||||
|
||||
export async function getCachedNotes(cwOppId: number): Promise<any[] | null> {
|
||||
return getJson<any[]>(notesCacheKey(cwOppId));
|
||||
}
|
||||
|
||||
export async function getCachedContacts(
|
||||
cwOppId: number
|
||||
): Promise<any[] | null> {
|
||||
return getJson<any[]>(contactsCacheKey(cwOppId));
|
||||
}
|
||||
|
||||
export async function getCachedProducts(cwOppId: number): Promise<any | null> {
|
||||
return getJson<any>(productsCacheKey(cwOppId));
|
||||
}
|
||||
|
||||
export async function getCachedSite(
|
||||
cwCompanyId: number,
|
||||
siteId: number
|
||||
): Promise<any | null> {
|
||||
return getJson<any>(siteCacheKey(cwCompanyId, siteId));
|
||||
}
|
||||
|
||||
export async function getCachedOppCwData(cwOppId: number): Promise<any | null> {
|
||||
return getJson<any>(oppCwDataCacheKey(cwOppId));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Write helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function fetchAndCacheActivities(
|
||||
cwOppId: number,
|
||||
ttlMs: number
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const activities = await activityCw.fetchByOpportunityDirect(cwOppId);
|
||||
await redis.set(
|
||||
activityCacheKey(cwOppId),
|
||||
JSON.stringify(activities),
|
||||
"PX",
|
||||
ttlMs
|
||||
);
|
||||
return activities;
|
||||
} catch (err) {
|
||||
if (isAxios404(err) || isTransient(err)) return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAndCacheNotes(
|
||||
cwOppId: number,
|
||||
ttlMs: number
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const notes = await opportunityCw.fetchNotes(cwOppId);
|
||||
await redis.set(
|
||||
notesCacheKey(cwOppId),
|
||||
JSON.stringify(notes),
|
||||
"PX",
|
||||
ttlMs
|
||||
);
|
||||
return notes;
|
||||
} catch (err) {
|
||||
if (isAxios404(err) || isTransient(err)) return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* fetchCwCompany
|
||||
*
|
||||
* Helpers for fetching ConnectWise company data via the CW REST API.
|
||||
*/
|
||||
|
||||
import { connectWiseApi } from "../../constants";
|
||||
|
||||
export interface CWCompany {
|
||||
id: number;
|
||||
identifier: string;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a CW company by its numeric CW company ID.
|
||||
* Returns null when not found or on API error.
|
||||
*/
|
||||
export async function fetchCwCompanyById(
|
||||
cwCompanyId: number
|
||||
): Promise<CWCompany | null> {
|
||||
try {
|
||||
const response = await connectWiseApi.get<CWCompany>(
|
||||
`/company/companies/${cwCompanyId}`
|
||||
);
|
||||
return response.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* fetchAllMembers
|
||||
*
|
||||
* Utilities for fetching ConnectWise member records and resolving
|
||||
* CW identifiers from email addresses.
|
||||
*/
|
||||
|
||||
import { connectWiseApi, prisma } from "../../../constants";
|
||||
|
||||
export interface CWMember {
|
||||
id: number;
|
||||
identifier: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
officeEmail?: string | null;
|
||||
inactiveFlag: boolean;
|
||||
_info?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all active members from ConnectWise.
|
||||
*/
|
||||
export async function fetchAllCwMembers(): Promise<CWMember[]> {
|
||||
const response = await connectWiseApi.get<CWMember[]>(
|
||||
"/system/members?pageSize=1000&conditions=inactiveFlag=false"
|
||||
);
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a ConnectWise member identifier given an email address.
|
||||
*
|
||||
* First checks the local database, then falls back to CW API.
|
||||
* Returns null when no match is found.
|
||||
*/
|
||||
export async function findCwIdentifierByEmail(
|
||||
email: string
|
||||
): Promise<string | null> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
|
||||
const local = await prisma.cwMember
|
||||
.findFirst({ where: { officeEmail: normalised } })
|
||||
.catch(() => null);
|
||||
|
||||
if (local) return local.identifier;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* memberCache
|
||||
*
|
||||
* In-process Collection cache for ConnectWise member records.
|
||||
* Used to avoid repeated DB lookups when resolving member names.
|
||||
*/
|
||||
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { prisma } from "../../../constants";
|
||||
import type { CWMember } from "./fetchAllMembers";
|
||||
|
||||
let _cache: Collection<string, CWMember> = new Collection();
|
||||
|
||||
/**
|
||||
* Replace the entire member cache with a new collection.
|
||||
*/
|
||||
export async function setMemberCache(
|
||||
members: Collection<string, CWMember>
|
||||
): Promise<void> {
|
||||
_cache = members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current member cache collection.
|
||||
*/
|
||||
export async function getMemberCache(): Promise<Collection<string, CWMember>> {
|
||||
return _cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a display name from a member identifier.
|
||||
*
|
||||
* Returns "<firstName> <lastName>" when the member is found in the cache,
|
||||
* or the raw identifier string when not found / name parts are empty.
|
||||
*/
|
||||
export function resolveMemberName(identifier: string): string {
|
||||
const member = _cache.get(identifier);
|
||||
if (!member) return identifier;
|
||||
|
||||
const full = `${member.firstName} ${member.lastName}`.trim();
|
||||
return full || identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a member identifier to a structured object containing
|
||||
* the member's name, CW member ID, and local user ID (if any).
|
||||
*/
|
||||
export async function resolveMember(identifier: string): Promise<{
|
||||
identifier: string;
|
||||
name: string;
|
||||
cwMemberId: number | null;
|
||||
id: string | null;
|
||||
}> {
|
||||
const member = _cache.get(identifier);
|
||||
const name = resolveMemberName(identifier);
|
||||
const cwMemberId = member?.id ?? null;
|
||||
|
||||
const localUser = await prisma.user
|
||||
.findFirst({ where: { cwIdentifier: identifier }, select: { id: true } })
|
||||
.catch(() => null);
|
||||
|
||||
return {
|
||||
identifier,
|
||||
name,
|
||||
cwMemberId,
|
||||
id: localUser?.id ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* companySites
|
||||
*
|
||||
* Types and helpers for ConnectWise company sites (addresses).
|
||||
*/
|
||||
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
|
||||
export interface CWCompanySite {
|
||||
id: number;
|
||||
name: string;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
stateReference?: { id: number; identifier: string; name: string } | null;
|
||||
zip?: string | null;
|
||||
country?: { id: number; name: string } | null;
|
||||
phoneNumber?: string | null;
|
||||
faxNumber?: string | null;
|
||||
taxCodeId?: number | null;
|
||||
expenseReimbursement?: number | null;
|
||||
primaryAddressFlag?: boolean;
|
||||
defaultShippingFlag?: boolean;
|
||||
defaultBillingFlag?: boolean;
|
||||
defaultMailingFlag?: boolean;
|
||||
mobileGuid?: string | null;
|
||||
calendar?: any | null;
|
||||
timeZone?: any | null;
|
||||
company?: { id: number; identifier: string; name: string } | null;
|
||||
_info?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SerializedCwSite {
|
||||
id: number;
|
||||
name: string;
|
||||
address: {
|
||||
line1: string | null;
|
||||
line2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
country: string;
|
||||
};
|
||||
phoneNumber: string | null;
|
||||
faxNumber: string | null;
|
||||
primaryAddressFlag: boolean;
|
||||
defaultShippingFlag: boolean;
|
||||
defaultBillingFlag: boolean;
|
||||
defaultMailingFlag: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a raw ConnectWise company site into a clean API shape.
|
||||
*/
|
||||
export function serializeCwSite(site: CWCompanySite): SerializedCwSite {
|
||||
return {
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
address: {
|
||||
line1: site.addressLine1 ?? null,
|
||||
line2: site.addressLine2 ?? null,
|
||||
city: site.city ?? null,
|
||||
state: site.stateReference?.name ?? null,
|
||||
zip: site.zip ?? null,
|
||||
country: site.country?.name ?? "United States",
|
||||
},
|
||||
phoneNumber: site.phoneNumber || null,
|
||||
faxNumber: site.faxNumber || null,
|
||||
primaryAddressFlag: site.primaryAddressFlag ?? false,
|
||||
defaultShippingFlag: site.defaultShippingFlag ?? false,
|
||||
defaultBillingFlag: site.defaultBillingFlag ?? false,
|
||||
defaultMailingFlag: site.defaultMailingFlag ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all sites for a given CW company ID.
|
||||
*/
|
||||
export async function fetchCompanySites(
|
||||
cwCompanyId: number
|
||||
): Promise<CWCompanySite[]> {
|
||||
const response = await connectWiseApi.get<CWCompanySite[]>(
|
||||
`/company/companies/${cwCompanyId}/sites?pageSize=1000`
|
||||
);
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single site by CW company ID and site ID.
|
||||
*/
|
||||
export async function fetchCompanySite(
|
||||
cwCompanyId: number,
|
||||
siteId: number
|
||||
): Promise<CWCompanySite | null> {
|
||||
try {
|
||||
const response = await connectWiseApi.get<CWCompanySite>(
|
||||
`/company/companies/${cwCompanyId}/sites/${siteId}`
|
||||
);
|
||||
return response.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user