import { createLogger } from "@link-stack/logger"; import { db } from "@link-stack/bridge-common"; 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: phone -> email // Note: We can't search by Signal account since Signal group IDs aren't phone numbers let customer; // Try phone if provided if (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; } // Use phone number if provided (don't use Signal group ID as phone) if (phone) { userData.phone = phone; } 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); // Check if this is a Signal ticket let signalArticleType = null; let signalChannelId = null; let signalBotToken = null; if (signalAccount) { try { logger.info({ signalAccount }, "Looking up Signal channel and article type"); // Look up Signal channels from Zammad (admin-only endpoint) // Note: bot_token is NOT included in this response for security reasons const channels = await zammad.get("cdr_signal_channels"); if (channels.length > 0) { const zammadChannel = channels[0]; // Use first active Signal channel signalChannelId = zammadChannel.id; logger.info( { channelId: zammadChannel.id, phoneNumber: zammadChannel.phone_number, }, "Found active Signal channel from Zammad", ); // Look up the bot_token from our own cdr database using the phone number const signalBot = await db .selectFrom("SignalBot") .selectAll() .where("phoneNumber", "=", zammadChannel.phone_number) .executeTakeFirst(); if (signalBot) { signalBotToken = signalBot.token; logger.info( { botId: signalBot.id, phoneNumber: signalBot.phoneNumber }, "Found Signal bot token from cdr database", ); } else { logger.warn( { phoneNumber: zammadChannel.phone_number }, "Signal bot not found in cdr database", ); } } else { logger.warn("No active Signal channels found"); } // Look up cdr_signal article type const articleTypes = await zammad.get("ticket_article_types"); signalArticleType = articleTypes.find((t: any) => t.name === "cdr_signal"); if (!signalArticleType) { logger.warn("Signal article type (cdr_signal) not found, using default type"); } else { logger.info( { articleTypeId: signalArticleType.id }, "Found Signal article type", ); } } catch (error: any) { logger.warn( { error: error.message }, "Failed to look up Signal article type, creating regular ticket", ); } } // Create the ticket const articleData: any = { subject: descriptionOfIssue || "Support Request", body, content_type: "text/html", internal: false, }; // Use Signal article type if available, otherwise use configured default if (signalArticleType) { articleData.type_id = signalArticleType.id; logger.info({ typeId: signalArticleType.id }, "Using Signal article type"); // IMPORTANT: Set sender to "Customer" for Signal tickets created from Formstack // This prevents the article from being echoed back to the user via Signal // (enqueue_communicate_cdr_signal_job only sends if sender != 'Customer') articleData.sender = "Customer"; } else if (articleTypeId) { articleData.type_id = articleTypeId; } const ticketData: any = { title, group_id: targetGroup.id, customer_id: customer.id, article: articleData, ...customFields, }; // Add Signal preferences if we have Signal channel and article type // Note: signalAccount from Formstack is the phone number the user typed in // Groups are added later via update_group webhook from bridge-worker if (signalChannelId && signalBotToken && signalArticleType && signalAccount) { ticketData.preferences = { channel_id: signalChannelId, cdr_signal: { bot_token: signalBotToken, chat_id: signalAccount, // Use Signal phone number as chat_id }, }; logger.info( { channelId: signalChannelId, chatId: signalAccount, }, "Adding Signal preferences to ticket", ); } logger.info( { title, groupId: targetGroup.id, customerId: customer.id, hasArticleType: !!articleTypeId || !!signalArticleType, isSignalTicket: !!signalArticleType && !!signalAccount, customFieldCount: Object.keys(customFields).length, }, "Creating ticket", ); const ticket = await zammad.ticket.create(ticketData); // Set create_article_type_id for Signal tickets to enable proper replies if (signalArticleType && signalChannelId) { try { await zammad.ticket.update(ticket.id, { create_article_type_id: signalArticleType.id, }); logger.info( { ticketId: ticket.id, articleTypeId: signalArticleType.id, }, "Set create_article_type_id for Signal ticket", ); } catch (error: any) { logger.warn( { error: error.message, ticketId: ticket.id, }, "Failed to set create_article_type_id, ticket may not support Signal replies", ); } } logger.info( { ticketId: ticket.id, ticketNumber: ticket.id, title, isSignalTicket: !!signalChannelId, }, "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;