deal-defaults.ts•21.7 kB
/**
* Deal defaults configuration
*
* This module provides configurable defaults for deal creation.
* Users can set environment variables to customize default behavior.
*
* AVAILABLE DEAL FIELDS IN ATTIO:
* - name: Deal title (required, formatted as array with {value: "text"})
* - stage: Deal stage/status (required, formatted as array with {status: "stage_name"})
* - value: Deal amount (number only - Attio handles currency formatting)
* - owner: Deal owner (workspace member reference)
* - associated_company: Link to company record
* - associated_people: Links to people/contact records
*
* FIELDS THAT DON'T EXIST (use custom fields instead):
* - description: Use notes API after deal creation
* - close_date/expected_close_date: Use custom date field
* - probability: Use custom number field or encode in stage names
* - source/lead_source: Use custom field
* - type/deal_type: Use custom field or stages
*
* ENVIRONMENT VARIABLES (Runtime Behavior Configuration):
*
* @env ATTIO_DEFAULT_DEAL_STAGE - Default stage for new deals (default: "Interested")
* Example: ATTIO_DEFAULT_DEAL_STAGE="Qualified"
* Impact: Changes default fallback stage when none provided
*
* @env ATTIO_DEFAULT_DEAL_OWNER - Default owner workspace member ID
* Example: ATTIO_DEFAULT_DEAL_OWNER="member-uuid-here"
* Impact: Auto-assigns deals to specified owner when none provided
*
* @env ATTIO_DEFAULT_CURRENCY - Default currency code (default: "USD")
* Example: ATTIO_DEFAULT_CURRENCY="EUR"
* Impact: Sets currency for deal values when not specified
*
* @env STRICT_DEAL_STAGE_VALIDATION - Enable strict stage validation (default: false)
* Values: "true" | "false" | undefined
* Example: STRICT_DEAL_STAGE_VALIDATION="true"
* Impact: When "true", throws errors for invalid stages instead of silent fallbacks
* WARNING: Changing this in production can cause previously working deals to fail
*
* PRODUCTION SAFETY NOTES:
* - Environment variables change runtime behavior and can cause production inconsistencies
* - Test all environment variable combinations before deploying
* - Document environment variables in deployment guides
* - Consider gradual rollout when changing validation strictness
* - Monitor error rates when enabling strict validation
*/
import { warn, error } from '../utils/logger.js';
export interface DealDefaults {
stage?: string;
owner?: string;
currency?: string;
}
/**
* Clear all caches (useful for testing or when configuration changes)
*/
export function clearDealCaches(): void {
stageCache = null;
stageCacheTimestamp = 0;
errorCache = null;
}
/**
* Pre-warm the stage cache (useful at startup to avoid first-request latency)
*/
export async function prewarmStageCache(): Promise<void> {
try {
await getAvailableDealStages();
} catch {
// Cache pre-warming is optional - silently continue if it fails
}
}
// Cache for available deal stages to avoid repeated API calls
let stageCache: string[] | null = null;
let stageCacheTimestamp: number = 0;
const STAGE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Error cache to prevent repeated failed API calls during outages
let errorCache: { timestamp: number; error: unknown } | null = null;
const ERROR_CACHE_TTL = 30 * 1000; // 30 seconds - shorter TTL for errors
/**
* Get deal defaults from environment configuration
*
* Environment variables:
* - ATTIO_DEFAULT_DEAL_STAGE: Default stage for new deals (e.g., "Interested")
* - ATTIO_DEFAULT_DEAL_OWNER: Default owner workspace member ID
* - ATTIO_DEFAULT_CURRENCY: Default currency code (e.g., "USD")
*/
export function getDealDefaults(): DealDefaults {
return {
stage: process.env.ATTIO_DEFAULT_DEAL_STAGE || 'Interested',
owner: process.env.ATTIO_DEFAULT_DEAL_OWNER,
currency: process.env.ATTIO_DEFAULT_CURRENCY || 'USD',
};
}
/**
* Deal field name aliases for common variations
* Maps user-friendly field names to Attio API expected names
*/
const DEAL_FIELD_ALIASES: Record<string, string> = {
deal_name: 'name',
deal_value: 'value',
deal_stage: 'stage',
deal_owner: 'owner',
company_id: 'associated_company',
company: 'associated_company',
};
/**
* Apply field name conversions for legacy compatibility
* Handles company_id → associated_company, deal_name → name, deal_owner → owner, etc.
*/
function applyFieldNameConversions(
dealData: Record<string, unknown>
): Record<string, unknown> {
// Apply generic field aliases
for (const [alias, canonical] of Object.entries(DEAL_FIELD_ALIASES)) {
if (dealData[alias] !== undefined && dealData[canonical] === undefined) {
dealData[canonical] = dealData[alias];
delete dealData[alias];
}
}
// Ensure name is properly formatted as array (if it's not already)
if (dealData.name && typeof dealData.name === 'string') {
dealData.name = [{ value: dealData.name }];
}
return dealData;
}
/**
* Apply stage defaults and convert stage formats
* Handles deal_stage → stage conversion and proper array formatting
*/
function applyStageDefaults(
dealData: Record<string, unknown>,
defaults: DealDefaults
): Record<string, unknown> {
// Apply stage default if not provided, or convert to proper format
if (!dealData.stage && !dealData.deal_stage && defaults.stage) {
dealData.stage = [{ status: defaults.stage }];
} else if (dealData.stage && typeof dealData.stage === 'string') {
// Convert string stage to proper array format
dealData.stage = [{ status: dealData.stage }];
} else if (dealData.deal_stage && typeof dealData.deal_stage === 'string') {
// Convert deal_stage to stage with proper format
dealData.stage = [{ status: dealData.deal_stage }];
delete dealData.deal_stage;
}
return dealData;
}
/**
* Apply owner defaults
* Note: Attio accepts email addresses directly in the owner field
*/
function applyOwnerDefaults(
dealData: Record<string, unknown>,
defaults: DealDefaults
): Record<string, unknown> {
// Apply owner default if not provided
if (!dealData.owner && defaults.owner) {
dealData.owner = defaults.owner;
}
return dealData;
}
/**
* Apply value/currency defaults and convert various formats
* Handles object formats, arrays, and legacy deal_value field
*/
function applyValueDefaults(
dealData: Record<string, unknown>
): Record<string, unknown> {
// Handle various value formats - Attio accepts simple numbers for currency fields
if (dealData.value && typeof dealData.value === 'number') {
// Simple number format: value: 9780 - Attio accepts this directly
return dealData;
}
if (
dealData.value &&
typeof dealData.value === 'object' &&
!Array.isArray(dealData.value)
) {
// Handle different object formats - convert to simple number
if ('value' in dealData.value) {
dealData.value = dealData.value.value;
} else if ('amount' in dealData.value) {
dealData.value = dealData.value.amount;
} else if ('currency_value' in dealData.value) {
dealData.value = dealData.value.currency_value;
}
} else if (
dealData.value &&
Array.isArray(dealData.value) &&
dealData.value[0]
) {
// If already an array, extract the numeric value
const firstValue = dealData.value[0];
if (typeof firstValue === 'object' && 'currency_value' in firstValue) {
dealData.value = firstValue.currency_value;
} else if (typeof firstValue === 'number') {
dealData.value = firstValue;
}
} else if (dealData.deal_value && typeof dealData.deal_value === 'number') {
// Legacy deal_value field
dealData.value = dealData.deal_value;
delete dealData.deal_value;
}
return dealData;
}
/**
* Apply deal defaults and handle all field conversions
*
* This function:
* 1. Applies configured defaults to deal data
* 2. Handles all legacy field name conversions
* 3. Formats values to proper Attio API format
* 4. Allows user-provided values to override defaults
*/
export function applyDealDefaults(
recordData: Record<string, unknown>
): Record<string, unknown> {
const defaults = getDealDefaults();
let dealData = { ...recordData };
// Apply transformations in logical order
dealData = applyFieldNameConversions(dealData);
dealData = applyStageDefaults(dealData, defaults);
dealData = applyOwnerDefaults(dealData, defaults);
dealData = applyValueDefaults(dealData);
return dealData;
}
/**
* Input validation helper for deal data
* Provides immediate feedback on common mistakes before API calls
*
* Note: Field aliases (deal_name, deal_value, deal_stage, deal_owner, company_id, company)
* are automatically converted by applyFieldNameConversions(), so we note them but don't error.
*/
export function validateDealInput(recordData: Record<string, unknown>): {
isValid: boolean;
errors: string[];
warnings: string[];
suggestions: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
const suggestions: string[] = [];
// Note field aliases that will be auto-converted (informational only)
const aliasedFields: string[] = [];
for (const alias of Object.keys(DEAL_FIELD_ALIASES)) {
if (recordData[alias] !== undefined) {
aliasedFields.push(`${alias} → ${DEAL_FIELD_ALIASES[alias]}`);
}
}
if (aliasedFields.length > 0) {
suggestions.push(
`Field aliases auto-converted: ${aliasedFields.join(', ')}`
);
}
// Check value format
if (
recordData.value &&
typeof recordData.value !== 'number' &&
typeof recordData.value !== 'object'
) {
errors.push('Deal value must be a number (e.g., 9780) or currency object');
suggestions.push('Example: value: 9780 (as a simple number)');
}
// Check for required fields (name is required)
if (!recordData.name && !recordData.deal_name) {
errors.push('Deal name is required');
suggestions.push('Add a "name" field with the deal title');
}
// Check stage format
if (
recordData.stage &&
typeof recordData.stage === 'object' &&
Array.isArray(recordData.stage)
) {
if (!recordData.stage[0]?.status) {
warnings.push('Stage array format detected but missing status field');
suggestions.push(
'Stage should be: [{"status": "stage_name"}] or just "stage_name"'
);
}
}
// Check owner format
if (recordData.owner && typeof recordData.owner === 'string') {
warnings.push(
'Owner should be in proper format for workspace member reference'
);
suggestions.push(
'Owner will be auto-formatted to proper workspace member reference'
);
}
return {
isValid: errors.length === 0,
errors,
warnings,
suggestions,
};
}
/**
* Get available deal stages from Attio API with caching
*
* NOTE: This function makes an API call and should NOT be used in error handling paths
* to prevent cascading failures during high error rates.
*/
async function getAvailableDealStages(): Promise<string[]> {
const now = Date.now();
// Return cached stages if still valid
if (stageCache && now - stageCacheTimestamp < STAGE_CACHE_TTL) {
return stageCache;
}
// Check error cache to prevent repeated failed requests
if (errorCache && now - errorCache.timestamp < ERROR_CACHE_TTL) {
// Return common fallback stages when API is unavailable
return getCommonDealStages();
}
try {
// Import here to avoid circular dependencies
const { getStatusOptions } = await import('../api/attio-client.js');
// Get status options for the deal stage attribute
const statusOptions = await getStatusOptions('deals', 'stage');
// Extract stage titles from the status options
const stages = statusOptions
.filter((option) => !option.is_archived) // Only include active stages
.map((option) => option.title)
.filter((title) => typeof title === 'string' && title.length > 0);
// Update cache and clear error cache on success
stageCache = stages;
stageCacheTimestamp = now;
errorCache = null;
return stages;
} catch (error: unknown) {
// Cache the error to prevent cascading failures
errorCache = { timestamp: now, error };
// Log warning about falling back to common stages
warn(
'deal-defaults',
'Failed to fetch deal stages from API, falling back to common stages',
{ error: error instanceof Error ? error.message : String(error) }
);
// Return previously cached stages if available, otherwise use common fallback stages
return stageCache || getCommonDealStages();
}
}
/**
* Get common deal stages as fallback when API is unavailable
* These are typical stages found in most CRM systems
*/
function getCommonDealStages(): string[] {
return [
'Interested',
'Qualified',
'Demo Scheduled',
'Demo',
'Demo No Show',
'Proposal',
'Negotiation',
'Closed Won',
'Closed Lost',
];
}
/**
* Result of deal stage validation
*/
export interface DealStageValidationResult {
validatedStage: string | undefined;
warnings: string[];
suggestions: string[];
}
/**
* Find close matches for invalid stage names using fuzzy matching
*/
function getStageSuggestions(
invalidStage: string,
availableStages: string[]
): string[] {
const lowercaseInvalid = invalidStage.toLowerCase();
const suggestions: string[] = [];
// Find exact case-insensitive matches first
const exactMatch = availableStages.find(
(stage) => stage.toLowerCase() === lowercaseInvalid
);
if (exactMatch) {
suggestions.push(exactMatch);
return suggestions;
}
// Find stages that contain the invalid stage or vice versa
const partialMatches = availableStages.filter((stage) => {
const lowercaseStage = stage.toLowerCase();
return (
lowercaseStage.includes(lowercaseInvalid) ||
lowercaseInvalid.includes(lowercaseStage)
);
});
if (partialMatches.length > 0) {
suggestions.push(...partialMatches.slice(0, 3)); // Limit to 3 suggestions
return suggestions;
}
// Levenshtein distance for close matches
const calculateDistance = (str1: string, str2: string): number => {
const matrix = Array(str2.length + 1)
.fill(null)
.map(() => Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
for (let j = 1; j <= str2.length; j++) {
for (let i = 1; i <= str1.length; i++) {
const substitutionCost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j - 1][i] + 1, // deletion
matrix[j][i - 1] + 1, // insertion
matrix[j - 1][i - 1] + substitutionCost // substitution
);
}
}
return matrix[str2.length][str1.length];
};
// Find stages with small edit distance (similar spelling)
const closeMatches = availableStages
.map((stage) => ({
stage,
distance: calculateDistance(lowercaseInvalid, stage.toLowerCase()),
}))
.filter(({ distance }) => distance <= 3) // Allow up to 3 character differences
.sort((a, b) => a.distance - b.distance)
.slice(0, 3) // Limit to 3 suggestions
.map(({ stage }) => stage);
suggestions.push(...closeMatches);
return suggestions;
}
/**
* Validate and correct deal stage
* Returns validation result with warnings and suggestions
*
* @param stage - The stage to validate
* @param skipApiCall - If true, skip API call and use cached data only
*/
export async function validateDealStage(
stage: string | undefined,
skipApiCall: boolean = false
): Promise<DealStageValidationResult> {
if (!stage) {
return {
validatedStage: undefined,
warnings: [],
suggestions: [],
};
}
try {
// If skipApiCall is true, only use cached data
let availableStages: string[] = [];
if (skipApiCall) {
// Use cached stages if available, otherwise skip validation
if (stageCache) {
availableStages = stageCache;
} else {
// No cache available and can't make API call, return original
return {
validatedStage: stage,
warnings: ['Unable to validate stage - no cached data available'],
suggestions: [],
};
}
} else {
availableStages = await getAvailableDealStages();
}
// Check if provided stage exists (case-insensitive)
const validStage = availableStages.find(
(s) => s.toLowerCase() === stage.toLowerCase()
);
if (validStage) {
return {
validatedStage: validStage, // Return the correctly cased version
warnings: [],
suggestions: [],
};
}
// Stage not found - either fail or use default based on strict validation mode
const defaults = getDealDefaults();
const availableStagesText =
availableStages.length > 0
? availableStages.join(', ')
: 'Unable to fetch available stages. Use records_discover_attributes(resource_type: "deals") to see your workspace stages.';
// Get suggestions for the invalid stage
const stageSuggestions = getStageSuggestions(stage, availableStages);
// Build warning message with suggestions and discovery guidance
let warningMessage = `Deal stage "${stage}" not found in workspace.`;
if (stageSuggestions.length > 0) {
warningMessage += ` Did you mean "${stageSuggestions[0]}"?`;
}
warningMessage += ` Falling back to default "${defaults.stage}".`;
warningMessage += ` Tip: Use records_discover_attributes(resource_type: "deals") to see all available stages for your workspace.`;
const result: DealStageValidationResult = {
validatedStage: defaults.stage,
warnings: [warningMessage],
suggestions: [
`Available stages: ${availableStagesText}`,
...stageSuggestions.map((s) => `"${s}"`),
],
};
// If strict validation is enabled, throw an error instead of silent fallback
// WARNING: This environment variable changes runtime behavior
// Production Impact: Previously working deals may start failing
if (process.env.STRICT_DEAL_STAGE_VALIDATION === 'true') {
const { UniversalValidationError, ErrorType } = await import(
'../handlers/tool-configs/universal/schemas.js'
);
throw new UniversalValidationError(
`Deal stage "${stage}" not found. Available stages: ${availableStagesText}`,
ErrorType.USER_ERROR,
{
field: 'stage',
suggestion:
stageSuggestions.length > 0
? `Did you mean "${stageSuggestions[0]}"? Available stages: ${availableStagesText}`
: `Use one of the available stages: ${availableStagesText}`,
}
);
}
// Also log warning for backward compatibility
warn('deal-defaults', warningMessage);
return result;
} catch (err: unknown) {
error('deal-defaults', 'Stage validation failed', err);
return {
validatedStage: stage, // Return original stage if validation fails
warnings: [
`Stage validation failed: ${err instanceof Error ? err.message : String(err)}`,
],
suggestions: [],
};
}
}
/**
* Get available deal stages for error reporting
* This is a non-caching version for immediate error feedback
*/
export async function getAvailableStagesForErrors(): Promise<string[]> {
try {
// Try to get from cache first (fast path)
if (stageCache && Date.now() - stageCacheTimestamp < STAGE_CACHE_TTL) {
return stageCache;
}
// If no valid cache, return common stages for immediate error reporting
return getCommonDealStages();
} catch {
// Always return common stages if anything fails
return getCommonDealStages();
}
}
/**
* Enhanced deal defaults result with validation metadata
*/
export interface DealDefaultsValidationResult {
dealData: Record<string, unknown>;
warnings: string[];
suggestions: string[];
}
/**
* Enhanced apply deal defaults with stage validation
* Returns both processed data and validation warnings for user feedback
*
* @param recordData - The deal data to process
* @param skipValidation - Skip API validation (used in error paths to prevent cascading failures)
*/
export async function applyDealDefaultsWithValidation(
recordData: Record<string, unknown>,
skipValidation: boolean = false
): Promise<DealDefaultsValidationResult> {
const dealData = applyDealDefaults(recordData);
const result: DealDefaultsValidationResult = {
dealData,
warnings: [],
suggestions: [],
};
// Validate stage if present
if (
dealData.stage &&
Array.isArray(dealData.stage) &&
dealData.stage[0]?.status
) {
// Pass skipValidation flag to validateDealStage to control API calls
const stageValidation = await validateDealStage(
dealData.stage[0].status,
skipValidation // Skip API calls when in error paths
);
if (stageValidation.validatedStage) {
dealData.stage = [{ status: stageValidation.validatedStage }];
}
// Collect validation warnings and suggestions
result.warnings.push(...stageValidation.warnings);
result.suggestions.push(...stageValidation.suggestions);
}
return result;
}
/**
* Backward compatible version that returns only the data (for existing callers)
*/
export async function applyDealDefaultsWithValidationLegacy(
recordData: Record<string, unknown>,
skipValidation: boolean = false
): Promise<Record<string, unknown>> {
const result = await applyDealDefaultsWithValidation(
recordData,
skipValidation
);
return result.dealData;
}