feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage

This commit is contained in:
2026-03-09 02:56:08 -05:00
parent c0a4d4f919
commit f53b390e18
50 changed files with 8837 additions and 63 deletions
@@ -0,0 +1,123 @@
/**
* @module algo.coldThreshold
*
* Cold-Detection Algorithm
* ========================
*
* Determines whether an opportunity has stalled in a status long enough
* to be considered "cold". When an opportunity goes cold it is
* automatically moved to InternalReview, a system-generated activity is
* logged, and it is flagged for the internal review report.
*
* ## Thresholds (defaults)
*
* | Status | Stall Threshold |
* |-----------------|-----------------|
* | QuoteSent | 14 days |
* | ConfirmedQuote | 30 days |
*
* Only these two statuses are eligible for cold detection. All other
* statuses return `cold: false`.
*
* ## How "last activity date" is determined
*
* The algorithm uses `lastActivityDate` — the most recent of:
* - the latest activity's `dateStart`
* - the opportunity's `cwLastUpdated`
*
* The caller is responsible for resolving this value before calling
* `checkColdStatus`.
*/
import type { OpportunityController } from "../../controllers/OpportunityController";
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/** Stall thresholds in milliseconds, keyed by CW status ID. */
export const COLD_THRESHOLDS: Record<number, { days: number; ms: number }> = {
/** QuoteSent — CW status ID 43, "03. Quote Sent" */
43: { days: 14, ms: 14 * 24 * 60 * 60 * 1000 },
/** ConfirmedQuote — CW status ID 57, "04. Confirmed Quote" */
57: { days: 30, ms: 30 * 24 * 60 * 60 * 1000 },
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ColdCheckInput {
/** Current CW status ID of the opportunity. */
statusCwId: number | null;
/**
* The most recent meaningful date to measure staleness from.
* Typically the latest of the last activity dateStart or cwLastUpdated.
*/
lastActivityDate: Date | null;
/** Override for "now" — useful for testing. Defaults to `new Date()`. */
now?: Date;
}
export interface ColdCheckResult {
/** Whether the opportunity is considered cold. */
cold: boolean;
/**
* Which threshold triggered the cold flag.
* `null` when `cold` is `false`.
*/
triggeredBy: {
statusCwId: number;
statusName: string;
thresholdDays: number;
staleDays: number;
} | null;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_NAMES: Record<number, string> = {
43: "QuoteSent",
57: "ConfirmedQuote",
};
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
/**
* Evaluate whether an opportunity has exceeded its cold-stall threshold.
*
* @returns A `ColdCheckResult` indicating cold status and trigger metadata.
*/
export function checkColdStatus(input: ColdCheckInput): ColdCheckResult {
const NOT_COLD: ColdCheckResult = { cold: false, triggeredBy: null };
if (!input.statusCwId) return NOT_COLD;
const threshold = COLD_THRESHOLDS[input.statusCwId];
if (!threshold) return NOT_COLD;
if (!input.lastActivityDate) return NOT_COLD;
const now = input.now ?? new Date();
const elapsed = now.getTime() - input.lastActivityDate.getTime();
if (elapsed < threshold.ms) return NOT_COLD;
return {
cold: true,
triggeredBy: {
statusCwId: input.statusCwId,
statusName: STATUS_NAMES[input.statusCwId] ?? "Unknown",
thresholdDays: threshold.days,
staleDays: Math.floor(elapsed / (24 * 60 * 60 * 1000)),
},
};
}
@@ -0,0 +1,97 @@
/**
* @module algo.followUpScheduler
*
* Follow-Up Scheduling Algorithm
* ===============================
*
* Determines the due date for follow-up activities created by the
* opportunity workflow. The follow-up is always assigned to the user
* who triggered its creation.
*
* ## TODO — Calendar-aware scheduling
*
* This module currently uses a **dummy algorithm** that schedules the
* follow-up for the next business day at 10:00 AM local time.
*
* It needs to be replaced with an availability-aware algorithm that:
* 1. Reads the assigned user's calendar (Microsoft Graph / CW schedule).
* 2. Finds the earliest open slot of sufficient duration.
* 3. Respects company-wide blackout dates (holidays, company events).
* 4. Accounts for the user's working-hours preferences.
*
* Until that integration is complete, the simple "next business day"
* heuristic is used as a placeholder.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FollowUpScheduleInput {
/** The user who triggered the activity (follow-up is assigned to them). */
triggeredByUserId: string;
/** Optional override for "now" — useful for testing. */
now?: Date;
}
export interface FollowUpScheduleResult {
/** Suggested due date for the follow-up activity. */
dueDate: Date;
/** ISO string version for CW API payloads. */
dueDateIso: string;
}
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
/**
* Schedule a follow-up activity.
*
* Returns a suggested `dueDate` for the follow-up activity.
* Currently uses dummy logic: next business day at 10:00 AM.
*
* @param input - Scheduling parameters
* @returns The scheduled follow-up date
*/
export function scheduleFollowUp(
input: FollowUpScheduleInput,
): FollowUpScheduleResult {
const now = input.now ?? new Date();
const dueDate = getNextBusinessDay(now);
// Set to 10:00 AM
dueDate.setHours(10, 0, 0, 0);
return {
dueDate,
dueDateIso: dueDate.toISOString(),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Returns the next business day (MonFri) from the given date.
* If the given date is already a weekday before 10 AM, returns
* the NEXT business day (not the same day).
*/
function getNextBusinessDay(from: Date): Date {
const result = new Date(from);
// Always advance at least one day
result.setDate(result.getDate() + 1);
const day = result.getDay();
// Saturday → Monday (+2)
if (day === 6) result.setDate(result.getDate() + 2);
// Sunday → Monday (+1)
if (day === 0) result.setDate(result.getDate() + 1);
return result;
}
+18
View File
@@ -499,6 +499,24 @@ export async function invalidateProductsCache(
await redis.del(productsCacheKey(cwOpportunityId));
}
/**
* Invalidate all cached data for an opportunity.
*
* Removes activities, notes, contacts, products, and CW data cache keys.
* Call this when an opportunity is deleted.
*/
export async function invalidateAllOpportunityCaches(
cwOpportunityId: number,
): Promise<void> {
await redis.del(
activityCacheKey(cwOpportunityId),
notesCacheKey(cwOpportunityId),
contactsCacheKey(cwOpportunityId),
productsCacheKey(cwOpportunityId),
oppCwDataCacheKey(cwOpportunityId),
);
}
/**
* Site TTL — 20 minutes. Site/address data rarely changes so we cache
* aggressively. The background refresh does NOT proactively warm site keys;
@@ -295,6 +295,31 @@ export const opportunityCw = {
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
},
/**
* Delete Forecast Item
*
* Removes a forecast item from an opportunity by PUTting the forecast
* without the target item. CW's forecast endpoint replaces the entire
* forecast items list on PUT.
*/
deleteProduct: async (
opportunityId: number,
forecastItemId: number,
): Promise<void> => {
const forecast = await opportunityCw.fetchProducts(opportunityId);
const items = forecast.forecastItems ?? [];
const filtered = items.filter((fi) => fi.id !== forecastItemId);
if (filtered.length === items.length) {
throw new Error(
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
);
}
const url = `/sales/opportunities/${opportunityId}/forecast`;
await connectWiseApi.put(url, { ...forecast, forecastItems: filtered });
},
/**
* Fetch Opportunity Notes
*
@@ -463,4 +488,13 @@ export const opportunityCw = {
);
return response.data as CWProcurementProduct;
},
/**
* Delete Opportunity
*
* Deletes an opportunity from ConnectWise by its CW opportunity ID.
*/
delete: async (opportunityId: number): Promise<void> => {
await connectWiseApi.delete(`/sales/opportunities/${opportunityId}`);
},
};
@@ -213,7 +213,6 @@ export interface CWForecastItemCreate {
catalogItem?: { id: number };
forecastDescription?: string;
productDescription?: string;
customerDescription?: string;
quantity?: number;
status?: { id: number };
productClass?: string;