Repo cleanup and updates
This commit is contained in:
parent
3a1063e40e
commit
99f8d7e2eb
72 changed files with 11857 additions and 16439 deletions
|
|
@ -1,11 +1,6 @@
|
|||
/* eslint-disable camelcase */
|
||||
// import { SavedVoiceProvider } from "@digiresilience/bridge-db";
|
||||
import Twilio from "twilio";
|
||||
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||
import { Zammad, getOrCreateUser } from "./zammad.js";
|
||||
import { createLogger } from "@link-stack/logger";
|
||||
|
||||
const logger = createLogger('bridge-worker-common');
|
||||
|
||||
type SavedVoiceProvider = any;
|
||||
|
||||
|
|
@ -23,51 +18,3 @@ export const twilioClientFor = (
|
|||
});
|
||||
};
|
||||
|
||||
export const createZammadTicket = async (
|
||||
call: CallInstance,
|
||||
mp3: Buffer,
|
||||
): Promise<void> => {
|
||||
const title = `Call from ${call.fromFormatted} at ${call.startTime}`;
|
||||
const body = `<ul>
|
||||
<li>Caller: ${call.fromFormatted}</li>
|
||||
<li>Service Number: ${call.toFormatted}</li>
|
||||
<li>Call Duration: ${call.duration} seconds</li>
|
||||
<li>Start Time: ${call.startTime}</li>
|
||||
<li>End Time: ${call.endTime}</li>
|
||||
</ul>
|
||||
<p>See the attached recording.</p>`;
|
||||
const filename = `${call.sid}-${call.startTime}.mp3`;
|
||||
const zammad = Zammad(
|
||||
{
|
||||
token: "EviH_WL0p6YUlCoIER7noAZEAPsYA_fVU4FZCKdpq525Vmzzvl8d7dNuP_8d-Amb",
|
||||
},
|
||||
"https://demo.digiresilience.org",
|
||||
);
|
||||
try {
|
||||
const customer = await getOrCreateUser(zammad, call.fromFormatted);
|
||||
await zammad.ticket.create({
|
||||
title,
|
||||
group: "Finances",
|
||||
note: "This ticket was created automaticaly from a recorded phone call.",
|
||||
customer_id: customer.id,
|
||||
article: {
|
||||
body,
|
||||
subject: title,
|
||||
content_type: "text/html",
|
||||
type: "note",
|
||||
attachments: [
|
||||
{
|
||||
filename,
|
||||
data: mp3.toString("base64"),
|
||||
"mime-type": "audio/mpeg",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.isBoom) {
|
||||
logger.error({ output: error.output }, 'Zammad ticket creation failed');
|
||||
throw new Error("Failed to create zamamd ticket");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
272
apps/bridge-worker/lib/formstack-field-mapping.ts
Normal file
272
apps/bridge-worker/lib/formstack-field-mapping.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ const formatAuth = (credentials: any) => {
|
|||
return (
|
||||
"Basic " +
|
||||
Buffer.from(`${credentials.username}:${credentials.password}`).toString(
|
||||
"base64"
|
||||
"base64",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ const formatAuth = (credentials: any) => {
|
|||
export const Zammad = (
|
||||
credentials: ZammadCredentials,
|
||||
host: string,
|
||||
opts?: ZammadClientOpts
|
||||
opts?: ZammadClientOpts,
|
||||
): ZammadClient => {
|
||||
const extraHeaders = (opts && opts.headers) || {};
|
||||
|
||||
|
|
@ -76,7 +76,9 @@ export const Zammad = (
|
|||
return result as Ticket;
|
||||
},
|
||||
update: async (id, payload) => {
|
||||
const { payload: result } = await wreck.put(`tickets/${id}`, { payload });
|
||||
const { payload: result } = await wreck.put(`tickets/${id}`, {
|
||||
payload,
|
||||
});
|
||||
return result as Ticket;
|
||||
},
|
||||
},
|
||||
|
|
@ -99,18 +101,30 @@ export const Zammad = (
|
|||
};
|
||||
|
||||
export const getUser = async (zammad: ZammadClient, phoneNumber: string) => {
|
||||
const mungedNumber = phoneNumber.replace("+", "");
|
||||
const results = await zammad.user.search(`phone:${mungedNumber}`);
|
||||
// Sanitize phone number: only allow digits and + symbol
|
||||
const mungedNumber = phoneNumber.replace(/[^\d+]/g, "");
|
||||
|
||||
// Validate phone number format (10-15 digits, optional + prefix)
|
||||
if (!/^\+?\d{10,15}$/.test(mungedNumber)) {
|
||||
throw new Error(`Invalid phone number format: ${phoneNumber}`);
|
||||
}
|
||||
|
||||
// Remove + for search query
|
||||
const searchNumber = mungedNumber.replace("+", "");
|
||||
const results = await zammad.user.search(`phone:${searchNumber}`);
|
||||
if (results.length > 0) return results[0];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getOrCreateUser = async (zammad: ZammadClient, phoneNumber: string) => {
|
||||
export const getOrCreateUser = async (
|
||||
zammad: ZammadClient,
|
||||
phoneNumber: string,
|
||||
) => {
|
||||
const customer = await getUser(zammad, phoneNumber);
|
||||
if (customer) return customer;
|
||||
|
||||
return zammad.user.create({
|
||||
phone: phoneNumber,
|
||||
note: "User created by Grabadora from incoming voice call",
|
||||
note: "User created from incoming voice call",
|
||||
});
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue