273 lines
7.9 KiB
TypeScript
273 lines
7.9 KiB
TypeScript
|
|
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<string, string>;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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<string, string>;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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, string | undefined>
|
||
|
|
): 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<string, string> {
|
||
|
|
const config = mapping || loadFieldMapping();
|
||
|
|
const result: Record<string, string> = {};
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|