import { createLogger } from "@link-stack/logger"; const logger = createLogger('formstack-field-mapping'); /** * Field mapping configuration for Formstack to Zammad integration * * This configuration is completely flexible - you define your own internal field names * and map them to both Formstack source fields and Zammad custom fields. */ export interface FieldMappingConfig { /** * Map internal field keys to Formstack field names * * Required keys (system): * - formId: The Formstack Form ID field * - uniqueId: The Formstack submission unique ID field * * Optional keys with special behavior: * - email: Used for user lookup/creation (if provided) * - phone: Used for user lookup/creation (if provided) * - signalAccount: Used for Signal-based user lookup (tried first before phone) * - name: User's full name (can be nested object with first/last, used in user creation) * - organization: Used in ticket title template placeholder {organization} * - typeOfSupport: Used in ticket title template placeholder {typeOfSupport} * - descriptionOfIssue: Used as article subject (defaults to "Support Request" if not provided) * * All other keys are completely arbitrary and defined by your form. */ sourceFields: Record; /** * Map Zammad custom field names to internal field keys (from sourceFields) * * Example: * { * "us_state": "state", // Zammad field "us_state" gets value from sourceFields["state"] * "zip_code": "zipCode", // Zammad field "zip_code" gets value from sourceFields["zipCode"] * "custom_field": "myField" // Any custom field mapping * } * * The values in this object must correspond to keys in sourceFields. */ zammadFields: Record; /** * Configuration for ticket creation */ ticket: { /** Zammad group name to assign tickets to */ group: string; /** Article type name (e.g., "note", "cdr_signal", "email") */ defaultArticleType: string; /** * Template for ticket title * Supports placeholders: {name}, {organization}, {typeOfSupport} * Placeholders reference internal field keys from sourceFields */ titleTemplate?: string; }; /** * Configuration for extracting nested field values */ nestedFields?: { /** * How to extract first/last name from a nested Name field * Example: { firstNamePath: "first", lastNamePath: "last" } * for a field like { "Name": { "first": "John", "last": "Doe" } } */ name?: { firstNamePath?: string; lastNamePath?: string; }; }; } let cachedMapping: FieldMappingConfig | null = null; /** * Load field mapping configuration from environment variable (REQUIRED) */ export function loadFieldMapping(): FieldMappingConfig { if (cachedMapping) { return cachedMapping; } const configJson = process.env.FORMSTACK_FIELD_MAPPING; if (!configJson) { throw new Error( 'FORMSTACK_FIELD_MAPPING environment variable is required. ' + 'Please set it to a JSON string containing your field mapping configuration.' ); } logger.info('Loading Formstack field mapping from environment variable'); try { const config = JSON.parse(configJson) as FieldMappingConfig; // Validate required sections exist if (!config.sourceFields || typeof config.sourceFields !== 'object') { throw new Error('Invalid field mapping configuration: sourceFields must be an object'); } if (!config.zammadFields || typeof config.zammadFields !== 'object') { throw new Error('Invalid field mapping configuration: zammadFields must be an object'); } if (!config.ticket || typeof config.ticket !== 'object') { throw new Error('Invalid field mapping configuration: ticket must be an object'); } // Validate required ticket fields if (!config.ticket.group) { throw new Error('Invalid field mapping configuration: ticket.group is required'); } if (!config.ticket.defaultArticleType) { throw new Error('Invalid field mapping configuration: ticket.defaultArticleType is required'); } // Validate required source fields const systemRequiredFields = ['formId', 'uniqueId']; for (const field of systemRequiredFields) { if (!config.sourceFields[field]) { throw new Error(`Invalid field mapping configuration: sourceFields.${field} is required (system field)`); } } // Validate zammadFields reference valid sourceFields for (const [zammadField, sourceKey] of Object.entries(config.zammadFields)) { if (!config.sourceFields[sourceKey]) { logger.warn( { zammadField, sourceKey }, 'Zammad field maps to non-existent source field key' ); } } logger.info('Successfully loaded Formstack field mapping configuration'); cachedMapping = config; return cachedMapping; } catch (error) { logger.error({ error: error instanceof Error ? error.message : error, jsonLength: configJson.length }, 'Failed to parse field mapping configuration'); throw new Error( `Failed to parse Formstack field mapping JSON: ${error instanceof Error ? error.message : error}` ); } } /** * Get a field value from formData using the source field name mapping */ export function getFieldValue( formData: any, internalFieldKey: string, mapping?: FieldMappingConfig ): any { const config = mapping || loadFieldMapping(); const sourceFieldName = config.sourceFields[internalFieldKey]; if (!sourceFieldName) { return undefined; } return formData[sourceFieldName]; } /** * Get a nested field value (e.g., Name.first) */ export function getNestedFieldValue( fieldValue: any, path: string | undefined ): any { if (!path || !fieldValue) { return undefined; } const parts = path.split('.'); let current = fieldValue; for (const part of parts) { if (current && typeof current === 'object') { current = current[part]; } else { return undefined; } } return current; } /** * Format field value (handle arrays, objects, etc.) */ export function formatFieldValue(value: any): string | undefined { if (value === null || value === undefined || value === '') { return undefined; } if (Array.isArray(value)) { return value.join(', '); } if (typeof value === 'object') { return JSON.stringify(value); } return String(value); } /** * Build ticket title from template and data * Replaces placeholders like {name}, {organization}, {typeOfSupport} with provided values */ export function buildTicketTitle( mapping: FieldMappingConfig, data: Record ): string { const template = mapping.ticket.titleTemplate || '{name}'; let title = template; // Replace all placeholders in the template for (const [key, value] of Object.entries(data)) { const placeholder = `{${key}}`; if (title.includes(placeholder)) { if (value) { title = title.replace(placeholder, value); } else { // Remove empty placeholder and surrounding separators title = title.replace(` - ${placeholder}`, '').replace(`${placeholder} - `, '').replace(placeholder, ''); } } } return title.trim(); } /** * Get all Zammad field values from form data using the mapping * Returns an object with Zammad field names as keys and formatted values */ export function getZammadFieldValues( formData: any, mapping?: FieldMappingConfig ): Record { const config = mapping || loadFieldMapping(); const result: Record = {}; for (const [zammadFieldName, sourceKey] of Object.entries(config.zammadFields)) { const value = getFieldValue(formData, sourceKey, config); const formatted = formatFieldValue(value); if (formatted !== undefined) { result[zammadFieldName] = formatted; } } return result; } /** * Reset cached mapping (useful for testing) */ export function resetMappingCache(): void { cachedMapping = null; }