import { createLogger } from "@link-stack/logger"; import { Zammad, getUser } from "../../lib/zammad.js"; import { loadFieldMapping, getFieldValue, getNestedFieldValue, formatFieldValue, buildTicketTitle, getZammadFieldValues, type FieldMappingConfig, } from "../../lib/formstack-field-mapping.js"; const logger = createLogger('create-ticket-from-form'); export interface CreateTicketFromFormOptions { formData: any; receivedAt: string; } const createTicketFromFormTask = async ( options: CreateTicketFromFormOptions, ): Promise => { const { formData, receivedAt } = options; // Load field mapping configuration const mapping = loadFieldMapping(); // Log only non-PII metadata using configured field names const formId = getFieldValue(formData, 'formId', mapping); const uniqueId = getFieldValue(formData, 'uniqueId', mapping); logger.info({ formId, uniqueId, receivedAt, fieldCount: Object.keys(formData).length }, 'Processing Formstack form submission'); // Extract fields using dynamic mapping const nameField = getFieldValue(formData, 'name', mapping); const firstName = mapping.nestedFields?.name?.firstNamePath ? getNestedFieldValue(nameField, mapping.nestedFields.name.firstNamePath) || '' : ''; const lastName = mapping.nestedFields?.name?.lastNamePath ? getNestedFieldValue(nameField, mapping.nestedFields.name.lastNamePath) || '' : ''; const fullName = (firstName && lastName) ? `${firstName} ${lastName}`.trim() : firstName || lastName || 'Unknown'; // Extract well-known fields used for special logic (all optional) const email = getFieldValue(formData, 'email', mapping); const phone = getFieldValue(formData, 'phone', mapping); const signalAccount = getFieldValue(formData, 'signalAccount', mapping); const organization = getFieldValue(formData, 'organization', mapping); const typeOfSupport = getFieldValue(formData, 'typeOfSupport', mapping); const descriptionOfIssue = getFieldValue(formData, 'descriptionOfIssue', mapping); // Validate that at least one contact method is provided if (!email && !phone && !signalAccount) { logger.error({ formId, uniqueId }, 'No contact information provided - at least one of email, phone, or signalAccount is required'); throw new Error('At least one contact method (email, phone, or signalAccount) is required for ticket creation'); } // Build ticket title using configured template // Pass all potentially used fields - the template determines which are actually used const title = buildTicketTitle(mapping, { name: fullName, organization: formatFieldValue(organization), typeOfSupport: formatFieldValue(typeOfSupport), }); // Build article body - format all fields as HTML const formatAllFields = (data: any): string => { let html = ''; // Add formatted name field first if we have it if (fullName && fullName !== 'Unknown') { html += `Name:
${fullName}
`; } for (const [key, value] of Object.entries(data)) { // Skip metadata fields and name field (we already formatted it above) const skipFields = [ mapping.sourceFields.formId, mapping.sourceFields.uniqueId, mapping.sourceFields.name, // Skip raw name field 'HandshakeKey', ].filter(Boolean); if (skipFields.includes(key)) continue; if (value === null || value === undefined || value === '') continue; const displayValue = Array.isArray(value) ? value.join(', ') : typeof value === 'object' ? JSON.stringify(value) : value; html += `${key}:
${displayValue}
`; } return html; }; const body = formatAllFields(formData); // Get Zammad configuration from environment const zammadUrl = process.env.ZAMMAD_URL || 'http://zammad-nginx:8080'; const zammadToken = process.env.ZAMMAD_API_TOKEN; if (!zammadToken) { logger.error('ZAMMAD_API_TOKEN environment variable is not configured'); throw new Error('ZAMMAD_API_TOKEN is required'); } const zammad = Zammad({ token: zammadToken }, zammadUrl); try { // Look up the configured article type let articleTypeId: number | undefined; try { const articleTypes = await zammad.get('ticket_article_types'); const configuredType = articleTypes.find((t: any) => t.name === mapping.ticket.defaultArticleType); articleTypeId = configuredType?.id; if (articleTypeId) { logger.info({ articleTypeId, typeName: mapping.ticket.defaultArticleType }, 'Found configured article type'); } else { logger.warn({ typeName: mapping.ticket.defaultArticleType }, 'Configured article type not found, ticket will use default type'); } } catch (error: any) { logger.warn({ error: error.message }, 'Failed to look up article type'); } // Get or create user // Try to find existing user by: signalAccount -> phone -> email let customer; // Try Signal account first if provided if (signalAccount) { customer = await getUser(zammad, signalAccount); if (customer) { logger.info({ customerId: customer.id, method: 'signal' }, 'Found existing user by Signal account'); } } // Fall back to phone if no customer found yet if (!customer && phone) { customer = await getUser(zammad, phone); if (customer) { logger.info({ customerId: customer.id, method: 'phone' }, 'Found existing user by phone'); } } // Fall back to email if no customer found yet if (!customer && email) { // Validate email format before using in search const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; if (emailRegex.test(email)) { const emailResults = await zammad.user.search(`email:${email}`); if (emailResults.length > 0) { customer = emailResults[0]; logger.info({ customerId: customer.id, method: 'email' }, 'Found existing user by email'); } } else { logger.warn({ email }, 'Invalid email format provided, skipping email search'); } } if (!customer) { // Create new user logger.info('Creating new user from form submission'); // Build user data with whatever contact info we have const userData: any = { firstname: firstName, lastname: lastName, roles: ['Customer'], }; // Add contact info only if provided if (email) { userData.email = email; } const userPhone = signalAccount || phone; if (userPhone) { userData.phone = userPhone; } customer = await zammad.user.create(userData); } logger.info({ customerId: customer.id, email: customer.email, }, 'Using customer for ticket'); // Look up the configured group const groups = await zammad.get('groups'); const targetGroup = groups.find((g: any) => g.name === mapping.ticket.group); if (!targetGroup) { logger.error({ groupName: mapping.ticket.group }, 'Configured group not found'); throw new Error(`Zammad group "${mapping.ticket.group}" not found`); } logger.info({ groupId: targetGroup.id, groupName: targetGroup.name }, 'Using configured group'); // Build custom fields using Zammad field mapping // This dynamically maps all configured fields without hardcoding const customFields = getZammadFieldValues(formData, mapping); // Create the ticket const articleData: any = { subject: descriptionOfIssue || 'Support Request', body, content_type: 'text/html', internal: false, }; if (articleTypeId) { articleData.type_id = articleTypeId; } const ticketData = { title, group_id: targetGroup.id, customer_id: customer.id, article: articleData, ...customFields, }; logger.info({ title, groupId: targetGroup.id, customerId: customer.id, hasArticleType: !!articleTypeId, customFieldCount: Object.keys(customFields).length, }, 'Creating ticket'); const ticket = await zammad.ticket.create(ticketData); logger.info({ ticketId: ticket.id, ticketNumber: ticket.id, title, }, 'Successfully created ticket from Formstack submission'); } catch (error: any) { logger.error({ error: error.message, stack: error.stack, formId, uniqueId, }, 'Failed to create ticket from Formstack submission'); throw error; } }; export default createTicketFromFormTask;